0fcab0d0dc
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>
161 lines
5.9 KiB
Python
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())
|