# 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`.