diff --git a/docs/superpowers/plans/2026-10-31-e1608-source.md b/docs/superpowers/plans/2026-10-31-e1608-source.md new file mode 100644 index 0000000..3db59b7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-e1608-source.md @@ -0,0 +1,3592 @@ +# 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-`, 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= status=` + - `DOUT 0x<2 hex>`, `DCONF 0x<2 hex>` + - `AOUT ch= code= volts=<%.4f>` (volts = the code through the *inverse* of the served DAC calibration) + - `COUNTER_RESET` + - `QUEUE : ...` + - `SCAN_START pacer= options= channels=`, `STREAM rate=<%.1f> channels=`, `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("= 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(" 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(" 1: + return 2, b"" + ch, code = struct.unpack(" 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("= 5: + (code,) = struct.unpack(" 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("`; `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 sweep_targets(const std::string& subnet)`; `std::vector discover(const std::vector& 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`, `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&, const Calibration&, std::vector>& 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 +#include +#include +#include +#include +#include + +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; + +/// 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 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 discover(const std::vector& 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 command(std::uint8_t cmd, const std::vector& 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(deadline - Clock::now()); + return d.count() < 0 ? 0 : static_cast(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(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(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(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(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(&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(c - '0'); + else if (c >= 'a' && c <= 'f') v |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= static_cast(c - 'A' + 10); + else return false; + } + m[i] = static_cast(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 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 out; + out.reserve(254); + for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h)); + return out; +} + +std::vector discover(const std::vector& targets, int port, int wait_ms) { + std::vector 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(&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(&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(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(&a), sizeof a) < 0) + sys_fail("handshake with " + host); + const std::uint8_t msg[5] = {'C', static_cast(code & 0xFF), + static_cast((code >> 8) & 0xFF), + static_cast((code >> 16) & 0xFF), + static_cast((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 Link::command(std::uint8_t cmd, const std::vector& 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 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(payload.size() & 0xFF)); + f.push_back(static_cast((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(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(h[4] | (h[5] << 8)); + if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply"); + std::vector 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(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(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 +#include +#include +#include + +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& queue, + const Calibration& cal, std::vector>& 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& 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 +#include +#include +#include + +namespace udpscope::e1608 { + +namespace { + +constexpr double kClockHz = 80e6; + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(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(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); + float f; + std::memcpy(&f, &u, sizeof f); + return f; +} + +void put_le32(std::vector& v, std::uint32_t x) { + for (int i = 0; i < 4; ++i) v.push_back(static_cast((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(count * 8); + const std::vector req = {static_cast(addr & 0xFF), + static_cast(addr >> 8), + static_cast(bytes & 0xFF), + static_cast(bytes >> 8)}; + const std::vector r = link.command(0x40, req, bytes); + for (int i = 0; i < count; ++i) { + out[i].slope = le_f32(&r[static_cast(i) * 8]); + out[i].intercept = le_f32(&r[static_cast(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(r)]; +} + +const char* range_name(Range r) { + static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"}; + return n[static_cast(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(i)))) { + out = static_cast(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(range)]; + const double code = clamp_code(std::round(static_cast(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(clamp_code(std::round(nominal * c.slope + c.intercept))); +} + +void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector& queue, + const Calibration& cal, std::vector>& 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(ch & 1), static_cast(code & 0xFF), + static_cast(code >> 8)}); + return v; +} + +std::uint32_t Device::counter() { + const std::vector r = link_.command(0x30, {}, 4); + return static_cast(r[0]) | (static_cast(r[1]) << 8) | + (static_cast(r[2]) << 16) | (static_cast(r[3]) << 24); +} + +void Device::counter_reset() { link_.command(0x31); } + +std::uint16_t Device::status() { + const std::vector r = link_.command(0x52, {}, 2); + return static_cast(r[0] | (r[1] << 8)); +} + +void Device::scan_start(const std::vector& queue, double aggregate_hz, bool ext_clock, + Trigger trigger) { + link_.open_scan(); + try { + std::vector q; + q.push_back(static_cast(queue.size())); + for (const QueueEntry& e : queue) { + q.push_back(e.wire_channel); + q.push_back(static_cast(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::min(4294967295.0, std::max(1.0, p))); + } + std::vector s; + put_le32(s, 0); // count 0: scan until stopped + put_le32(s, pacer); + s.push_back(static_cast(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 +#include +#include +#include +#include +#include + +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& v) { + double s = 0; + for (double x : v) s += x; + return s / static_cast(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 q = {{0, Range::Bip10V}, {1, Range::Bip5V}}; + const std::uint16_t raw[4] = {0x8000, 0x4000, 0xC000, 0x6000}; + std::vector> 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 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 q = {{0, Range::Bip10V}, {1, Range::Bip10V}}; + dev.scan_start(q, 2000.0, false, Trigger::None); + std::vector 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 raw(400); + std::memcpy(raw.data(), bytes.data(), 800); + std::vector> 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`), `settings()`, `connects()` (`unsigned`, thread-safe), `last_message()` (`std::string`, thread-safe). + - In `udpscope`: `std::unique_ptr 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 +#include +#include +#include +#include +#include +#include +#include + +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 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 di{0}; ///< the DIN byte as last read + std::atomic do_latch{0}; ///< the DOUT byte as last written + std::atomic counter{0}; + std::atomic ao[2]; ///< the last DAC voltage written; NaN before any + std::atomic gen_on[2]; + std::atomic 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 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 queue_; + std::vector signals_; + std::vector plotted_bits_; ///< the DI bits that are plot channels, ascending + std::size_t block_ = 10; + + std::shared_ptr readouts_ = std::make_shared(); + std::atomic connected_{false}; + std::atomic connects_{0}; + mutable std::mutex msg_mu_; + std::string last_message_; + std::string last_logged_; + + std::vector rx_; ///< the read buffer + std::vector pending_; ///< bytes of an incomplete scan + std::vector> ready_; ///< decoded, not yet framed: [queue index][scan] + std::vector> dec_; + std::vector> vals_; ///< the frame being built: [signal][sample] + std::vector views_; + std::vector 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 +#include +#include +#include +#include + +namespace udpscope::e1608 { + +namespace { + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(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(s[a]))) ++a; + while (b > a && std::isspace(static_cast(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(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(c.get_int("port", kDefaultPort)); + const long code = c.get_int("code", 0); + s.code = static_cast(std::min(4294967295L, std::max(0L, code))); + s.timeout_ms = static_cast(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(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 E1608Settings::queue() const { + std::vector q; + for (int i = 0; i < 8; ++i) { + if (ai_on[i]) q.push_back({static_cast(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(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 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 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(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 targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const std::vector 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(inputs | (1u << b)); + } + dev_.dconf(inputs); + do_latch_ = static_cast(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(next - now).count(); + return static_cast(std::max(0LL, std::min(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(n)); + const std::size_t nq = queue_.size(); + const std::size_t scans = pending_.size() / (2 * nq); + if (scans > 0) { + std::vector raw(scans * nq); + for (std::size_t i = 0; i < raw.size(); ++i) + raw[i] = static_cast(pending_[2 * i] | (pending_[2 * i + 1] << 8)); + pending_.erase(pending_.begin(), + pending_.begin() + static_cast(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(count)); + ready_[q].erase(ready_[q].begin(), ready_[q].begin() + static_cast(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(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(count); + f.num_signals = static_cast(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( + std::chrono::duration(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(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(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(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 ``, 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 + +#include +#include +#include +#include + +namespace udpscope { +namespace { + +using namespace e1608; +using ChangeCb = std::shared_ptr>; + +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(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 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>(); + 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(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(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(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
; + +void on_notify(GObject*, GParamSpec*, gpointer user) { + Form& f = **static_cast(user); + f.refresh(); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void on_edited(GtkEditable*, gpointer user) { + Form& f = **static_cast(user); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void free_form(gpointer d, GClosure*) { delete static_cast(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(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(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 make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + auto f = std::make_shared(); + 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 fn) { *cb = std::move(fn); }; + return out; + } +}; + +} // namespace + +std::unique_ptr make_e1608_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +Note: `out.write/read/valid` capture `f` (a `shared_ptr`) 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 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 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-mode` (`AdwComboRow`), `-level`, `-amp`, `-offset`, `-freq` (`AdwSpinRow`), `-run` (`AdwSwitchRow`), `-last` (`AdwActionRow`), for `n` = 0, 1; + - `e1608-do` (`AdwSwitchRow`, output bits only), `e1608-di` (`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 + +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 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 + +#include +#include +#include +#include +#include + +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 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(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(user); + ao_push(static_cast(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(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(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(0)); + g_signal_connect_data(w.run, "notify::active", G_CALLBACK(on_ao_changed), &c->hooks[ch], + nullptr, static_cast(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(p); }); + gtk_widget_add_tick_callback(root, ao_tick, c, nullptr); + return root; +} + +// --- Digital I/O ------------------------------------------------------------------------------ + +struct DioCtx { + E1608Source* src = nullptr; + std::shared_ptr 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(c->ro->counter.load())); + set_text(c->counter, buf); +} + +gboolean dio_tick(GtkWidget*, GdkFrameClock*, gpointer user) { + DioCtx* c = static_cast(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(user); + DioCtx* c = static_cast(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(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(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(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(p); }); + gtk_widget_add_tick_callback(root, dio_tick, c, nullptr); + return root; +} + +} // namespace + +std::vector make_e1608_panels(DataSource& source) { + E1608Source* src = static_cast(&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 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 ``, 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=` and `UDPSCOPE_PROBE_E1608_RECONNECT=`, 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(std::atoi(env)), e1608_probe, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { + g_timeout_add(static_cast(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(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 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> 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(i)] - want0[i]) > 1e-9) return false; + if (std::fabs(out[1][static_cast(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(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& 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(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(std::lround(2.0 * s.scan_rate / 5.0)); + if (want > 0 && n > want + 50) { + std::vector v(static_cast(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(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 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(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(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] +[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='' +[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= 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] (once, near the 3 s mark) +[e1608] reconnect: connects=2 connected=1 ok=1 +[e1608] resumed: frames_before= frames_after= 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=`: **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`.