diff --git a/SLM-stress-test/nl52_usb_probe.py b/SLM-stress-test/nl52_usb_probe.py new file mode 100644 index 0000000..c2f789a --- /dev/null +++ b/SLM-stress-test/nl52_usb_probe.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +RION NL-42 / NL-52 USB serial probe — zero dependencies (stdlib termios only). + +The NL-52's USB port enumerates as a virtual COM port ("RION USB to RS232C +Converter"). On Linux that is almost always handled by an in-kernel USB-serial +driver (ftdi_sio / cp210x / ch341) which creates /dev/ttyUSB*. This script +opens that port and sends a few harmless REQUEST commands (no settings are +changed) to confirm two-way communication. + +Protocol (NL-42/NL-52 Serial Interface Manual 55779): + Request: "?" + CRLF + Setting: "$," + CRLF (NOT used here — read-only probe) + Reply: result code "R+0000" + CRLF, then data line(s) for requests. + +Before running: + 1. On the meter: MENU -> I/O -> Communication Interface -> "USB" + (set this BEFORE plugging in the cable). + 2. Connect a generic USB-A -> mini-B cable directly (no hub). + 3. Find the port: ls -l /dev/ttyUSB* (and `dmesg | tail` after plugging in) + +Usage: + python3 nl52_usb_probe.py # defaults to /dev/ttyUSB0 + python3 nl52_usb_probe.py --port /dev/ttyUSB0 --baud 115200 + python3 nl52_usb_probe.py --cmd "System Version?" --cmd "Clock?" +""" + +import argparse +import os +import select +import sys +import termios +import time + +# Safe, read-only probe commands (all are pure requests). +DEFAULT_COMMANDS = [ + "System Version?", # firmware version — proves the link end-to-end + "Clock?", # current date/time + "SD Card Free Size?", + "DOD?", # snapshot of currently displayed values +] + +BAUD_CONSTANTS = { + 9600: termios.B9600, + 19200: termios.B19200, + 38400: termios.B38400, + 57600: termios.B57600, + 115200: termios.B115200, +} + + +def open_serial(port: str, baud: int) -> int: + """Open a serial port in raw 8N1, no flow control. Returns an fd.""" + if baud not in BAUD_CONSTANTS: + raise ValueError(f"Unsupported baud {baud}; choose from {sorted(BAUD_CONSTANTS)}") + + fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + + attrs = termios.tcgetattr(fd) + iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs + + # Raw mode + iflag = 0 + oflag = 0 + lflag = 0 + # 8 data bits, enable receiver, ignore modem control lines + cflag = termios.CS8 | termios.CREAD | termios.CLOCAL + # (no PARENB = no parity, no CSTOPB = 1 stop bit, no CRTSCTS = no flow control) + + bconst = BAUD_CONSTANTS[baud] + ispeed = bconst + ospeed = bconst + + termios.tcsetattr(fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc]) + termios.tcflush(fd, termios.TCIOFLUSH) + return fd + + +def send(fd: int, line: str): + os.write(fd, (line + "\r\n").encode("ascii")) + + +def read_reply(fd: int, timeout: float = 3.0) -> bytes: + """Read whatever arrives within `timeout` seconds (idle-gap terminated).""" + buf = bytearray() + deadline = time.time() + timeout + while time.time() < deadline: + r, _, _ = select.select([fd], [], [], 0.3) + if r: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + continue + if chunk: + buf.extend(chunk) + # Once we've seen a CRLF and there's a brief idle, stop early + deadline = min(deadline, time.time() + 0.4) + return bytes(buf) + + +def main(): + ap = argparse.ArgumentParser(description="RION NL-42/NL-52 USB serial probe") + ap.add_argument("--port", default="/dev/ttyUSB0") + ap.add_argument("--baud", type=int, default=115200, + help="USB CDC usually ignores baud, but RS-232C needs a match") + ap.add_argument("--cmd", action="append", dest="cmds", + help="Override probe command(s); repeatable") + ap.add_argument("--timeout", type=float, default=3.0) + args = ap.parse_args() + + commands = args.cmds or DEFAULT_COMMANDS + + if not os.path.exists(args.port): + print(f"[!] {args.port} does not exist.") + print(" Plug in the meter (Comm Interface = USB) and check: ls -l /dev/ttyUSB*") + print(" Also check the kernel saw it: dmesg | tail -20") + return 2 + + try: + fd = open_serial(args.port, args.baud) + except PermissionError: + print(f"[!] Permission denied on {args.port}.") + print(" Add yourself to the 'dialout' group, or run with sudo:") + print(f" sudo usermod -aG dialout $USER (then log out/in)") + return 2 + except Exception as e: + print(f"[!] Could not open {args.port}: {e}") + return 2 + + print(f"[*] Opened {args.port} @ {args.baud} 8N1 (raw, no flow control)") + print(f"[*] Sending {len(commands)} read-only request command(s)\n") + + ok = 0 + try: + for cmd in commands: + send(fd, cmd) + reply = read_reply(fd, args.timeout) + decoded = reply.decode("ascii", errors="replace").replace("\r", "\\r").replace("\n", "\\n\n") + if reply: + ok += 1 + print(f" > {cmd}") + for line in decoded.splitlines(): + print(f" {line}") + else: + print(f" > {cmd}") + print(f" (no response within {args.timeout}s)") + print() + time.sleep(1.0) # NL-series likes >=1s between commands + finally: + os.close(fd) + + if ok == 0: + print("[!] No responses. Things to check:") + print(" - Meter's Communication Interface is set to USB (not RS-232C)") + print(" - ECO / Sleep mode is OFF (both disable the comm interface)") + print(" - Right port (try other /dev/ttyUSB* or /dev/ttyACM*)") + print(" - For RS-232C path, baud must match the meter's setting") + return 1 + + print(f"[OK] {ok}/{len(commands)} commands answered — two-way comms confirmed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())