3593 lines
156 KiB
Markdown
3593 lines
156 KiB
Markdown
# MCC E-1608 Source Plugin Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add an `e1608` source kind to udpscope: the Measurement Computing E-1608 Ethernet DAQ as a plot source, with its own connect form and two side-stack panels (Analog out, Digital I/O), verified against a protocol mock because no board is available.
|
|
|
|
**Architecture:** Three layers under `src/source/e1608/`: a *link* (POSIX UDP discovery and handshake, TCP command framing, the scan socket), a *device* (typed commands, calibration, `decode_scans`), and a *source* (`DataSource` with a `poll()` state machine, timers, `Frame` building). Neither of the bottom two layers knows GTK or `DataSource`. The kind (`e1608_kind.cpp`) owns the connect form and `e1608_panels.cpp` the panels; panels reach the source only through `post(Command)` and read live values from a shared atomic `E1608Readouts`. The wire protocol is ported from the user's MARTe2 `E1608DataSource` (primary) and cross-checked against the Go `uopi/internal/datasource/mcc`.
|
|
|
|
**Tech Stack:** C++20, plain POSIX sockets, GTK4 + libadwaita 1.10 (`AdwExpanderRow`, `AdwSpinRow`, `AdwComboRow`, `AdwSwitchRow`), CMake, Python 3 + numpy (the mock).
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-10-31-e1608-source-design.md` (committed `69585a9`). **Branch:** `feature/source-e1608` (from `feature/source-plugins`).
|
|
|
|
## Global Constraints
|
|
|
|
These are the spec's project-wide requirements. Every task's requirements include them.
|
|
|
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`; `#` for Python).
|
|
- C++20; the build uses `-Wall -Wextra -Wpedantic`. **Baselines unchanged:** the UDPS self-check reads as on `main`, a clean build emits **exactly three** warnings (one on an incremental build), and there is **no `Gtk-CRITICAL`** (`gtk warnings: 0`).
|
|
- "Everything runs inside `poll()` on the producer thread. The GTK thread reaches the board only through `post(Command)`."
|
|
- "The aggregate rate (enabled channels times scan rate) must stay at or below 250 kS/s. The form refuses a configuration above it."
|
|
- "A connect attempt can block `poll()` for up to the command timeout; that delays only the command queue." A link error fires `on_event(Disconnected)` and **retries every 2 s**.
|
|
- "A rejected connection code stops the retries: the source stays in Error with that message until the user reconnects."
|
|
- Hardware-paced acquisition only (scan rate or external clock). Software-paced mode (`ScanRate = 0`) is not ported.
|
|
- The generator is a static level plus a software waveform (sine, square, triangle, ramp), 10 Hz at most, run as a timer inside `poll()`; no extra thread. DACs clamp to ±10 V.
|
|
- Both side-stack panels are insensitive while disconnected: **disabled, not hidden** (project rule). In DIFF mode AI rows 4 to 7 are insensitive, not hidden.
|
|
- DI lines and the counter are plot channels the user chooses, **off by default**. `has_datagrams()` is false, so the capture button is disabled.
|
|
- `SourceConfig` stays flat; a per-channel setting is a key such as `ai0=BIP10V` (absent means off). A session file is named `e1608-<identity>`, port 1.
|
|
- One global SE/DIFF mode (no per-channel mixing); DIO directions are fixed at connect (no runtime change).
|
|
- No board exists: `tools/mock_e1608.py` is the contract, and its header **lists every unverified command layout**.
|
|
- Out of scope: software-paced acquisition, a generator faster than 10 Hz or a dedicated I/O thread, runtime DIO direction changes, per-channel SE/DIFF mixing, verification against a real board.
|
|
- Docs to update: `docs/claude/architecture.md`, `build-and-run.md`, `verification.md`, `probe-readings.md`, and `README.md`.
|
|
- There is **no unit-test suite** in this repo (CLAUDE.md). The checks are the headless self-check (extend the probe in `src/ui_probe.cpp`) and throwaway drivers kept in `/tmp`; **do not commit a test harness**.
|
|
- Verification runs are short and serial; clean up every mock process you start (`kill $MOCK`); do not re-run to chase cosmetics.
|
|
|
|
## Deviations from the spec, to report in the final summary
|
|
|
|
1. The spec says the generator tick is "at most 10 Hz". A 10 Hz tick would sample a 10 Hz wave once per period, so this plan reads "10 Hz" as the **waveform frequency** bound (0.1 to 10 Hz) and uses a **20 ms tick**.
|
|
2. The spec's `valid()` says "an address, or a MAC plus a subnet". The plan accepts a MAC with an **empty subnet** (a broadcast discovery).
|
|
3. `valid()` also requires **at least one enabled analog input**: the scan drives the frames, so a configuration with none can never produce data. When a config has no `ai0`..`ai7` key at all, `ai0=BIP10V` is the default (so `--source-opt address=...` alone is connectable).
|
|
4. A reply status other than 0 is `LinkError::Kind::Command`, distinct from framing errors (`Protocol`); only `Command` is swallowed by the calibration read.
|
|
5. `Acquisition::on_event` ignores the detail string, so `Error` messages are also written to stderr as `[e1608] ...`.
|
|
6. The two side-stack panels live in their own `e1608_panels.{hpp,cpp}` rather than inside `e1608_kind.cpp`, for one responsibility per file.
|
|
|
|
## File structure
|
|
|
|
| File | Responsibility |
|
|
| --- | --- |
|
|
| `tools/mock_e1608.py` (new) | The protocol mock; header lists the unverified commands. |
|
|
| `src/source/e1608/e1608_link.{hpp,cpp}` (new) | Sockets: discovery, handshake, framed commands, scan socket. No GTK, no `DataSource`. |
|
|
| `src/source/e1608/e1608_device.{hpp,cpp}` (new) | Typed commands, calibration, `decode_scans`. No GTK, no `DataSource`. |
|
|
| `src/source/e1608/e1608_source.{hpp,cpp}` (new) | `E1608Settings`, `E1608Readouts`, `AoSettings`, `E1608Source` (the `DataSource`). |
|
|
| `src/source/e1608/e1608_kind.cpp` (new) | `SourceKind`: the connect form; `make_panels` delegates to `e1608_panels`. |
|
|
| `src/source/e1608/e1608_panels.{hpp,cpp}` (new) | `make_e1608_panels(DataSource&)`: the Analog out and Digital I/O panels. |
|
|
| `src/source/registry.{hpp,cpp}` | Declare and register `make_e1608_kind()` after udps. |
|
|
| `CMakeLists.txt` | Add the five `.cpp` files (link, device, source, kind, panels) after `probe_source.cpp`. |
|
|
| `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp` | `UDPSCOPE_PROBE_E1608`, `UDPSCOPE_PROBE_E1608_RECONNECT`; the `e1608` sample in `source_kinds_probe`. |
|
|
| the five docs | See Task 6. |
|
|
|
|
Session ledger: `.superpowers/sdd/e1608/progress.md` (git-ignored scratch). Models: sonnet for implementers and task reviewers; opus for the final whole-branch review.
|
|
|
|
---
|
|
|
|
### Task 0: Baseline and branch check
|
|
|
|
**Files:** none modified.
|
|
|
|
- [ ] **Step 1: Confirm the branch and a clean tree**
|
|
|
|
Run: `git status -sb | head -3 && git log --oneline -3`
|
|
Expected: `## feature/source-e1608`, the top commit is the plan or `69585a9`.
|
|
|
|
- [ ] **Step 2: Build and take the UDPS baseline**
|
|
|
|
```sh
|
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | tee /tmp/e1608-build0.log | grep -c "warning:"
|
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
|
sleep 1
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/udps-baseline.raw 2>&1
|
|
kill $MOCK
|
|
sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.raw > /tmp/udps-baseline.norm
|
|
grep -c "gtk warnings: 0" /tmp/udps-baseline.raw
|
|
```
|
|
Expected: the warning count is `3` on a clean build (`1` if the tree was already built; touch nothing to chase it), and the last line prints `1`. `/tmp/udps-baseline.norm` is the file Task 5 diffs against. If the first build is incremental, `grep -c warning:` prints 1; that is the baseline for incremental builds.
|
|
|
|
- [ ] **Step 3: Record the ledger**
|
|
|
|
```sh
|
|
mkdir -p .superpowers/sdd/e1608 && printf '# E-1608 source: progress ledger\n\nBase before Task 1: %s\nPlan: docs/superpowers/plans/2026-10-31-e1608-source.md\n' "$(git rev-parse --short HEAD)" > .superpowers/sdd/e1608/progress.md
|
|
```
|
|
|
|
No commit.
|
|
|
|
---
|
|
|
|
### Task 1: The protocol mock
|
|
|
|
**Files:**
|
|
- Create: `tools/mock_e1608.py`
|
|
- Throwaway (not committed): `/tmp/e1608_smoke.py`
|
|
|
|
**Interfaces:**
|
|
- Produces: a mock board on `--port` (default 54211): UDP discovery/handshake, TCP command channel, TCP scan stream on `port+1`. Log lines (to `--log FILE` and stdout), one per event, **exactly these formats** (Task 5 greps them):
|
|
- `HANDSHAKE code=<n> status=<n>`
|
|
- `DOUT 0x<2 hex>`, `DCONF 0x<2 hex>`
|
|
- `AOUT ch=<n> code=<n> volts=<%.4f>` (volts = the code through the *inverse* of the served DAC calibration)
|
|
- `COUNTER_RESET`
|
|
- `QUEUE <wire>:<range> ...`
|
|
- `SCAN_START pacer=<n> options=<n> channels=<n>`, `STREAM rate=<%.1f> channels=<n>`, `SCAN_STOP`
|
|
- `DROP`, `OVERRUN`
|
|
- The synthetic signal: for the queue entry (wire channel `w`, range `r`): `ch = w - 8` if `w >= 8` else `w`; `dc = min(0.5*(ch+1), 0.8*fs)`; value `dc + 0.1*sin(2*pi*5*t)` volts; converted to a raw code through the **inverse** of the served calibration, so that applying the calibration recovers `dc`. A decoder that skips the calibration is off by about 0.3 V on `ch=0`, `BIP10V`.
|
|
- Served calibration (float32-rounded): DIFF `(1.01 - 0.004 r, 150 - 30 r)`, SE `(1.02 - 0.005 r, 300 - 40 r)` for `r` in 0..3, DAC `(0.99 + 0.01 c, 200 - 100 c)` for `c` in 0..1, in a single 80-byte memory: DIFF at 0x00, SE at 0x20, DAC at 0x40.
|
|
- DIN: `(pins & dconf) | (dout & ~dconf)` with `pins = int(t*4) & 0xFF` (bit 0 toggles at 2 Hz). Counter: `int((now - counter_t0) * 5)`; reset rebases `counter_t0`.
|
|
|
|
- [ ] **Step 1: Write the mock**
|
|
|
|
Create `tools/mock_e1608.py` (mode 755):
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: MIT
|
|
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
"""A protocol mock of the Measurement Computing E-1608 Ethernet DAQ, for exercising udpscope
|
|
without a board. It is the *contract* the `e1608` source is verified against until a board is
|
|
attached, so its layouts are only as good as the two references they were taken from:
|
|
|
|
* MARTe2 `E1608DataSource` (~/Projects/marte2_projects/MCC_E1608_DataSource), the primary, and
|
|
* the Go `uopi/internal/datasource/mcc` implementation, the cross-check.
|
|
|
|
./tools/mock_e1608.py --port 54211 [--code N] [--drop-after S] [--overrun-after S] [--log F]
|
|
|
|
Serves: UDP `D` discovery and `C` connection-code handshake on --port; the TCP command channel on
|
|
--port; the TCP scan stream on --port+1 (it starts when the one filler byte follows the scan-start
|
|
command). Frame: 0xDB, cmd, frame id, status, count LE16, payload, checksum (the whole frame sums
|
|
to 0xFF mod 256). Reply: cmd | 0x80, same id.
|
|
|
|
UNVERIFIED -- every item below rests on a single reference, or the references disagree. They are
|
|
the first places to look when a real board misbehaves:
|
|
|
|
* 0x31 counter reset: Go only (no payload, no reply payload).
|
|
* Pacer: MARTe2 sends round(80e6 / (scan_rate * channels) - 1), the *aggregate* rate; Go sends the
|
|
passed frequency. This mock follows MARTe2.
|
|
* The scan-start options byte (trigger): none 0, rising 4, falling 8, high 12, low 16 -- MARTe2 only.
|
|
* Handshake status 2 ("code ignored") is accepted by MARTe2 and refused by Go. The mock never sends it.
|
|
* The first frame id: MARTe2 pre-increments (first frame is 1), Go starts at 0. The mock echoes it.
|
|
* The maximum frame size (MARTe2: 1024 in total; Go: 1024 of payload). The mock enforces neither.
|
|
* Agreed by both references (not in doubt): the filler byte after scan start, the scan port being
|
|
command port + 1, DIFF wire channel = channel + 8.
|
|
|
|
Switches: --drop-after S closes both connections once, S seconds after the first command connection
|
|
(logs DROP); --overrun-after S raises the overrun bit and stops the stream S seconds after a scan
|
|
starts, once (logs OVERRUN); --code N is the connection code the board accepts (anything else gets
|
|
status 1).
|
|
"""
|
|
|
|
import argparse
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
CLOCK = 80e6
|
|
PRODUCT_ID = 0x012F
|
|
FS = [10.0, 5.0, 2.0, 1.0] # full scale per range code BIP10V, BIP5V, BIP2V, BIP1V
|
|
|
|
|
|
def f32(x):
|
|
return struct.unpack("<f", struct.pack("<f", x))[0]
|
|
|
|
|
|
DF_CAL = [(f32(1.01 - 0.004 * r), f32(150.0 - 30.0 * r)) for r in range(4)]
|
|
SE_CAL = [(f32(1.02 - 0.005 * r), f32(300.0 - 40.0 * r)) for r in range(4)]
|
|
AO_CAL = [(f32(0.99 + 0.01 * c), f32(200.0 - 100.0 * c)) for c in range(2)]
|
|
CAL_MEM = b"".join(struct.pack("<ff", s, i) for s, i in DF_CAL + SE_CAL + AO_CAL) # 80 bytes
|
|
|
|
|
|
def close_quietly(sock):
|
|
if sock is None:
|
|
return
|
|
try:
|
|
sock.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def recv_exact(conn, n):
|
|
buf = b""
|
|
while len(buf) < n:
|
|
chunk = conn.recv(n - len(buf))
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
return buf
|
|
|
|
|
|
class Board:
|
|
def __init__(self, args):
|
|
self.args = args
|
|
self.mac = bytes(int(x, 16) for x in args.mac.split(":"))
|
|
self.lock = threading.RLock()
|
|
self.cmd_conn = None
|
|
self.scan_conn = None
|
|
self.dout = 0
|
|
self.dconf = 0xFF
|
|
self.ao = [32768, 32768]
|
|
self.queue = []
|
|
self.pacer = 0
|
|
self.armed = False
|
|
self.streaming = False
|
|
self.stream_gen = 0
|
|
self.overrun = False
|
|
self.overrun_done = False
|
|
self.t_boot = time.monotonic()
|
|
self.counter_t0 = self.t_boot
|
|
self.first_conn = None
|
|
self.logfile = open(args.log, "a", buffering=1) if args.log else None
|
|
|
|
def log(self, text):
|
|
print("[mock] " + text, flush=True)
|
|
if self.logfile:
|
|
self.logfile.write(text + "\n")
|
|
|
|
# --- the pretend hardware -------------------------------------------------------------
|
|
def din(self):
|
|
pins = int((time.monotonic() - self.t_boot) * 4) & 0xFF
|
|
return (pins & self.dconf) | (self.dout & ~self.dconf & 0xFF)
|
|
|
|
def counter(self):
|
|
return int((time.monotonic() - self.counter_t0) * 5) & 0xFFFFFFFF
|
|
|
|
def raw(self, wire, rng, t):
|
|
diff = wire >= 8
|
|
ch = wire - 8 if diff else wire
|
|
fs = FS[rng]
|
|
dc = min(0.5 * (ch + 1), 0.8 * fs)
|
|
v = np.clip(dc + 0.1 * np.sin(2.0 * np.pi * 5.0 * t), -0.98 * fs, 0.98 * fs)
|
|
code = v * 32768.0 / fs + 32768.0
|
|
slope, icpt = (DF_CAL if diff else SE_CAL)[rng]
|
|
return np.clip(np.rint((code - icpt) / slope), 0, 65535)
|
|
|
|
# --- commands ---------------------------------------------------------------------------
|
|
def handle(self, cmd, p):
|
|
"""Returns (status, reply payload). Status 1 protocol, 2 parameter."""
|
|
if cmd == 0x00:
|
|
return 0, bytes([self.din()])
|
|
if cmd == 0x02:
|
|
return 0, bytes([self.dout])
|
|
if cmd == 0x03:
|
|
if len(p) < 1:
|
|
return 2, b""
|
|
self.dout = p[0]
|
|
self.log("DOUT 0x%02x" % p[0])
|
|
return 0, b""
|
|
if cmd == 0x05:
|
|
if len(p) < 1:
|
|
return 2, b""
|
|
self.dconf = p[0]
|
|
self.log("DCONF 0x%02x" % p[0])
|
|
return 0, b""
|
|
if cmd == 0x10:
|
|
return 0, struct.pack("<H", 32768)
|
|
if cmd == 0x11:
|
|
if len(p) < 9 or not self.queue:
|
|
return 2, b""
|
|
_count, pacer, opts = struct.unpack("<IIB", p[:9])
|
|
with self.lock:
|
|
self.pacer = pacer
|
|
self.armed = True
|
|
self.overrun = False
|
|
self.log("SCAN_START pacer=%d options=%d channels=%d" % (pacer, opts, len(self.queue)))
|
|
return 0, b""
|
|
if cmd == 0x13:
|
|
self.stop_stream(close_scan=bool(p and p[0]))
|
|
self.log("SCAN_STOP")
|
|
return 0, b""
|
|
if cmd == 0x15:
|
|
if not p:
|
|
return 2, b""
|
|
n = p[0]
|
|
if n < 1 or n > 8 or len(p) != 1 + 2 * n:
|
|
return 2, b""
|
|
q = [(p[1 + 2 * i], p[2 + 2 * i]) for i in range(n)]
|
|
if any(r > 3 for _, r in q):
|
|
return 2, b""
|
|
self.queue = q
|
|
self.log("QUEUE " + " ".join("%d:%d" % e for e in q))
|
|
return 0, b""
|
|
if cmd == 0x20:
|
|
return 0, struct.pack("<HH", *self.ao)
|
|
if cmd == 0x21:
|
|
if len(p) < 3 or p[0] > 1:
|
|
return 2, b""
|
|
ch, code = struct.unpack("<BH", p[:3])
|
|
self.ao[ch] = code
|
|
slope, icpt = AO_CAL[ch]
|
|
volts = ((code - icpt) / slope - 32768.0) * 10.0 / 32768.0
|
|
self.log("AOUT ch=%d code=%d volts=%.4f" % (ch, code, volts))
|
|
return 0, b""
|
|
if cmd == 0x30:
|
|
return 0, struct.pack("<I", self.counter())
|
|
if cmd == 0x31:
|
|
self.counter_t0 = time.monotonic()
|
|
self.log("COUNTER_RESET")
|
|
return 0, b""
|
|
if cmd == 0x40:
|
|
if len(p) < 4:
|
|
return 2, b""
|
|
addr, count = struct.unpack("<HH", p[:4])
|
|
data = CAL_MEM[addr:addr + count]
|
|
if len(data) != count:
|
|
return 2, b""
|
|
return 0, data
|
|
if cmd == 0x50:
|
|
self.log("BLINK")
|
|
return 0, b""
|
|
if cmd == 0x52:
|
|
st = 0
|
|
if self.scan_conn is not None:
|
|
st |= 1
|
|
if self.streaming:
|
|
st |= 2
|
|
if self.overrun:
|
|
st |= 4
|
|
return 0, struct.pack("<H", st)
|
|
return 1, b""
|
|
|
|
# --- streaming --------------------------------------------------------------------------
|
|
def stop_stream(self, close_scan):
|
|
with self.lock:
|
|
self.stream_gen += 1
|
|
self.streaming = False
|
|
self.armed = False
|
|
conn = None
|
|
if close_scan:
|
|
conn = self.scan_conn
|
|
self.scan_conn = None
|
|
close_quietly(conn)
|
|
|
|
def start_stream(self):
|
|
for _ in range(100): # the scan socket's accept may trail the filler by a moment
|
|
with self.lock:
|
|
if self.scan_conn is not None:
|
|
break
|
|
time.sleep(0.01)
|
|
with self.lock:
|
|
conn = self.scan_conn
|
|
if conn is None or not self.armed:
|
|
return
|
|
self.armed = False
|
|
self.streaming = True
|
|
self.stream_gen += 1
|
|
gen = self.stream_gen
|
|
queue = list(self.queue)
|
|
pacer = self.pacer
|
|
threading.Thread(target=self.stream, args=(gen, conn, queue, pacer), daemon=True).start()
|
|
|
|
def stream(self, gen, conn, queue, pacer):
|
|
nq = len(queue)
|
|
agg = CLOCK / (pacer + 1) if pacer > 0 else 1000.0 * nq # external clock: 1 kS/s each
|
|
rate = agg / nq
|
|
t0 = time.monotonic()
|
|
sent = 0
|
|
ov_at = None
|
|
if self.args.overrun_after > 0 and not self.overrun_done:
|
|
ov_at = t0 + self.args.overrun_after
|
|
self.log("STREAM rate=%.1f channels=%d" % (rate, nq))
|
|
try:
|
|
while self.stream_gen == gen:
|
|
now = time.monotonic()
|
|
if ov_at is not None and now >= ov_at:
|
|
with self.lock:
|
|
if self.stream_gen == gen:
|
|
self.overrun = True
|
|
self.overrun_done = True
|
|
self.streaming = False
|
|
self.log("OVERRUN")
|
|
return
|
|
due = min(int((now - t0) * rate) - sent, 65536)
|
|
if due > 0:
|
|
t = np.arange(sent, sent + due) / rate
|
|
cols = [self.raw(w, r, t) for (w, r) in queue]
|
|
conn.sendall(np.stack(cols, axis=1).astype("<u2").tobytes())
|
|
sent += due
|
|
time.sleep(0.005)
|
|
except (OSError, ValueError):
|
|
pass
|
|
finally:
|
|
with self.lock:
|
|
if self.stream_gen == gen:
|
|
self.streaming = False
|
|
|
|
def drop(self):
|
|
self.log("DROP")
|
|
with self.lock:
|
|
self.stream_gen += 1
|
|
self.streaming = False
|
|
self.armed = False
|
|
conns = [self.cmd_conn, self.scan_conn]
|
|
self.cmd_conn = None
|
|
self.scan_conn = None
|
|
for c in conns:
|
|
close_quietly(c)
|
|
|
|
# --- servers ----------------------------------------------------------------------------
|
|
def serve_udp(self, port):
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind(("", port))
|
|
while True:
|
|
data, addr = sock.recvfrom(64)
|
|
if data[:1] == b"D":
|
|
reply = b"D" + self.mac + struct.pack("<HH", PRODUCT_ID, 0x0100)
|
|
reply += b"E-1608".ljust(16, b"\0") + struct.pack("<H", port)
|
|
sock.sendto(reply.ljust(64, b"\0"), addr)
|
|
elif data[:1] == b"C" and len(data) >= 5:
|
|
(code,) = struct.unpack("<I", data[1:5])
|
|
status = 0 if code == self.args.code else 1
|
|
self.log("HANDSHAKE code=%d status=%d" % (code, status))
|
|
sock.sendto(b"C" + bytes([status]), addr)
|
|
|
|
def serve_scan_port(self, port):
|
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
srv.bind(("", port))
|
|
srv.listen(4)
|
|
while True:
|
|
conn, _ = srv.accept()
|
|
with self.lock:
|
|
old = self.scan_conn
|
|
self.scan_conn = conn
|
|
close_quietly(old)
|
|
|
|
def serve_cmd(self, conn):
|
|
with self.lock:
|
|
old = self.cmd_conn
|
|
self.cmd_conn = conn
|
|
first = self.first_conn is None
|
|
if first:
|
|
self.first_conn = time.monotonic()
|
|
close_quietly(old)
|
|
if first and self.args.drop_after > 0:
|
|
threading.Timer(self.args.drop_after, self.drop).start()
|
|
try:
|
|
while True:
|
|
b = conn.recv(1)
|
|
if not b:
|
|
break
|
|
if b[0] == 0x00:
|
|
self.start_stream() # the filler byte after scan start
|
|
continue
|
|
if b[0] != 0xDB:
|
|
continue
|
|
rest = recv_exact(conn, 5)
|
|
if rest is None:
|
|
break
|
|
cmd, fid, _status, count = struct.unpack("<BBBH", rest)
|
|
body = recv_exact(conn, count + 1)
|
|
if body is None:
|
|
break
|
|
frame = b"\xDB" + rest + body
|
|
if (sum(frame) & 0xFF) == 0xFF:
|
|
status, reply = self.handle(cmd, body[:count])
|
|
else:
|
|
status, reply = 1, b""
|
|
out = struct.pack("<BBBBH", 0xDB, cmd | 0x80, fid, status, len(reply)) + reply
|
|
out += bytes([(0xFF - sum(out)) & 0xFF])
|
|
conn.sendall(out)
|
|
except (OSError, ValueError):
|
|
pass
|
|
finally:
|
|
with self.lock:
|
|
mine = self.cmd_conn is conn
|
|
if mine:
|
|
self.cmd_conn = None
|
|
if mine:
|
|
self.stop_stream(close_scan=True)
|
|
close_quietly(conn)
|
|
|
|
def serve_cmd_port(self, port):
|
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
srv.bind(("", port))
|
|
srv.listen(4)
|
|
while True:
|
|
conn, _ = srv.accept()
|
|
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
threading.Thread(target=self.serve_cmd, args=(conn,), daemon=True).start()
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="E-1608 protocol mock")
|
|
ap.add_argument("--port", type=int, default=54211)
|
|
ap.add_argument("--mac", default="00:80:2f:00:16:08")
|
|
ap.add_argument("--code", type=int, default=0, help="connection code the board accepts")
|
|
ap.add_argument("--drop-after", type=float, default=0.0, help="close both links once, S s after the first command connection")
|
|
ap.add_argument("--overrun-after", type=float, default=0.0, help="raise the overrun bit S s after a scan starts, once")
|
|
ap.add_argument("--log", default="", help="append event lines to this file")
|
|
args = ap.parse_args()
|
|
board = Board(args)
|
|
for target in (board.serve_udp, board.serve_scan_port, board.serve_cmd_port):
|
|
port = args.port + 1 if target == board.serve_scan_port else args.port
|
|
threading.Thread(target=target, args=(port,), daemon=True).start()
|
|
print("mock_e1608: listening on port %d (scan %d)" % (args.port, args.port + 1), flush=True)
|
|
try:
|
|
while True:
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
- [ ] **Step 2: Write the throwaway smoke client**
|
|
|
|
Create `/tmp/e1608_smoke.py` (not committed). It speaks the protocol independently of the C++ code, so a mock bug and a C++ bug cannot cancel:
|
|
|
|
```python
|
|
import socket, struct, sys, time
|
|
port = int(sys.argv[1])
|
|
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); u.settimeout(1)
|
|
u.sendto(b"D", ("127.0.0.1", port))
|
|
d, _ = u.recvfrom(64)
|
|
assert d[0:1] == b"D" and struct.unpack("<H", d[7:9])[0] == 0x012F, d
|
|
print("discovery ok, mac", d[1:7].hex(":"))
|
|
u.sendto(b"C" + struct.pack("<I", 999), ("127.0.0.1", port)); a, _ = u.recvfrom(8)
|
|
assert a == b"C\x01", a
|
|
u.sendto(b"C" + struct.pack("<I", 0), ("127.0.0.1", port)); a, _ = u.recvfrom(8)
|
|
assert a == b"C\x00", a
|
|
print("handshake ok (wrong code gets status 1)")
|
|
c = socket.create_connection(("127.0.0.1", port)); c.settimeout(2)
|
|
fid = 0
|
|
def cmd(code, payload=b""):
|
|
global fid
|
|
fid += 1
|
|
f = struct.pack("<BBBBH", 0xDB, code, fid, 0, len(payload)) + payload
|
|
f += bytes([(0xFF - sum(f)) & 0xFF]); c.sendall(f)
|
|
h = c.recv(6, socket.MSG_WAITALL); n = h[4] | h[5] << 8
|
|
body = c.recv(n + 1, socket.MSG_WAITALL)
|
|
assert (sum(h + body) & 0xFF) == 0xFF and h[1] == code | 0x80 and h[2] == fid
|
|
return h[3], body[:n]
|
|
st, cal = cmd(0x40, struct.pack("<HH", 0x20, 32)); assert st == 0 and len(cal) == 32
|
|
slope, icpt = struct.unpack("<ff", cal[:8]); print("SE cal r=0:", slope, icpt); assert abs(slope - 1.02) < 1e-4
|
|
assert cmd(0x15, bytes([1, 0, 0]))[0] == 0
|
|
s = socket.create_connection(("127.0.0.1", port + 1)); s.settimeout(3)
|
|
assert cmd(0x11, struct.pack("<IIB", 0, 79999, 0))[0] == 0 # 1 channel at 1 kS/s
|
|
c.sendall(b"\x00") # the filler byte
|
|
data = b""
|
|
while len(data) < 400: data += s.recv(4096)
|
|
raw = struct.unpack("<%dH" % (len(data) // 2), data[:len(data) // 2 * 2])
|
|
print("scan bytes", len(data), "first raw", raw[:3])
|
|
st, sb = cmd(0x52); print("status", struct.unpack("<H", sb)[0]); assert struct.unpack("<H", sb)[0] & 2
|
|
assert cmd(0x13, b"\x01")[0] == 0
|
|
print("SMOKE OK")
|
|
```
|
|
|
|
- [ ] **Step 3: Run the smoke test**
|
|
|
|
```sh
|
|
chmod +x tools/mock_e1608.py
|
|
python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-smoke.log & MOCK=$!
|
|
sleep 1; python3 /tmp/e1608_smoke.py 54711; kill $MOCK
|
|
cat /tmp/e1608-smoke.log
|
|
```
|
|
Expected: `discovery ok`, `handshake ok`, `SE cal r=0: 1.0199999809265137 300.0`, a `status` that has bit 1 (value 3 or 2), and `SMOKE OK`; the log holds `HANDSHAKE code=999 status=1`, `HANDSHAKE code=0 status=0`, `QUEUE 0:0`, `SCAN_START pacer=79999 options=0 channels=1`, `STREAM rate=1000.0 channels=1`, `SCAN_STOP`. Any assertion failure is a mock bug: fix the mock, not the client.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```sh
|
|
git add tools/mock_e1608.py
|
|
git commit -m "tools: a protocol mock of the MCC E-1608, with its unverified commands listed"
|
|
```
|
|
|
|
---
|
|
### Task 2: Link and device layers, plus `decode_scans`
|
|
|
|
**Files:**
|
|
- Create: `src/source/e1608/e1608_link.hpp`, `src/source/e1608/e1608_link.cpp`
|
|
- Create: `src/source/e1608/e1608_device.hpp`, `src/source/e1608/e1608_device.cpp`
|
|
- Modify: `CMakeLists.txt` (after `src/source/probe_source.cpp`)
|
|
- Throwaway (not committed): `/tmp/e1608_drv.cpp`
|
|
|
|
**Interfaces:**
|
|
- Consumes: the mock from Task 1 (`tools/mock_e1608.py`).
|
|
- Produces (namespace `udpscope::e1608`; **no GTK, no `DataSource` types**), used by Task 3:
|
|
- `link.hpp`: `kDefaultPort = 54211`, `kProductId = 0x012F`; `class LinkError : std::runtime_error` with `enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command }` and `kind()`; `class Fd` (RAII); `using Mac = std::array<std::uint8_t, 6>`; `bool parse_mac(const std::string&, Mac&)`; `std::string format_mac(const Mac&)`; `struct DeviceInfo { std::string ip; Mac mac; std::uint16_t product_id; }`; `std::vector<std::string> sweep_targets(const std::string& subnet)`; `std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms)`; `class Link` with `connect(host, port, code, timeout_ms)`, `close()`, `is_open()`, `command(cmd, payload = {}, expect = -1) -> std::vector<uint8_t>`, `open_scan()`, `close_scan()`, `scan_open()`, `send_filler()`, `read_scan(buf, cap, timeout_ms) -> size_t` (0 on timeout; throws `Io` when the board closed the scan socket).
|
|
- `device.hpp`: `enum class Range`, `full_scale(Range)`, `range_name(Range)`, `parse_range(str, Range&)`; `enum class Trigger { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 }`, `trigger_name`, `parse_trigger`; `struct Cal { double slope = 1.0, intercept = 0.0; }`; `struct Calibration { Cal se[4], df[4], ao[2]; bool loaded; double volts(uint16_t raw, Range, bool diff) const; uint16_t dac_code(int ch, double volts) const; }`; `struct QueueEntry { uint8_t wire_channel; Range range; bool diff() const; }`; `void decode_scans(const uint16_t* raw, size_t scans, const std::vector<QueueEntry>&, const Calibration&, std::vector<std::vector<double>>& out)`; `class Device` (`read_calibration`, `din`, `dout`, `dconf`, `aout`, `counter`, `counter_reset`, `status`, `scan_start`, `scan_stop`).
|
|
|
|
Protocol facts (from the two references; the mock implements the same):
|
|
- UDP on the command port: `D` -> a 64-byte reply (`D`, MAC[1..6], product id LE16 at [7..8], firmware [9..10], name [11..26], port [27..28]); `C` + code LE32 -> `C` + status (0 ok, 2 ok "code ignored", 1 rejected, 3 busy).
|
|
- TCP command frame: `0xDB, cmd, frame id, status 0, count LE16, payload, checksum` with `checksum = 0xFF - (sum of the previous bytes mod 256)`. A reply is `cmd | 0x80`, the id echoed, status 0 on success, and the whole frame sums to `0xFF`. The first frame id is 1.
|
|
- Commands: `0x00` DIN (1 byte), `0x03` DOUT write, `0x05` DCONF (bit set = input), `0x11` scan start (`count LE32 = 0, pacer LE32, options`), `0x13` scan stop (payload `1` = close), `0x15` queue write (`n`, then `(wire channel, range)` pairs), `0x21` AOUT write (`ch`, code LE16), `0x30` counter read (LE32), `0x31` counter reset, `0x40` calibration read (`addr LE16, count LE16`), `0x52` status LE16 (bit 0 scan socket open, bit 1 running, bit 2 overrun).
|
|
- After scan start, send one `0x00` filler byte on the **command** socket; the stream is TCP on `port + 1`, little-endian `uint16`, interleaved in queue order.
|
|
- Pacer = `round(80e6 / aggregate - 1)` (aggregate = scan rate times channels); `0` for an external clock. Options byte = the trigger value.
|
|
- Calibration memory: float32 LE `(slope, intercept)` pairs: DIFF 4 pairs at `0x00`, SE 4 pairs at `0x20`, DAC 2 pairs at `0x40`; indexed by range (AI) or channel (DAC).
|
|
- ADC: `code = clamp(round(raw*slope + intercept), 0, 65535)`; `volts = (code - 32768) * fs / 32768`. DAC: `nominal = clamp(round(v*32768/10 + 32768))`, then `clamp(round(nominal*slope + intercept))`; clamp the volts to +-10 first. DIFF wire channel = channel + 8.
|
|
|
|
- [ ] **Step 1: Write `e1608_link.hpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include <array>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope::e1608 {
|
|
|
|
constexpr int kDefaultPort = 54211;
|
|
constexpr std::uint16_t kProductId = 0x012F;
|
|
|
|
/// Every failure of the link. `Rejected` (a bad connection code) is the only one a retry cannot
|
|
/// fix. `Command` is a reply the board answered with a nonzero status, or a reply of the wrong
|
|
/// length: the link itself is fine. `Protocol` is a framing error (sync, checksum, id).
|
|
class LinkError : public std::runtime_error {
|
|
public:
|
|
enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command };
|
|
LinkError(Kind kind, const std::string& what) : std::runtime_error(what), kind_(kind) {}
|
|
Kind kind() const noexcept { return kind_; }
|
|
|
|
private:
|
|
Kind kind_;
|
|
};
|
|
|
|
/// An owned file descriptor.
|
|
class Fd {
|
|
public:
|
|
Fd() = default;
|
|
explicit Fd(int fd) : fd_(fd) {}
|
|
Fd(const Fd&) = delete;
|
|
Fd& operator=(const Fd&) = delete;
|
|
Fd(Fd&& o) noexcept : fd_(o.release()) {}
|
|
Fd& operator=(Fd&& o) noexcept {
|
|
if (this != &o) reset(o.release());
|
|
return *this;
|
|
}
|
|
~Fd() { reset(); }
|
|
int get() const noexcept { return fd_; }
|
|
bool valid() const noexcept { return fd_ >= 0; }
|
|
int release() noexcept {
|
|
const int f = fd_;
|
|
fd_ = -1;
|
|
return f;
|
|
}
|
|
/// Closes the current descriptor (if any) and takes `fd`.
|
|
void reset(int fd = -1) noexcept;
|
|
|
|
private:
|
|
int fd_ = -1;
|
|
};
|
|
|
|
using Mac = std::array<std::uint8_t, 6>;
|
|
|
|
/// Six hex bytes separated by `:` or `-`. False (and `out` untouched) on anything else.
|
|
bool parse_mac(const std::string& text, Mac& out);
|
|
/// Lower-case `aa:bb:cc:dd:ee:ff`.
|
|
std::string format_mac(const Mac& mac);
|
|
|
|
struct DeviceInfo {
|
|
std::string ip;
|
|
Mac mac{};
|
|
std::uint16_t product_id = 0;
|
|
};
|
|
|
|
/// Where to send a discovery `D`: an empty subnet is the limited broadcast; `a.b.c` (an optional
|
|
/// trailing dot) is a unicast to hosts .1 to .254; anything else is empty.
|
|
std::vector<std::string> sweep_targets(const std::string& subnet);
|
|
|
|
/// Sends `D` to every target, then collects replies for `wait_ms`. Keeps E-1608s only (product id
|
|
/// `kProductId`) and one entry per MAC.
|
|
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);
|
|
|
|
/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`).
|
|
/// Not thread safe; the source's producer thread owns it.
|
|
class Link {
|
|
public:
|
|
/// Handshake, then the command connection. Throws `LinkError` (`Rejected` for a bad code).
|
|
void connect(const std::string& host, int port, std::uint32_t code, int timeout_ms);
|
|
/// Closes the command and scan sockets. Safe to call at any time.
|
|
void close();
|
|
bool is_open() const noexcept { return cmd_.valid(); }
|
|
|
|
/// One framed exchange. `expect >= 0` demands a reply payload of exactly that length. Throws
|
|
/// `Command` for a nonzero status or a length mismatch, `Protocol` for a bad frame.
|
|
std::vector<std::uint8_t> command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload = {},
|
|
int expect = -1);
|
|
|
|
/// Opens the scan stream socket (a 1 MiB receive buffer, set before connecting).
|
|
void open_scan();
|
|
void close_scan();
|
|
bool scan_open() const noexcept { return scan_.valid(); }
|
|
/// The single `0x00` byte on the command socket that starts the stream after scan start.
|
|
void send_filler();
|
|
/// Waits up to `timeout_ms` for scan bytes and reads what is there (at most `cap`). Returns 0 on
|
|
/// a timeout. Throws `Io` when the board closed the socket.
|
|
std::size_t read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms);
|
|
|
|
private:
|
|
Fd cmd_;
|
|
Fd scan_;
|
|
std::string host_;
|
|
int port_ = kDefaultPort;
|
|
int timeout_ms_ = 2000;
|
|
std::uint8_t next_id_ = 0;
|
|
};
|
|
|
|
} // namespace udpscope::e1608
|
|
```
|
|
|
|
- [ ] **Step 2: Write `e1608_link.cpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source/e1608/e1608_link.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <arpa/inet.h>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <netdb.h>
|
|
#include <netinet/in.h>
|
|
#include <netinet/tcp.h>
|
|
#include <poll.h>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
|
|
namespace udpscope::e1608 {
|
|
|
|
void Fd::reset(int fd) noexcept {
|
|
if (fd_ >= 0) ::close(fd_);
|
|
fd_ = fd;
|
|
}
|
|
|
|
namespace {
|
|
|
|
using Clock = std::chrono::steady_clock;
|
|
using Kind = LinkError::Kind;
|
|
|
|
constexpr std::size_t kMaxFrame = 1024;
|
|
|
|
[[noreturn]] void sys_fail(const std::string& what) {
|
|
throw LinkError(Kind::Io, what + ": " + std::strerror(errno));
|
|
}
|
|
|
|
int ms_left(Clock::time_point deadline) {
|
|
const auto d = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now());
|
|
return d.count() < 0 ? 0 : static_cast<int>(d.count());
|
|
}
|
|
|
|
/// True when `fd` is ready for `events` (or has an error/hang-up to report); false on timeout.
|
|
bool wait_fd(int fd, short events, int timeout_ms) {
|
|
pollfd p{fd, events, 0};
|
|
for (;;) {
|
|
const int r = ::poll(&p, 1, timeout_ms);
|
|
if (r > 0) return true;
|
|
if (r == 0) return false;
|
|
if (errno == EINTR) continue;
|
|
sys_fail("poll");
|
|
}
|
|
}
|
|
|
|
sockaddr_in make_addr(const std::string& host, int port) {
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_port = htons(static_cast<std::uint16_t>(port));
|
|
if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) == 1) return a;
|
|
addrinfo hints{};
|
|
hints.ai_family = AF_INET;
|
|
hints.ai_socktype = SOCK_STREAM;
|
|
addrinfo* res = nullptr;
|
|
if (::getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr)
|
|
throw LinkError(Kind::Io, "cannot resolve '" + host + "'");
|
|
a.sin_addr = reinterpret_cast<const sockaddr_in*>(res->ai_addr)->sin_addr;
|
|
::freeaddrinfo(res);
|
|
return a;
|
|
}
|
|
|
|
void send_all(int fd, const std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
|
|
while (n > 0) {
|
|
if (!wait_fd(fd, POLLOUT, ms_left(deadline)))
|
|
throw LinkError(Kind::Timeout, "timed out sending to the board");
|
|
const ssize_t w = ::send(fd, p, n, MSG_NOSIGNAL | MSG_DONTWAIT);
|
|
if (w < 0) {
|
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
|
sys_fail("send");
|
|
}
|
|
p += w;
|
|
n -= static_cast<std::size_t>(w);
|
|
}
|
|
}
|
|
|
|
void read_exact(int fd, std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
|
|
while (n > 0) {
|
|
if (!wait_fd(fd, POLLIN, ms_left(deadline)))
|
|
throw LinkError(Kind::Timeout, "timed out waiting for the board");
|
|
const ssize_t r = ::recv(fd, p, n, MSG_DONTWAIT);
|
|
if (r == 0) throw LinkError(Kind::Io, "the board closed the connection");
|
|
if (r < 0) {
|
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
|
sys_fail("recv");
|
|
}
|
|
p += r;
|
|
n -= static_cast<std::size_t>(r);
|
|
}
|
|
}
|
|
|
|
/// A connected, non-blocking TCP socket with TCP_NODELAY. `rcvbuf > 0` sets SO_RCVBUF first.
|
|
Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
|
|
const sockaddr_in a = make_addr(host, port);
|
|
Fd fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0));
|
|
if (!fd.valid()) sys_fail("socket");
|
|
const int one = 1;
|
|
::setsockopt(fd.get(), IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
|
if (rcvbuf > 0) ::setsockopt(fd.get(), SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
|
|
const std::string where = host + ":" + std::to_string(port);
|
|
if (::connect(fd.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0 &&
|
|
errno != EINPROGRESS)
|
|
sys_fail("cannot connect to " + where);
|
|
if (!wait_fd(fd.get(), POLLOUT, timeout_ms))
|
|
throw LinkError(Kind::Timeout, "timed out connecting to " + where);
|
|
int err = 0;
|
|
socklen_t len = sizeof err;
|
|
::getsockopt(fd.get(), SOL_SOCKET, SO_ERROR, &err, &len);
|
|
if (err != 0) {
|
|
errno = err;
|
|
sys_fail("cannot connect to " + where);
|
|
}
|
|
return fd;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// --- addresses ------------------------------------------------------------------------------
|
|
|
|
bool parse_mac(const std::string& text, Mac& out) {
|
|
Mac m{};
|
|
std::size_t pos = 0;
|
|
for (std::size_t i = 0; i < 6; ++i) {
|
|
if (pos + 2 > text.size()) return false;
|
|
unsigned v = 0;
|
|
for (std::size_t k = 0; k < 2; ++k) {
|
|
const char c = text[pos + k];
|
|
v <<= 4;
|
|
if (c >= '0' && c <= '9') v |= static_cast<unsigned>(c - '0');
|
|
else if (c >= 'a' && c <= 'f') v |= static_cast<unsigned>(c - 'a' + 10);
|
|
else if (c >= 'A' && c <= 'F') v |= static_cast<unsigned>(c - 'A' + 10);
|
|
else return false;
|
|
}
|
|
m[i] = static_cast<std::uint8_t>(v);
|
|
pos += 2;
|
|
if (i < 5) {
|
|
if (pos >= text.size() || (text[pos] != ':' && text[pos] != '-')) return false;
|
|
++pos;
|
|
}
|
|
}
|
|
if (pos != text.size()) return false;
|
|
out = m;
|
|
return true;
|
|
}
|
|
|
|
std::string format_mac(const Mac& mac) {
|
|
char buf[24];
|
|
std::snprintf(buf, sizeof buf, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
|
|
mac[4], mac[5]);
|
|
return buf;
|
|
}
|
|
|
|
std::vector<std::string> sweep_targets(const std::string& subnet) {
|
|
std::string s = subnet;
|
|
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
|
|
while (!s.empty() && (s.back() == ' ' || s.back() == '.')) s.pop_back();
|
|
if (s.empty()) return {"255.255.255.255"};
|
|
int parts = 0;
|
|
std::size_t i = 0;
|
|
while (i <= s.size()) {
|
|
const std::size_t dot = s.find('.', i);
|
|
const std::string part = s.substr(i, dot == std::string::npos ? std::string::npos : dot - i);
|
|
if (part.empty() || part.size() > 3) return {};
|
|
int v = 0;
|
|
for (char c : part) {
|
|
if (c < '0' || c > '9') return {};
|
|
v = v * 10 + (c - '0');
|
|
}
|
|
if (v > 255) return {};
|
|
++parts;
|
|
if (dot == std::string::npos) break;
|
|
i = dot + 1;
|
|
}
|
|
if (parts != 3) return {};
|
|
std::vector<std::string> out;
|
|
out.reserve(254);
|
|
for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h));
|
|
return out;
|
|
}
|
|
|
|
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms) {
|
|
std::vector<DeviceInfo> found;
|
|
Fd u(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
|
|
if (!u.valid()) sys_fail("socket");
|
|
const int one = 1;
|
|
::setsockopt(u.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
|
|
const std::uint8_t probe = 'D';
|
|
for (const std::string& t : targets) {
|
|
sockaddr_in a{};
|
|
try {
|
|
a = make_addr(t, port);
|
|
} catch (const LinkError&) {
|
|
continue;
|
|
}
|
|
// A sweep may hit unreachable hosts; those sends fail and are simply skipped.
|
|
(void)::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&a),
|
|
sizeof a);
|
|
}
|
|
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
|
|
for (;;) {
|
|
if (!wait_fd(u.get(), POLLIN, ms_left(deadline))) break;
|
|
std::uint8_t buf[128];
|
|
sockaddr_in from{};
|
|
socklen_t flen = sizeof from;
|
|
const ssize_t n = ::recvfrom(u.get(), buf, sizeof buf, MSG_DONTWAIT,
|
|
reinterpret_cast<sockaddr*>(&from), &flen);
|
|
if (n < 0) {
|
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
|
break;
|
|
}
|
|
if (n < 29 || buf[0] != 'D') continue;
|
|
DeviceInfo info;
|
|
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
|
|
if (info.product_id != kProductId) continue;
|
|
std::copy(buf + 1, buf + 7, info.mac.begin());
|
|
bool dup = false;
|
|
for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac;
|
|
if (dup) continue;
|
|
char ip[INET_ADDRSTRLEN] = {};
|
|
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
|
|
info.ip = ip;
|
|
found.push_back(info);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// --- Link -----------------------------------------------------------------------------------
|
|
|
|
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
|
|
close();
|
|
host_ = host;
|
|
port_ = port;
|
|
timeout_ms_ = timeout_ms;
|
|
next_id_ = 0;
|
|
|
|
// The connection-code handshake: a connected UDP socket, so an ICMP refusal surfaces.
|
|
{
|
|
const sockaddr_in a = make_addr(host, port);
|
|
Fd u(::socket(AF_INET, SOCK_DGRAM, 0));
|
|
if (!u.valid()) sys_fail("socket");
|
|
if (::connect(u.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0)
|
|
sys_fail("handshake with " + host);
|
|
const std::uint8_t msg[5] = {'C', static_cast<std::uint8_t>(code & 0xFF),
|
|
static_cast<std::uint8_t>((code >> 8) & 0xFF),
|
|
static_cast<std::uint8_t>((code >> 16) & 0xFF),
|
|
static_cast<std::uint8_t>((code >> 24) & 0xFF)};
|
|
if (::send(u.get(), msg, sizeof msg, MSG_NOSIGNAL) < 0) sys_fail("handshake with " + host);
|
|
if (!wait_fd(u.get(), POLLIN, timeout_ms))
|
|
throw LinkError(Kind::Timeout, "no answer from the board at " + host);
|
|
std::uint8_t rep[16];
|
|
const ssize_t n = ::recv(u.get(), rep, sizeof rep, 0);
|
|
if (n < 0) sys_fail("handshake with " + host);
|
|
if (n < 2 || rep[0] != 'C') throw LinkError(Kind::Protocol, "unexpected handshake reply");
|
|
switch (rep[1]) {
|
|
case 0:
|
|
case 2:
|
|
break;
|
|
case 1:
|
|
throw LinkError(Kind::Rejected, "the board rejected the connection code");
|
|
case 3:
|
|
throw LinkError(Kind::Busy, "the board is busy (another client holds it)");
|
|
default:
|
|
throw LinkError(Kind::Protocol, "unexpected handshake status " + std::to_string(rep[1]));
|
|
}
|
|
}
|
|
cmd_ = tcp_connect(host, port, timeout_ms, 0);
|
|
}
|
|
|
|
void Link::close() {
|
|
scan_.reset();
|
|
cmd_.reset();
|
|
}
|
|
|
|
std::vector<std::uint8_t> Link::command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload,
|
|
int expect) {
|
|
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
|
|
if (payload.size() + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "command too long");
|
|
// Bytes left by an earlier exchange that timed out must not be read as this reply.
|
|
{
|
|
std::uint8_t junk[256];
|
|
while (::recv(cmd_.get(), junk, sizeof junk, MSG_DONTWAIT) > 0) {
|
|
}
|
|
}
|
|
const std::uint8_t id = ++next_id_;
|
|
std::vector<std::uint8_t> f;
|
|
f.reserve(7 + payload.size());
|
|
f.push_back(0xDB);
|
|
f.push_back(cmd);
|
|
f.push_back(id);
|
|
f.push_back(0);
|
|
f.push_back(static_cast<std::uint8_t>(payload.size() & 0xFF));
|
|
f.push_back(static_cast<std::uint8_t>((payload.size() >> 8) & 0xFF));
|
|
f.insert(f.end(), payload.begin(), payload.end());
|
|
unsigned sum = 0;
|
|
for (std::uint8_t b : f) sum += b;
|
|
f.push_back(static_cast<std::uint8_t>(0xFF - (sum & 0xFF)));
|
|
|
|
const auto deadline = Clock::now() + std::chrono::milliseconds(timeout_ms_);
|
|
send_all(cmd_.get(), f.data(), f.size(), deadline);
|
|
|
|
std::uint8_t h[6];
|
|
read_exact(cmd_.get(), h, sizeof h, deadline);
|
|
if (h[0] != 0xDB) throw LinkError(Kind::Protocol, "bad sync byte in a reply");
|
|
const std::size_t n = static_cast<std::size_t>(h[4] | (h[5] << 8));
|
|
if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply");
|
|
std::vector<std::uint8_t> body(n + 1);
|
|
read_exact(cmd_.get(), body.data(), body.size(), deadline);
|
|
unsigned rsum = 0;
|
|
for (std::uint8_t b : h) rsum += b;
|
|
for (std::uint8_t b : body) rsum += b;
|
|
if ((rsum & 0xFF) != 0xFF) throw LinkError(Kind::Protocol, "bad checksum in a reply");
|
|
if (h[1] != (cmd | 0x80) || h[2] != id)
|
|
throw LinkError(Kind::Protocol, "reply does not match the command");
|
|
if (h[3] != 0) {
|
|
static const char* const kText[] = {"ok", "protocol error", "parameter error",
|
|
"busy", "not ready", "timeout",
|
|
"other error"};
|
|
const char* t = h[3] < 7 ? kText[h[3]] : "unknown error";
|
|
char hex[8];
|
|
std::snprintf(hex, sizeof hex, "%02x", cmd);
|
|
throw LinkError(Kind::Command, std::string("the board refused command 0x") + hex + ": " + t);
|
|
}
|
|
body.pop_back();
|
|
if (expect >= 0 && body.size() != static_cast<std::size_t>(expect))
|
|
throw LinkError(Kind::Command, "reply of " + std::to_string(body.size()) +
|
|
" bytes, expected " + std::to_string(expect));
|
|
return body;
|
|
}
|
|
|
|
void Link::open_scan() {
|
|
close_scan();
|
|
scan_ = tcp_connect(host_, port_ + 1, timeout_ms_, 1 << 20);
|
|
}
|
|
|
|
void Link::close_scan() { scan_.reset(); }
|
|
|
|
void Link::send_filler() {
|
|
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
|
|
const std::uint8_t z = 0;
|
|
send_all(cmd_.get(), &z, 1, Clock::now() + std::chrono::milliseconds(timeout_ms_));
|
|
}
|
|
|
|
std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) {
|
|
if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open");
|
|
if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0;
|
|
const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT);
|
|
if (n > 0) return static_cast<std::size_t>(n);
|
|
if (n == 0) throw LinkError(Kind::Io, "scan socket closed by the board");
|
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0;
|
|
sys_fail("scan recv");
|
|
}
|
|
|
|
} // namespace udpscope::e1608
|
|
```
|
|
|
|
- [ ] **Step 3: Write `e1608_device.hpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include "source/e1608/e1608_link.hpp"
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope::e1608 {
|
|
|
|
enum class Range : std::uint8_t { Bip10V = 0, Bip5V = 1, Bip2V = 2, Bip1V = 3 };
|
|
|
|
double full_scale(Range r); ///< 10, 5, 2, 1
|
|
const char* range_name(Range r); ///< "BIP10V" ...
|
|
/// Case-insensitive: "BIP10V", "bip5v", ...
|
|
bool parse_range(const std::string& text, Range& out);
|
|
|
|
enum class Trigger : std::uint8_t { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 };
|
|
|
|
const char* trigger_name(Trigger t); ///< "none", "rising", "falling", "high", "low"
|
|
bool parse_trigger(const std::string& text, Trigger& out); ///< case-insensitive
|
|
|
|
struct Cal {
|
|
double slope = 1.0;
|
|
double intercept = 0.0;
|
|
};
|
|
|
|
/// The factory calibration: one (slope, intercept) per range for single-ended and differential
|
|
/// inputs, one per DAC. Defaults are the nominal (uncalibrated) values.
|
|
struct Calibration {
|
|
Cal se[4];
|
|
Cal df[4];
|
|
Cal ao[2];
|
|
bool loaded = false;
|
|
|
|
/// A raw ADC count as calibrated volts.
|
|
double volts(std::uint16_t raw, Range range, bool diff) const;
|
|
/// The DAC code that makes `ch` output `volts` (clamped to +-10 V).
|
|
std::uint16_t dac_code(int ch, double volts) const;
|
|
};
|
|
|
|
/// One entry of the scan queue. DIFF channels are `channel + 8` on the wire.
|
|
struct QueueEntry {
|
|
std::uint8_t wire_channel;
|
|
Range range;
|
|
bool diff() const noexcept { return wire_channel >= 8; }
|
|
};
|
|
|
|
/// Turns `scans` interleaved scans of `queue.size()` raw counts into calibrated volts:
|
|
/// `out[queue index][scan]`.
|
|
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
|
|
const Calibration& cal, std::vector<std::vector<double>>& out);
|
|
|
|
/// Typed commands over a `Link`. Holds a reference: the link must outlive it.
|
|
class Device {
|
|
public:
|
|
explicit Device(Link& link) : link_(link) {}
|
|
|
|
/// Fills `cal` from the board's calibration memory and sets `cal.loaded`. A refusal from the
|
|
/// board (`LinkError::Kind::Command`) leaves the nominal values and returns false; any other
|
|
/// error propagates.
|
|
bool read_calibration(Calibration& cal);
|
|
|
|
std::uint8_t din();
|
|
void dout(std::uint8_t value);
|
|
/// `inputs_mask`: a set bit makes that line an input.
|
|
void dconf(std::uint8_t inputs_mask);
|
|
/// Writes `volts` (clamped to +-10 V, calibrated) to DAC `ch`; returns the clamped volts.
|
|
double aout(int ch, double volts, const Calibration& cal);
|
|
std::uint32_t counter();
|
|
void counter_reset();
|
|
/// Bit 0 scan socket open, bit 1 scan running, bit 2 overrun.
|
|
std::uint16_t status();
|
|
|
|
/// Opens the scan socket, writes the queue, starts the scan and sends the filler byte.
|
|
/// `aggregate_hz` is the scan rate times the number of channels. On failure the scan socket
|
|
/// is closed again.
|
|
void scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
|
|
Trigger trigger);
|
|
/// Closes the scan socket and tells the board to stop; board errors are ignored.
|
|
void scan_stop();
|
|
|
|
private:
|
|
Link& link_;
|
|
};
|
|
|
|
} // namespace udpscope::e1608
|
|
```
|
|
|
|
- [ ] **Step 4: Write `e1608_device.cpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source/e1608/e1608_device.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
|
|
namespace udpscope::e1608 {
|
|
|
|
namespace {
|
|
|
|
constexpr double kClockHz = 80e6;
|
|
|
|
std::string lower(std::string s) {
|
|
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
return s;
|
|
}
|
|
|
|
double clamp_code(double v) { return std::min(65535.0, std::max(0.0, v)); }
|
|
|
|
float le_f32(const std::uint8_t* p) {
|
|
const std::uint32_t u = static_cast<std::uint32_t>(p[0]) | (static_cast<std::uint32_t>(p[1]) << 8) |
|
|
(static_cast<std::uint32_t>(p[2]) << 16) |
|
|
(static_cast<std::uint32_t>(p[3]) << 24);
|
|
float f;
|
|
std::memcpy(&f, &u, sizeof f);
|
|
return f;
|
|
}
|
|
|
|
void put_le32(std::vector<std::uint8_t>& v, std::uint32_t x) {
|
|
for (int i = 0; i < 4; ++i) v.push_back(static_cast<std::uint8_t>((x >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
/// Reads `count` (slope, intercept) pairs at `addr`.
|
|
void read_table(Link& link, std::uint16_t addr, int count, Cal* out) {
|
|
const std::uint16_t bytes = static_cast<std::uint16_t>(count * 8);
|
|
const std::vector<std::uint8_t> req = {static_cast<std::uint8_t>(addr & 0xFF),
|
|
static_cast<std::uint8_t>(addr >> 8),
|
|
static_cast<std::uint8_t>(bytes & 0xFF),
|
|
static_cast<std::uint8_t>(bytes >> 8)};
|
|
const std::vector<std::uint8_t> r = link.command(0x40, req, bytes);
|
|
for (int i = 0; i < count; ++i) {
|
|
out[i].slope = le_f32(&r[static_cast<std::size_t>(i) * 8]);
|
|
out[i].intercept = le_f32(&r[static_cast<std::size_t>(i) * 8 + 4]);
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
double full_scale(Range r) {
|
|
static const double fs[] = {10.0, 5.0, 2.0, 1.0};
|
|
return fs[static_cast<int>(r)];
|
|
}
|
|
|
|
const char* range_name(Range r) {
|
|
static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"};
|
|
return n[static_cast<int>(r)];
|
|
}
|
|
|
|
bool parse_range(const std::string& text, Range& out) {
|
|
const std::string t = lower(text);
|
|
for (int i = 0; i < 4; ++i) {
|
|
if (t == lower(range_name(static_cast<Range>(i)))) {
|
|
out = static_cast<Range>(i);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const char* trigger_name(Trigger t) {
|
|
switch (t) {
|
|
case Trigger::None: return "none";
|
|
case Trigger::Rising: return "rising";
|
|
case Trigger::Falling: return "falling";
|
|
case Trigger::High: return "high";
|
|
case Trigger::Low: return "low";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
bool parse_trigger(const std::string& text, Trigger& out) {
|
|
const std::string t = lower(text);
|
|
for (Trigger c : {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, Trigger::Low}) {
|
|
if (t == trigger_name(c)) {
|
|
out = c;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
double Calibration::volts(std::uint16_t raw, Range range, bool diff) const {
|
|
const Cal& c = (diff ? df : se)[static_cast<int>(range)];
|
|
const double code = clamp_code(std::round(static_cast<double>(raw) * c.slope + c.intercept));
|
|
return (code - 32768.0) * full_scale(range) / 32768.0;
|
|
}
|
|
|
|
std::uint16_t Calibration::dac_code(int ch, double v) const {
|
|
v = std::min(10.0, std::max(-10.0, v));
|
|
const double nominal = clamp_code(std::round(v * 32768.0 / 10.0 + 32768.0));
|
|
const Cal& c = ao[ch & 1];
|
|
return static_cast<std::uint16_t>(clamp_code(std::round(nominal * c.slope + c.intercept)));
|
|
}
|
|
|
|
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
|
|
const Calibration& cal, std::vector<std::vector<double>>& out) {
|
|
const std::size_t nq = queue.size();
|
|
out.resize(nq);
|
|
for (std::size_t q = 0; q < nq; ++q) {
|
|
out[q].resize(scans);
|
|
for (std::size_t s = 0; s < scans; ++s)
|
|
out[q][s] = cal.volts(raw[s * nq + q], queue[q].range, queue[q].diff());
|
|
}
|
|
}
|
|
|
|
bool Device::read_calibration(Calibration& cal) {
|
|
Calibration c;
|
|
try {
|
|
read_table(link_, 0x00, 4, c.df);
|
|
read_table(link_, 0x20, 4, c.se);
|
|
read_table(link_, 0x40, 2, c.ao);
|
|
} catch (const LinkError& e) {
|
|
if (e.kind() != LinkError::Kind::Command) throw;
|
|
cal = Calibration{};
|
|
return false;
|
|
}
|
|
c.loaded = true;
|
|
cal = c;
|
|
return true;
|
|
}
|
|
|
|
std::uint8_t Device::din() { return link_.command(0x00, {}, 1)[0]; }
|
|
|
|
void Device::dout(std::uint8_t value) { link_.command(0x03, {value}); }
|
|
|
|
void Device::dconf(std::uint8_t inputs_mask) { link_.command(0x05, {inputs_mask}); }
|
|
|
|
double Device::aout(int ch, double volts, const Calibration& cal) {
|
|
const double v = std::min(10.0, std::max(-10.0, volts));
|
|
const std::uint16_t code = cal.dac_code(ch, v);
|
|
link_.command(0x21, {static_cast<std::uint8_t>(ch & 1), static_cast<std::uint8_t>(code & 0xFF),
|
|
static_cast<std::uint8_t>(code >> 8)});
|
|
return v;
|
|
}
|
|
|
|
std::uint32_t Device::counter() {
|
|
const std::vector<std::uint8_t> r = link_.command(0x30, {}, 4);
|
|
return static_cast<std::uint32_t>(r[0]) | (static_cast<std::uint32_t>(r[1]) << 8) |
|
|
(static_cast<std::uint32_t>(r[2]) << 16) | (static_cast<std::uint32_t>(r[3]) << 24);
|
|
}
|
|
|
|
void Device::counter_reset() { link_.command(0x31); }
|
|
|
|
std::uint16_t Device::status() {
|
|
const std::vector<std::uint8_t> r = link_.command(0x52, {}, 2);
|
|
return static_cast<std::uint16_t>(r[0] | (r[1] << 8));
|
|
}
|
|
|
|
void Device::scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
|
|
Trigger trigger) {
|
|
link_.open_scan();
|
|
try {
|
|
std::vector<std::uint8_t> q;
|
|
q.push_back(static_cast<std::uint8_t>(queue.size()));
|
|
for (const QueueEntry& e : queue) {
|
|
q.push_back(e.wire_channel);
|
|
q.push_back(static_cast<std::uint8_t>(e.range));
|
|
}
|
|
link_.command(0x15, q);
|
|
|
|
std::uint32_t pacer = 0;
|
|
if (!ext_clock) {
|
|
const double p = std::round(kClockHz / aggregate_hz - 1.0);
|
|
pacer = static_cast<std::uint32_t>(std::min(4294967295.0, std::max(1.0, p)));
|
|
}
|
|
std::vector<std::uint8_t> s;
|
|
put_le32(s, 0); // count 0: scan until stopped
|
|
put_le32(s, pacer);
|
|
s.push_back(static_cast<std::uint8_t>(trigger));
|
|
link_.command(0x11, s);
|
|
link_.send_filler();
|
|
} catch (...) {
|
|
link_.close_scan();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
void Device::scan_stop() {
|
|
link_.close_scan();
|
|
try {
|
|
link_.command(0x13, {1});
|
|
} catch (const LinkError&) {
|
|
}
|
|
}
|
|
|
|
} // namespace udpscope::e1608
|
|
```
|
|
|
|
- [ ] **Step 5: Add the sources to CMake**
|
|
|
|
In `CMakeLists.txt`, after the line `src/source/probe_source.cpp` add:
|
|
|
|
```
|
|
src/source/e1608/e1608_link.cpp
|
|
src/source/e1608/e1608_device.cpp
|
|
```
|
|
|
|
- [ ] **Step 6: Write the throwaway driver `/tmp/e1608_drv.cpp`**
|
|
|
|
It exercises both layers against the mock and checks the decode against hand-computed volts (no mock needed for that part).
|
|
|
|
```cpp
|
|
#include "source/e1608/e1608_device.hpp"
|
|
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <thread>
|
|
|
|
using namespace udpscope::e1608;
|
|
using namespace std::chrono_literals;
|
|
|
|
static int fails = 0;
|
|
#define CHECK(c) \
|
|
do { \
|
|
if (!(c)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #c); \
|
|
++fails; \
|
|
} \
|
|
} while (0)
|
|
|
|
static double mean(const std::vector<double>& v) {
|
|
double s = 0;
|
|
for (double x : v) s += x;
|
|
return s / static_cast<double>(v.size());
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
const int port = argc > 1 ? std::atoi(argv[1]) : 54711;
|
|
|
|
// --- decode against hand-computed volts (needs no board) ---
|
|
{
|
|
Calibration cal;
|
|
cal.se[1] = {2.0, -32768.0}; // BIP5V
|
|
cal.df[0] = {1.0, 100.0}; // DIFF BIP10V
|
|
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip5V}};
|
|
const std::uint16_t raw[4] = {0x8000, 0x4000, 0xC000, 0x6000};
|
|
std::vector<std::vector<double>> out;
|
|
decode_scans(raw, 2, q, cal, out);
|
|
CHECK(out.size() == 2 && out[0].size() == 2);
|
|
CHECK(out[0][0] == 0.0 && out[0][1] == 5.0);
|
|
CHECK(out[1][0] == -5.0 && out[1][1] == -2.5);
|
|
const std::vector<QueueEntry> qd = {{8, Range::Bip10V}};
|
|
const std::uint16_t rd[1] = {0x8000};
|
|
decode_scans(rd, 1, qd, cal, out);
|
|
CHECK(out[0][0] == 0.030517578125);
|
|
CHECK(cal.dac_code(0, 0.0) == 32768);
|
|
CHECK(cal.dac_code(0, 99.0) == 65535);
|
|
Range r;
|
|
CHECK(parse_range("bip5v", r) && r == Range::Bip5V && !parse_range("BIP3V", r));
|
|
Trigger t;
|
|
CHECK(parse_trigger("Rising", t) && t == Trigger::Rising && !parse_trigger("x", t));
|
|
Mac m;
|
|
CHECK(parse_mac("00:80:2F:00:16:08", m) && format_mac(m) == "00:80:2f:00:16:08");
|
|
CHECK(!parse_mac("zz", m) && !parse_mac("00:80:2f:00:16", m) && !parse_mac("00:80:2f:00:16:08:", m));
|
|
CHECK(sweep_targets("").size() == 1 && sweep_targets("192.168.1").size() == 254 &&
|
|
sweep_targets("192.168.1.").size() == 254 && sweep_targets("bad").empty() &&
|
|
sweep_targets("1.2").empty() && sweep_targets("1.2.3.4").empty());
|
|
}
|
|
|
|
// --- no board: a refusal, not a hang ---
|
|
try {
|
|
Link l;
|
|
l.connect("127.0.0.1", port + 7, 0, 300);
|
|
CHECK(false);
|
|
} catch (const LinkError&) {
|
|
}
|
|
|
|
// --- discovery ---
|
|
auto devs = discover({"127.0.0.1"}, port, 500);
|
|
CHECK(devs.size() == 1);
|
|
if (devs.size() == 1) {
|
|
CHECK(format_mac(devs[0].mac) == "00:80:2f:00:16:08");
|
|
CHECK(devs[0].product_id == 0x012F);
|
|
}
|
|
CHECK(discover(sweep_targets("127.0.0"), port, 800).size() == 1);
|
|
|
|
// --- a wrong connection code ---
|
|
try {
|
|
Link l;
|
|
l.connect("127.0.0.1", port, 999, 1000);
|
|
CHECK(false);
|
|
} catch (const LinkError& e) {
|
|
CHECK(e.kind() == LinkError::Kind::Rejected);
|
|
}
|
|
|
|
// --- a session ---
|
|
Link link;
|
|
link.connect("127.0.0.1", port, 0, 2000);
|
|
CHECK(link.is_open());
|
|
Device dev(link);
|
|
Calibration cal;
|
|
CHECK(dev.read_calibration(cal) && cal.loaded);
|
|
CHECK(std::fabs(cal.se[0].slope - 1.02) < 1e-4 && std::fabs(cal.se[0].intercept - 300.0) < 1e-3);
|
|
CHECK(std::fabs(cal.df[1].slope - 1.006) < 1e-4);
|
|
CHECK(std::fabs(cal.ao[1].intercept - 100.0) < 1e-3);
|
|
|
|
dev.dconf(0x0F);
|
|
dev.dout(0xA0);
|
|
CHECK((dev.din() & 0xF0) == 0xA0);
|
|
CHECK(dev.aout(0, 2.5, cal) == 2.5);
|
|
CHECK(dev.aout(1, 99.0, cal) == 10.0);
|
|
|
|
const std::uint32_t c1 = dev.counter();
|
|
std::this_thread::sleep_for(450ms);
|
|
const std::uint32_t c2 = dev.counter();
|
|
CHECK(c2 > c1);
|
|
dev.counter_reset();
|
|
CHECK(dev.counter() <= 1);
|
|
|
|
// --- a scan: two channels, 1000 S/s each; 200 scans = exactly one 5 Hz period ---
|
|
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip10V}};
|
|
dev.scan_start(q, 2000.0, false, Trigger::None);
|
|
std::vector<std::uint8_t> bytes;
|
|
std::uint8_t buf[4096];
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
while (bytes.size() < 800 && std::chrono::steady_clock::now() - t0 < 5s) {
|
|
const std::size_t n = link.read_scan(buf, sizeof buf, 500);
|
|
bytes.insert(bytes.end(), buf, buf + n);
|
|
}
|
|
CHECK(bytes.size() >= 800);
|
|
if (bytes.size() >= 800) {
|
|
std::vector<std::uint16_t> raw(400);
|
|
std::memcpy(raw.data(), bytes.data(), 800);
|
|
std::vector<std::vector<double>> out;
|
|
decode_scans(raw.data(), 200, q, cal, out);
|
|
std::printf("calibrated means: %.4f %.4f\n", mean(out[0]), mean(out[1]));
|
|
CHECK(std::fabs(mean(out[0]) - 0.5) < 0.01);
|
|
CHECK(std::fabs(mean(out[1]) - 1.0) < 0.01);
|
|
Calibration nominal;
|
|
decode_scans(raw.data(), 200, q, nominal, out);
|
|
std::printf("uncalibrated mean: %.4f\n", mean(out[0]));
|
|
CHECK(std::fabs(mean(out[0]) - 0.5) > 0.05);
|
|
}
|
|
CHECK((dev.status() & 2) != 0);
|
|
dev.scan_stop();
|
|
std::this_thread::sleep_for(100ms);
|
|
CHECK((dev.status() & 3) == 0);
|
|
|
|
std::printf(fails == 0 ? "DRIVER OK\n" : "DRIVER FAILED (%d)\n", fails);
|
|
return fails == 0 ? 0 : 1;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Build the driver, run it against the mock**
|
|
|
|
```sh
|
|
g++ -std=c++20 -Wall -Wextra -Wpedantic -Isrc src/source/e1608/e1608_link.cpp src/source/e1608/e1608_device.cpp /tmp/e1608_drv.cpp -o /tmp/e1608_drv
|
|
python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-drv.log > /dev/null & MOCK=$!
|
|
sleep 1; /tmp/e1608_drv 54711; kill $MOCK
|
|
cat /tmp/e1608-drv.log
|
|
```
|
|
Expected: **zero compiler warnings**; output `calibrated means: 0.5000 1.0000` (to within 0.01), an `uncalibrated mean:` of about `0.2043` (more than 0.05 from 0.5), and `DRIVER OK`. The log holds `HANDSHAKE code=999 status=1`, `HANDSHAKE code=0 status=0`, `DCONF 0x0f`, `DOUT 0xa0`, two `AOUT` lines (`ch=0 code=40750 volts=2.4999`, `ch=1 code=65535 volts=9.9692`; the mock inverts the served DAC calibration and the +10 V code saturates, so these exact values are the reading), `COUNTER_RESET`, `QUEUE 0:0 1:0`, `SCAN_START pacer=39999 options=0 channels=2`, `STREAM rate=1000.0 channels=2`, `SCAN_STOP`. A `FAIL line` names the first thing to fix; a mean outside tolerance is a decode or calibration-index bug (check the table order: DIFF, SE, DAC), not a reason to widen the tolerance.
|
|
|
|
- [ ] **Step 8: Build the app (warning baseline) and commit**
|
|
|
|
```sh
|
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"
|
|
git add CMakeLists.txt src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_device.hpp src/source/e1608/e1608_device.cpp
|
|
git commit -m "e1608: link and device layers (discovery, handshake, framed commands, calibration, decode)"
|
|
```
|
|
Expected: the warning count is the Task 0 baseline (`3` clean, `1` incremental): the new files add none. Record the commit in the ledger.
|
|
|
|
---
|
|
### Task 3: The settings, the `DataSource`, and the connect form
|
|
|
|
**Files:**
|
|
- Create: `src/source/e1608/e1608_source.hpp`, `src/source/e1608/e1608_source.cpp`, `src/source/e1608/e1608_kind.cpp`
|
|
- Modify: `src/source/registry.hpp` (declare `make_e1608_kind`), `src/source/registry.cpp` (register after udps), `CMakeLists.txt` (the two new `.cpp` files after `e1608_device.cpp`)
|
|
|
|
**Interfaces:**
|
|
- Consumes (Task 2): everything in `e1608_link.hpp` / `e1608_device.hpp`; framework types `DataSource`, `SourceSink`, `Frame`, `SignalValues`, `SignalInfo`, `SourceStats`, `SourceEvent`, `SourceConfig`, `SourceKind`, `ConfigForm`, `SourcePanel`.
|
|
- Produces (namespace `udpscope::e1608`), used by Tasks 4 and 5:
|
|
- `struct E1608Settings` with `static E1608Settings from(const SourceConfig&)`, `std::string problem() const` (empty = connectable), `queue()`, `active_channels()`, `aggregate()`, `identity()`; fields `address, mac_text, subnet, port, code, timeout_ms, scan_rate, ext_clock, trigger, auto_restart, calibrate, poll_rate, diff, ai_on[8], ai_range[8], dir_out[8], plot[8], plot_counter, do_init, ao_init[2]`.
|
|
- `struct E1608Readouts` (atomics: `di`, `do_latch`, `counter`, `ao[2]`, `gen_on[2]`, `epoch`).
|
|
- `enum class AoMode { Level, Sine, Square, Triangle, Ramp }`, `struct AoSettings { AoMode mode; double level, amplitude, offset, freq_hz; bool run; }`.
|
|
- `class E1608Source final : public DataSource` with the producer-thread-only methods `set_output_bit(int bit, bool on)`, `reset_counter()`, `set_analog_out(int ch, const AoSettings&)`, and accessors `readouts()` (`std::shared_ptr<E1608Readouts>`), `settings()`, `connects()` (`unsigned`, thread-safe), `last_message()` (`std::string`, thread-safe).
|
|
- In `udpscope`: `std::unique_ptr<SourceKind> make_e1608_kind()`; the kind's id is `"e1608"`.
|
|
- Config keys (all optional): `address`, `mac`, `subnet`, `port` (54211), `code` (0), `timeout_ms` (2000), `scan_rate` (1000), `ext_clock` (0), `trigger` (`none|rising|falling|high|low`), `auto_restart` (1), `calibrate` (1), `poll_rate` (10), `mode` (`se|diff`), `ai0`..`ai7` (`BIP10V|BIP5V|BIP2V|BIP1V|off`; absent = off; if **no** `aiN` key is present at all, `ai0=BIP10V`), `dir0`..`dir7` (`i|o`), `plot0`..`plot7` (0/1, inputs only), `plot_counter`, `do_init` (0-255), `ao0_init`, `ao1_init` (volts).
|
|
|
|
Behaviour the code below implements (the spec, made concrete):
|
|
- `poll()` is a state machine `Down -> Scanning`, or `Halted` after a rejected connection code. Every exception is caught inside `poll()`; the producer thread never sees one. `drain_commands()` runs inside the same `try`, so a posted DAC write that hits a dead link reconnects instead of terminating the thread.
|
|
- Frames are fixed-size blocks of `block = clamp(round(scan_rate / 100), 1, 4096)` scans (about 10 ms), so the producer's batch-size check never warns. Decoded scans that do not fill a block wait for the next read.
|
|
- DI lines and the counter are read at `poll_rate`, held across a block (a step trace), and shown in the panels through `E1608Readouts`.
|
|
- Every 500 ms the status word is read: an overrun with `auto_restart` fires `Error` ("scan overrun; restarting"), restarts the scan, and marks the next frame `lost = 1`; an overrun without it halts. A scan that is not running with `auto_restart` set is restarted. (Whether bit 1 stays set while the board waits for a trigger is **unverified**.)
|
|
- The generator ticks every 20 ms while a waveform is running. Waveform frequency is bounded to 0.1 to 10 Hz.
|
|
|
|
- [ ] **Step 1: Write `e1608_source.hpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include "source/data_source.hpp"
|
|
#include "source/e1608/e1608_device.hpp"
|
|
#include "source/e1608/e1608_link.hpp"
|
|
#include "source/source_config.hpp"
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope::e1608 {
|
|
|
|
constexpr double kMaxAggregateHz = 250000.0;
|
|
constexpr double kMinAggregateHz = 0.02;
|
|
|
|
/// Everything a configured E-1608 source needs, parsed once from the flat `SourceConfig`.
|
|
struct E1608Settings {
|
|
std::string address; ///< host or IP; empty means "find it by MAC"
|
|
std::string mac_text; ///< as typed; empty means "any board at `address`"
|
|
std::string subnet; ///< `a.b.c` to sweep when looking up a MAC; empty means broadcast
|
|
int port = kDefaultPort;
|
|
std::uint32_t code = 0;
|
|
int timeout_ms = 2000;
|
|
double scan_rate = 1000.0; ///< per channel; with an external clock, that clock's rate
|
|
bool ext_clock = false;
|
|
Trigger trigger = Trigger::None;
|
|
bool auto_restart = true;
|
|
bool calibrate = true;
|
|
double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter
|
|
bool diff = false;
|
|
bool ai_on[8] = {};
|
|
Range ai_range[8] = {};
|
|
bool dir_out[8] = {};
|
|
bool plot[8] = {};
|
|
bool plot_counter = false;
|
|
int do_init = 0;
|
|
double ao_init[2] = {0.0, 0.0};
|
|
|
|
static E1608Settings from(const SourceConfig& cfg);
|
|
/// The first reason this cannot connect, in words for the user; empty when it can.
|
|
std::string problem() const;
|
|
int active_channels() const;
|
|
std::vector<QueueEntry> queue() const;
|
|
double aggregate() const { return scan_rate * active_channels(); }
|
|
/// The address, else the MAC; names the session file.
|
|
std::string identity() const;
|
|
};
|
|
|
|
/// What the panels read. Written by the producer thread, read by the GTK thread's timer.
|
|
struct E1608Readouts {
|
|
std::atomic<unsigned> di{0}; ///< the DIN byte as last read
|
|
std::atomic<unsigned> do_latch{0}; ///< the DOUT byte as last written
|
|
std::atomic<std::uint32_t> counter{0};
|
|
std::atomic<double> ao[2]; ///< the last DAC voltage written; NaN before any
|
|
std::atomic<bool> gen_on[2];
|
|
std::atomic<unsigned> epoch{0}; ///< bumped at each (re)connect: panels re-sync from it
|
|
E1608Readouts() {
|
|
for (int i = 0; i < 2; ++i) {
|
|
ao[i].store(std::nan(""));
|
|
gen_on[i].store(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
enum class AoMode { Level, Sine, Square, Triangle, Ramp };
|
|
|
|
struct AoSettings {
|
|
AoMode mode = AoMode::Level;
|
|
double level = 0.0; ///< Level mode, volts
|
|
double amplitude = 1.0; ///< waveform modes, volts
|
|
double offset = 0.0;
|
|
double freq_hz = 1.0; ///< clamped to 0.1 .. 10
|
|
bool run = false; ///< waveform modes only
|
|
};
|
|
|
|
class E1608Source final : public DataSource {
|
|
public:
|
|
explicit E1608Source(const SourceConfig& cfg);
|
|
|
|
void open(SourceSink& sink) override;
|
|
void poll(int timeout_ms) override;
|
|
void close() override;
|
|
SourceStats stats() const override;
|
|
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
|
|
|
|
// Producer thread only: reach these through `post()`. Each is ignored unless scanning.
|
|
void set_output_bit(int bit, bool on);
|
|
void reset_counter();
|
|
void set_analog_out(int ch, const AoSettings& s);
|
|
|
|
std::shared_ptr<E1608Readouts> readouts() const { return readouts_; }
|
|
const E1608Settings& settings() const { return cfg_; }
|
|
unsigned connects() const { return connects_.load(std::memory_order_relaxed); }
|
|
std::string last_message() const;
|
|
|
|
private:
|
|
using Clock = std::chrono::steady_clock;
|
|
enum class State { Down, Scanning, Halted };
|
|
|
|
void connect_and_start();
|
|
void scan_step(int timeout_ms);
|
|
void emit_frame(std::size_t count);
|
|
void run_timers();
|
|
void slow_read();
|
|
void check_status();
|
|
void restart_scan();
|
|
void generator_tick(Clock::time_point now);
|
|
void write_ao(int ch, double volts);
|
|
void update_gen_flags();
|
|
void fail(const std::string& msg, bool fatal);
|
|
void note(const std::string& msg);
|
|
int wait_ms(int timeout_ms, Clock::time_point now) const;
|
|
|
|
E1608Settings cfg_;
|
|
SourceSink* sink_ = nullptr;
|
|
State state_ = State::Down;
|
|
Link link_;
|
|
Device dev_{link_};
|
|
Calibration cal_;
|
|
std::vector<QueueEntry> queue_;
|
|
std::vector<SignalInfo> signals_;
|
|
std::vector<int> plotted_bits_; ///< the DI bits that are plot channels, ascending
|
|
std::size_t block_ = 10;
|
|
|
|
std::shared_ptr<E1608Readouts> readouts_ = std::make_shared<E1608Readouts>();
|
|
std::atomic<bool> connected_{false};
|
|
std::atomic<unsigned> connects_{0};
|
|
mutable std::mutex msg_mu_;
|
|
std::string last_message_;
|
|
std::string last_logged_;
|
|
|
|
std::vector<std::uint8_t> rx_; ///< the read buffer
|
|
std::vector<std::uint8_t> pending_; ///< bytes of an incomplete scan
|
|
std::vector<std::vector<double>> ready_; ///< decoded, not yet framed: [queue index][scan]
|
|
std::vector<std::vector<double>> dec_;
|
|
std::vector<std::vector<double>> vals_; ///< the frame being built: [signal][sample]
|
|
std::vector<SignalValues> views_;
|
|
std::vector<double> held_di_;
|
|
double held_counter_ = 0.0;
|
|
std::uint8_t do_latch_ = 0;
|
|
|
|
AoSettings ao_[2];
|
|
Clock::time_point gen_t0_[2];
|
|
bool any_gen_ = false;
|
|
|
|
Clock::time_point retry_at_;
|
|
Clock::time_point next_slow_, next_status_, next_gen_;
|
|
std::uint32_t frame_counter_ = 0;
|
|
std::uint32_t pending_lost_ = 0;
|
|
std::uint64_t packets_ = 0, frames_ = 0, reconnects_ = 0;
|
|
};
|
|
|
|
} // namespace udpscope::e1608
|
|
```
|
|
|
|
- [ ] **Step 2: Write `e1608_source.cpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source/e1608/e1608_source.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <stdexcept>
|
|
#include <thread>
|
|
|
|
namespace udpscope::e1608 {
|
|
|
|
namespace {
|
|
|
|
std::string lower(std::string s) {
|
|
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
return s;
|
|
}
|
|
|
|
std::string trim(const std::string& s) {
|
|
std::size_t a = 0, b = s.size();
|
|
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
|
return s.substr(a, b - a);
|
|
}
|
|
|
|
double clamp(double v, double lo, double hi) { return std::min(hi, std::max(lo, v)); }
|
|
|
|
double wave(AoMode m, double f) {
|
|
switch (m) {
|
|
case AoMode::Sine: return std::sin(2.0 * M_PI * f);
|
|
case AoMode::Square: return f < 0.5 ? 1.0 : -1.0;
|
|
case AoMode::Triangle: return f < 0.5 ? 4.0 * f - 1.0 : 3.0 - 4.0 * f;
|
|
case AoMode::Ramp: return 2.0 * f - 1.0;
|
|
case AoMode::Level: break;
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
double epoch_seconds() {
|
|
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// --- E1608Settings --------------------------------------------------------------------------
|
|
|
|
E1608Settings E1608Settings::from(const SourceConfig& c) {
|
|
E1608Settings s;
|
|
s.address = trim(c.get("address"));
|
|
s.mac_text = trim(c.get("mac"));
|
|
s.subnet = trim(c.get("subnet"));
|
|
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
|
|
const long code = c.get_int("code", 0);
|
|
s.code = static_cast<std::uint32_t>(std::min(4294967295L, std::max(0L, code)));
|
|
s.timeout_ms = static_cast<int>(c.get_int("timeout_ms", 2000));
|
|
s.scan_rate = c.get_double("scan_rate", 1000.0);
|
|
s.ext_clock = c.get_bool("ext_clock", false);
|
|
parse_trigger(c.get("trigger", "none"), s.trigger);
|
|
s.auto_restart = c.get_bool("auto_restart", true);
|
|
s.calibrate = c.get_bool("calibrate", true);
|
|
s.poll_rate = c.get_double("poll_rate", 10.0);
|
|
s.diff = lower(c.get("mode", "se")) == "diff";
|
|
|
|
bool any_key = false;
|
|
for (int i = 0; i < 8; ++i) {
|
|
const std::string key = "ai" + std::to_string(i);
|
|
if (!c.has(key)) continue;
|
|
any_key = true;
|
|
Range r;
|
|
if (parse_range(c.get(key), r)) {
|
|
s.ai_on[i] = true;
|
|
s.ai_range[i] = r;
|
|
}
|
|
}
|
|
if (!any_key) { // a bare `--source-opt address=...` is connectable
|
|
s.ai_on[0] = true;
|
|
s.ai_range[0] = Range::Bip10V;
|
|
}
|
|
if (s.diff) {
|
|
for (int i = 4; i < 8; ++i) s.ai_on[i] = false;
|
|
}
|
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
const std::string d = lower(c.get("dir" + std::to_string(i), "i"));
|
|
s.dir_out[i] = d == "o" || d == "out" || d == "output";
|
|
s.plot[i] = c.get_bool("plot" + std::to_string(i), false) && !s.dir_out[i];
|
|
}
|
|
s.plot_counter = c.get_bool("plot_counter", false);
|
|
s.do_init = static_cast<int>(c.get_int("do_init", 0) & 0xFF);
|
|
for (int i = 0; i < 2; ++i)
|
|
s.ao_init[i] = clamp(c.get_double("ao" + std::to_string(i) + "_init", 0.0), -10.0, 10.0);
|
|
return s;
|
|
}
|
|
|
|
int E1608Settings::active_channels() const {
|
|
int n = 0;
|
|
for (int i = 0; i < 8; ++i) n += ai_on[i] ? 1 : 0;
|
|
return n;
|
|
}
|
|
|
|
std::vector<QueueEntry> E1608Settings::queue() const {
|
|
std::vector<QueueEntry> q;
|
|
for (int i = 0; i < 8; ++i) {
|
|
if (ai_on[i]) q.push_back({static_cast<std::uint8_t>(diff ? i + 8 : i), ai_range[i]});
|
|
}
|
|
return q;
|
|
}
|
|
|
|
std::string E1608Settings::identity() const {
|
|
if (!address.empty()) return address;
|
|
Mac m;
|
|
return parse_mac(mac_text, m) ? format_mac(m) : mac_text;
|
|
}
|
|
|
|
std::string E1608Settings::problem() const {
|
|
Mac m;
|
|
if (address.empty() && mac_text.empty()) return "Enter an address, or a MAC address to look up";
|
|
if (!mac_text.empty() && !parse_mac(mac_text, m))
|
|
return "The MAC address must be six hex bytes, such as 00:80:2f:00:16:08";
|
|
if (address.empty() && !subnet.empty() && sweep_targets(subnet).empty())
|
|
return "The subnet must look like 192.168.1";
|
|
if (port < 1 || port > 65534) return "The port must be between 1 and 65534";
|
|
if (timeout_ms < 100 || timeout_ms > 60000) return "The timeout must be 100 to 60000 ms";
|
|
if (!(scan_rate > 0.0)) return "The scan rate must be positive";
|
|
if (active_channels() < 1) return "Enable at least one analog input";
|
|
if (aggregate() > kMaxAggregateHz) {
|
|
char b[96];
|
|
std::snprintf(b, sizeof b, "%d channels at %.6g S/s is %.6g kS/s; the board takes 250 kS/s",
|
|
active_channels(), scan_rate, aggregate() / 1000.0);
|
|
return b;
|
|
}
|
|
if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s";
|
|
if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz";
|
|
return {};
|
|
}
|
|
|
|
// --- E1608Source ----------------------------------------------------------------------------
|
|
|
|
E1608Source::E1608Source(const SourceConfig& cfg) : cfg_(E1608Settings::from(cfg)) {
|
|
queue_ = cfg_.queue();
|
|
block_ = static_cast<std::size_t>(clamp(std::round(cfg_.scan_rate / 100.0), 1.0, 4096.0));
|
|
rx_.resize(65536);
|
|
ready_.resize(queue_.size());
|
|
|
|
auto base = [&](const std::string& name, const char* unit, double lo, double hi) {
|
|
SignalInfo s;
|
|
s.name = name;
|
|
s.unit = unit;
|
|
s.type_code = 9; // float64
|
|
s.rows = 1;
|
|
s.cols = 1;
|
|
s.range_min = lo;
|
|
s.range_max = hi;
|
|
s.time_mode = kTimePacket;
|
|
s.sampling_rate = cfg_.scan_rate;
|
|
return s;
|
|
};
|
|
for (int i = 0; i < 8; ++i) {
|
|
if (!cfg_.ai_on[i]) continue;
|
|
const double fs = full_scale(cfg_.ai_range[i]);
|
|
signals_.push_back(base("AI" + std::to_string(i), "V", -fs, fs));
|
|
}
|
|
for (int b = 0; b < 8; ++b) {
|
|
if (!cfg_.plot[b]) continue;
|
|
plotted_bits_.push_back(b);
|
|
signals_.push_back(base("DI" + std::to_string(b), "", 0.0, 1.0));
|
|
}
|
|
if (cfg_.plot_counter) signals_.push_back(base("Counter", "counts", 0.0, 1000.0));
|
|
held_di_.assign(plotted_bits_.size(), 0.0);
|
|
}
|
|
|
|
std::string E1608Source::last_message() const {
|
|
std::lock_guard<std::mutex> lk(msg_mu_);
|
|
return last_message_;
|
|
}
|
|
|
|
void E1608Source::open(SourceSink& sink) {
|
|
const std::string p = cfg_.problem();
|
|
if (!p.empty()) throw std::runtime_error(p);
|
|
sink_ = &sink;
|
|
state_ = State::Down;
|
|
retry_at_ = Clock::now();
|
|
}
|
|
|
|
void E1608Source::close() {
|
|
if (state_ == State::Scanning) dev_.scan_stop();
|
|
link_.close();
|
|
connected_.store(false, std::memory_order_relaxed);
|
|
}
|
|
|
|
SourceStats E1608Source::stats() const {
|
|
SourceStats s;
|
|
s.packets = packets_;
|
|
s.frames = frames_;
|
|
s.reconnects = reconnects_;
|
|
s.connected = connected_.load(std::memory_order_relaxed);
|
|
return s;
|
|
}
|
|
|
|
void E1608Source::note(const std::string& msg) {
|
|
{
|
|
std::lock_guard<std::mutex> lk(msg_mu_);
|
|
last_message_ = msg;
|
|
}
|
|
if (msg != last_logged_) { // a retry every 2 s must not flood stderr
|
|
last_logged_ = msg;
|
|
std::fprintf(stderr, "[e1608] %s\n", msg.c_str());
|
|
std::fflush(stderr);
|
|
}
|
|
}
|
|
|
|
void E1608Source::fail(const std::string& msg, bool fatal) {
|
|
link_.close();
|
|
const bool was_up = connected_.exchange(false, std::memory_order_relaxed);
|
|
note(msg);
|
|
if (was_up) sink_->on_event(SourceEvent::Disconnected, msg.c_str());
|
|
sink_->on_event(SourceEvent::Error, msg.c_str());
|
|
any_gen_ = false;
|
|
readouts_->gen_on[0].store(false);
|
|
readouts_->gen_on[1].store(false);
|
|
if (fatal) {
|
|
state_ = State::Halted;
|
|
} else {
|
|
state_ = State::Down;
|
|
retry_at_ = Clock::now() + std::chrono::seconds(2);
|
|
}
|
|
}
|
|
|
|
void E1608Source::poll(int timeout_ms) {
|
|
if (sink_ == nullptr) return;
|
|
try {
|
|
drain_commands();
|
|
switch (state_) {
|
|
case State::Down: {
|
|
const auto now = Clock::now();
|
|
if (now < retry_at_) {
|
|
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(retry_at_ - now);
|
|
std::this_thread::sleep_for(std::min(left, std::chrono::milliseconds(timeout_ms)));
|
|
return;
|
|
}
|
|
connect_and_start();
|
|
break;
|
|
}
|
|
case State::Scanning:
|
|
scan_step(timeout_ms);
|
|
break;
|
|
case State::Halted:
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms));
|
|
break;
|
|
}
|
|
} catch (const LinkError& e) {
|
|
fail(e.what(), e.kind() == LinkError::Kind::Rejected);
|
|
} catch (const std::exception& e) {
|
|
fail(e.what(), false);
|
|
}
|
|
}
|
|
|
|
void E1608Source::connect_and_start() {
|
|
std::string host = cfg_.address;
|
|
Mac want{};
|
|
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
|
|
if (host.empty() || have_mac) {
|
|
const std::vector<std::string> targets =
|
|
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
|
|
const std::vector<DeviceInfo> found =
|
|
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
|
|
if (host.empty()) {
|
|
for (const DeviceInfo& d : found) {
|
|
if (d.mac == want) host = d.ip;
|
|
}
|
|
if (host.empty())
|
|
throw LinkError(LinkError::Kind::Io,
|
|
"no E-1608 with MAC " + format_mac(want) + " answered");
|
|
} else {
|
|
if (found.empty()) throw LinkError(LinkError::Kind::Io, "no E-1608 answered at " + host);
|
|
if (found[0].mac != want)
|
|
throw LinkError(LinkError::Kind::Protocol, "the board at " + host + " has MAC " +
|
|
format_mac(found[0].mac) + ", not " +
|
|
format_mac(want));
|
|
}
|
|
}
|
|
|
|
link_.connect(host, cfg_.port, cfg_.code, cfg_.timeout_ms);
|
|
cal_ = Calibration{};
|
|
if (cfg_.calibrate && !dev_.read_calibration(cal_))
|
|
note("calibration memory refused; using nominal values");
|
|
|
|
std::uint8_t inputs = 0;
|
|
for (int b = 0; b < 8; ++b) {
|
|
if (!cfg_.dir_out[b]) inputs = static_cast<std::uint8_t>(inputs | (1u << b));
|
|
}
|
|
dev_.dconf(inputs);
|
|
do_latch_ = static_cast<std::uint8_t>(cfg_.do_init);
|
|
dev_.dout(do_latch_);
|
|
readouts_->do_latch.store(do_latch_);
|
|
for (int ch = 0; ch < 2; ++ch) {
|
|
ao_[ch] = AoSettings{};
|
|
ao_[ch].level = cfg_.ao_init[ch];
|
|
write_ao(ch, cfg_.ao_init[ch]);
|
|
}
|
|
any_gen_ = false;
|
|
update_gen_flags();
|
|
|
|
dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger);
|
|
|
|
pending_.clear();
|
|
for (auto& r : ready_) r.clear();
|
|
pending_lost_ = 0;
|
|
const auto now = Clock::now();
|
|
next_slow_ = now;
|
|
next_status_ = now + std::chrono::milliseconds(500);
|
|
next_gen_ = now + std::chrono::milliseconds(20);
|
|
slow_read();
|
|
|
|
state_ = State::Scanning;
|
|
connected_.store(true, std::memory_order_relaxed);
|
|
if (connects_.fetch_add(1) > 0) ++reconnects_;
|
|
readouts_->epoch.fetch_add(1);
|
|
last_logged_.clear();
|
|
sink_->on_config(signals_, 1);
|
|
sink_->on_event(SourceEvent::Connected, "");
|
|
}
|
|
|
|
int E1608Source::wait_ms(int timeout_ms, Clock::time_point now) const {
|
|
Clock::time_point next = std::min(next_slow_, next_status_);
|
|
if (any_gen_) next = std::min(next, next_gen_);
|
|
const long long d = std::chrono::duration_cast<std::chrono::milliseconds>(next - now).count();
|
|
return static_cast<int>(std::max(0LL, std::min<long long>(d, timeout_ms)));
|
|
}
|
|
|
|
void E1608Source::scan_step(int timeout_ms) {
|
|
const std::size_t n = link_.read_scan(rx_.data(), rx_.size(), wait_ms(timeout_ms, Clock::now()));
|
|
if (n > 0) {
|
|
++packets_;
|
|
pending_.insert(pending_.end(), rx_.begin(), rx_.begin() + static_cast<std::ptrdiff_t>(n));
|
|
const std::size_t nq = queue_.size();
|
|
const std::size_t scans = pending_.size() / (2 * nq);
|
|
if (scans > 0) {
|
|
std::vector<std::uint16_t> raw(scans * nq);
|
|
for (std::size_t i = 0; i < raw.size(); ++i)
|
|
raw[i] = static_cast<std::uint16_t>(pending_[2 * i] | (pending_[2 * i + 1] << 8));
|
|
pending_.erase(pending_.begin(),
|
|
pending_.begin() + static_cast<std::ptrdiff_t>(scans * nq * 2));
|
|
decode_scans(raw.data(), scans, queue_, cal_, dec_);
|
|
for (std::size_t q = 0; q < nq; ++q)
|
|
ready_[q].insert(ready_[q].end(), dec_[q].begin(), dec_[q].end());
|
|
}
|
|
}
|
|
while (!ready_.empty() && ready_[0].size() >= block_) emit_frame(block_);
|
|
run_timers();
|
|
}
|
|
|
|
void E1608Source::emit_frame(std::size_t count) {
|
|
const std::size_t nq = queue_.size();
|
|
const std::size_t total = signals_.size();
|
|
vals_.resize(total);
|
|
views_.resize(total);
|
|
for (std::size_t q = 0; q < nq; ++q) {
|
|
vals_[q].assign(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
|
|
ready_[q].erase(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
|
|
}
|
|
for (std::size_t k = 0; k < plotted_bits_.size(); ++k) vals_[nq + k].assign(count, held_di_[k]);
|
|
if (cfg_.plot_counter) vals_[total - 1].assign(count, held_counter_);
|
|
for (std::size_t i = 0; i < total; ++i)
|
|
views_[i] = SignalValues{vals_[i].data(), static_cast<std::uint32_t>(count)};
|
|
|
|
Frame f;
|
|
f.counter = frame_counter_++;
|
|
f.lost = pending_lost_;
|
|
pending_lost_ = 0;
|
|
f.late = false;
|
|
f.recv_time = epoch_seconds();
|
|
f.publish_mode = 1;
|
|
f.num_samples = static_cast<std::uint32_t>(count);
|
|
f.num_signals = static_cast<std::uint32_t>(total);
|
|
f.signals = signals_.data();
|
|
f.values = views_.data();
|
|
++frames_;
|
|
sink_->on_data(f);
|
|
}
|
|
|
|
void E1608Source::run_timers() {
|
|
const auto now = Clock::now();
|
|
if (now >= next_slow_) {
|
|
next_slow_ = now + std::chrono::duration_cast<Clock::duration>(
|
|
std::chrono::duration<double>(1.0 / cfg_.poll_rate));
|
|
slow_read();
|
|
}
|
|
if (now >= next_status_) {
|
|
next_status_ = now + std::chrono::milliseconds(500);
|
|
check_status();
|
|
}
|
|
if (any_gen_ && now >= next_gen_) {
|
|
next_gen_ = now + std::chrono::milliseconds(20);
|
|
generator_tick(now);
|
|
}
|
|
}
|
|
|
|
void E1608Source::slow_read() {
|
|
const std::uint8_t d = dev_.din();
|
|
const std::uint32_t c = dev_.counter();
|
|
readouts_->di.store(d);
|
|
readouts_->counter.store(c);
|
|
for (std::size_t k = 0; k < plotted_bits_.size(); ++k)
|
|
held_di_[k] = (d >> plotted_bits_[k]) & 1 ? 1.0 : 0.0;
|
|
held_counter_ = static_cast<double>(c);
|
|
}
|
|
|
|
void E1608Source::check_status() {
|
|
const std::uint16_t st = dev_.status();
|
|
if (st & 4) {
|
|
if (!cfg_.auto_restart) {
|
|
fail("scan overrun; the scan is stopped (auto-restart is off)", true);
|
|
return;
|
|
}
|
|
note("scan overrun; restarting");
|
|
sink_->on_event(SourceEvent::Error, "scan overrun; restarting");
|
|
restart_scan();
|
|
pending_lost_ = 1;
|
|
} else if (!(st & 2) && cfg_.auto_restart) {
|
|
restart_scan();
|
|
}
|
|
}
|
|
|
|
void E1608Source::restart_scan() {
|
|
dev_.scan_stop();
|
|
pending_.clear();
|
|
for (auto& r : ready_) r.clear();
|
|
dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger);
|
|
}
|
|
|
|
void E1608Source::write_ao(int ch, double volts) {
|
|
readouts_->ao[ch].store(dev_.aout(ch, volts, cal_));
|
|
}
|
|
|
|
void E1608Source::update_gen_flags() {
|
|
any_gen_ = false;
|
|
for (int ch = 0; ch < 2; ++ch) {
|
|
const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run;
|
|
readouts_->gen_on[ch].store(on);
|
|
any_gen_ = any_gen_ || on;
|
|
}
|
|
}
|
|
|
|
void E1608Source::generator_tick(Clock::time_point now) {
|
|
for (int ch = 0; ch < 2; ++ch) {
|
|
const AoSettings& s = ao_[ch];
|
|
if (s.mode == AoMode::Level || !s.run) continue;
|
|
const double t = std::chrono::duration<double>(now - gen_t0_[ch]).count();
|
|
const double phase = std::fmod(t * s.freq_hz, 1.0);
|
|
write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0));
|
|
}
|
|
}
|
|
|
|
void E1608Source::set_output_bit(int bit, bool on) {
|
|
if (state_ != State::Scanning || bit < 0 || bit > 7 || !cfg_.dir_out[bit]) return;
|
|
do_latch_ = static_cast<std::uint8_t>(on ? (do_latch_ | (1u << bit)) : (do_latch_ & ~(1u << bit)));
|
|
dev_.dout(do_latch_);
|
|
readouts_->do_latch.store(do_latch_);
|
|
}
|
|
|
|
void E1608Source::reset_counter() {
|
|
if (state_ != State::Scanning) return;
|
|
dev_.counter_reset();
|
|
held_counter_ = 0.0;
|
|
readouts_->counter.store(0);
|
|
}
|
|
|
|
void E1608Source::set_analog_out(int ch, const AoSettings& s) {
|
|
if (state_ != State::Scanning || ch < 0 || ch > 1) return;
|
|
ao_[ch] = s;
|
|
ao_[ch].freq_hz = clamp(s.freq_hz, 0.1, 10.0);
|
|
ao_[ch].amplitude = clamp(s.amplitude, 0.0, 10.0);
|
|
ao_[ch].offset = clamp(s.offset, -10.0, 10.0);
|
|
if (s.mode == AoMode::Level) {
|
|
ao_[ch].run = false;
|
|
write_ao(ch, clamp(s.level, -10.0, 10.0));
|
|
} else if (s.run) {
|
|
gen_t0_[ch] = Clock::now();
|
|
}
|
|
update_gen_flags();
|
|
}
|
|
|
|
} // namespace udpscope::e1608
|
|
```
|
|
|
|
Note: `M_PI` needs `<cmath>`, already included through the header.
|
|
|
|
- [ ] **Step 3: Write `e1608_kind.cpp` (the connect form; the panels are Task 4)**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source/e1608/e1608_source.hpp"
|
|
#include "source/registry.hpp"
|
|
|
|
#include <adwaita.h>
|
|
|
|
#include <cstdio>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
using namespace e1608;
|
|
using ChangeCb = std::shared_ptr<std::function<void()>>;
|
|
|
|
constexpr const char* kTriggerNames[] = {"none", "rising", "falling", "high", "low"};
|
|
constexpr Trigger kTriggers[] = {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High,
|
|
Trigger::Low};
|
|
|
|
std::string num(double v) {
|
|
char b[48];
|
|
std::snprintf(b, sizeof b, "%.10g", v);
|
|
return b;
|
|
}
|
|
|
|
GtkWidget* entry_row(const char* title) {
|
|
GtkWidget* r = adw_entry_row_new();
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
|
return r;
|
|
}
|
|
|
|
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
|
|
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
|
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
|
|
return r;
|
|
}
|
|
|
|
GtkWidget* switch_row(const char* title, bool def) {
|
|
GtkWidget* r = adw_switch_row_new();
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(r), def);
|
|
return r;
|
|
}
|
|
|
|
GtkWidget* combo_row(const char* title, const char* const* items) {
|
|
GtkWidget* r = adw_combo_row_new();
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
|
|
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
|
|
return r;
|
|
}
|
|
|
|
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
|
|
GtkWidget* e = adw_expander_row_new();
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
|
|
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
|
|
return e;
|
|
}
|
|
|
|
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
|
|
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
|
|
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
|
|
bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); }
|
|
|
|
/// The form's widgets. Held by a `shared_ptr` that every signal closure and every `ConfigForm`
|
|
/// closure shares; the raw widget pointers are valid while the chooser keeps the rows.
|
|
struct Form {
|
|
GtkWidget *address, *mac, *subnet, *port, *code, *timeout;
|
|
GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate;
|
|
GtkWidget *mode, *ai[8], *ai_group;
|
|
GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2];
|
|
ChangeCb changed = std::make_shared<std::function<void()>>();
|
|
bool updating = false;
|
|
|
|
bool diff() const { return sel_of(mode) == 1; }
|
|
int enabled_ai() const {
|
|
int n = 0;
|
|
for (int i = 0; i < (diff() ? 4 : 8); ++i) n += sel_of(ai[i]) > 0 ? 1 : 0;
|
|
return n;
|
|
}
|
|
|
|
/// In DIFF mode AI rows 4 to 7 are insensitive, not hidden; the group's subtitle shows the load.
|
|
void refresh() {
|
|
const bool d = diff();
|
|
for (int i = 0; i < 8; ++i) gtk_widget_set_sensitive(ai[i], !(d && i >= 4));
|
|
const int n = enabled_ai();
|
|
char b[96];
|
|
std::snprintf(b, sizeof b, "%d channel%s, %.3g of 250 kS/s", n, n == 1 ? "" : "s",
|
|
n * value_of(rate) / 1000.0);
|
|
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(ai_group), b);
|
|
}
|
|
|
|
SourceConfig read() const {
|
|
SourceConfig c;
|
|
if (!text_of(address).empty()) c.set("address", text_of(address));
|
|
if (!text_of(mac).empty()) c.set("mac", text_of(mac));
|
|
if (!text_of(subnet).empty()) c.set("subnet", text_of(subnet));
|
|
c.set("port", num(value_of(port)));
|
|
c.set("code", num(value_of(code)));
|
|
c.set("timeout_ms", num(value_of(timeout)));
|
|
c.set("scan_rate", num(value_of(rate)));
|
|
c.set("ext_clock", on_of(ext_clock) ? "1" : "0");
|
|
c.set("trigger", kTriggerNames[std::min<guint>(sel_of(trigger), 4)]);
|
|
c.set("auto_restart", on_of(auto_restart) ? "1" : "0");
|
|
c.set("calibrate", on_of(calibrate) ? "1" : "0");
|
|
c.set("poll_rate", num(value_of(poll_rate)));
|
|
c.set("mode", diff() ? "diff" : "se");
|
|
static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"};
|
|
for (int i = 0; i < 8; ++i) {
|
|
const guint s = sel_of(ai[i]);
|
|
if (s > 0 && s <= 4 && !(diff() && i >= 4)) c.set("ai" + std::to_string(i), kRange[s]);
|
|
}
|
|
for (int i = 0; i < 8; ++i) {
|
|
const guint s = sel_of(dir[i]); // 0 input, 1 input plotted, 2 output
|
|
c.set("dir" + std::to_string(i), s == 2 ? "o" : "i");
|
|
c.set("plot" + std::to_string(i), s == 1 ? "1" : "0");
|
|
}
|
|
c.set("plot_counter", on_of(plot_counter) ? "1" : "0");
|
|
c.set("do_init", num(value_of(do_init)));
|
|
c.set("ao0_init", num(value_of(ao_init[0])));
|
|
c.set("ao1_init", num(value_of(ao_init[1])));
|
|
return c;
|
|
}
|
|
|
|
void write(const SourceConfig& cfg) {
|
|
const E1608Settings s = E1608Settings::from(cfg);
|
|
updating = true;
|
|
gtk_editable_set_text(GTK_EDITABLE(address), s.address.c_str());
|
|
gtk_editable_set_text(GTK_EDITABLE(mac), s.mac_text.c_str());
|
|
gtk_editable_set_text(GTK_EDITABLE(subnet), s.subnet.c_str());
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(code), static_cast<double>(s.code));
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(timeout), s.timeout_ms);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.scan_rate);
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(ext_clock), s.ext_clock);
|
|
for (guint i = 0; i < 5; ++i) {
|
|
if (kTriggers[i] == s.trigger) adw_combo_row_set_selected(ADW_COMBO_ROW(trigger), i);
|
|
}
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart);
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate);
|
|
adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0);
|
|
for (int i = 0; i < 8; ++i) {
|
|
adw_combo_row_set_selected(
|
|
ADW_COMBO_ROW(ai[i]), s.ai_on[i] ? static_cast<guint>(s.ai_range[i]) + 1 : 0);
|
|
adw_combo_row_set_selected(ADW_COMBO_ROW(dir[i]), s.dir_out[i] ? 2 : (s.plot[i] ? 1 : 0));
|
|
}
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(plot_counter), s.plot_counter);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(do_init), s.do_init);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[0]), s.ao_init[0]);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[1]), s.ao_init[1]);
|
|
updating = false;
|
|
refresh();
|
|
}
|
|
|
|
bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); }
|
|
};
|
|
|
|
using FormPtr = std::shared_ptr<Form>;
|
|
|
|
void on_notify(GObject*, GParamSpec*, gpointer user) {
|
|
Form& f = **static_cast<FormPtr*>(user);
|
|
f.refresh();
|
|
if (!f.updating && f.changed && *f.changed) (*f.changed)();
|
|
}
|
|
|
|
void on_edited(GtkEditable*, gpointer user) {
|
|
Form& f = **static_cast<FormPtr*>(user);
|
|
if (!f.updating && f.changed && *f.changed) (*f.changed)();
|
|
}
|
|
|
|
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
|
|
|
|
void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) {
|
|
g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form,
|
|
static_cast<GConnectFlags>(0));
|
|
}
|
|
|
|
void hook_entry(GtkWidget* w, const FormPtr& f) {
|
|
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
|
|
static_cast<GConnectFlags>(0));
|
|
}
|
|
|
|
class E1608Kind final : public SourceKind {
|
|
public:
|
|
std::string id() const override { return "e1608"; }
|
|
std::string display_name() const override { return "MCC E-1608"; }
|
|
bool has_datagrams() const override { return false; }
|
|
|
|
std::string identity(const SourceConfig& cfg) const override {
|
|
return E1608Settings::from(cfg).identity();
|
|
}
|
|
|
|
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
|
return std::make_unique<E1608Source>(cfg);
|
|
}
|
|
|
|
ConfigForm make_config_form() const override {
|
|
auto f = std::make_shared<Form>();
|
|
static const char* const kTrig[] = {"None", "Rising edge", "Falling edge", "High level",
|
|
"Low level", nullptr};
|
|
static const char* const kMode[] = {"Single-ended (8 channels)", "Differential (4 channels)",
|
|
nullptr};
|
|
static const char* const kAi[] = {"Off", "\u00b110 V", "\u00b15 V", "\u00b12 V", "\u00b11 V",
|
|
nullptr};
|
|
static const char* const kDir[] = {"Input", "Input, plotted", "Output", nullptr};
|
|
|
|
f->address = entry_row("_Address");
|
|
f->mac = entry_row("_MAC address");
|
|
f->subnet = entry_row("_Discovery subnet");
|
|
f->port = spin_row("_Port", 1, 65534, 1, kDefaultPort, 0);
|
|
f->code = spin_row("Connection _code", 0, 4294967295.0, 1, 0, 0);
|
|
f->timeout = spin_row("_Timeout (ms)", 100, 60000, 100, 2000, 0);
|
|
f->rate = spin_row("Scan _rate (S/s per channel)", 0.02, 250000, 1, 1000, 2);
|
|
f->ext_clock = switch_row("_External clock", false);
|
|
f->trigger = combo_row("T_rigger", kTrig);
|
|
f->auto_restart = switch_row("_Restart after an overrun", true);
|
|
f->calibrate = switch_row("Apply _factory calibration", true);
|
|
f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1);
|
|
f->mode = combo_row("Input _mode", kMode);
|
|
for (int i = 0; i < 8; ++i) {
|
|
const std::string title = "Channel " + std::to_string(i);
|
|
f->ai[i] = combo_row(title.c_str(), kAi);
|
|
adw_combo_row_set_selected(ADW_COMBO_ROW(f->ai[i]), i == 0 ? 1 : 0);
|
|
}
|
|
for (int i = 0; i < 8; ++i) {
|
|
const std::string title = "Bit " + std::to_string(i);
|
|
f->dir[i] = combo_row(title.c_str(), kDir);
|
|
}
|
|
f->plot_counter = switch_row("Plot the _counter", false);
|
|
f->do_init = spin_row("Initial output value", 0, 255, 1, 0, 0);
|
|
f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3);
|
|
f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3);
|
|
|
|
GtkWidget* conn = expander("Connection", {f->address, f->mac, f->subnet, f->port, f->code,
|
|
f->timeout});
|
|
GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
|
|
f->calibrate, f->poll_rate});
|
|
GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3],
|
|
f->ai[4], f->ai[5], f->ai[6], f->ai[7]});
|
|
GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4],
|
|
f->dir[5], f->dir[6], f->dir[7], f->plot_counter,
|
|
f->do_init, f->ao_init[0], f->ao_init[1]});
|
|
f->ai_group = ai;
|
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(conn), TRUE);
|
|
|
|
for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f);
|
|
for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->do_init,
|
|
f->ao_init[0], f->ao_init[1]})
|
|
hook_notify(w, "notify::value", f);
|
|
for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter})
|
|
hook_notify(w, "notify::active", f);
|
|
hook_notify(f->trigger, "notify::selected", f);
|
|
hook_notify(f->mode, "notify::selected", f);
|
|
for (int i = 0; i < 8; ++i) {
|
|
hook_notify(f->ai[i], "notify::selected", f);
|
|
hook_notify(f->dir[i], "notify::selected", f);
|
|
}
|
|
f->refresh();
|
|
|
|
ConfigForm out;
|
|
out.rows = {conn, acq, ai, dio};
|
|
out.description =
|
|
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)";
|
|
out.focus = f->address;
|
|
out.write = [f](const SourceConfig& c) { f->write(c); };
|
|
out.read = [f]() { return f->read(); };
|
|
out.valid = [f]() { return f->valid(); };
|
|
ChangeCb cb = f->changed;
|
|
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
|
return out;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
std::unique_ptr<SourceKind> make_e1608_kind() { return std::make_unique<E1608Kind>(); }
|
|
|
|
} // namespace udpscope
|
|
```
|
|
|
|
Note: `out.write/read/valid` capture `f` (a `shared_ptr<Form>`) by value, and the signal closures hold their own copies; `Form` holds no `shared_ptr` to itself, so there is no cycle. `Form::changed` is read through `f->changed` (the same `shared_ptr` object the `on_change` lambda captured).
|
|
|
|
- [ ] **Step 4: Register the kind and add the sources to CMake**
|
|
|
|
`src/source/registry.hpp`, in the declarations block:
|
|
|
|
```cpp
|
|
std::unique_ptr<SourceKind> make_e1608_kind();
|
|
```
|
|
(after `make_probe_kind()`), and update the comment above to `(\`udps_kind.cpp\`, \`probe_source.cpp\`, \`e1608/e1608_kind.cpp\`)`.
|
|
|
|
`src/source/registry.cpp`, in `register_builtin_sources()` between the udps line and the probe line:
|
|
|
|
```cpp
|
|
register_kind(make_e1608_kind());
|
|
```
|
|
|
|
`CMakeLists.txt`, after `src/source/e1608/e1608_device.cpp`:
|
|
|
|
```
|
|
src/source/e1608/e1608_source.cpp
|
|
src/source/e1608/e1608_kind.cpp
|
|
```
|
|
|
|
- [ ] **Step 5: Build, and read the warnings against the baseline**
|
|
|
|
```sh
|
|
cmake --build build -j 2>&1 | tee /tmp/e1608-build3.log | grep -E "warning:|error" | head -20
|
|
```
|
|
Expected: no errors; **no new warning** (the total stays at the Task 0 baseline: `3` on a clean build, `1` incremental).
|
|
|
|
- [ ] **Step 6: Check the form's defaults and the registry with the real app (headless), against the mock**
|
|
|
|
```sh
|
|
python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-t3.log > /dev/null & MOCK=$!
|
|
sleep 1
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 \
|
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
|
--source-opt scan_rate=1000 --source-opt ai1=BIP5V --source-opt plot0=1 --source-opt plot_counter=1 \
|
|
2>&1 | tee /tmp/e1608-t3.out | grep -E "e1608|gtk warnings|Gtk-CRITICAL|Gtk-WARNING|signals|connected" | head -20
|
|
kill $MOCK; cat /tmp/e1608-t3.log
|
|
```
|
|
Expected: no `Gtk-CRITICAL`, `gtk warnings: 0`, no `[e1608]` error line; the mock log shows `HANDSHAKE code=0 status=0`, `DCONF 0xff`, `DOUT 0x00`, two `AOUT` lines with `volts=0.0000`-ish (the DAC calibration moves the code, not the nominal: `volts` reads about `0.00`), `QUEUE 0:0 1:1`, `SCAN_START pacer=39999 options=0 channels=2`, `STREAM rate=1000.0 channels=2`, and `SCAN_STOP` at exit. (The `[stream]` lines of the self-check show the signals `AI0`, `AI1`, `DI0`, `Counter`; their exact format is documented in `probe-readings.md`.) If `--source e1608` is rejected as an unknown kind, the registration in Step 4 was missed.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```sh
|
|
git add CMakeLists.txt src/source/registry.hpp src/source/registry.cpp src/source/e1608/e1608_source.hpp src/source/e1608/e1608_source.cpp src/source/e1608/e1608_kind.cpp
|
|
git commit -m "e1608: the settings, the DataSource state machine, and the connect form"
|
|
```
|
|
Record the commit in the ledger.
|
|
|
|
---
|
|
### Task 4: The two side-stack panels
|
|
|
|
**Files:**
|
|
- Create: `src/source/e1608/e1608_panels.hpp`, `src/source/e1608/e1608_panels.cpp`
|
|
- Modify: `src/source/e1608/e1608_kind.cpp` (override `make_panels`), `CMakeLists.txt` (add `e1608_panels.cpp` after `e1608_kind.cpp`)
|
|
|
|
**Interfaces:**
|
|
- Consumes (Task 3): `E1608Source::post` (inherited), `set_output_bit`, `reset_counter`, `set_analog_out`, `readouts()`, `settings()`; `E1608Readouts`; `AoMode`, `AoSettings`; framework `SourcePanel`, `DataSource`.
|
|
- Produces: `std::vector<SourcePanel> make_e1608_panels(DataSource& source)` in namespace `udpscope`, two panels: id `ao` (title "Analog out", icon `audio-volume-high-symbolic`) and id `dio` (title "Digital I/O", icon `input-dialpad-symbolic`). The app turns the ids into stack pages `source-ao` and `source-dio`. Task 5's probe finds these **widget names** (set with `gtk_widget_set_name` on the widget itself), and they are a contract:
|
|
- `e1608-ao<n>-mode` (`AdwComboRow`), `-level`, `-amp`, `-offset`, `-freq` (`AdwSpinRow`), `-run` (`AdwSwitchRow`), `-last` (`AdwActionRow`), for `n` = 0, 1;
|
|
- `e1608-do<b>` (`AdwSwitchRow`, output bits only), `e1608-di<b>` (`GtkLabel`, input bits only), for `b` = 0..7;
|
|
- `e1608-counter` (`GtkLabel`), `e1608-counter-reset` (`GtkButton`).
|
|
|
|
Behaviour (spec section 3): both panels reach the board **only** through `source.post(...)`; neither is hidden when disconnected (the app makes the panel insensitive; Task 4 adds no code for that). A panel refreshes from `E1608Readouts` in a tick callback throttled to 100 ms. When `readouts->epoch` changes (a (re)connect) the panel resets its widgets to what the source reset to: mode Level, level = the configured initial DAC voltage, amplitude 1, offset 0, frequency 1 Hz, Run off, and each output switch = the DOUT latch bit.
|
|
|
|
Why a second file: `e1608_kind.cpp` is already about 280 lines of form code; the panels are a separate responsibility. (Deviation from the spec's file list, which puts both in `e1608_kind.cpp`; noted in the closing report.)
|
|
|
|
- [ ] **Step 1: Write `e1608_panels.hpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include "source/source_kind.hpp"
|
|
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
|
|
/// The E-1608's two capability panels, "Analog out" and "Digital I/O". `source` must be an
|
|
/// `e1608::E1608Source`. The widgets reach it only through `source.post()`.
|
|
std::vector<SourcePanel> make_e1608_panels(DataSource& source);
|
|
|
|
} // namespace udpscope
|
|
```
|
|
|
|
- [ ] **Step 2: Write `e1608_panels.cpp`**
|
|
|
|
```cpp
|
|
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source/e1608/e1608_panels.hpp"
|
|
|
|
#include "source/e1608/e1608_source.hpp"
|
|
|
|
#include <adwaita.h>
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
using namespace e1608;
|
|
|
|
constexpr gint64 kRefreshUs = 100000; // the panels redraw their readouts at 10 Hz
|
|
|
|
/// A signal closure's argument: the panel's context and the index (DAC or bit) it concerns.
|
|
struct Hook {
|
|
void* ctx = nullptr;
|
|
int idx = 0;
|
|
};
|
|
|
|
GtkWidget* spin(const std::string& name, const char* title, double lo, double hi, double step,
|
|
double def, guint digits) {
|
|
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
|
|
gtk_widget_set_name(r, name.c_str());
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
|
adw_spin_row_set_digits(ADW_SPIN_ROW(r), digits);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
|
|
return r;
|
|
}
|
|
|
|
GtkWidget* switch_row(const std::string& name, const char* title, const char* subtitle) {
|
|
GtkWidget* r = adw_switch_row_new();
|
|
gtk_widget_set_name(r, name.c_str());
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
|
|
if (subtitle != nullptr) adw_action_row_set_subtitle(ADW_ACTION_ROW(r), subtitle);
|
|
return r;
|
|
}
|
|
|
|
bool due(gint64& last_us) {
|
|
const gint64 now = g_get_monotonic_time();
|
|
if (now - last_us < kRefreshUs) return false;
|
|
last_us = now;
|
|
return true;
|
|
}
|
|
|
|
void set_text(GtkWidget* label, const char* text) {
|
|
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
|
|
}
|
|
|
|
GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) {
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
gtk_box_append(GTK_BOX(box), a);
|
|
if (b != nullptr) gtk_box_append(GTK_BOX(box), b);
|
|
return box;
|
|
}
|
|
|
|
// --- Analog out -------------------------------------------------------------------------------
|
|
|
|
struct AoWidgets {
|
|
GtkWidget *mode = nullptr, *level = nullptr, *amp = nullptr, *offset = nullptr, *freq = nullptr,
|
|
*run = nullptr, *last = nullptr;
|
|
};
|
|
|
|
struct AoCtx {
|
|
E1608Source* src = nullptr;
|
|
std::shared_ptr<E1608Readouts> ro;
|
|
AoWidgets w[2];
|
|
Hook hooks[2];
|
|
bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing
|
|
unsigned epoch_seen = 0;
|
|
gint64 last_us = 0;
|
|
};
|
|
|
|
void ao_sensitivity(const AoWidgets& w) {
|
|
const bool level = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode)) == 0;
|
|
gtk_widget_set_sensitive(w.level, level);
|
|
for (GtkWidget* x : {w.amp, w.offset, w.freq, w.run}) gtk_widget_set_sensitive(x, !level);
|
|
}
|
|
|
|
/// Back to what the source holds after a (re)connect.
|
|
void ao_reset(AoCtx* c) {
|
|
c->updating = true;
|
|
for (int ch = 0; ch < 2; ++ch) {
|
|
const AoWidgets& w = c->w[ch];
|
|
adw_combo_row_set_selected(ADW_COMBO_ROW(w.mode), 0);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.level), c->src->settings().ao_init[ch]);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.amp), 1.0);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.offset), 0.0);
|
|
adw_spin_row_set_value(ADW_SPIN_ROW(w.freq), 1.0);
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
|
|
ao_sensitivity(w);
|
|
}
|
|
c->updating = false;
|
|
}
|
|
|
|
void ao_push(AoCtx* c, int ch) {
|
|
if (c->updating) return;
|
|
const AoWidgets& w = c->w[ch];
|
|
const guint mode = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode));
|
|
if (mode == 0 && adw_switch_row_get_active(ADW_SWITCH_ROW(w.run))) { // Level has no generator
|
|
c->updating = true;
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
|
|
c->updating = false;
|
|
}
|
|
ao_sensitivity(w);
|
|
AoSettings s;
|
|
s.mode = static_cast<AoMode>(mode);
|
|
s.level = adw_spin_row_get_value(ADW_SPIN_ROW(w.level));
|
|
s.amplitude = adw_spin_row_get_value(ADW_SPIN_ROW(w.amp));
|
|
s.offset = adw_spin_row_get_value(ADW_SPIN_ROW(w.offset));
|
|
s.freq_hz = adw_spin_row_get_value(ADW_SPIN_ROW(w.freq));
|
|
s.run = adw_switch_row_get_active(ADW_SWITCH_ROW(w.run));
|
|
E1608Source* src = c->src;
|
|
src->post([src, ch, s] { src->set_analog_out(ch, s); });
|
|
}
|
|
|
|
void on_ao_changed(GObject*, GParamSpec*, gpointer user) {
|
|
const Hook* h = static_cast<const Hook*>(user);
|
|
ao_push(static_cast<AoCtx*>(h->ctx), h->idx);
|
|
}
|
|
|
|
void ao_refresh(AoCtx* c) {
|
|
const unsigned ep = c->ro->epoch.load();
|
|
if (ep != c->epoch_seen) {
|
|
c->epoch_seen = ep;
|
|
ao_reset(c);
|
|
}
|
|
for (int ch = 0; ch < 2; ++ch) {
|
|
const double v = c->ro->ao[ch].load();
|
|
char b[48];
|
|
if (std::isnan(v))
|
|
std::snprintf(b, sizeof b, "not written");
|
|
else
|
|
std::snprintf(b, sizeof b, "%+.4f V", v);
|
|
const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->w[ch].last));
|
|
if (cur == nullptr || std::strcmp(cur, b) != 0)
|
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b);
|
|
}
|
|
}
|
|
|
|
gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
|
|
AoCtx* c = static_cast<AoCtx*>(user);
|
|
if (due(c->last_us)) ao_refresh(c);
|
|
return G_SOURCE_CONTINUE;
|
|
}
|
|
|
|
GtkWidget* build_ao(E1608Source* src) {
|
|
auto* c = new AoCtx;
|
|
c->src = src;
|
|
c->ro = src->readouts();
|
|
|
|
GtkWidget* groups[2];
|
|
static const char* const kModes[] = {"Level", "Sine", "Square", "Triangle", "Ramp", nullptr};
|
|
for (int ch = 0; ch < 2; ++ch) {
|
|
const std::string p = "e1608-ao" + std::to_string(ch);
|
|
AoWidgets& w = c->w[ch];
|
|
w.mode = adw_combo_row_new();
|
|
gtk_widget_set_name(w.mode, (p + "-mode").c_str());
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.mode), "Mode");
|
|
adw_combo_row_set_model(ADW_COMBO_ROW(w.mode), G_LIST_MODEL(gtk_string_list_new(kModes)));
|
|
w.level = spin(p + "-level", "Level (V)", -10, 10, 0.1, src->settings().ao_init[ch], 3);
|
|
w.amp = spin(p + "-amp", "Amplitude (V)", 0, 10, 0.1, 1.0, 3);
|
|
w.offset = spin(p + "-offset", "Offset (V)", -10, 10, 0.1, 0.0, 3);
|
|
w.freq = spin(p + "-freq", "Frequency (Hz)", 0.1, 10, 0.1, 1.0, 1);
|
|
w.run = switch_row(p + "-run", "Run", nullptr);
|
|
w.last = adw_action_row_new();
|
|
gtk_widget_set_name(w.last, (p + "-last").c_str());
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.last), "Last written");
|
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(w.last), "not written");
|
|
|
|
GtkWidget* g = adw_preferences_group_new();
|
|
const std::string title = "DAC " + std::to_string(ch);
|
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(g), title.c_str());
|
|
for (GtkWidget* r : {w.mode, w.level, w.amp, w.offset, w.freq, w.run, w.last})
|
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(g), r);
|
|
groups[ch] = g;
|
|
|
|
c->hooks[ch] = {c, ch};
|
|
g_signal_connect_data(w.mode, "notify::selected", G_CALLBACK(on_ao_changed), &c->hooks[ch],
|
|
nullptr, static_cast<GConnectFlags>(0));
|
|
for (GtkWidget* x : {w.level, w.amp, w.offset, w.freq})
|
|
g_signal_connect_data(x, "notify::value", G_CALLBACK(on_ao_changed), &c->hooks[ch], nullptr,
|
|
static_cast<GConnectFlags>(0));
|
|
g_signal_connect_data(w.run, "notify::active", G_CALLBACK(on_ao_changed), &c->hooks[ch],
|
|
nullptr, static_cast<GConnectFlags>(0));
|
|
ao_sensitivity(w);
|
|
}
|
|
|
|
GtkWidget* root = panel_root(groups[0], groups[1]);
|
|
g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c,
|
|
+[](gpointer p) { delete static_cast<AoCtx*>(p); });
|
|
gtk_widget_add_tick_callback(root, ao_tick, c, nullptr);
|
|
return root;
|
|
}
|
|
|
|
// --- Digital I/O ------------------------------------------------------------------------------
|
|
|
|
struct DioCtx {
|
|
E1608Source* src = nullptr;
|
|
std::shared_ptr<E1608Readouts> ro;
|
|
GtkWidget* led[8] = {}; ///< input bits
|
|
GtkWidget* out[8] = {}; ///< output bits (switch rows)
|
|
GtkWidget* counter = nullptr;
|
|
Hook hooks[8];
|
|
bool updating = false;
|
|
unsigned epoch_seen = 0;
|
|
gint64 last_us = 0;
|
|
};
|
|
|
|
void set_led(GtkWidget* l, bool on) {
|
|
if (gtk_widget_has_css_class(l, "success") == on) return;
|
|
if (on) {
|
|
gtk_widget_remove_css_class(l, "dim-label");
|
|
gtk_widget_add_css_class(l, "success");
|
|
} else {
|
|
gtk_widget_remove_css_class(l, "success");
|
|
gtk_widget_add_css_class(l, "dim-label");
|
|
}
|
|
}
|
|
|
|
void dio_refresh(DioCtx* c) {
|
|
const unsigned ep = c->ro->epoch.load();
|
|
if (ep != c->epoch_seen) { // a (re)connect re-latched the outputs from the form
|
|
c->epoch_seen = ep;
|
|
const unsigned latch = c->ro->do_latch.load();
|
|
c->updating = true;
|
|
for (int b = 0; b < 8; ++b) {
|
|
if (c->out[b] != nullptr)
|
|
adw_switch_row_set_active(ADW_SWITCH_ROW(c->out[b]), ((latch >> b) & 1u) != 0);
|
|
}
|
|
c->updating = false;
|
|
}
|
|
const unsigned di = c->ro->di.load();
|
|
for (int b = 0; b < 8; ++b) {
|
|
if (c->led[b] != nullptr) set_led(c->led[b], ((di >> b) & 1u) != 0);
|
|
}
|
|
char buf[24];
|
|
std::snprintf(buf, sizeof buf, "%u", static_cast<unsigned>(c->ro->counter.load()));
|
|
set_text(c->counter, buf);
|
|
}
|
|
|
|
gboolean dio_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
|
|
DioCtx* c = static_cast<DioCtx*>(user);
|
|
if (due(c->last_us)) dio_refresh(c);
|
|
return G_SOURCE_CONTINUE;
|
|
}
|
|
|
|
void on_do_changed(GObject* obj, GParamSpec*, gpointer user) {
|
|
const Hook* h = static_cast<const Hook*>(user);
|
|
DioCtx* c = static_cast<DioCtx*>(h->ctx);
|
|
if (c->updating) return;
|
|
const bool on = adw_switch_row_get_active(ADW_SWITCH_ROW(obj));
|
|
E1608Source* src = c->src;
|
|
const int bit = h->idx;
|
|
src->post([src, bit, on] { src->set_output_bit(bit, on); });
|
|
}
|
|
|
|
void on_counter_reset(GtkButton*, gpointer user) {
|
|
DioCtx* c = static_cast<DioCtx*>(user);
|
|
E1608Source* src = c->src;
|
|
src->post([src] { src->reset_counter(); });
|
|
}
|
|
|
|
GtkWidget* build_dio(E1608Source* src) {
|
|
auto* c = new DioCtx;
|
|
c->src = src;
|
|
c->ro = src->readouts();
|
|
|
|
GtkWidget* lines = adw_preferences_group_new();
|
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(lines), "Digital lines");
|
|
for (int b = 0; b < 8; ++b) {
|
|
const std::string title = "Bit " + std::to_string(b);
|
|
if (src->settings().dir_out[b]) {
|
|
GtkWidget* row = switch_row("e1608-do" + std::to_string(b), title.c_str(), "Output");
|
|
c->out[b] = row;
|
|
c->hooks[b] = {c, b};
|
|
g_signal_connect_data(row, "notify::active", G_CALLBACK(on_do_changed), &c->hooks[b], nullptr,
|
|
static_cast<GConnectFlags>(0));
|
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
|
|
} else {
|
|
GtkWidget* row = adw_action_row_new();
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title.c_str());
|
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), "Input");
|
|
GtkWidget* led = gtk_label_new("\u25cf");
|
|
gtk_widget_set_name(led, ("e1608-di" + std::to_string(b)).c_str());
|
|
gtk_widget_add_css_class(led, "dim-label");
|
|
adw_action_row_add_suffix(ADW_ACTION_ROW(row), led);
|
|
c->led[b] = led;
|
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
|
|
}
|
|
}
|
|
|
|
GtkWidget* counter_group = adw_preferences_group_new();
|
|
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(counter_group), "Counter");
|
|
GtkWidget* crow = adw_action_row_new();
|
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(crow), "Counts");
|
|
c->counter = gtk_label_new("0");
|
|
gtk_widget_set_name(c->counter, "e1608-counter");
|
|
gtk_widget_add_css_class(c->counter, "numeric");
|
|
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), c->counter);
|
|
GtkWidget* reset = gtk_button_new_with_label("Reset");
|
|
gtk_widget_set_name(reset, "e1608-counter-reset");
|
|
gtk_widget_set_valign(reset, GTK_ALIGN_CENTER);
|
|
g_signal_connect_data(reset, "clicked", G_CALLBACK(on_counter_reset), c, nullptr,
|
|
static_cast<GConnectFlags>(0));
|
|
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), reset);
|
|
adw_preferences_group_add(ADW_PREFERENCES_GROUP(counter_group), crow);
|
|
|
|
GtkWidget* root = panel_root(lines, counter_group);
|
|
g_object_set_data_full(G_OBJECT(root), "e1608-dio-ctx", c,
|
|
+[](gpointer p) { delete static_cast<DioCtx*>(p); });
|
|
gtk_widget_add_tick_callback(root, dio_tick, c, nullptr);
|
|
return root;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
|
|
E1608Source* src = static_cast<E1608Source*>(&source);
|
|
SourcePanel ao;
|
|
ao.id = "ao";
|
|
ao.title = "Analog out";
|
|
ao.icon = "audio-volume-high-symbolic";
|
|
ao.widget = build_ao(src);
|
|
SourcePanel dio;
|
|
dio.id = "dio";
|
|
dio.title = "Digital I/O";
|
|
dio.icon = "input-dialpad-symbolic";
|
|
dio.widget = build_dio(src);
|
|
return {ao, dio};
|
|
}
|
|
|
|
} // namespace udpscope
|
|
```
|
|
|
|
Two rules this code follows, from `docs/claude/invariants-gtk-and-chrome.md` (read it before editing GTK code): a notify handler is `(GObject*, GParamSpec*, gpointer)` and a button's `clicked` is `(GtkButton*, gpointer)`; every heap context is freed by a destroy function (`g_object_set_data_full` on the panel root), never leaked. The `Hook` arrays live inside the context, so the pointers handed to the signals stay valid for exactly as long as the widgets.
|
|
|
|
- [ ] **Step 3: Make `E1608Kind` return the panels**
|
|
|
|
In `src/source/e1608/e1608_kind.cpp`, add `#include "source/e1608/e1608_panels.hpp"` below the `registry.hpp` include, and in `class E1608Kind`, after `make_config_form()`:
|
|
|
|
```cpp
|
|
std::vector<SourcePanel> make_panels(DataSource& source) const override {
|
|
return make_e1608_panels(source);
|
|
}
|
|
```
|
|
|
|
`CMakeLists.txt`, after `src/source/e1608/e1608_kind.cpp`:
|
|
|
|
```
|
|
src/source/e1608/e1608_panels.cpp
|
|
```
|
|
|
|
- [ ] **Step 4: Build, and read the warnings against the baseline**
|
|
|
|
```sh
|
|
cmake --build build -j 2>&1 | tee /tmp/e1608-build4.log | grep -E "warning:|error" | head -20
|
|
```
|
|
Expected: no errors, no new warning (baseline: 3 on a clean build, 1 incremental). If `adw_action_row_get_subtitle` or `adw_switch_row_*` is flagged as undeclared, the header is `<adwaita.h>`, already included; a typo in a name is the likelier cause.
|
|
|
|
- [ ] **Step 5: Run the app against the mock and look for GTK complaints**
|
|
|
|
The semantic checks come with Task 5's probe; this step only proves the panels build and run without a GTK complaint.
|
|
|
|
```sh
|
|
python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-t4.log > /dev/null & MOCK=$!
|
|
sleep 1
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=4000 \
|
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
|
--source-opt dir1=o --source-opt ao0_init=1.5 2>&1 | tee /tmp/e1608-t4.out | grep -E "e1608|gtk warnings|Gtk-|GLib|Adwaita" | head -20
|
|
kill $MOCK; grep -c AOUT /tmp/e1608-t4.log
|
|
```
|
|
Expected: no `Gtk-CRITICAL`, no `Gtk-WARNING`, no `GLib-GObject` or `Adwaita-WARNING` line, `gtk warnings: 0`, no `[e1608]` error line; the mock log has `AOUT` twice (the two initial DAC writes: `grep -c` prints `2`).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```sh
|
|
git add CMakeLists.txt src/source/e1608/e1608_panels.hpp src/source/e1608/e1608_panels.cpp src/source/e1608/e1608_kind.cpp
|
|
git commit -m "e1608: the Analog out and Digital I/O panels"
|
|
```
|
|
Record the commit in the ledger.
|
|
|
|
---
|
|
|
|
### Task 5: The probe and the verification runs
|
|
|
|
**Files:**
|
|
- Modify: `src/ui_probe.cpp` (new probes; the `e1608` sample in `source_kinds_probe`; guard the click), `src/ui_app.hpp` (declarations near line 1878), `src/ui.cpp` (schedule near line 6113)
|
|
|
|
**Interfaces:**
|
|
- Consumes (Tasks 3 and 4): `E1608Source` (`settings()`, `connects()`, `last_message()`), `E1608Settings`, `decode_scans`, `Calibration`, `QueueEntry`, `Range`; the panel pages `source-ao` and `source-dio` and the widget names listed under Task 4; app members `a->acq->source()`, `a->source_panels` (`SourcePanelPage{name, title, content}`), `a->side_stack`, `a->capture_button`, `a->model` (`stats()`, `signals()`, `channels()`, `channel_count()`, `live().channel(i)`, `set_signals_enabled()`).
|
|
- Produces: environment variables `UDPSCOPE_PROBE_E1608=<ms>` and `UDPSCOPE_PROBE_E1608_RECONNECT=<ms>`, and the `[e1608]` output lines Task 6 documents. Four functions in `udpscope::ui_impl`: `gboolean e1608_probe(gpointer)`, `e1608_probe_report`, `e1608_probe_reconnect`, `e1608_probe_resumed`.
|
|
|
|
Design: the probe enables the stream's signals itself in one `set_signals_enabled()` call (the way `persist_probe` does) and **not** through `UDPSCOPE_ENABLE_ALL_MS`: that probe switches the first signal off again two seconds later, and a run that depended on it would lose AI0 mid-measurement. Phase 1 (`e1608_probe`, at the variable's delay) enables the signals, reads the panels and drives the three controls; phase 2 (`e1608_probe_report`, 1000 ms later) reads the stream, the calibration, the capture button and the source's state, and runs the decode check.
|
|
|
|
The calibration reading: the mock serves AI0 as `0.5 V + 0.1 V * sin(2 pi 5 t)` on BIP10V through non-trivial factory coefficients (single-ended slope 1.02, intercept 300 on that range). Two 5 Hz periods at the scan rate is `2 * rate / 5` samples (400 at 1000 S/s), whose mean is 0.5 V when the calibration is applied. Without it the mean reads about 0.2043 (Task 2 measured that), so the tolerance of 0.02 separates the two.
|
|
|
|
- [ ] **Step 1: Declare the probes**
|
|
|
|
`src/ui_app.hpp`, after `gboolean source_kinds_probe(gpointer user);` (line ~1878):
|
|
|
|
```cpp
|
|
/// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and
|
|
/// their three controls, the capture button and a hand-checked decode. `_report` is the second phase.
|
|
/// `_reconnect` reads the state after the mock dropped the link; `_resumed` shows the stream moved on.
|
|
gboolean e1608_probe(gpointer user);
|
|
gboolean e1608_probe_report(gpointer user);
|
|
gboolean e1608_probe_reconnect(gpointer user);
|
|
gboolean e1608_probe_resumed(gpointer user);
|
|
```
|
|
|
|
- [ ] **Step 2: Schedule them**
|
|
|
|
`src/ui.cpp`, after the `UDPSCOPE_PROBE_SOURCE_KINDS` block (line ~6112):
|
|
|
|
```cpp
|
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608")) {
|
|
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe, &app);
|
|
}
|
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) {
|
|
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe_reconnect, &app);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write the probes**
|
|
|
|
In `src/ui_probe.cpp` add `#include "source/e1608/e1608_source.hpp"` with the other source includes (after `#include "source/probe_source.hpp"`). Put the code below **directly after `source_kinds_probe`** (before `pan_probe`), inside `namespace ui_impl`:
|
|
|
|
```cpp
|
|
namespace {
|
|
|
|
/// The first widget named `name` at or under `root` (the panels name their controls).
|
|
GtkWidget* find_named(GtkWidget* root, const char* name) {
|
|
if (root == nullptr) return nullptr;
|
|
if (std::strcmp(gtk_widget_get_name(root), name) == 0) return root;
|
|
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
|
|
c = gtk_widget_get_next_sibling(c)) {
|
|
if (GtkWidget* f = find_named(c, name)) return f;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
GtkWidget* source_panel_content(App* a, const char* page) {
|
|
for (const SourcePanelPage& p : a->source_panels) {
|
|
if (p.name == page) return p.content;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
e1608::E1608Source* e1608_source(App* a) {
|
|
return a->acq != nullptr ? dynamic_cast<e1608::E1608Source*>(a->acq->source()) : nullptr;
|
|
}
|
|
|
|
std::uint64_t g_e1608_frames_before = 0;
|
|
|
|
/// A fixed raw buffer decoded against volts worked out by hand: single-ended, ch0 on BIP10V with
|
|
/// intercept 100 (slope 1), ch1 on BIP5V uncalibrated. `(code - 32768) * fs / 32768`, the code
|
|
/// clamped to 0..65535.
|
|
bool e1608_decode_check() {
|
|
using namespace e1608;
|
|
Calibration cal;
|
|
cal.se[0] = {1.0, 100.0};
|
|
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip5V}};
|
|
const std::uint16_t raw[] = {32668, 49152, 49052, 16384, 65535, 0}; // 3 scans x 2 channels
|
|
std::vector<std::vector<double>> out;
|
|
decode_scans(raw, 3, q, cal, out);
|
|
const double want0[] = {0.0, 5.0, 32767.0 * 10.0 / 32768.0};
|
|
const double want1[] = {2.5, -2.5, -5.0};
|
|
if (out.size() != 2 || out[0].size() != 3 || out[1].size() != 3) return false;
|
|
for (int i = 0; i < 3; ++i) {
|
|
if (std::fabs(out[0][static_cast<std::size_t>(i)] - want0[i]) > 1e-9) return false;
|
|
if (std::fabs(out[1][static_cast<std::size_t>(i)] - want1[i]) > 1e-9) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button,
|
|
/// the source's state and the decode.
|
|
gboolean e1608_probe_report(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
e1608::E1608Source* src = e1608_source(a);
|
|
if (src == nullptr) return G_SOURCE_REMOVE;
|
|
const e1608::E1608Settings& s = src->settings();
|
|
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
|
|
|
const std::vector<SignalInfo>& sigs = a->model->signals();
|
|
std::size_t plotted = 0;
|
|
for (bool p : s.plot) plotted += p ? 1 : 0;
|
|
const std::size_t expect =
|
|
static_cast<std::size_t>(s.active_channels()) + plotted + (s.plot_counter ? 1 : 0);
|
|
std::string names;
|
|
for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name;
|
|
const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate;
|
|
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
|
const bool stream_ok = up && sigs.size() == expect && std::fabs(rate - s.scan_rate) < 1e-6 &&
|
|
frames > 0;
|
|
std::fprintf(stderr,
|
|
"[e1608] stream: connected=%d signals=%zu expect=%zu names=%s frames=%" G_GUINT64_FORMAT
|
|
" rate=%.3f ok=%d\n",
|
|
up ? 1 : 0, sigs.size(), expect, names.c_str(), frames, rate, stream_ok ? 1 : 0);
|
|
|
|
// The mock's AI0 is 0.5 V DC under a 5 Hz sine: two periods average to the DC.
|
|
double mean = std::nan("");
|
|
if (up && a->model->channel_count() > 0 && !a->model->channels().empty() &&
|
|
a->model->channels().front().name == "AI0") {
|
|
ChannelBuffer& ch = a->model->live().channel(0);
|
|
const u64 n = ch.size();
|
|
const u64 want = static_cast<u64>(std::lround(2.0 * s.scan_rate / 5.0));
|
|
if (want > 0 && n > want + 50) {
|
|
std::vector<f32> v(static_cast<std::size_t>(want));
|
|
ch.copy_raw(n - want - 50, n - 50, v.data()); // stay clear of the head the producer is writing
|
|
double sum = 0.0;
|
|
for (f32 x : v) sum += double(x);
|
|
mean = sum / double(v.size());
|
|
}
|
|
}
|
|
std::fprintf(stderr, "[e1608] calibration: ai0_mean=%.4f expect=0.5000 ok=%d\n", mean,
|
|
std::fabs(mean - 0.5) < 0.02 ? 1 : 0);
|
|
|
|
std::fprintf(stderr, "[e1608] capture button: present=%d sensitive=%d\n",
|
|
a->capture_button != nullptr ? 1 : 0,
|
|
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
|
|
std::fprintf(stderr, "[e1608] state: connects=%u message='%s'\n", src->connects(),
|
|
src->last_message().c_str());
|
|
std::fprintf(stderr, "[e1608] decode: ok=%d\n", e1608_decode_check() ? 1 : 0);
|
|
std::fflush(stderr);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
/// Phase 1: the panels, then the three controls (a DAC level, a digital output, the counter reset).
|
|
gboolean e1608_probe(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
e1608::E1608Source* src = e1608_source(a);
|
|
if (src == nullptr) {
|
|
std::fprintf(stderr, "[e1608] no e1608 source (run with --source e1608)\n");
|
|
std::fflush(stderr);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
|
|
|
for (const char* id : {"ao", "dio"}) {
|
|
const std::string page = std::string("source-") + id;
|
|
GtkWidget* content = source_panel_content(a, page.c_str());
|
|
const bool present =
|
|
content != nullptr && a->side_stack != nullptr &&
|
|
adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page.c_str()) != nullptr;
|
|
std::fprintf(stderr, "[e1608] panel %s: present=%d sensitive=%d connected=%d\n", id,
|
|
present ? 1 : 0, content != nullptr && gtk_widget_get_sensitive(content) ? 1 : 0,
|
|
up ? 1 : 0);
|
|
}
|
|
|
|
if (up) {
|
|
// All the stream's signals, in one structural change; the plots do not matter to these readings.
|
|
std::vector<std::size_t> all(a->model->signals().size());
|
|
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
|
|
if (!all.empty()) a->model->set_signals_enabled(all, true);
|
|
|
|
GtkWidget* ao = source_panel_content(a, "source-ao");
|
|
GtkWidget* dio = source_panel_content(a, "source-dio");
|
|
GtkWidget* level = find_named(ao, "e1608-ao0-level");
|
|
GtkWidget* out1 = find_named(dio, "e1608-do1");
|
|
GtkWidget* reset = find_named(dio, "e1608-counter-reset");
|
|
// 2.5 V must differ from the configured initial level, or the spin row notifies nothing.
|
|
if (level != nullptr) adw_spin_row_set_value(ADW_SPIN_ROW(level), 2.5);
|
|
if (out1 != nullptr) adw_switch_row_set_active(ADW_SWITCH_ROW(out1), TRUE);
|
|
if (reset != nullptr) g_signal_emit_by_name(reset, "clicked");
|
|
std::fprintf(stderr, "[e1608] actions: ao0_level=%d do1=%d counter_reset=%d\n",
|
|
level != nullptr ? 1 : 0, out1 != nullptr ? 1 : 0, reset != nullptr ? 1 : 0);
|
|
} else {
|
|
std::fprintf(stderr, "[e1608] actions: skipped (not connected)\n");
|
|
}
|
|
std::fflush(stderr);
|
|
g_timeout_add(1000, e1608_probe_report, a);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
/// After the mock dropped the link: has the source reconnected, and does the stream move again?
|
|
gboolean e1608_probe_resumed(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed);
|
|
std::fprintf(stderr, "[e1608] resumed: frames_before=%" G_GUINT64_FORMAT " frames_after=%" G_GUINT64_FORMAT
|
|
" ok=%d\n",
|
|
g_e1608_frames_before, after, after > g_e1608_frames_before ? 1 : 0);
|
|
std::fflush(stderr);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean e1608_probe_reconnect(gpointer user) {
|
|
App* a = static_cast<App*>(user);
|
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
|
e1608::E1608Source* src = e1608_source(a);
|
|
if (src == nullptr) return G_SOURCE_REMOVE;
|
|
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
|
g_e1608_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
|
|
std::fprintf(stderr, "[e1608] reconnect: connects=%u connected=%d ok=%d\n", src->connects(),
|
|
up ? 1 : 0, src->connects() >= 2 && up ? 1 : 0);
|
|
std::fflush(stderr);
|
|
g_timeout_add(500, e1608_probe_resumed, a);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
```
|
|
|
|
If the build reports that `ChannelBuffer`, `u64` or `f32` are not found in `ui_probe.cpp`, they are already used unqualified elsewhere in this file (the `[gather]` probe near line 345 uses `u64` and `f32`); copy that file's own spelling. If `copy_raw` is not accessible on `ChannelBuffer`, call it through `const plot::data::Series&` (it is the base class's public virtual).
|
|
|
|
- [ ] **Step 4: Add the `e1608` sample to `source_kinds_probe`, and guard its click**
|
|
|
|
In `source_kinds_probe`, in the `sample` lambda, extend the chain after the `probe` branch:
|
|
|
|
```cpp
|
|
} else if (id == "e1608") {
|
|
c.set("address", "10.0.0.5");
|
|
c.set("port", "54300");
|
|
c.set("scan_rate", "2000");
|
|
c.set("mode", "diff");
|
|
c.set("ai1", "BIP5V");
|
|
c.set("trigger", "rising");
|
|
c.set("dir1", "o");
|
|
c.set("plot0", "1");
|
|
}
|
|
```
|
|
(the existing `}` that closes the `probe` branch moves to the end of this one). The sample has one analog input on, so `valid=1`.
|
|
|
|
Then guard the click, which assumed the probe kind's button is the panel's last child:
|
|
|
|
```cpp
|
|
if (page != nullptr && src != nullptr) {
|
|
```
|
|
in place of `if (page != nullptr) {`. (With `--source e1608` the active source is not a `ProbeSource`, so `src` is null and nothing is clicked.)
|
|
|
|
- [ ] **Step 5: Build**
|
|
|
|
```sh
|
|
cmake --build build -j 2>&1 | tee /tmp/e1608-build5.log | grep -E "warning:|error" | head -20
|
|
```
|
|
Expected: no errors; the warning count stays at the baseline (1 incremental).
|
|
|
|
- [ ] **Step 6: Run A, no board: the failure path**
|
|
|
|
```sh
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 \
|
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
|
2>&1 | tee /tmp/e1608-runA.out | grep -E "\[e1608\]|gtk warnings|Gtk-"
|
|
```
|
|
Expected, in this order:
|
|
```
|
|
[e1608] <one line, a refusal or a timeout from the handshake; printed once, not every retry>
|
|
[e1608] panel ao: present=1 sensitive=0 connected=0
|
|
[e1608] panel dio: present=1 sensitive=0 connected=0
|
|
[e1608] actions: skipped (not connected)
|
|
[e1608] stream: connected=0 signals=0 expect=1 names= frames=0 rate=0.000 ok=0
|
|
[e1608] calibration: ai0_mean=nan expect=0.5000 ok=0
|
|
[e1608] capture button: present=1 sensitive=0
|
|
[e1608] state: connects=0 message='<the same text as the first line>'
|
|
[e1608] decode: ok=1
|
|
gtk warnings: 0
|
|
```
|
|
The point of A: the panels exist and are insensitive with no board (disabled, not hidden), the source retries without flooding stderr, and `decode: ok=1` needs no board. Anything else is a defect; `sensitive=1` here means `apply_source_panels` is not reaching the panel, `present=0` means `make_panels` returned nothing.
|
|
|
|
- [ ] **Step 7: Run B, the mock: stream, calibration, controls, reconnect**
|
|
|
|
```sh
|
|
rm -f /tmp/e1608-mock.log
|
|
python3 tools/mock_e1608.py --port 54711 --drop-after 3 --log /tmp/e1608-mock.log > /dev/null & MOCK=$!
|
|
sleep 1
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_PROBE_E1608_RECONNECT=8000 \
|
|
UDPSCOPE_EXIT_AFTER_MS=10500 \
|
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
|
--source-opt scan_rate=1000 --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 \
|
|
--source-opt plot_counter=1 --source-opt dir1=o --source-opt ao0_init=1.5 \
|
|
2>&1 | tee /tmp/e1608-runB.out | grep -E "\[e1608\]|gtk warnings|Gtk-"
|
|
kill $MOCK
|
|
```
|
|
(The mock's `--drop-after 3` counts from the first command connection; the app first connects within a fraction of a second, so the link drops at about 3 s, is seen at the next status read, and comes back after the 2 s retry, at about 5.5 s, well before the 8 s reconnect probe.)
|
|
|
|
Expected:
|
|
```
|
|
[e1608] panel ao: present=1 sensitive=1 connected=1
|
|
[e1608] panel dio: present=1 sensitive=1 connected=1
|
|
[e1608] actions: ao0_level=1 do1=1 counter_reset=1
|
|
[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=<N> rate=1000.000 ok=1
|
|
[e1608] calibration: ai0_mean=0.50xx expect=0.5000 ok=1
|
|
[e1608] capture button: present=1 sensitive=0
|
|
[e1608] state: connects=1 message=''
|
|
[e1608] decode: ok=1
|
|
[e1608] <a line about the drop: the link closed / the scan socket closed> (once, near the 3 s mark)
|
|
[e1608] reconnect: connects=2 connected=1 ok=1
|
|
[e1608] resumed: frames_before=<N> frames_after=<M> ok=1
|
|
gtk warnings: 0
|
|
```
|
|
(`names=` lists the enabled channels in signal order, which is `AI0,AI1,DI0,Counter`. `message=` is empty at 2.5 s unless the calibration read was refused, in which case it reads `calibration memory refused; using nominal values` and the calibration line would be `ok=0`: the mock serves the memory, so that would be a defect.) Then the mock's side:
|
|
|
|
```sh
|
|
python3 - <<'EOF'
|
|
import re
|
|
log = open('/tmp/e1608-mock.log').read()
|
|
ao0 = [float(m) for m in re.findall(r"AOUT ch=0 code=\d+ volts=(-?[0-9.]+)", log)]
|
|
print("ao0 :", ao0)
|
|
print("dout:", re.findall(r"DOUT (0x[0-9a-f]+)", log))
|
|
for needle in ("COUNTER_RESET", "DROP", "SCAN_START", "STREAM"):
|
|
print(needle, log.count(needle))
|
|
EOF
|
|
```
|
|
Expected: `ao0 : [1.5, 2.5, 1.5]` (each within 0.01: the initial level, the probe's 2.5 V from the panel, the initial level again after the reconnect), `dout: ['0x00', '0x02', '0x00']` (initial, the Bit 1 switch, re-latched after the reconnect), `COUNTER_RESET 1`, `DROP 1`, `SCAN_START 2`, `STREAM 2`. If `ao0` has no 2.5 entry, the posted command did not reach the board: check `post()` is drained inside the `try` (`E1608Source::poll`), and that the level spin's `notify::value` is connected. If `dout` lacks `0x02`, the `e1608-do1` row was not found (`actions: do1=0` says so) or `dir1=o` did not reach the settings.
|
|
|
|
- [ ] **Step 8: Run C, a rejected connection code**
|
|
|
|
```sh
|
|
python3 tools/mock_e1608.py --port 54711 --code 1234 > /dev/null & MOCK=$!
|
|
sleep 1
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 \
|
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
|
--source-opt code=7 2>&1 | tee /tmp/e1608-runC.out | grep -E "\[e1608\]|gtk warnings|Gtk-"
|
|
kill $MOCK
|
|
```
|
|
Expected: exactly one `[e1608] the board rejected the connection code` line (no repeats: a rejected code stops the retries), the panels `sensitive=0 connected=0`, `stream: connected=0 ... ok=0`, `state: connects=0 message='the board rejected the connection code'`, `gtk warnings: 0`. Repeated rejection lines mean the retry loop was not halted (`fail(..., true)` for `Kind::Rejected`).
|
|
|
|
Then the same run with the right code, `--source-opt code=1234` against `--code 1234`, must connect (`stream: ... ok=1`).
|
|
|
|
- [ ] **Step 9: The UDPS regression, and the framework probe**
|
|
|
|
The UDPS path must read as it did on `main`. Re-run Task 0's self-check against the UDPS mock and compare the distinct lines (the counts that repeat vary with timing, so the sorted unique lines are what is compared):
|
|
|
|
```sh
|
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
|
sleep 1
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/udps-now.raw 2>&1
|
|
kill $MOCK
|
|
sed 's/[0-9.]\+/N/g' /tmp/udps-now.raw > /tmp/udps-now.norm
|
|
diff <(sort -u /tmp/udps-baseline.norm) <(sort -u /tmp/udps-now.norm) && echo "UDPS self-check unchanged"
|
|
grep -c "gtk warnings: 0" /tmp/udps-now.raw
|
|
```
|
|
Expected: `UDPS self-check unchanged`, then `1`; no `Gtk-CRITICAL` in `/tmp/udps-now.raw`. Then the framework probe, which now also exercises the `e1608` form:
|
|
|
|
```sh
|
|
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=3000 \
|
|
./build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "source-kinds|gtk warnings|Gtk-"
|
|
```
|
|
Expected: `kinds=udps,e1608,probe`, `form udps: round_trip=1 valid=1`, `form e1608: round_trip=1 valid=1`, `form probe: round_trip=1 valid=1`, `panel disconnected: panels=1 present=1 sensitive=0`, `panel connected: panels=1 present=1 sensitive=1`, `capture button: present=1 sensitive=0`, `command: before=0 after=1 drained=1`, `gtk warnings: 0`. `round_trip=0` on the e1608 line names a `SourceConfig` key that `Form::write` and `Form::read` disagree about; the sample's keys are `address port scan_rate mode ai1 trigger dir1 plot0`.
|
|
|
|
Finally the clean-build warning count: `cmake --build build --clean-first 2>&1 | grep -c "warning:"` must print `3`.
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```sh
|
|
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp
|
|
git commit -m "e1608: UDPSCOPE_PROBE_E1608 and the e1608 form sample in the source-kinds probe"
|
|
```
|
|
Record the commit in the ledger.
|
|
|
|
---
|
|
### Task 6: Documentation
|
|
|
|
**Files (all modify):** `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `README.md`.
|
|
|
|
**Interfaces:** consumes the behaviour and the `[e1608]` lines of Tasks 3 to 5. Produces nothing code depends on. Edit the text to match what the code and the probe **actually printed** in Task 5's runs; where a line below disagrees with a recorded run, the run wins.
|
|
|
|
Read each file around the anchor first; the wording below is the content to add, in the voice and format of the surrounding text (these documents state rules and the defect a violation names, not tutorials).
|
|
|
|
- [ ] **Step 1: `docs/claude/architecture.md`, "### Sources"**
|
|
|
|
After the bullet that ends `... and nothing existing moves.`, append this bullet:
|
|
|
|
```markdown
|
|
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
|
|
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
|
|
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types;
|
|
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
|
|
never reaches the UI files.
|
|
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
|
|
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check, the digital
|
|
input/counter poll and the 20 ms waveform tick. There is no second thread. A connect attempt can
|
|
block `poll()` for up to the command timeout; that delays only the command queue. Every exception is
|
|
caught inside `poll()`: an escape would end the producer thread, and `drain_commands()` runs inside
|
|
the same `try` for that reason.
|
|
- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not
|
|
whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the
|
|
first one's. Digital lines and the counter are held across a block (a step trace).
|
|
- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes
|
|
and a 100 ms GTK tick reads; a panel never touches the source's internals, and writes only through
|
|
`post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`, so the
|
|
source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per
|
|
retry); that line is where a user sees why a connection failed.
|
|
```
|
|
|
|
- [ ] **Step 2: `docs/claude/build-and-run.md`**
|
|
|
|
After the paragraph that begins `Options: \`--source NAME\``, add:
|
|
|
|
```markdown
|
|
`--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional
|
|
(`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel,
|
|
`ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10,
|
|
`mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (absent = off; no `aiN` at all means
|
|
`ai0=BIP10V`), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`).
|
|
The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol
|
|
mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine
|
|
per channel, calibration memory, `--drop-after S` and `--overrun-after S` switches, `--code N`); there is
|
|
no real board in the loop, and the mock's header lists the commands whose layout is unverified.
|
|
|
|
```sh
|
|
python3 tools/mock_e1608.py --port 54711 &
|
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
|
--source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1
|
|
```
|
|
```
|
|
|
|
- [ ] **Step 3: `docs/claude/verification.md`**
|
|
|
|
After the `UDPSCOPE_PROBE_SOURCE_KINDS` entry (it ends `... the reading's own entry is in \`probe-readings.md\`.`), add two entries, and change `kinds=` wherever this file quotes it to `udps,e1608,probe`:
|
|
|
|
```markdown
|
|
- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against
|
|
`tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a
|
|
digital-output toggle and a counter reset each reaching the mock, the stream's channels and rate, the
|
|
factory calibration applied, the capture button disabled, and a hand-computed decode. It enables the
|
|
signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS` (that probe switches the first signal off
|
|
after two seconds). Three runs, each short and serial, with the mock killed afterwards:
|
|
- **no board:** panels `sensitive=0`, `connected=0`, no retry flood on stderr;
|
|
- **the mock:** `--drop-after 3 --log /tmp/e1608-mock.log`, options `scan_rate=1000 ai0=BIP10V
|
|
ai1=BIP5V plot0=1 plot_counter=1 dir1=o ao0_init=1.5`, with `UDPSCOPE_PROBE_E1608=1500
|
|
UDPSCOPE_PROBE_E1608_RECONNECT=8000 UDPSCOPE_EXIT_AFTER_MS=10500`; then read the mock's log: the AO
|
|
levels `1.5, 2.5, 1.5`, the DOUT bytes `0x00, 0x02, 0x00`, one `COUNTER_RESET`, one `DROP`;
|
|
- **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop.
|
|
- `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms
|
|
later checks the frame count moved on. The reading's own entry is in `probe-readings.md`.
|
|
```
|
|
|
|
- [ ] **Step 4: `docs/claude/probe-readings.md`**
|
|
|
|
(a) In the `## \`[source-kinds]\`` entry, change the first output line to `[source-kinds] kinds=udps,e1608,probe`, add the line `[source-kinds] form e1608: round_trip=1 valid=1` between the `udps` and `probe` form lines, change the bullet `kinds=udps,probe` to `kinds=udps,e1608,probe`, and add to the `round_trip=0` bullet: `The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those.`
|
|
|
|
(b) After that section (before `## \`[prefs-probe]\``), add a new section:
|
|
|
|
````markdown
|
|
## `[e1608]`
|
|
|
|
```
|
|
[e1608] panel ao: present=1 sensitive=1 connected=1
|
|
[e1608] panel dio: present=1 sensitive=1 connected=1
|
|
[e1608] actions: ao0_level=1 do1=1 counter_reset=1
|
|
[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=300 rate=1000.000 ok=1
|
|
[e1608] calibration: ai0_mean=0.5002 expect=0.5000 ok=1
|
|
[e1608] capture button: present=1 sensitive=0
|
|
[e1608] state: connects=1 message=''
|
|
[e1608] decode: ok=1
|
|
[e1608] reconnect: connects=2 connected=1 ok=1
|
|
[e1608] resumed: frames_before=2900 frames_after=2950 ok=1
|
|
```
|
|
|
|
`UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and
|
|
`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the last two), run as `--source e1608` against `tools/mock_e1608.py`
|
|
with the options in `verification.md`. `connected=0` throughout, with `actions: skipped (not connected)`, is the
|
|
no-board run; it is a reading and not a failure.
|
|
|
|
- `panel ...: present=1 sensitive=<connected>`: **disable, don't hide.** `present=0` is a panel that was never
|
|
added; `sensitive` that differs from `connected` is `apply_source_panels` acting on the wrong widget.
|
|
- `actions: ao0_level=1 do1=1 counter_reset=1`: each control was found by its widget name (`e1608-ao0-level`,
|
|
`e1608-do1`, `e1608-counter-reset`). A `0` is a renamed widget, or (`do1`) `dir1=o` missing from the run. The
|
|
effects are read off the **mock's log**, not here: a DAC write near 2.5 V (within 0.01), `DOUT 0x02`, `COUNTER_RESET`.
|
|
A control that was found but whose effect is missing is a command that `post()` did not deliver or `poll()`
|
|
did not run.
|
|
- `stream: signals=4 expect=4 rate=1000.000 ok=1`: the enabled channels plus the plotted digital lines plus the
|
|
counter, at the scan rate. `signals` below `expect` is a signal the source did not declare; `rate` off by a
|
|
factor is the pacer computed from the wrong aggregate.
|
|
- `calibration: ai0_mean=0.50xx ok=1`: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through
|
|
non-trivial factory coefficients; the mean over two periods lands on 0.5 V only when `decode()` applies them.
|
|
About `0.2043` is the **uncalibrated** reading (the calibration read failed, or `calibrate=0`); `nan` is a
|
|
run that never got AI0 (not connected, or AI0 not enabled).
|
|
- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI disabled, not hidden.
|
|
- `state: connects=1 message=''`: a non-empty message at this point is a refusal that happened (for example
|
|
`calibration memory refused; using nominal values`), which the stream then ran through.
|
|
- `decode: ok=1`: a fixed raw buffer against hand-computed volts (intercept 100, BIP10V and BIP5V, the code
|
|
clamp at both ends). It needs no board; `ok=0` is a change to the calibration arithmetic.
|
|
- `reconnect: connects=2 connected=1`, `resumed: ok=1`: the mock dropped both links at about 3 s; the source
|
|
reported it, retried after 2 s and the frame count moved on. `connects=1` is a source that never noticed the
|
|
drop (the status check does not run), `resumed: ok=0` is a reconnect that did not restart the scan.
|
|
````
|
|
|
|
- [ ] **Step 5: `README.md`, "## Data sources"**
|
|
|
|
(a) After the paragraph ending `... only when \`UDPSCOPE_PROBE_SOURCE_KINDS\` is set, and exists only for the self-check.`, add:
|
|
|
|
```markdown
|
|
The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two
|
|
analog outputs, eight digital lines and a 32-bit counter). Its connection form takes an address, or a MAC address
|
|
to look up (optionally with the subnet to sweep), and groups the acquisition settings (scan rate, external clock,
|
|
trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the
|
|
direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input
|
|
and the counter is an extra channel only if you ask for it. Two side-stack tabs appear while it runs: **Analog out**
|
|
(per DAC: a static level, or a sine, square, triangle or ramp from 0.1 to 10 Hz) and **Digital I/O** (a live LED per
|
|
input bit, a switch per output bit, the counter with a reset). Acquisition is hardware-paced only (the scan rate or
|
|
an external clock), the channels times the rate must stay at or below 250 kS/s, and the form refuses more.
|
|
|
|
**This kind has not been run against a board.** It is verified against `tools/mock_e1608.py`, a protocol mock written
|
|
from two independent implementations of the wire protocol. The following rest on a single reference or the two
|
|
disagree, and are the first thing to check when a board is attached: the trigger option bits (none 0, rising 4,
|
|
falling 8, high 12, low 16), the counter reset command (0x31), the calibration memory layout and its
|
|
coefficient type, the DIFF input numbering (channel + 8) and the status word's bits, in particular whether the
|
|
"scan running" bit stays set while the board waits for a trigger (the source restarts a scan that reads as stopped
|
|
when auto-restart is on). The scan overrun count is approximate: the board does not say how many scans it
|
|
dropped, so the frame after a restart reports one lost. Errors (no device, a rejected connection code, a
|
|
timeout, the scan socket closing) are written to stderr as `[e1608] ...` once per distinct message; the window itself shows them as the
|
|
connection status.
|
|
```
|
|
|
|
Check the last sentence against what the window actually does with an `Error` event before keeping it: `Acquisition::on_event` ignores the detail string, so if the window shows nothing but `Disconnected`, change "the window itself shows them as the connection status" to "the window shows only that the source is disconnected".
|
|
|
|
(b) Replace the paragraph `Planned, each as its own sub-project: a flat UDP kind, the MCC E-1608 and ABScope. ABScope has ...` with the same sentence minus the E-1608: `Planned, each as its own sub-project: a flat UDP kind and ABScope. ABScope has more than one timebase, so it will change the frame and the data path together rather than fit the present single-timebase frame.` (Keep the original continuation of the sentence word for word.)
|
|
|
|
(c) In README's source-tree listing (the block that lists `src/layout_store.*` and its neighbours), add the line `src/source/e1608/ the MCC E-1608 kind: link, device, source, connect form and panels` beside the existing `src/source/` entry, if one exists; if the listing has no `src/source/` line, skip this item.
|
|
|
|
- [ ] **Step 6: Re-read the baselines, then commit**
|
|
|
|
```sh
|
|
cmake --build build -j 2>&1 | grep -c "warning:" # no source changed in this task: the incremental baseline, 0 or 1
|
|
git diff --stat
|
|
git add docs/claude/architecture.md docs/claude/build-and-run.md docs/claude/verification.md docs/claude/probe-readings.md README.md
|
|
git commit -m "docs: the E-1608 source kind, its probe readings and its unverified commands"
|
|
```
|
|
Record the commit in the ledger.
|
|
|
|
---
|
|
|
|
## Closing report (for the final message to the user, not a task)
|
|
|
|
After the final whole-branch review passes, report in this order, briefly:
|
|
|
|
1. What was built (the three layers, the two panels, the mock, the probe) and the commit range on `feature/source-e1608`.
|
|
2. **The deviations from the spec**, each with its reason:
|
|
1. The waveform generator ticks every 20 ms; "10 Hz at most" is applied to the waveform **frequency** (0.1 to 10 Hz), not to the write rate.
|
|
2. `valid()` accepts a MAC with an empty subnet (it means "broadcast discovery"); the spec said a MAC plus a subnet.
|
|
3. `valid()` also requires at least one enabled analog input; with no `aiN` key at all, `ai0=BIP10V` is the default, so a bare `--source-opt address=...` connects.
|
|
4. A nonzero reply status is `LinkError::Kind::Command`, distinct from `Protocol`; the calibration read swallows only `Command`, so a board that refuses the calibration memory runs on nominal values while a broken link still fails.
|
|
5. Errors are also written to stderr as `[e1608] ...`, because `Acquisition::on_event` ignores the detail string and does nothing for `Error`.
|
|
6. The two panels live in `e1608_panels.cpp`, not in `e1608_kind.cpp` as the spec's file list says, to keep each file to one responsibility.
|
|
3. **The unverified-command list** from the README paragraph in Task 6 (it is the same list as the mock's header), and that no real board has been run.
|
|
4. That nothing was pushed or merged; `feature/source-e1608` is branched from the unmerged `feature/source-plugins`.
|