#!/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())