diff --git a/README.md b/README.md index 150d93d..4fc1ace 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,74 @@ -# fori_ZLTECHDRIVER_ID_SET +# ZLAC8015D V4 RS485 슬레이브 ID 운용 가이드 -fori_ZLTECHDRIVER_ID_SET -ZLTECH 드라이버의 RS485 ID 부여 코드입니다. \ No newline at end of file +ZLTECH ZLAC8015D V4 듀얼 드라이버의 RS485(Modbus RTU) 슬레이브 ID를 확인/변경하기 위한 파이썬 스크립트 모음. +프로토콜 세부 사항은 동봉된 `ZLAC8015D V4 Series RS485 Communication Version 1.06` 매뉴얼 기준. + +## 사전 준비 + +```bash +python3 -m venv .venv +./.venv/bin/pip install -r requirements.txt # pymodbus + pyserial +``` + +- 통신 라이브러리로 `pymodbus`(ModbusSerialClient, RTU) 사용 +- RS485-USB 어댑터로 드라이버와 연결 (A/B 라인, GND 공통) +- 기본 통신 설정: Baud rate 115200, 8 data bit, No parity, Stop bit 1 +- 드라이버 출고 시 기본 슬레이브 ID는 **1** +- 실제 하드웨어 운용 테스트는 우분투 환경에서 진행 예정 (개발/문법 검증은 macOS에서 완료) + +## 파일 구성 + +| 파일 | 역할 | +|---|---| +| `zltech_modbus.py` | pymodbus 기반 통신 공용 함수 (read/write register) | +| `zltech_scan_ids.py` | 버스에 연결된 슬레이브 ID 스캔 | +| `zltech_set_id.py` | 슬레이브 ID 변경 (EEPROM 저장 포함) | +| `requirements.txt` | 의존 패키지 (pymodbus, pyserial) | + +## 1. 슬레이브 ID 확인 (스캔) + +```bash +./.venv/bin/python zltech_scan_ids.py --port /dev/tty.usbserial-XXXX --range 1 16 +``` + +- `--port`: 시리얼 포트 (Ubuntu: `/dev/ttyUSB0` 등, macOS: `/dev/tty.usbserial-XXXX`, Windows: `COM5` 등) +- `--baud`: 기본값 115200 +- `--range START END`: 스캔할 ID 범위 (기본 1~16) + +응답한 ID 목록이 출력됨. + +## 2. 슬레이브 ID 변경 (1과 2로 설정) + +⚠️ **두 드라이버 모두 기본 ID가 1**이라, 동시에 버스에 연결하면 응답이 충돌합니다. +**반드시 한 번에 하나씩만 연결**한 상태로 진행하세요. + +### 순서 + +1. 드라이버 A만 연결 → 기본값 그대로 ID 1 유지 (변경 불필요) +2. 드라이버 A 전원 off, 분리 +3. 드라이버 B만 연결 → ID를 2로 변경 + + ```bash + ./.venv/bin/python zltech_set_id.py --port /dev/tty.usbserial-XXXX --old-id 1 --new-id 2 + ``` + +4. 드라이버 B 전원을 껐다 켜서(파워사이클) 변경 사항 완전 반영 +5. 이제 드라이버 A(ID 1), B(ID 2)를 같은 버스에 함께 연결 + +6. 확인: + + ```bash + ./.venv/bin/python zltech_scan_ids.py --port /dev/tty.usbserial-XXXX --range 1 2 + ``` + + ID 1, 2 모두 응답하면 성공. + +## 참고: 레지스터 주소 + +| 주소 | 이름 | 범위 | 비고 | +|---|---|---|---| +| `0x2001` | RS485 Custom Drive Node Number | 1~127 | 기본값 1 | +| `0x2002` | RS485 custom communication baud rate | 1~6 (128000~9600bps) | 기본값 2 (115200bps) | +| `0x2010` | RW 속성 파라미터 EEPROM 저장 | 0/1 | 1 = 저장 | + +자세한 레지스터 목록은 동봉 매뉴얼의 "Address Directionary" 섹션 참고. diff --git a/__pycache__/zltech_modbus.cpython-313.pyc b/__pycache__/zltech_modbus.cpython-313.pyc new file mode 100644 index 0000000..f9ae3fe Binary files /dev/null and b/__pycache__/zltech_modbus.cpython-313.pyc differ diff --git a/__pycache__/zltech_scan_ids.cpython-313.pyc b/__pycache__/zltech_scan_ids.cpython-313.pyc new file mode 100644 index 0000000..b11eeee Binary files /dev/null and b/__pycache__/zltech_scan_ids.cpython-313.pyc differ diff --git a/__pycache__/zltech_set_id.cpython-313.pyc b/__pycache__/zltech_set_id.cpython-313.pyc new file mode 100644 index 0000000..8d79f42 Binary files /dev/null and b/__pycache__/zltech_set_id.cpython-313.pyc differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..57f6029 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pymodbus>=3.7 +pyserial>=3.5 diff --git a/resource/ZLAC8015D V4 Series CANopen Communication Quick Start Guide Version 1.00-20251111.pdf b/resource/ZLAC8015D V4 Series CANopen Communication Quick Start Guide Version 1.00-20251111.pdf new file mode 100644 index 0000000..b5d2965 Binary files /dev/null and b/resource/ZLAC8015D V4 Series CANopen Communication Quick Start Guide Version 1.00-20251111.pdf differ diff --git a/resource/ZLAC8015D V4 Series CANopen Communication Routine Version 1.07-20251111.pdf b/resource/ZLAC8015D V4 Series CANopen Communication Routine Version 1.07-20251111.pdf new file mode 100644 index 0000000..a117610 Binary files /dev/null and b/resource/ZLAC8015D V4 Series CANopen Communication Routine Version 1.07-20251111.pdf differ diff --git a/resource/ZLAC8015D V4 Series Manual Version 1.03-20251111.pdf b/resource/ZLAC8015D V4 Series Manual Version 1.03-20251111.pdf new file mode 100644 index 0000000..316a60e Binary files /dev/null and b/resource/ZLAC8015D V4 Series Manual Version 1.03-20251111.pdf differ diff --git a/resource/ZLAC8015D V4 Series RS485 Communication Quick Start Guide Version 1.00-20251111.pdf b/resource/ZLAC8015D V4 Series RS485 Communication Quick Start Guide Version 1.00-20251111.pdf new file mode 100644 index 0000000..a561e06 Binary files /dev/null and b/resource/ZLAC8015D V4 Series RS485 Communication Quick Start Guide Version 1.00-20251111.pdf differ diff --git a/resource/ZLAC8015D V4 Series RS485 Communication Version 1.06-20251111.pdf b/resource/ZLAC8015D V4 Series RS485 Communication Version 1.06-20251111.pdf new file mode 100644 index 0000000..6afdfc8 Binary files /dev/null and b/resource/ZLAC8015D V4 Series RS485 Communication Version 1.06-20251111.pdf differ diff --git a/resource/ZLAC8015D V4 Series.pdf b/resource/ZLAC8015D V4 Series.pdf new file mode 100644 index 0000000..e3ca940 Binary files /dev/null and b/resource/ZLAC8015D V4 Series.pdf differ diff --git a/zltech_modbus.py b/zltech_modbus.py new file mode 100644 index 0000000..3aae020 --- /dev/null +++ b/zltech_modbus.py @@ -0,0 +1,55 @@ +"""pymodbus-based helpers for the ZLAC8015D V4 RS485 (Modbus RTU) protocol. + +Register reference (from "ZLAC8015D V4 Series RS485 Communication" manual): + 0x2001 RS485 Custom Drive Node Number (RW/S, range 1-127, default 1) + 0x2002 RS485 custom communication baud rate (RW/S) + 0x2010 Save all RW attribute parameters to EEPROM (write 1 to save) + +Requires: pip install pymodbus pyserial +""" +from pymodbus.client import ModbusSerialClient + +REG_NODE_ID = 0x2001 +REG_SAVE_EEPROM = 0x2010 + +# pymodbus renamed the "slave id" keyword across major versions +# (unit -> slave -> device_id). Try each so this works regardless of +# which pymodbus version is installed (e.g. on the Ubuntu test box). +_ID_KWARGS = ("device_id", "slave", "unit") + + +def _call(fn, *args, node_id, **kwargs): + last_err = None + for kw in _ID_KWARGS: + try: + return fn(*args, **{kw: node_id}, **kwargs) + except TypeError as exc: + last_err = exc + continue + raise TypeError(f"pymodbus client does not accept any of {_ID_KWARGS}: {last_err}") + + +def make_client(port: str, baud: int = 115200, timeout: float = 0.3) -> ModbusSerialClient: + return ModbusSerialClient( + port, + baudrate=baud, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + ) + + +def read_register(client: ModbusSerialClient, node_id: int, address: int, count: int = 1): + """Return list of register values, or None on error/no response.""" + result = _call(client.read_holding_registers, address, node_id=node_id, count=count) + if result is None or result.isError(): + return None + return result.registers + + +def write_register(client: ModbusSerialClient, node_id: int, address: int, value: int) -> bool: + result = _call(client.write_register, address, value, node_id=node_id) + if result is None or result.isError(): + return False + return True diff --git a/zltech_scan_ids.py b/zltech_scan_ids.py new file mode 100644 index 0000000..7dc0e45 --- /dev/null +++ b/zltech_scan_ids.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Scan an RS485 bus for ZLAC8015D drivers and report which node IDs answer. + +Requires: pip install pymodbus pyserial + +Example: + python3 zltech_scan_ids.py --port /dev/tty.usbserial-XXXX + python3 zltech_scan_ids.py --port COM5 --baud 115200 --range 1 10 +""" +import argparse +import sys + +from zltech_modbus import REG_NODE_ID, make_client, read_register + + +def scan(port: str, baud: int, id_range, timeout: float): + found = [] + client = make_client(port, baud, timeout) + if not client.connect(): + print(f"ERROR: could not open serial port {port}") + sys.exit(1) + try: + for node_id in id_range: + values = read_register(client, node_id, REG_NODE_ID, 1) + if values is not None: + found.append((node_id, values[0])) + print(f" id {node_id:3d}: responded (node-id register reads {values[0]})") + finally: + client.close() + return found + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", required=True, help="Serial port, e.g. /dev/tty.usbserial-XXXX or COM5") + parser.add_argument("--baud", type=int, default=115200, help="Baud rate (default 115200, the driver default)") + parser.add_argument("--range", nargs=2, type=int, metavar=("START", "END"), default=[1, 16], + help="Inclusive node-ID range to scan (default 1 16)") + parser.add_argument("--timeout", type=float, default=0.15, help="Per-ID response timeout in seconds") + args = parser.parse_args() + + start, end = args.range + id_range = range(start, end + 1) + + print(f"Scanning {args.port} @ {args.baud}bps for node IDs {start}-{end} ...") + found = scan(args.port, args.baud, id_range, args.timeout) + + print() + if found: + print(f"Found {len(found)} driver(s):") + for node_id, _ in found: + print(f" -> ID {node_id}") + else: + print("No drivers responded. Check wiring, baud rate, and that only powered drivers are on the bus.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/zltech_set_id.py b/zltech_set_id.py new file mode 100644 index 0000000..fefa917 --- /dev/null +++ b/zltech_set_id.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Set the RS485 node ID of a ZLAC8015D driver. + +Only ONE driver should be connected to the RS485 bus while running this +script (both drivers ship with default ID 1, so with two on the bus their +replies would collide). Recommended workflow to end up with IDs 1 and 2: + + 1. Connect ONLY the first driver. Leave it at the default ID: + python3 zltech_set_id.py --port --old-id 1 --new-id 1 + (or just skip it, it is already 1) + 2. Power off, disconnect it, connect ONLY the second driver. + 3. Set its ID to 2: + python3 zltech_set_id.py --port --old-id 1 --new-id 2 + 4. Power-cycle that driver. + 5. Now connect both drivers to the same bus; they will answer as + ID 1 and ID 2 respectively. + +Requires: pip install pymodbus pyserial +""" +import argparse +import sys +import time + +from zltech_modbus import REG_NODE_ID, REG_SAVE_EEPROM, make_client, read_register, write_register + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", required=True, help="Serial port, e.g. /dev/tty.usbserial-XXXX or COM5") + parser.add_argument("--baud", type=int, default=115200, help="Baud rate (default 115200, the driver default)") + parser.add_argument("--old-id", type=int, required=True, help="Current node ID of the driver (1-127)") + parser.add_argument("--new-id", type=int, required=True, help="New node ID to assign (1-127)") + parser.add_argument("--timeout", type=float, default=0.3, help="Response timeout in seconds") + args = parser.parse_args() + + for name, value in (("--old-id", args.old_id), ("--new-id", args.new_id)): + if not (1 <= value <= 127): + parser.error(f"{name} must be in range 1-127") + + client = make_client(args.port, args.baud, args.timeout) + if not client.connect(): + print(f"ERROR: could not open serial port {args.port}") + sys.exit(1) + + try: + print(f"Checking driver at ID {args.old_id} ...") + values = read_register(client, args.old_id, REG_NODE_ID, 1) + if values is None: + print(f"ERROR: no response from ID {args.old_id}. Check wiring/baud rate, " + f"and that exactly one driver is on the bus.") + sys.exit(1) + print(f" driver responded, current node-id register = {values[0]}") + + print(f"Writing new node ID {args.new_id} ...") + if not write_register(client, args.old_id, REG_NODE_ID, args.new_id): + print("ERROR: write did not get a valid response.") + sys.exit(1) + + # The ID change can take effect immediately, so try to save under + # the new ID first, and fall back to the old ID if that fails. + time.sleep(0.05) + saved = write_register(client, args.new_id, REG_SAVE_EEPROM, 1) + if not saved: + saved = write_register(client, args.old_id, REG_SAVE_EEPROM, 1) + if saved: + print("Saved to EEPROM.") + else: + print("WARNING: could not confirm the save-to-EEPROM write; " + "the ID change may be lost on power-cycle.") + + print(f"Done. Power-cycle the driver, then verify with:\n" + f" python3 zltech_scan_ids.py --port {args.port} --range {args.new_id} {args.new_id}") + finally: + client.close() + + +if __name__ == "__main__": + main()