Files
slmm/nl52/cli.py
T
serversdown 0fcab0d0dc feat: NL-42/NL-52 serial control layer (USB/RS-232, dependency-free)
Adds a stdlib-only (termios, no pyserial) control layer for the older RION
NL-42/NL-52 meters, which have no Ethernet option and speak the same ASCII
command family as the NL-43 over RS-232C or USB (virtual COM port).

- nl52/protocol.py: I/O-free parsers for DOD? (14 fields) and DRD? (9
  fields), result codes, and level tokens. Handles '--.-' disabled-channel
  nulls and space-padded fixed-width fields.
- nl52/client.py: termios SerialPort + NL52Client. Echo-tolerant result-code
  reading, buffered multi-line reads, NL-52 rate limits (200ms; 1s for DOD?).
  is_request = "?" in cmd (NL-52 requests can carry a param after '?', e.g.
  'System Version?NL').
- nl52/cli.py: bench tool — probe/status/start/stop/monitor/poll/raw — for
  testing over USB on the dev server with nothing to install.
- test_nl52_protocol.py: 45 parser assertions, host-runnable (no hardware).
  Client exchange logic separately validated via pty loopback.

NL-52 quirks vs NL-43: DOD has no Lpeak and no measurement-state (query
Measure? separately); DRD streaming requires the NX-42EX option.

Not yet: SLMM integration (async transport / serial->TCP bridge for the RX55
PAD-mode path), DB model, validation against real hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:02:51 +00:00

161 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""
NL-42 / NL-52 bench CLI — talk to the meter over USB (or RS-232C), no deps.
Setup on the meter first:
MENU -> I/O -> Communication Interface -> "USB" (set BEFORE plugging in)
Connect a generic USB-A -> mini-B cable directly (no hub).
Find the port: ls -l /dev/ttyUSB* (and `dmesg | tail` after plugging in)
Examples:
python3 -m nl52.cli probe
python3 -m nl52.cli --port /dev/ttyUSB0 status
python3 -m nl52.cli start
python3 -m nl52.cli stop
python3 -m nl52.cli monitor --seconds 10 # DRD stream (needs NX-42EX)
python3 -m nl52.cli poll --seconds 10 # DOD polling fallback (~1/s)
python3 -m nl52.cli raw "System Version?NL"
Run from the slmm/ directory (so the nl52 package is importable), or just run
this file directly — it adds slmm/ to sys.path automatically.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from nl52.client import NL52Client # noqa: E402
from nl52.protocol import DODSnapshot, DRDSample, ResultError # noqa: E402
def _fmt(v):
return "--" if v is None else (f"{v:.1f}" if isinstance(v, float) else str(v))
def _print_dod(s: DODSnapshot):
print(f" Lp = {_fmt(s.lp)} dB Leq = {_fmt(s.leq)} dB LE = {_fmt(s.le)} dB")
print(f" Lmax = {_fmt(s.lmax)} dB Lmin = {_fmt(s.lmin)} dB")
print(f" Ly = {_fmt(s.ly)} sub Lp = {_fmt(s.sub_lp)} dB")
print(f" LN1..5 = {_fmt(s.ln1)} / {_fmt(s.ln2)} / {_fmt(s.ln3)} / {_fmt(s.ln4)} / {_fmt(s.ln5)}")
print(f" overload={_fmt(s.overload)} underrange={_fmt(s.underrange)}")
def cmd_probe(m: NL52Client, args):
print("[*] Probing meter (read-only)...")
print(f" System Version : {m.system_version()}")
print(f" Clock : {m.get_clock()}")
print(f" Measure state : {m.measure_state()}")
try:
print(f" Battery type : {m.battery_type()}")
except Exception as e:
print(f" Battery type : (n/a: {e})")
try:
print(f" SD free (MB?) : {m.sd_free_size()}")
except Exception as e:
print(f" SD free : (n/a: {e})")
print("[OK] Two-way communication confirmed.")
def cmd_status(m: NL52Client, args):
state = m.measure_state()
print(f"[*] Measure state: {state}")
print("[*] DOD snapshot:")
_print_dod(m.request_dod())
def cmd_start(m: NL52Client, args):
print(f"[*] Measure,Start -> {m.measure_start()}")
print(f" state now: {m.measure_state()}")
def cmd_stop(m: NL52Client, args):
print(f"[*] Measure,Stop -> {m.measure_stop()}")
print(f" state now: {m.measure_state()}")
def cmd_monitor(m: NL52Client, args):
print(f"[*] DRD stream for {args.seconds}s (requires NX-42EX). Ctrl+C to stop.")
def on_sample(s: DRDSample):
print(f" #{_fmt(s.counter):>4} Lp={_fmt(s.lp)} Leq={_fmt(s.leq)} "
f"Lmax={_fmt(s.lmax)} Lmin={_fmt(s.lmin)} sub={_fmt(s.sub_lp)}"
f"{' OVERLOAD' if s.overload else ''}{' UNDER' if s.underrange else ''}")
try:
m.stream_drd(on_sample, duration=args.seconds)
except ResultError as e:
print(f"[!] DRD not available ({e}). The meter likely lacks the NX-42EX "
f"option — use `poll` instead.")
def cmd_poll(m: NL52Client, args):
import time
print(f"[*] Polling DOD ~1/s for {args.seconds}s. Ctrl+C to stop.")
end = time.time() + args.seconds
while time.time() < end:
s = m.request_dod()
print(f" Lp={_fmt(s.lp)} Leq={_fmt(s.leq)} Lmax={_fmt(s.lmax)} "
f"Lmin={_fmt(s.lmin)} sub={_fmt(s.sub_lp)}"
f"{' OVERLOAD' if s.overload else ''}{' UNDER' if s.underrange else ''}")
def cmd_raw(m: NL52Client, args):
resp = m.send(args.command)
print(f" > {args.command}")
print(f" < {resp}")
def main():
ap = argparse.ArgumentParser(description="RION NL-42/NL-52 bench CLI")
ap.add_argument("--port", default="/dev/ttyUSB0")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--timeout", type=float, default=3.0)
ap.add_argument("--echo-on", action="store_true",
help="Don't send Echo,Off on connect")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("probe", help="read-only sanity check")
sub.add_parser("status", help="measure state + DOD snapshot")
sub.add_parser("start", help="Measure,Start")
sub.add_parser("stop", help="Measure,Stop")
mon = sub.add_parser("monitor", help="DRD stream (needs NX-42EX)")
mon.add_argument("--seconds", type=float, default=10)
pol = sub.add_parser("poll", help="DOD polling fallback (~1/s)")
pol.add_argument("--seconds", type=float, default=10)
raw = sub.add_parser("raw", help="send an arbitrary command")
raw.add_argument("command")
args = ap.parse_args()
if not os.path.exists(args.port):
print(f"[!] {args.port} not found. Plug in the meter (Comm Interface=USB) and check:")
print(" ls -l /dev/ttyUSB* ; dmesg | tail -20")
return 2
handlers = {
"probe": cmd_probe, "status": cmd_status, "start": cmd_start,
"stop": cmd_stop, "monitor": cmd_monitor, "poll": cmd_poll, "raw": cmd_raw,
}
try:
with NL52Client(args.port, baud=args.baud, timeout=args.timeout,
disable_echo=not args.echo_on) as m:
handlers[args.cmd](m, args)
except PermissionError:
print(f"[!] Permission denied on {args.port}. Add yourself to dialout:")
print(" sudo usermod -aG dialout $USER (then log out/in)")
return 2
except KeyboardInterrupt:
print("\n[*] Interrupted.")
except (TimeoutError, ResultError) as e:
print(f"[!] {type(e).__name__}: {e}")
print(" Check: Comm Interface=USB, ECO/Sleep OFF, correct port/baud.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())