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>
135 lines
4.3 KiB
Python
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)
|