Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c79e9c2a | |||
| 0fcab0d0dc | |||
| 63a95a2ed3 | |||
| 450509d210 | |||
| fefa9eace8 | |||
| 98a8d357e5 | |||
| 0a7422eceb | |||
| 996b993cb9 | |||
| 01337696b3 | |||
| a302fd15d4 | |||
| af5ecc1a92 |
@@ -1,5 +1,6 @@
|
|||||||
/manuals/
|
/manuals/
|
||||||
/data/
|
/data/
|
||||||
|
/data-dev/
|
||||||
/SLM-stress-test/stress_test_logs/
|
/SLM-stress-test/stress_test_logs/
|
||||||
/SLM-stress-test/tcpdump-runs/
|
/SLM-stress-test/tcpdump-runs/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# NL-43 + RX55 TCP “Wedge” Investigation (2255 Refusal) — Full Log & Next Steps
|
||||||
|
**Last updated:** 2026-02-18
|
||||||
|
**Owner:** Brian / serversdown
|
||||||
|
**Context:** Terra-View / SLMM / field-deployed Rion NL-43 behind Sierra Wireless RX55
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) What this document is
|
||||||
|
This is a **comprehensive, chronological** record of the debugging we did to isolate a failure where the **NL-43’s TCP control port (2255) eventually stops accepting connections** (“wedges”), while other services (notably FTP/21) remain reachable.
|
||||||
|
|
||||||
|
This is written to be fed back into future troubleshooting, so it intentionally includes the **full reasoning chain, experiments, commands, packet evidence, and conclusions**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Architecture (as tested)
|
||||||
|
### Network path
|
||||||
|
- **Server (SLMM host):** `10.0.0.40`
|
||||||
|
- **RX55 WAN IP:** `63.45.161.30`
|
||||||
|
- **RX55 LAN subnet:** `192.168.1.0/24`
|
||||||
|
- **RX55 LAN gateway:** `192.168.1.1`
|
||||||
|
- **NL-43 LAN IP:** `192.168.1.10` (confirmed via ARP OUI + ping; see LAN validation)
|
||||||
|
|
||||||
|
### RX55 details
|
||||||
|
- **Sierra Wireless RX55**
|
||||||
|
- **OS:** 5.2
|
||||||
|
- **Firmware:** `01.14.24.00`
|
||||||
|
- **Carrier:** Verizon LTE (Band 66)
|
||||||
|
|
||||||
|
### Port forwarding rules (RX55)
|
||||||
|
- **WAN:2255 → NL-43:2255** (NL-43 TCP control)
|
||||||
|
- **WAN:21 → NL-43:21** (NL-43 FTP control)
|
||||||
|
|
||||||
|
You also experimented with additional forwards:
|
||||||
|
- **WAN:2253 → NL-43:2255** (test)
|
||||||
|
- **WAN:2253 → NL-43:2253** (test)
|
||||||
|
- **WAN:4450 → NL-43:4450** (test)
|
||||||
|
|
||||||
|
**Important:** Rule “Input zone / interface” was set to **WAN-NAT**, and Source IP left as **Any IPv4**. This is correct for inbound port-forward behavior on Sierra OS 5.x.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Original problem statement (the “wedge”)
|
||||||
|
After running for hours, the NL-43 becomes unreachable over TCP control.
|
||||||
|
|
||||||
|
### Symptom signature (WAN-side)
|
||||||
|
- Client attempts to connect to `63.45.161.30:2255`
|
||||||
|
- Instead of timing out, the client gets **connection refused** quickly.
|
||||||
|
- Packet-level: SYN from client → **RST,ACK** back (meaning active refusal vs silent drop)
|
||||||
|
|
||||||
|
### Critical operational behavior
|
||||||
|
- **Power cycling the NL-43 fixes it.**
|
||||||
|
- **Power cycling the RX55 does NOT fix it.**
|
||||||
|
- FTP sometimes remains available even while TCP control (2255) is dead.
|
||||||
|
|
||||||
|
This combination is what forced us to determine whether:
|
||||||
|
- The RX55 is rejecting connections, OR
|
||||||
|
- The NL-43 is no longer listening on 2255, OR
|
||||||
|
- Something about the RX55 path triggers the NL-43’s control listener to die.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Event timeline evidence (SLMM logs)
|
||||||
|
A concrete wedge window was observed on **2026-02-18**:
|
||||||
|
|
||||||
|
- 10:55:46 AM — Poll success (Start)
|
||||||
|
- 11:00:28 AM — Measurement STOPPED (scheduled stop/download cycle succeeded)
|
||||||
|
- 11:55:50 AM — Poll success (Stop)
|
||||||
|
- 12:55:55 PM — Poll success (Stop)
|
||||||
|
- **1:55:58 PM — Poll failed (attempt 1/3): Errno 111 (connection refused)**
|
||||||
|
- 2:56:02 PM — Poll failed (attempt 2/3): Errno 111 (connection refused)
|
||||||
|
|
||||||
|
Key interpretation:
|
||||||
|
- The wedge occurred sometime between **12:55 and 1:55**.
|
||||||
|
- The failure type is **refused**, not timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Early hypotheses (before proof)
|
||||||
|
We considered two main buckets:
|
||||||
|
|
||||||
|
### A) NL-43-side failure (most suspicious)
|
||||||
|
- NL-43 TCP control service crashes / exits / unbinds from 2255
|
||||||
|
- socket leak / accept backlog exhaustion
|
||||||
|
- “single control session allowed” and it gets stuck thinking a session is active
|
||||||
|
- mode/service manager bug (service restart fails after other activities)
|
||||||
|
- firmware bug in TCP daemon
|
||||||
|
|
||||||
|
### B) RX55-side failure (possible trigger / less likely once FTP works)
|
||||||
|
- NAT/forwarding table corruption
|
||||||
|
- firewall behavior
|
||||||
|
- helper/ALG interference
|
||||||
|
- MSS/MTU weirdness causing edge-case behavior
|
||||||
|
- session churn behavior causing downstream issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Key experiments and what they proved
|
||||||
|
|
||||||
|
### 5.1) LAN-only stability test (No RX55 path)
|
||||||
|
**Test:** NL-43 tested directly on LAN (no modem path involved).
|
||||||
|
- Ran **24+ hours**
|
||||||
|
- Scheduler start/stop cycles worked
|
||||||
|
- Stress test: **500 commands @ 1/sec** → no failure
|
||||||
|
- Response time trend decreased (not degrading)
|
||||||
|
|
||||||
|
**Result:** The NL-43 appears stable in a “pure LAN” environment.
|
||||||
|
|
||||||
|
**Interpretation:** The trigger is likely related to the RX55/WAN environment, connection patterns, or service switching patterns—not just simple uptime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2) Port-forward behavior: timeout vs refused (RX55 behavior characterization)
|
||||||
|
You observed:
|
||||||
|
|
||||||
|
- **If a WAN port is NOT forwarded (no rule):** connecting to that port **times out** (silent drop)
|
||||||
|
- **If a WAN port IS forwarded to NL-43 but nothing listens:** it **actively refuses** (RST)
|
||||||
|
|
||||||
|
Concrete example:
|
||||||
|
- Port **4450** with no rule → timeout
|
||||||
|
- Port **4450 → NL-43:4450** rule created → connection refused
|
||||||
|
|
||||||
|
**Interpretation:** This confirms the RX55 is actually forwarding packets to the NL-43 when a rule exists. “Refused” is consistent with the NL-43 (or RX55 relay behavior) responding quickly because the packet reached the target.
|
||||||
|
|
||||||
|
Important nuance:
|
||||||
|
- A “refused” on forwarded ports does **not** automatically prove the NL-43 is the one generating RST, because NAT hides the inside host and the RX55 could reject on behalf of an unreachable target. We needed a LAN-side proof test to close the loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3) UDP test confusion (and resolution)
|
||||||
|
You ran:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nc -vzu 63.45.161.30 2255
|
||||||
|
nc -vz 63.45.161.30 2255
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
- UDP: “succeeded”
|
||||||
|
- TCP: “connection refused”
|
||||||
|
|
||||||
|
Resolution:
|
||||||
|
- UDP has **no handshake**. netcat prints “succeeded” if it doesn’t immediately receive an ICMP unreachable. It does **not** mean a UDP service exists.
|
||||||
|
- TCP refused is meaningful: a RST implies “no listener” or “actively rejected.”
|
||||||
|
|
||||||
|
**Net effect:** UDP test did not change the diagnosis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4) Packet capture proof (WAN-side)
|
||||||
|
You captured a Wireshark/tcpdump summary with these key patterns:
|
||||||
|
|
||||||
|
#### Port 2255 (TCP control)
|
||||||
|
Example:
|
||||||
|
- `10.0.0.40 → 63.45.161.30:2255` SYN
|
||||||
|
- `63.45.161.30 → 10.0.0.40` **RST, ACK** within ~50ms
|
||||||
|
|
||||||
|
This happened repeatedly.
|
||||||
|
|
||||||
|
#### Port 2253 (test port)
|
||||||
|
Multiple SYN attempts to 2253 showed **retransmissions and no response**, i.e., **silent drop** (consistent with no rule or not forwarded at that moment).
|
||||||
|
|
||||||
|
#### Port 21 (FTP)
|
||||||
|
Clean 3-way handshake:
|
||||||
|
- SYN → SYN/ACK → ACK
|
||||||
|
Then:
|
||||||
|
- FTP server banner: `220 Connection Ready`
|
||||||
|
Then:
|
||||||
|
- `530 Not logged in` (because SLMM was sending non-FTP “requests” as an experiment)
|
||||||
|
Session closes cleanly.
|
||||||
|
|
||||||
|
**Key takeaway from capture:**
|
||||||
|
- TCP transport to NL-43 via RX55 is definitely working (port 21 proves it).
|
||||||
|
- Port 2255 is being actively refused.
|
||||||
|
|
||||||
|
This strongly suggested “2255 listener is gone,” but still didn’t fully prove whether the refusal was generated internally by NL-43 or by RX55 on behalf of NL-43.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) The decisive experiment: LAN-side test while wedged (final proof)
|
||||||
|
Because the RX55 does not offer SSH, the plan was to test from **inside the LAN behind the RX55**.
|
||||||
|
|
||||||
|
### 6.1) Physical LAN tap setup
|
||||||
|
Constraint:
|
||||||
|
- NL-43 has only one Ethernet port.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
- Insert an unmanaged switch:
|
||||||
|
- RX55 LAN → switch
|
||||||
|
- NL-43 → switch
|
||||||
|
- Windows 10 laptop → switch
|
||||||
|
|
||||||
|
This creates a shared L2 segment where the laptop can test NL-43 directly.
|
||||||
|
|
||||||
|
### 6.2) Windows LAN validation
|
||||||
|
On the Windows laptop:
|
||||||
|
|
||||||
|
- `ipconfig` showed:
|
||||||
|
- IP: `192.168.1.100`
|
||||||
|
- Gateway: `192.168.1.1` (RX55)
|
||||||
|
- Initial `arp -a` only showed RX55, not NL-43.
|
||||||
|
|
||||||
|
You then:
|
||||||
|
- pinged likely host addresses and discovered NL-43 responds on **192.168.1.10**
|
||||||
|
- `arp -a` then showed:
|
||||||
|
- `192.168.1.10 → 00-10-50-14-0a-d8`
|
||||||
|
- OUI `00-10-50` recognized as **Rion** (matches NL-43)
|
||||||
|
|
||||||
|
So LAN identities were confirmed:
|
||||||
|
- RX55: `192.168.1.1`
|
||||||
|
- NL-43: `192.168.1.10`
|
||||||
|
|
||||||
|
### 6.3) The LAN port tests (the smoking gun)
|
||||||
|
From Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-NetConnection -ComputerName 192.168.1.10 -Port 2255
|
||||||
|
Test-NetConnection -ComputerName 192.168.1.10 -Port 21
|
||||||
|
```
|
||||||
|
|
||||||
|
Results (while the unit was “wedged” from the WAN perspective):
|
||||||
|
- **2255:** `TcpTestSucceeded : False`
|
||||||
|
- **21:** `TcpTestSucceeded : True`
|
||||||
|
|
||||||
|
**Conclusion (PROVEN):**
|
||||||
|
- The NL-43 is reachable on the LAN
|
||||||
|
- FTP port 21 is alive
|
||||||
|
- **The NL-43 is NOT listening on TCP port 2255**
|
||||||
|
- Therefore the RX55 is not the root cause of the refusal. The WAN refusal is consistent with the NL-43 having no listener on 2255.
|
||||||
|
|
||||||
|
This is now settled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) What we learned (final conclusions)
|
||||||
|
### 7.1) RX55 innocence (for this failure mode)
|
||||||
|
The RX55 is not “randomly rejecting” or “breaking TCP” in the way originally feared.
|
||||||
|
|
||||||
|
It successfully forwards and supports TCP to the NL-43 on port 21, and the LAN-side test proves the 2255 failure exists *even without NAT/WAN involvement*.
|
||||||
|
|
||||||
|
### 7.2) NL-43 control listener failure
|
||||||
|
The NL-43’s TCP control service (port 2255) stops listening while:
|
||||||
|
- the device remains alive
|
||||||
|
- the LAN stack remains alive (ping)
|
||||||
|
- FTP remains alive (port 21)
|
||||||
|
|
||||||
|
This looks like one of:
|
||||||
|
- control daemon crash/exit
|
||||||
|
- service unbind
|
||||||
|
- stuck service state (e.g., “busy” / “session active forever”)
|
||||||
|
- resource leak (sockets/file descriptors) specific to the control service
|
||||||
|
- firmware service manager bug (start/stop of services fails after certain sequences)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Additional constraint discovered: “Web App mode” conflicts
|
||||||
|
You noted an important operational constraint:
|
||||||
|
|
||||||
|
> Turning on the web app disables other interfaces like TCP and FTP.
|
||||||
|
|
||||||
|
Meaning the NL-43 appears to have mutually exclusive service/mode behavior (or at least serious conflicts). That matters because:
|
||||||
|
- If any workflow toggles modes (explicitly or implicitly), it could destabilize the service lifecycle.
|
||||||
|
- It reduces the possibility of using “web UI toggle” as an easy remote recovery mechanism **if** it disables the services needed.
|
||||||
|
|
||||||
|
We have not yet run a controlled long test to determine whether:
|
||||||
|
- mode switching contributes directly to the 2255 listener dying, OR
|
||||||
|
- it happens even in a pure TCP-only mode with no switching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Immediate operational decision (field tomorrow)
|
||||||
|
Because the device is needed in the field immediately, you chose:
|
||||||
|
- **Old-school manual deployment**
|
||||||
|
- **Manual SD card downloads**
|
||||||
|
- Avoid reliance on 2255/TCP control and remote workflows for now.
|
||||||
|
|
||||||
|
**Important operational note:**
|
||||||
|
The 2255 listener dying does not necessarily stop the NL-43 from measuring; it primarily breaks remote control/polling. Manual SD workflow sidesteps the entire remote control dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) What’s next (future work — when the unit is back)
|
||||||
|
Because long tests can’t be run before tomorrow, the plan is to resume in a few weeks with controlled experiments designed to isolate the trigger and develop an operational mitigation.
|
||||||
|
|
||||||
|
### 10.1) Controlled experiment matrix (recommended)
|
||||||
|
Run each test for 24–72 hours, or until wedge occurs, and record:
|
||||||
|
- number of TCP connects
|
||||||
|
- whether connections are persistent
|
||||||
|
- whether FTP is used
|
||||||
|
- whether any mode toggling is performed
|
||||||
|
- time-to-wedge
|
||||||
|
|
||||||
|
#### Test A — TCP-only (ideal baseline)
|
||||||
|
- TCP control only (2255)
|
||||||
|
- **True persistent connection** (open once, keep forever)
|
||||||
|
- No FTP
|
||||||
|
- No web mode toggling
|
||||||
|
|
||||||
|
Outcome interpretation:
|
||||||
|
- If stable: connection churn and/or FTP/mode switching is the trigger.
|
||||||
|
- If wedges anyway: pure 2255 daemon leak/bug.
|
||||||
|
|
||||||
|
#### Test B — TCP with connection churn
|
||||||
|
- Same as A but intentionally reconnect on a schedule (current SLMM behavior)
|
||||||
|
- No FTP
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- If this wedges but A doesn’t: churn is the trigger.
|
||||||
|
|
||||||
|
#### Test C — FTP activity + TCP
|
||||||
|
- Introduce scheduled FTP sessions (downloads) while using TCP control
|
||||||
|
- Observe whether wedge correlates with FTP use or with post-download periods.
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- If wedge correlates with FTP, suspect internal service lifecycle conflict.
|
||||||
|
|
||||||
|
#### Test D — Web mode interaction (only if safe/possible)
|
||||||
|
- Evaluate what toggling web mode does to TCP/FTP services.
|
||||||
|
- Determine if any remote-safe “soft reset” exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Mitigation options (ranked)
|
||||||
|
### Option 1 — Make SLMM truly persistent (highest probability of success)
|
||||||
|
If the NL-43 wedges due to session churn or leaked socket states, the best mitigation is:
|
||||||
|
- Open one TCP socket per device
|
||||||
|
- Keep it open indefinitely
|
||||||
|
- Use OS keepalive
|
||||||
|
- Do **not** rotate connections on timers
|
||||||
|
- Reconnect only when the socket actually dies
|
||||||
|
|
||||||
|
This reduces:
|
||||||
|
- connect/close cycles
|
||||||
|
- NAT edge-case exposure
|
||||||
|
- resource churn inside NL-43
|
||||||
|
|
||||||
|
### Option 2 — Service “soft reset” (if possible without disabling required services)
|
||||||
|
If there exists any way to restart the 2255 service without power cycling:
|
||||||
|
- LAN TCP toggle (if it doesn’t require web mode)
|
||||||
|
- any “restart comms” command (unknown)
|
||||||
|
- any maintenance menu sequence
|
||||||
|
then SLMM could:
|
||||||
|
- detect wedge
|
||||||
|
- trigger soft reset
|
||||||
|
- recover automatically
|
||||||
|
|
||||||
|
Current constraint: web app mode appears to disable other services, so this may not be viable.
|
||||||
|
|
||||||
|
### Option 3 — Hardware watchdog power cycle (industrial but reliable)
|
||||||
|
If this is a firmware bug with no clean workaround:
|
||||||
|
- Add a remotely controlled relay/power switch
|
||||||
|
- On wedge detection, power-cycle NL-43 automatically
|
||||||
|
- Optionally schedule a nightly power cycle to prevent leak accumulation
|
||||||
|
|
||||||
|
This is “field reality” and often the only long-term move with embedded devices.
|
||||||
|
|
||||||
|
### Option 4 — Vendor escalation (Rion)
|
||||||
|
You now have excellent evidence:
|
||||||
|
- LAN-side proof: 2255 dead while 21 alive
|
||||||
|
- WAN packet evidence
|
||||||
|
- clear isolation of RX55 innocence
|
||||||
|
|
||||||
|
This is strong enough to send to Rion support as a firmware defect report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Repro “wedge bundle” checklist (for future captures)
|
||||||
|
When the wedge happens again, capture these before power cycling:
|
||||||
|
|
||||||
|
1) From server:
|
||||||
|
- `nc -vz 63.45.161.30 2255` (expect refused)
|
||||||
|
- `nc -vz 63.45.161.30 21` (expect success if FTP alive)
|
||||||
|
|
||||||
|
2) From LAN side (via switch/laptop):
|
||||||
|
- `Test-NetConnection 192.168.1.10 -Port 2255`
|
||||||
|
- `Test-NetConnection 192.168.1.10 -Port 21`
|
||||||
|
|
||||||
|
3) Optional: packet capture around the refused attempt.
|
||||||
|
|
||||||
|
4) Record:
|
||||||
|
- last successful poll timestamp
|
||||||
|
- last FTP session timestamp
|
||||||
|
- any scheduled start/stop/download cycles near wedge time
|
||||||
|
- SLMM connection reuse/rotation settings in effect
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Final, current-state summary (as of 2026-02-18)
|
||||||
|
- The issue is **NOT** the RX55 rejecting inbound connections.
|
||||||
|
- The NL-43 is **alive**, reachable on LAN, and FTP works.
|
||||||
|
- The NL-43’s **TCP control listener on 2255 stops listening** while the device remains otherwise healthy.
|
||||||
|
- The wedge can occur hours after successful operations.
|
||||||
|
- The unit is needed in the field immediately, so investigation pauses.
|
||||||
|
- Next phase: controlled tests to isolate trigger + implement mitigation (persistent socket or watchdog reset).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14) Notes / misc observations
|
||||||
|
- The Wireshark trace showed repeated FTP sessions were opened and closed cleanly, but SLMM’s “FTP requests” were not valid FTP (causing `530 Not logged in`). That was part of experimentation, not a normal workflow.
|
||||||
|
- UDP “success” via netcat is not meaningful because UDP has no handshake; it simply indicates no ICMP unreachable was returned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of document.**
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
RION NL-42 / NL-52 USB serial probe — zero dependencies (stdlib termios only).
|
||||||
|
|
||||||
|
The NL-52's USB port enumerates as a virtual COM port ("RION USB to RS232C
|
||||||
|
Converter"). On Linux that is almost always handled by an in-kernel USB-serial
|
||||||
|
driver (ftdi_sio / cp210x / ch341) which creates /dev/ttyUSB*. This script
|
||||||
|
opens that port and sends a few harmless REQUEST commands (no settings are
|
||||||
|
changed) to confirm two-way communication.
|
||||||
|
|
||||||
|
Protocol (NL-42/NL-52 Serial Interface Manual 55779):
|
||||||
|
Request: "<Command>?" + CRLF
|
||||||
|
Setting: "$<Command>,<param>" + CRLF (NOT used here — read-only probe)
|
||||||
|
Reply: result code "R+0000" + CRLF, then data line(s) for requests.
|
||||||
|
|
||||||
|
Before running:
|
||||||
|
1. On the meter: MENU -> I/O -> Communication Interface -> "USB"
|
||||||
|
(set this BEFORE plugging in the cable).
|
||||||
|
2. Connect a generic USB-A -> mini-B cable directly (no hub).
|
||||||
|
3. Find the port: ls -l /dev/ttyUSB* (and `dmesg | tail` after plugging in)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 nl52_usb_probe.py # defaults to /dev/ttyUSB0
|
||||||
|
python3 nl52_usb_probe.py --port /dev/ttyUSB0 --baud 115200
|
||||||
|
python3 nl52_usb_probe.py --cmd "System Version?" --cmd "Clock?"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Safe, read-only probe commands (all are pure requests).
|
||||||
|
DEFAULT_COMMANDS = [
|
||||||
|
"System Version?", # firmware version — proves the link end-to-end
|
||||||
|
"Clock?", # current date/time
|
||||||
|
"SD Card Free Size?",
|
||||||
|
"DOD?", # snapshot of currently displayed values
|
||||||
|
]
|
||||||
|
|
||||||
|
BAUD_CONSTANTS = {
|
||||||
|
9600: termios.B9600,
|
||||||
|
19200: termios.B19200,
|
||||||
|
38400: termios.B38400,
|
||||||
|
57600: termios.B57600,
|
||||||
|
115200: termios.B115200,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def open_serial(port: str, baud: int) -> int:
|
||||||
|
"""Open a serial port in raw 8N1, no flow control. Returns an fd."""
|
||||||
|
if baud not in BAUD_CONSTANTS:
|
||||||
|
raise ValueError(f"Unsupported baud {baud}; choose from {sorted(BAUD_CONSTANTS)}")
|
||||||
|
|
||||||
|
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
|
||||||
|
attrs = termios.tcgetattr(fd)
|
||||||
|
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs
|
||||||
|
|
||||||
|
# Raw mode
|
||||||
|
iflag = 0
|
||||||
|
oflag = 0
|
||||||
|
lflag = 0
|
||||||
|
# 8 data bits, enable receiver, ignore modem control lines
|
||||||
|
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
||||||
|
# (no PARENB = no parity, no CSTOPB = 1 stop bit, no CRTSCTS = no flow control)
|
||||||
|
|
||||||
|
bconst = BAUD_CONSTANTS[baud]
|
||||||
|
ispeed = bconst
|
||||||
|
ospeed = bconst
|
||||||
|
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
return fd
|
||||||
|
|
||||||
|
|
||||||
|
def send(fd: int, line: str):
|
||||||
|
os.write(fd, (line + "\r\n").encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_reply(fd: int, timeout: float = 3.0) -> bytes:
|
||||||
|
"""Read whatever arrives within `timeout` seconds (idle-gap terminated)."""
|
||||||
|
buf = bytearray()
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
r, _, _ = select.select([fd], [], [], 0.3)
|
||||||
|
if r:
|
||||||
|
try:
|
||||||
|
chunk = os.read(fd, 4096)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
if chunk:
|
||||||
|
buf.extend(chunk)
|
||||||
|
# Once we've seen a CRLF and there's a brief idle, stop early
|
||||||
|
deadline = min(deadline, time.time() + 0.4)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="RION NL-42/NL-52 USB serial probe")
|
||||||
|
ap.add_argument("--port", default="/dev/ttyUSB0")
|
||||||
|
ap.add_argument("--baud", type=int, default=115200,
|
||||||
|
help="USB CDC usually ignores baud, but RS-232C needs a match")
|
||||||
|
ap.add_argument("--cmd", action="append", dest="cmds",
|
||||||
|
help="Override probe command(s); repeatable")
|
||||||
|
ap.add_argument("--timeout", type=float, default=3.0)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
commands = args.cmds or DEFAULT_COMMANDS
|
||||||
|
|
||||||
|
if not os.path.exists(args.port):
|
||||||
|
print(f"[!] {args.port} does not exist.")
|
||||||
|
print(" Plug in the meter (Comm Interface = USB) and check: ls -l /dev/ttyUSB*")
|
||||||
|
print(" Also check the kernel saw it: dmesg | tail -20")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd = open_serial(args.port, args.baud)
|
||||||
|
except PermissionError:
|
||||||
|
print(f"[!] Permission denied on {args.port}.")
|
||||||
|
print(" Add yourself to the 'dialout' group, or run with sudo:")
|
||||||
|
print(f" sudo usermod -aG dialout $USER (then log out/in)")
|
||||||
|
return 2
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Could not open {args.port}: {e}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(f"[*] Opened {args.port} @ {args.baud} 8N1 (raw, no flow control)")
|
||||||
|
print(f"[*] Sending {len(commands)} read-only request command(s)\n")
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
try:
|
||||||
|
for cmd in commands:
|
||||||
|
send(fd, cmd)
|
||||||
|
reply = read_reply(fd, args.timeout)
|
||||||
|
decoded = reply.decode("ascii", errors="replace").replace("\r", "\\r").replace("\n", "\\n\n")
|
||||||
|
if reply:
|
||||||
|
ok += 1
|
||||||
|
print(f" > {cmd}")
|
||||||
|
for line in decoded.splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
else:
|
||||||
|
print(f" > {cmd}")
|
||||||
|
print(f" (no response within {args.timeout}s)")
|
||||||
|
print()
|
||||||
|
time.sleep(1.0) # NL-series likes >=1s between commands
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
if ok == 0:
|
||||||
|
print("[!] No responses. Things to check:")
|
||||||
|
print(" - Meter's Communication Interface is set to USB (not RS-232C)")
|
||||||
|
print(" - ECO / Sleep mode is OFF (both disable the comm interface)")
|
||||||
|
print(" - Right port (try other /dev/ttyUSB* or /dev/ttyACM*)")
|
||||||
|
print(" - For RS-232C path, baud must match the meter's setting")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"[OK] {ok}/{len(commands)} commands answered — two-way comms confirmed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -38,6 +38,7 @@ class BackgroundPoller:
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
self._last_cleanup = None # Track last log cleanup time
|
self._last_cleanup = None # Track last log cleanup time
|
||||||
|
self._last_pool_log = None # Track last connection pool heartbeat log
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the background polling task."""
|
"""Start the background polling task."""
|
||||||
@@ -89,6 +90,24 @@ class BackgroundPoller:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.warning(f"Log cleanup failed: {e}")
|
self._logger.warning(f"Log cleanup failed: {e}")
|
||||||
|
|
||||||
|
# Log connection pool status every 15 minutes
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if self._last_pool_log is None or (now - self._last_pool_log).total_seconds() > 900:
|
||||||
|
from app.services import _connection_pool
|
||||||
|
stats = _connection_pool.get_stats()
|
||||||
|
conns = stats.get("connections", {})
|
||||||
|
if conns:
|
||||||
|
for key, c in conns.items():
|
||||||
|
self._logger.info(
|
||||||
|
f"[POOL] {key} — age={c['age_seconds']}s idle={c['idle_seconds']}s alive={c['alive']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._logger.info("[POOL] No active connections in pool")
|
||||||
|
self._last_pool_log = now
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.warning(f"Pool status log failed: {e}")
|
||||||
|
|
||||||
# Calculate dynamic sleep interval
|
# Calculate dynamic sleep interval
|
||||||
sleep_time = self._calculate_sleep_interval()
|
sleep_time = self._calculate_sleep_interval()
|
||||||
self._logger.debug(f"Sleeping for {sleep_time} seconds until next poll cycle")
|
self._logger.debug(f"Sleeping for {sleep_time} seconds until next poll cycle")
|
||||||
|
|||||||
+2
-2
@@ -76,12 +76,12 @@ app.include_router(routers.router)
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def index(request: Request):
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
return templates.TemplateResponse(request, "index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/roster", response_class=HTMLResponse)
|
@app.get("/roster", response_class=HTMLResponse)
|
||||||
def roster(request: Request):
|
def roster(request: Request):
|
||||||
return templates.TemplateResponse("roster.html", {"request": request})
|
return templates.TemplateResponse(request, "roster.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
+5
-5
@@ -338,14 +338,14 @@ class ConnectionPool:
|
|||||||
if self._is_alive(conn):
|
if self._is_alive(conn):
|
||||||
self._drain_buffer(conn.reader)
|
self._drain_buffer(conn.reader)
|
||||||
conn.last_used_at = time.time()
|
conn.last_used_at = time.time()
|
||||||
logger.debug(f"Pool hit for {device_key} (age={time.time() - conn.created_at:.0f}s)")
|
logger.info(f"Pool hit for {device_key} (age={time.time() - conn.created_at:.0f}s)")
|
||||||
return conn.reader, conn.writer, True
|
return conn.reader, conn.writer, True
|
||||||
else:
|
else:
|
||||||
await self._close_connection(conn, reason="stale")
|
await self._close_connection(conn, reason="stale")
|
||||||
|
|
||||||
# Open fresh connection
|
# Open fresh connection
|
||||||
reader, writer = await self._open_connection(host, port, timeout)
|
reader, writer = await self._open_connection(host, port, timeout)
|
||||||
logger.debug(f"New connection opened for {device_key}")
|
logger.info(f"New connection opened for {device_key}")
|
||||||
return reader, writer, False
|
return reader, writer, False
|
||||||
|
|
||||||
async def release(self, device_key: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, host: str, port: int):
|
async def release(self, device_key: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, host: str, port: int):
|
||||||
@@ -454,11 +454,11 @@ class ConnectionPool:
|
|||||||
"""Check whether a cached connection is still usable."""
|
"""Check whether a cached connection is still usable."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Age / idle checks
|
# Age / idle checks (value of -1 disables the check)
|
||||||
if now - conn.last_used_at > self._idle_ttl:
|
if self._idle_ttl >= 0 and now - conn.last_used_at > self._idle_ttl:
|
||||||
logger.debug(f"Connection {conn.device_key} idle too long ({now - conn.last_used_at:.0f}s > {self._idle_ttl}s)")
|
logger.debug(f"Connection {conn.device_key} idle too long ({now - conn.last_used_at:.0f}s > {self._idle_ttl}s)")
|
||||||
return False
|
return False
|
||||||
if now - conn.created_at > self._max_age:
|
if self._max_age >= 0 and now - conn.created_at > self._max_age:
|
||||||
logger.debug(f"Connection {conn.device_key} too old ({now - conn.created_at:.0f}s > {self._max_age}s)")
|
logger.debug(f"Connection {conn.device_key} too old ({now - conn.created_at:.0f}s > {self._max_age}s)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
RION NL-42 / NL-52 serial control layer.
|
||||||
|
|
||||||
|
The NL-52 has no Ethernet option (unlike the NL-43's NX-43EX LAN card); it
|
||||||
|
speaks the same ASCII command family over RS-232C or USB (virtual COM port).
|
||||||
|
This package provides a dependency-free serial client + parser so the meter
|
||||||
|
can be driven directly over USB on the bench, and later bridged onto the
|
||||||
|
network via the RX55's serial PAD mode (or a ser2net host).
|
||||||
|
|
||||||
|
Modules:
|
||||||
|
protocol — pure command/response parsing (no I/O, unit-testable)
|
||||||
|
client — termios-based serial transport + NL52Client command methods
|
||||||
|
cli — command-line tool for bench testing over USB
|
||||||
|
"""
|
||||||
|
|
||||||
|
from nl52.protocol import ( # noqa: F401
|
||||||
|
DODSnapshot,
|
||||||
|
DRDSample,
|
||||||
|
ResultError,
|
||||||
|
parse_dod,
|
||||||
|
parse_drd,
|
||||||
|
RESULT_CODES,
|
||||||
|
)
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/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())
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
NL-42 / NL-52 serial client — dependency-free (stdlib termios).
|
||||||
|
|
||||||
|
Opens the meter's virtual COM port (USB) or RS-232C port and exchanges
|
||||||
|
ASCII commands. No pyserial required, so it runs on the dev server with
|
||||||
|
nothing to install.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from nl52.client import NL52Client
|
||||||
|
with NL52Client("/dev/ttyUSB0") as m:
|
||||||
|
print(m.system_version())
|
||||||
|
print(m.measure_state())
|
||||||
|
snap = m.request_dod()
|
||||||
|
print(snap.lp, snap.leq)
|
||||||
|
|
||||||
|
Integration note: this client is synchronous for a robust bench tool. The
|
||||||
|
parsing in nl52.protocol is I/O-free and reused as-is when this is later
|
||||||
|
wrapped for SLMM (async transport, or a serial->TCP bridge behind the
|
||||||
|
existing NL43-style TCP client).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
|
from nl52.protocol import (
|
||||||
|
CRLF,
|
||||||
|
SUB,
|
||||||
|
DODSnapshot,
|
||||||
|
DRDSample,
|
||||||
|
ResultError,
|
||||||
|
is_result_code,
|
||||||
|
parse_dod,
|
||||||
|
parse_drd,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BAUD = {
|
||||||
|
9600: termios.B9600,
|
||||||
|
19200: termios.B19200,
|
||||||
|
38400: termios.B38400,
|
||||||
|
57600: termios.B57600,
|
||||||
|
115200: termios.B115200,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inter-command spacing (Serial Interface Manual "Rated Values"):
|
||||||
|
# - wait >=200 ms after a reply before the next command
|
||||||
|
# - wait >=1 s between DOD? requests
|
||||||
|
MIN_GAP_DEFAULT = 0.2
|
||||||
|
MIN_GAP_DOD = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class SerialPort:
|
||||||
|
"""Minimal raw 8N1 serial port over a tty fd (no flow control)."""
|
||||||
|
|
||||||
|
def __init__(self, device: str, baud: int = 115200):
|
||||||
|
if baud not in _BAUD:
|
||||||
|
raise ValueError(f"Unsupported baud {baud}; choose {sorted(_BAUD)}")
|
||||||
|
self.device = device
|
||||||
|
self.baud = baud
|
||||||
|
self.fd: Optional[int] = None
|
||||||
|
self._buf = bytearray() # holds bytes read past the last returned line
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
fd = os.open(self.device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = termios.tcgetattr(fd)
|
||||||
|
iflag = 0
|
||||||
|
oflag = 0
|
||||||
|
lflag = 0
|
||||||
|
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
||||||
|
ispeed = ospeed = _BAUD[self.baud]
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW,
|
||||||
|
[iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
self.fd = fd
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(self.fd)
|
||||||
|
finally:
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
def flush_input(self):
|
||||||
|
self._buf.clear()
|
||||||
|
if self.fd is not None:
|
||||||
|
termios.tcflush(self.fd, termios.TCIFLUSH)
|
||||||
|
|
||||||
|
def write(self, data: bytes):
|
||||||
|
assert self.fd is not None
|
||||||
|
os.write(self.fd, data)
|
||||||
|
|
||||||
|
def read_line(self, timeout: float) -> Optional[str]:
|
||||||
|
"""Read one CRLF/LF-terminated line. Returns the line without the
|
||||||
|
terminator, or None on timeout with no complete line.
|
||||||
|
|
||||||
|
Bytes received past the newline are retained in self._buf so the next
|
||||||
|
call returns the next line — handles multiple lines arriving in a
|
||||||
|
single read (e.g. result code + data line together)."""
|
||||||
|
assert self.fd is not None
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
if b"\n" in self._buf:
|
||||||
|
line, _, rest = self._buf.partition(b"\n")
|
||||||
|
self._buf = bytearray(rest)
|
||||||
|
return line.decode("ascii", errors="replace").strip()
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
r, _, _ = select.select([self.fd], [], [], remaining)
|
||||||
|
if not r:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = os.read(self.fd, 256)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
if chunk:
|
||||||
|
self._buf.extend(chunk)
|
||||||
|
# Timeout: surface any buffered (un-terminated) bytes as a last resort
|
||||||
|
if self._buf:
|
||||||
|
line = bytes(self._buf)
|
||||||
|
self._buf = bytearray()
|
||||||
|
return line.decode("ascii", errors="replace").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class NL52Client:
|
||||||
|
def __init__(self, device: str = "/dev/ttyUSB0", baud: int = 115200,
|
||||||
|
timeout: float = 3.0, disable_echo: bool = True):
|
||||||
|
self.port = SerialPort(device, baud)
|
||||||
|
self.timeout = timeout
|
||||||
|
self.disable_echo = disable_echo
|
||||||
|
self._last_cmd_time = 0.0
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self.port.open()
|
||||||
|
if self.disable_echo:
|
||||||
|
# Best-effort: turn echo-back off so replies aren't prefixed with
|
||||||
|
# the command. Ignore failures (some firmware defaults to off).
|
||||||
|
try:
|
||||||
|
self.send("$Echo,Off")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.port.close()
|
||||||
|
|
||||||
|
# -- core exchange ------------------------------------------------------
|
||||||
|
|
||||||
|
def _rate_limit(self, command: str):
|
||||||
|
gap = MIN_GAP_DOD if command.strip().upper().startswith("DOD") else MIN_GAP_DEFAULT
|
||||||
|
elapsed = time.time() - self._last_cmd_time
|
||||||
|
if elapsed < gap:
|
||||||
|
time.sleep(gap - elapsed)
|
||||||
|
|
||||||
|
def send(self, command: str) -> str:
|
||||||
|
"""Send one command and return its response.
|
||||||
|
|
||||||
|
For request commands (containing '?') returns the data line.
|
||||||
|
For setting commands returns the result code (e.g. 'R+0000').
|
||||||
|
Raises ResultError on R+0001..0004, TimeoutError if no reply.
|
||||||
|
|
||||||
|
Note: NL-52 request commands may carry a parameter *after* the '?'
|
||||||
|
(e.g. 'System Version?NL'), so detection is "contains ?", not
|
||||||
|
"ends with ?". Setting commands start with '$' and never contain '?'.
|
||||||
|
"""
|
||||||
|
is_request = "?" in command
|
||||||
|
self._rate_limit(command)
|
||||||
|
self.port.flush_input()
|
||||||
|
self.port.write((command + CRLF).encode("ascii"))
|
||||||
|
|
||||||
|
result_code = self._read_result_code(command)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if result_code != "R+0000":
|
||||||
|
raise ResultError(result_code)
|
||||||
|
if is_request:
|
||||||
|
data = self.port.read_line(self.timeout)
|
||||||
|
if data is None:
|
||||||
|
raise TimeoutError(f"No data line after {command!r}")
|
||||||
|
return data
|
||||||
|
return result_code
|
||||||
|
finally:
|
||||||
|
self._last_cmd_time = time.time()
|
||||||
|
|
||||||
|
def _read_result_code(self, command: str) -> str:
|
||||||
|
"""Read lines until a result code, skipping echo / '$' prompt lines."""
|
||||||
|
sent = command.strip().lstrip("$").strip().lower()
|
||||||
|
deadline = time.time() + self.timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = self.port.read_line(max(0.1, deadline - time.time()))
|
||||||
|
if line is None:
|
||||||
|
continue
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if is_result_code(stripped):
|
||||||
|
return stripped
|
||||||
|
# Skip an echoed command or a bare '$' prompt
|
||||||
|
norm = stripped.lstrip("$").strip().lower()
|
||||||
|
if norm == sent or stripped == "$":
|
||||||
|
continue
|
||||||
|
# Unexpected line — keep looking until timeout
|
||||||
|
raise TimeoutError(f"No result code after {command!r}")
|
||||||
|
|
||||||
|
# -- convenience commands ----------------------------------------------
|
||||||
|
|
||||||
|
def system_version(self, option: str = "NL") -> str:
|
||||||
|
return self.send(f"System Version?{option}")
|
||||||
|
|
||||||
|
def get_clock(self) -> str:
|
||||||
|
return self.send("Clock?")
|
||||||
|
|
||||||
|
def set_clock_now(self):
|
||||||
|
t = time.localtime()
|
||||||
|
# Clock,YYYY/MM/DD HH:MM:SS
|
||||||
|
stamp = time.strftime("%Y/%m/%d %H:%M:%S", t)
|
||||||
|
return self.send(f"$Clock,{stamp}")
|
||||||
|
|
||||||
|
def measure_state(self) -> str:
|
||||||
|
"""Returns 'Start' or 'Stop'."""
|
||||||
|
return self.send("Measure?")
|
||||||
|
|
||||||
|
def measure_start(self):
|
||||||
|
return self.send("$Measure,Start")
|
||||||
|
|
||||||
|
def measure_stop(self):
|
||||||
|
return self.send("$Measure,Stop")
|
||||||
|
|
||||||
|
def battery_type(self) -> str:
|
||||||
|
return self.send("Battery Type?")
|
||||||
|
|
||||||
|
def sd_free_size(self) -> str:
|
||||||
|
return self.send("SD Card Free Size?")
|
||||||
|
|
||||||
|
def request_dod(self) -> DODSnapshot:
|
||||||
|
return parse_dod(self.send("DOD?"))
|
||||||
|
|
||||||
|
# -- DRD streaming (requires NX-42EX) -----------------------------------
|
||||||
|
|
||||||
|
def stream_drd(self, on_sample: Callable[[DRDSample], None],
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
max_samples: Optional[int] = None):
|
||||||
|
"""Start DRD continuous output and call on_sample for each line.
|
||||||
|
|
||||||
|
Stops after `duration` seconds or `max_samples`, then sends SUB to
|
||||||
|
halt the stream. Requires the NX-42EX option on the meter.
|
||||||
|
"""
|
||||||
|
self._rate_limit("DRD?")
|
||||||
|
self.port.flush_input()
|
||||||
|
self.port.write(("DRD?" + CRLF).encode("ascii"))
|
||||||
|
|
||||||
|
# First line should be the result code
|
||||||
|
rc = self._read_result_code("DRD?")
|
||||||
|
if rc != "R+0000":
|
||||||
|
self._last_cmd_time = time.time()
|
||||||
|
raise ResultError(rc)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
deadline = time.time() + duration if duration else None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if deadline and time.time() >= deadline:
|
||||||
|
break
|
||||||
|
if max_samples and count >= max_samples:
|
||||||
|
break
|
||||||
|
line = self.port.read_line(timeout=2.0)
|
||||||
|
if line is None:
|
||||||
|
continue
|
||||||
|
if is_result_code(line):
|
||||||
|
continue
|
||||||
|
on_sample(parse_drd(line))
|
||||||
|
count += 1
|
||||||
|
finally:
|
||||||
|
self.port.write(SUB)
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.port.flush_input()
|
||||||
|
self._last_cmd_time = time.time()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
RION NL-42/NL-52 USB bulk probe — talks to the meter via libusb directly
|
||||||
|
(ctypes, no pyusb/pip needed).
|
||||||
|
|
||||||
|
The NL-52's USB serial interface (interface 0) is a plain vendor class with
|
||||||
|
two bulk endpoints and NO control protocol — not FTDI/CDC, so no kernel
|
||||||
|
serial driver applies. We just claim interface 0 and move ASCII over the
|
||||||
|
bulk pipe: write command -> EP 0x03 OUT, read reply <- EP 0x84 IN.
|
||||||
|
|
||||||
|
Needs write access to the USB device node (root, or a udev rule granting the
|
||||||
|
plugdev group). Run: sudo python3 nl52/usb_bulk_probe.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes as C
|
||||||
|
import time
|
||||||
|
|
||||||
|
VID, PID = 0x0EA3, 0x000F
|
||||||
|
EP_OUT, EP_IN = 0x03, 0x84
|
||||||
|
IFACE = 0
|
||||||
|
|
||||||
|
LIBUSB_ERROR_TIMEOUT = -7
|
||||||
|
|
||||||
|
lib = C.CDLL("libusb-1.0.so.0")
|
||||||
|
lib.libusb_init.argtypes = [C.POINTER(C.c_void_p)]
|
||||||
|
lib.libusb_open_device_with_vid_pid.argtypes = [C.c_void_p, C.c_uint16, C.c_uint16]
|
||||||
|
lib.libusb_open_device_with_vid_pid.restype = C.c_void_p
|
||||||
|
lib.libusb_set_auto_detach_kernel_driver.argtypes = [C.c_void_p, C.c_int]
|
||||||
|
lib.libusb_claim_interface.argtypes = [C.c_void_p, C.c_int]
|
||||||
|
lib.libusb_release_interface.argtypes = [C.c_void_p, C.c_int]
|
||||||
|
lib.libusb_set_interface_alt_setting.argtypes = [C.c_void_p, C.c_int, C.c_int]
|
||||||
|
lib.libusb_clear_halt.argtypes = [C.c_void_p, C.c_ubyte]
|
||||||
|
lib.libusb_close.argtypes = [C.c_void_p]
|
||||||
|
lib.libusb_exit.argtypes = [C.c_void_p]
|
||||||
|
lib.libusb_bulk_transfer.argtypes = [
|
||||||
|
C.c_void_p, C.c_ubyte, C.POINTER(C.c_ubyte), C.c_int, C.POINTER(C.c_int), C.c_uint
|
||||||
|
]
|
||||||
|
lib.libusb_strerror.argtypes = [C.c_int]
|
||||||
|
lib.libusb_strerror.restype = C.c_char_p
|
||||||
|
|
||||||
|
|
||||||
|
def err(code):
|
||||||
|
return lib.libusb_strerror(code).decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ctx = C.c_void_p()
|
||||||
|
if lib.libusb_init(C.byref(ctx)) != 0:
|
||||||
|
print("[!] libusb_init failed")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
h = lib.libusb_open_device_with_vid_pid(ctx, VID, PID)
|
||||||
|
if not h:
|
||||||
|
print(f"[!] open {VID:04x}:{PID:04x} failed — device present? running as root?")
|
||||||
|
lib.libusb_exit(ctx)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
lib.libusb_set_auto_detach_kernel_driver(h, 1)
|
||||||
|
rc = lib.libusb_claim_interface(h, IFACE)
|
||||||
|
if rc != 0:
|
||||||
|
print(f"[!] claim_interface({IFACE}) failed: {err(rc)}")
|
||||||
|
lib.libusb_close(h); lib.libusb_exit(ctx)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"[*] Claimed interface {IFACE} on {VID:04x}:{PID:04x} — bulk OUT 0x{EP_OUT:02x}, IN 0x{EP_IN:02x}")
|
||||||
|
|
||||||
|
# Select alt setting 0 explicitly and clear any endpoint halts left over
|
||||||
|
# from prior (wrong-driver) probing.
|
||||||
|
rc = lib.libusb_set_interface_alt_setting(h, IFACE, 0)
|
||||||
|
print(f"[*] set_interface_alt_setting(0): {err(rc) if rc else 'ok'}")
|
||||||
|
for ep in (EP_OUT, EP_IN):
|
||||||
|
rc = lib.libusb_clear_halt(h, ep)
|
||||||
|
print(f"[*] clear_halt(0x{ep:02x}): {err(rc) if rc else 'ok'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def bulk_out(data: bytes, timeout=2000):
|
||||||
|
buf = (C.c_ubyte * len(data)).from_buffer_copy(data)
|
||||||
|
actual = C.c_int(0)
|
||||||
|
rc = lib.libusb_bulk_transfer(h, EP_OUT, buf, len(data), C.byref(actual), timeout)
|
||||||
|
return rc, actual.value
|
||||||
|
|
||||||
|
def bulk_in(n=512, timeout=2000):
|
||||||
|
buf = (C.c_ubyte * n)()
|
||||||
|
actual = C.c_int(0)
|
||||||
|
rc = lib.libusb_bulk_transfer(h, EP_IN, buf, n, C.byref(actual), timeout)
|
||||||
|
return rc, bytes(buf[:actual.value])
|
||||||
|
|
||||||
|
def exchange(cmd: str):
|
||||||
|
rc, n = bulk_out((cmd + "\r\n").encode("ascii"))
|
||||||
|
if rc != 0:
|
||||||
|
print(f" > {cmd!r:24} OUT failed: {err(rc)}")
|
||||||
|
return
|
||||||
|
# Read until idle (collect result code + data lines)
|
||||||
|
chunks = bytearray()
|
||||||
|
end = time.time() + 2.0
|
||||||
|
while time.time() < end:
|
||||||
|
rrc, data = bulk_in(512, 600)
|
||||||
|
if rrc == 0 and data:
|
||||||
|
chunks.extend(data)
|
||||||
|
end = min(end, time.time() + 0.4) # brief idle window then stop
|
||||||
|
elif rrc == LIBUSB_ERROR_TIMEOUT:
|
||||||
|
if chunks:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if rrc != 0:
|
||||||
|
break
|
||||||
|
print(f" > {cmd!r:24} -> {bytes(chunks)!r}")
|
||||||
|
|
||||||
|
# Read-first sanity check: is the IN endpoint alive / does the device send
|
||||||
|
# anything unsolicited?
|
||||||
|
rrc, data = bulk_in(512, 1000)
|
||||||
|
print(f"[*] read-first IN: rc={err(rrc) if rrc else 'ok'} data={data!r}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
for cmd in ["System Version?NL", "Measure?", "DOD?", "Battery Type?"]:
|
||||||
|
exchange(cmd)
|
||||||
|
time.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
lib.libusb_release_interface(h, IFACE)
|
||||||
|
lib.libusb_close(h)
|
||||||
|
lib.libusb_exit(ctx)
|
||||||
|
|
||||||
|
print("\n[done]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/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)
|
||||||
Reference in New Issue
Block a user