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>
173 lines
5.0 KiB
Python
173 lines
5.0 KiB
Python
"""
|
|
NL-42 / NL-52 serial protocol — pure parsing, no I/O.
|
|
|
|
Reference: RION NL-42/NL-52 Serial Interface Manual (No. 55779).
|
|
|
|
Command grammar (shared with the NL-43 family):
|
|
Setting: "$" + name + "," + param + CRLF e.g. "$Measure,Start"
|
|
Request: name + "?" + CRLF e.g. "DOD?"
|
|
Reply: result code "R+0000" + CRLF, then for requests a data line.
|
|
Stop DRD stream: SUB (0x1A).
|
|
|
|
This module is intentionally I/O-free so it can be unit-tested without a
|
|
device and reused unchanged behind any transport (serial, or a serial->TCP
|
|
bridge).
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional
|
|
|
|
CR = "\r"
|
|
LF = "\n"
|
|
CRLF = "\r\n"
|
|
SUB = b"\x1a" # stop DRD streaming
|
|
|
|
# Result codes (Serial Interface Manual, "Result code")
|
|
RESULT_CODES: Dict[str, str] = {
|
|
"R+0000": "Normal end",
|
|
"R+0001": "Command error (command not recognized)",
|
|
"R+0002": "Parameter error (bad count/type of parameters)",
|
|
"R+0003": "Designation error (setting sent to request-only cmd, or vice versa)",
|
|
"R+0004": "Status error (command not valid in current state)",
|
|
}
|
|
|
|
# DOD? response field order (d1..d14). Main channel unless noted.
|
|
DOD_FIELDS: List[str] = [
|
|
"lp", "leq", "le", "lmax", "lmin",
|
|
"ly", # additional processing value (e.g. LCpeak)
|
|
"ln1", "ln2", "ln3", "ln4", "ln5",
|
|
"sub_lp", # sub channel Lp
|
|
"overload", "underrange", # 0/1 flags
|
|
]
|
|
|
|
# DRD? response field order (d0..d8). Requires NX-42EX.
|
|
DRD_FIELDS: List[str] = [
|
|
"counter", # d0: 1..600
|
|
"lp", "leq", "lmax", "lmin",
|
|
"ly",
|
|
"sub_lp",
|
|
"overload", "underrange",
|
|
]
|
|
|
|
# Token the meter returns for a disabled/unavailable display channel.
|
|
_NULL_TOKENS = {"--.-", "-.-", "---.-", ""}
|
|
|
|
|
|
class ResultError(Exception):
|
|
"""Raised when the meter returns a non-OK result code (R+0001..0004)."""
|
|
|
|
def __init__(self, code: str):
|
|
self.code = code
|
|
self.meaning = RESULT_CODES.get(code, "Unknown result code")
|
|
super().__init__(f"{code}: {self.meaning}")
|
|
|
|
|
|
def is_result_code(line: str) -> bool:
|
|
line = line.strip()
|
|
return line.startswith("R+") and len(line) == 6 and line[2:].isdigit()
|
|
|
|
|
|
def parse_level(token: str) -> Optional[float]:
|
|
"""Parse a space-padded level token into a float, or None if disabled.
|
|
|
|
The meter pads to 5 chars and returns '--.-' (leading space) for channels
|
|
whose display is OFF.
|
|
"""
|
|
t = token.strip()
|
|
if t in _NULL_TOKENS or set(t) <= set("-. "):
|
|
return None
|
|
try:
|
|
return float(t)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _parse_flag(token: str) -> Optional[bool]:
|
|
t = token.strip()
|
|
if t == "1":
|
|
return True
|
|
if t == "0":
|
|
return False
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class DODSnapshot:
|
|
"""Parsed DOD? snapshot (displayed values). Levels are dB or None if the
|
|
channel's display is OFF. Measurement state is NOT part of DOD — query
|
|
Measure? separately."""
|
|
|
|
lp: Optional[float] = None
|
|
leq: Optional[float] = None
|
|
le: Optional[float] = None
|
|
lmax: Optional[float] = None
|
|
lmin: Optional[float] = None
|
|
ly: Optional[float] = None
|
|
ln1: Optional[float] = None
|
|
ln2: Optional[float] = None
|
|
ln3: Optional[float] = None
|
|
ln4: Optional[float] = None
|
|
ln5: Optional[float] = None
|
|
sub_lp: Optional[float] = None
|
|
overload: Optional[bool] = None
|
|
underrange: Optional[bool] = None
|
|
raw: str = ""
|
|
|
|
|
|
@dataclass
|
|
class DRDSample:
|
|
"""Parsed DRD? sample (continuous output, ~every 100 ms)."""
|
|
|
|
counter: Optional[int] = None
|
|
lp: Optional[float] = None
|
|
leq: Optional[float] = None
|
|
lmax: Optional[float] = None
|
|
lmin: Optional[float] = None
|
|
ly: Optional[float] = None
|
|
sub_lp: Optional[float] = None
|
|
overload: Optional[bool] = None
|
|
underrange: Optional[bool] = None
|
|
raw: str = ""
|
|
|
|
|
|
def _split(resp: str) -> List[str]:
|
|
return [p for p in resp.strip().split(",")]
|
|
|
|
|
|
def parse_dod(resp: str) -> DODSnapshot:
|
|
"""Parse a DOD? data line into a DODSnapshot.
|
|
|
|
Tolerant of trailing/short field counts — only maps what is present.
|
|
"""
|
|
parts = _split(resp)
|
|
snap = DODSnapshot(raw=resp.strip())
|
|
|
|
for idx, name in enumerate(DOD_FIELDS):
|
|
if idx >= len(parts):
|
|
break
|
|
if name in ("overload", "underrange"):
|
|
setattr(snap, name, _parse_flag(parts[idx]))
|
|
else:
|
|
setattr(snap, name, parse_level(parts[idx]))
|
|
|
|
return snap
|
|
|
|
|
|
def parse_drd(resp: str) -> DRDSample:
|
|
"""Parse a single DRD? data line into a DRDSample."""
|
|
parts = _split(resp)
|
|
sample = DRDSample(raw=resp.strip())
|
|
|
|
for idx, name in enumerate(DRD_FIELDS):
|
|
if idx >= len(parts):
|
|
break
|
|
if name == "counter":
|
|
t = parts[idx].strip()
|
|
sample.counter = int(t) if t.isdigit() else None
|
|
elif name in ("overload", "underrange"):
|
|
setattr(sample, name, _parse_flag(parts[idx]))
|
|
else:
|
|
setattr(sample, name, parse_level(parts[idx]))
|
|
|
|
return sample
|