Files
slmm/test_nl52_protocol.py
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

135 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for nl52.protocol — DOD/DRD parsing. No hardware required.
Run: python3 test_nl52_protocol.py
"""
import sys
from nl52.protocol import (
parse_dod,
parse_drd,
parse_level,
is_result_code,
ResultError,
RESULT_CODES,
)
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f"{name}")
else:
FAIL += 1
print(f"{name} {('' + detail) if detail else ''}")
def test_level_tokens():
print("\n[1] Level token parsing")
check("plain value", parse_level("55.5") == 55.5)
check("space-padded value", parse_level(" 60.1") == 60.1)
check("disabled channel '--.-' -> None", parse_level(" --.-") is None)
check("dashes-only -> None", parse_level("---.-") is None)
check("empty -> None", parse_level(" ") is None)
check("negative value", parse_level("-3.2") == -3.2)
def test_result_codes():
print("\n[2] Result code recognition")
check("R+0000 is a result code", is_result_code("R+0000"))
check("R+0004 is a result code", is_result_code(" R+0004 "))
check("data line is not a result code", not is_result_code("55.5,54.2"))
check("all 5 codes documented", set(RESULT_CODES) == {
"R+0000", "R+0001", "R+0002", "R+0003", "R+0004"})
err = ResultError("R+0004")
check("ResultError carries meaning", "Status error" in err.meaning, err.meaning)
def test_dod_full():
print("\n[3] DOD? full 14-field parse")
# d1..d14: Lp,Leq,LE,Lmax,Lmin,Ly,LN1..5,subLp,overload,underrange
resp = "55.5,54.2,60.1,50.3,45.2,12.3,48.1,50.0,52.3,44.1,43.0,40.2,0,0"
s = parse_dod(resp)
check("lp", s.lp == 55.5, str(s.lp))
check("leq", s.leq == 54.2)
check("le", s.le == 60.1)
check("lmax", s.lmax == 50.3)
check("lmin", s.lmin == 45.2)
check("ly", s.ly == 12.3)
check("ln1", s.ln1 == 48.1)
check("ln5", s.ln5 == 43.0)
check("sub_lp", s.sub_lp == 40.2)
check("overload False", s.overload is False)
check("underrange False", s.underrange is False)
check("raw preserved", s.raw == resp)
def test_dod_disabled_channels():
print("\n[4] DOD? with disabled display channels ('--.-')")
# When display is OFF, d2..d12 come back as ' --.-'
resp = "55.5, --.-, --.-, --.-, --.-, --.-, --.-, --.-, --.-, --.-, --.-, --.-,1,0"
s = parse_dod(resp)
check("lp still present", s.lp == 55.5)
check("leq disabled -> None", s.leq is None)
check("sub_lp disabled -> None", s.sub_lp is None)
check("overload True", s.overload is True)
check("underrange False", s.underrange is False)
def test_dod_space_padded():
print("\n[5] DOD? space-padded fixed-width fields")
resp = " 55.5, 54.2, 60.1, 50.3, 45.2, 12.3, 48.1, 50.0, 52.3, 44.1, 43.0, 40.2, 0, 1"
s = parse_dod(resp)
check("padded lp", s.lp == 55.5)
check("padded sub_lp", s.sub_lp == 40.2)
check("padded underrange True", s.underrange is True)
def test_drd():
print("\n[6] DRD? sample parse (d0..d8)")
# d0=counter,d1=Lp,d2=Leq,d3=Lmax,d4=Lmin,d5=Ly,d6=subLp,d7=ovl,d8=under
resp = "12,55.5,54.2,60.1,50.3, --.-,40.2,0,1"
s = parse_drd(resp)
check("counter int", s.counter == 12, str(s.counter))
check("lp", s.lp == 55.5)
check("leq", s.leq == 54.2)
check("lmax", s.lmax == 60.1)
check("lmin", s.lmin == 50.3)
check("ly disabled -> None", s.ly is None)
check("sub_lp", s.sub_lp == 40.2)
check("overload False", s.overload is False)
check("underrange True", s.underrange is True)
def test_short_field_counts():
print("\n[7] Tolerance of short/odd field counts")
s = parse_dod("55.5,54.2") # only first two present
check("partial DOD: lp", s.lp == 55.5)
check("partial DOD: leq", s.leq == 54.2)
check("partial DOD: missing -> None", s.lmax is None)
d = parse_drd("7") # only counter
check("partial DRD: counter", d.counter == 7)
check("partial DRD: missing lp -> None", d.lp is None)
def main():
test_level_tokens()
test_result_codes()
test_dod_full()
test_dod_disabled_channels()
test_dod_space_padded()
test_drd()
test_short_field_counts()
print(f"\n{'='*50}\nResults: {PASS} passed, {FAIL} failed")
return FAIL == 0
if __name__ == "__main__":
sys.exit(0 if main() else 1)