156 KiB
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 onmain, a clean build emits exactly three warnings (one on an incremental build), and there is noGtk-CRITICAL(gtk warnings: 0). - "Everything runs inside
poll()on the producer thread. The GTK thread reaches the board only throughpost(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 fireson_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. SourceConfigstays flat; a per-channel setting is a key such asai0=BIP10V(absent means off). A session file is namede1608-<identity>, port 1.- One global SE/DIFF mode (no per-channel mixing); DIO directions are fixed at connect (no runtime change).
- No board exists:
tools/mock_e1608.pyis 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, andREADME.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
- 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.
- 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). 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 noai0..ai7key at all,ai0=BIP10Vis the default (so--source-opt address=...alone is connectable).- A reply status other than 0 is
LinkError::Kind::Command, distinct from framing errors (Protocol); onlyCommandis swallowed by the calibration read. Acquisition::on_eventignores the detail string, soErrormessages are also written to stderr as[e1608] ....- The two side-stack panels live in their own
e1608_panels.{hpp,cpp}rather than insidee1608_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
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
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 onport+1. Log lines (to--log FILEand stdout), one per event, exactly these formats (Task 5 greps them):HANDSHAKE code=<n> status=<n>DOUT 0x<2 hex>,DCONF 0x<2 hex>AOUT ch=<n> code=<n> volts=<%.4f>(volts = the code through the inverse of the served DAC calibration)COUNTER_RESETQUEUE <wire>:<range> ...SCAN_START pacer=<n> options=<n> channels=<n>,STREAM rate=<%.1f> channels=<n>,SCAN_STOPDROP,OVERRUN
-
The synthetic signal: for the queue entry (wire channel
w, ranger):ch = w - 8ifw >= 8elsew;dc = min(0.5*(ch+1), 0.8*fs); valuedc + 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 recoversdc. A decoder that skips the calibration is off by about 0.3 V onch=0,BIP10V. -
Served calibration (float32-rounded): DIFF
(1.01 - 0.004 r, 150 - 30 r), SE(1.02 - 0.005 r, 300 - 40 r)forrin 0..3, DAC(0.99 + 0.01 c, 200 - 100 c)forcin 0..1, in a single 80-byte memory: DIFF at 0x00, SE at 0x20, DAC at 0x40. -
DIN:
(pins & dconf) | (dout & ~dconf)withpins = int(t*4) & 0xFF(bit 0 toggles at 2 Hz). Counter:int((now - counter_t0) * 5); reset rebasescounter_t0. -
Step 1: Write the mock
Create tools/mock_e1608.py (mode 755):
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
"""A protocol mock of the Measurement Computing E-1608 Ethernet DAQ, for exercising udpscope
without a board. It is the *contract* the `e1608` source is verified against until a board is
attached, so its layouts are only as good as the two references they were taken from:
* MARTe2 `E1608DataSource` (~/Projects/marte2_projects/MCC_E1608_DataSource), the primary, and
* the Go `uopi/internal/datasource/mcc` implementation, the cross-check.
./tools/mock_e1608.py --port 54211 [--code N] [--drop-after S] [--overrun-after S] [--log F]
Serves: UDP `D` discovery and `C` connection-code handshake on --port; the TCP command channel on
--port; the TCP scan stream on --port+1 (it starts when the one filler byte follows the scan-start
command). Frame: 0xDB, cmd, frame id, status, count LE16, payload, checksum (the whole frame sums
to 0xFF mod 256). Reply: cmd | 0x80, same id.
UNVERIFIED -- every item below rests on a single reference, or the references disagree. They are
the first places to look when a real board misbehaves:
* 0x31 counter reset: Go only (no payload, no reply payload).
* Pacer: MARTe2 sends round(80e6 / (scan_rate * channels) - 1), the *aggregate* rate; Go sends the
passed frequency. This mock follows MARTe2.
* The scan-start options byte (trigger): none 0, rising 4, falling 8, high 12, low 16 -- MARTe2 only.
* Handshake status 2 ("code ignored") is accepted by MARTe2 and refused by Go. The mock never sends it.
* The first frame id: MARTe2 pre-increments (first frame is 1), Go starts at 0. The mock echoes it.
* The maximum frame size (MARTe2: 1024 in total; Go: 1024 of payload). The mock enforces neither.
* Agreed by both references (not in doubt): the filler byte after scan start, the scan port being
command port + 1, DIFF wire channel = channel + 8.
Switches: --drop-after S closes both connections once, S seconds after the first command connection
(logs DROP); --overrun-after S raises the overrun bit and stops the stream S seconds after a scan
starts, once (logs OVERRUN); --code N is the connection code the board accepts (anything else gets
status 1).
"""
import argparse
import socket
import struct
import sys
import threading
import time
import numpy as np
CLOCK = 80e6
PRODUCT_ID = 0x012F
FS = [10.0, 5.0, 2.0, 1.0] # full scale per range code BIP10V, BIP5V, BIP2V, BIP1V
def f32(x):
return struct.unpack("<f", struct.pack("<f", x))[0]
DF_CAL = [(f32(1.01 - 0.004 * r), f32(150.0 - 30.0 * r)) for r in range(4)]
SE_CAL = [(f32(1.02 - 0.005 * r), f32(300.0 - 40.0 * r)) for r in range(4)]
AO_CAL = [(f32(0.99 + 0.01 * c), f32(200.0 - 100.0 * c)) for c in range(2)]
CAL_MEM = b"".join(struct.pack("<ff", s, i) for s, i in DF_CAL + SE_CAL + AO_CAL) # 80 bytes
def close_quietly(sock):
if sock is None:
return
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
sock.close()
except OSError:
pass
def recv_exact(conn, n):
buf = b""
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
class Board:
def __init__(self, args):
self.args = args
self.mac = bytes(int(x, 16) for x in args.mac.split(":"))
self.lock = threading.RLock()
self.cmd_conn = None
self.scan_conn = None
self.dout = 0
self.dconf = 0xFF
self.ao = [32768, 32768]
self.queue = []
self.pacer = 0
self.armed = False
self.streaming = False
self.stream_gen = 0
self.overrun = False
self.overrun_done = False
self.t_boot = time.monotonic()
self.counter_t0 = self.t_boot
self.first_conn = None
self.logfile = open(args.log, "a", buffering=1) if args.log else None
def log(self, text):
print("[mock] " + text, flush=True)
if self.logfile:
self.logfile.write(text + "\n")
# --- the pretend hardware -------------------------------------------------------------
def din(self):
pins = int((time.monotonic() - self.t_boot) * 4) & 0xFF
return (pins & self.dconf) | (self.dout & ~self.dconf & 0xFF)
def counter(self):
return int((time.monotonic() - self.counter_t0) * 5) & 0xFFFFFFFF
def raw(self, wire, rng, t):
diff = wire >= 8
ch = wire - 8 if diff else wire
fs = FS[rng]
dc = min(0.5 * (ch + 1), 0.8 * fs)
v = np.clip(dc + 0.1 * np.sin(2.0 * np.pi * 5.0 * t), -0.98 * fs, 0.98 * fs)
code = v * 32768.0 / fs + 32768.0
slope, icpt = (DF_CAL if diff else SE_CAL)[rng]
return np.clip(np.rint((code - icpt) / slope), 0, 65535)
# --- commands ---------------------------------------------------------------------------
def handle(self, cmd, p):
"""Returns (status, reply payload). Status 1 protocol, 2 parameter."""
if cmd == 0x00:
return 0, bytes([self.din()])
if cmd == 0x02:
return 0, bytes([self.dout])
if cmd == 0x03:
if len(p) < 1:
return 2, b""
self.dout = p[0]
self.log("DOUT 0x%02x" % p[0])
return 0, b""
if cmd == 0x05:
if len(p) < 1:
return 2, b""
self.dconf = p[0]
self.log("DCONF 0x%02x" % p[0])
return 0, b""
if cmd == 0x10:
return 0, struct.pack("<H", 32768)
if cmd == 0x11:
if len(p) < 9 or not self.queue:
return 2, b""
_count, pacer, opts = struct.unpack("<IIB", p[:9])
with self.lock:
self.pacer = pacer
self.armed = True
self.overrun = False
self.log("SCAN_START pacer=%d options=%d channels=%d" % (pacer, opts, len(self.queue)))
return 0, b""
if cmd == 0x13:
self.stop_stream(close_scan=bool(p and p[0]))
self.log("SCAN_STOP")
return 0, b""
if cmd == 0x15:
if not p:
return 2, b""
n = p[0]
if n < 1 or n > 8 or len(p) != 1 + 2 * n:
return 2, b""
q = [(p[1 + 2 * i], p[2 + 2 * i]) for i in range(n)]
if any(r > 3 for _, r in q):
return 2, b""
self.queue = q
self.log("QUEUE " + " ".join("%d:%d" % e for e in q))
return 0, b""
if cmd == 0x20:
return 0, struct.pack("<HH", *self.ao)
if cmd == 0x21:
if len(p) < 3 or p[0] > 1:
return 2, b""
ch, code = struct.unpack("<BH", p[:3])
self.ao[ch] = code
slope, icpt = AO_CAL[ch]
volts = ((code - icpt) / slope - 32768.0) * 10.0 / 32768.0
self.log("AOUT ch=%d code=%d volts=%.4f" % (ch, code, volts))
return 0, b""
if cmd == 0x30:
return 0, struct.pack("<I", self.counter())
if cmd == 0x31:
self.counter_t0 = time.monotonic()
self.log("COUNTER_RESET")
return 0, b""
if cmd == 0x40:
if len(p) < 4:
return 2, b""
addr, count = struct.unpack("<HH", p[:4])
data = CAL_MEM[addr:addr + count]
if len(data) != count:
return 2, b""
return 0, data
if cmd == 0x50:
self.log("BLINK")
return 0, b""
if cmd == 0x52:
st = 0
if self.scan_conn is not None:
st |= 1
if self.streaming:
st |= 2
if self.overrun:
st |= 4
return 0, struct.pack("<H", st)
return 1, b""
# --- streaming --------------------------------------------------------------------------
def stop_stream(self, close_scan):
with self.lock:
self.stream_gen += 1
self.streaming = False
self.armed = False
conn = None
if close_scan:
conn = self.scan_conn
self.scan_conn = None
close_quietly(conn)
def start_stream(self):
for _ in range(100): # the scan socket's accept may trail the filler by a moment
with self.lock:
if self.scan_conn is not None:
break
time.sleep(0.01)
with self.lock:
conn = self.scan_conn
if conn is None or not self.armed:
return
self.armed = False
self.streaming = True
self.stream_gen += 1
gen = self.stream_gen
queue = list(self.queue)
pacer = self.pacer
threading.Thread(target=self.stream, args=(gen, conn, queue, pacer), daemon=True).start()
def stream(self, gen, conn, queue, pacer):
nq = len(queue)
agg = CLOCK / (pacer + 1) if pacer > 0 else 1000.0 * nq # external clock: 1 kS/s each
rate = agg / nq
t0 = time.monotonic()
sent = 0
ov_at = None
if self.args.overrun_after > 0 and not self.overrun_done:
ov_at = t0 + self.args.overrun_after
self.log("STREAM rate=%.1f channels=%d" % (rate, nq))
try:
while self.stream_gen == gen:
now = time.monotonic()
if ov_at is not None and now >= ov_at:
with self.lock:
if self.stream_gen == gen:
self.overrun = True
self.overrun_done = True
self.streaming = False
self.log("OVERRUN")
return
due = min(int((now - t0) * rate) - sent, 65536)
if due > 0:
t = np.arange(sent, sent + due) / rate
cols = [self.raw(w, r, t) for (w, r) in queue]
conn.sendall(np.stack(cols, axis=1).astype("<u2").tobytes())
sent += due
time.sleep(0.005)
except (OSError, ValueError):
pass
finally:
with self.lock:
if self.stream_gen == gen:
self.streaming = False
def drop(self):
self.log("DROP")
with self.lock:
self.stream_gen += 1
self.streaming = False
self.armed = False
conns = [self.cmd_conn, self.scan_conn]
self.cmd_conn = None
self.scan_conn = None
for c in conns:
close_quietly(c)
# --- servers ----------------------------------------------------------------------------
def serve_udp(self, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", port))
while True:
data, addr = sock.recvfrom(64)
if data[:1] == b"D":
reply = b"D" + self.mac + struct.pack("<HH", PRODUCT_ID, 0x0100)
reply += b"E-1608".ljust(16, b"\0") + struct.pack("<H", port)
sock.sendto(reply.ljust(64, b"\0"), addr)
elif data[:1] == b"C" and len(data) >= 5:
(code,) = struct.unpack("<I", data[1:5])
status = 0 if code == self.args.code else 1
self.log("HANDSHAKE code=%d status=%d" % (code, status))
sock.sendto(b"C" + bytes([status]), addr)
def serve_scan_port(self, port):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("", port))
srv.listen(4)
while True:
conn, _ = srv.accept()
with self.lock:
old = self.scan_conn
self.scan_conn = conn
close_quietly(old)
def serve_cmd(self, conn):
with self.lock:
old = self.cmd_conn
self.cmd_conn = conn
first = self.first_conn is None
if first:
self.first_conn = time.monotonic()
close_quietly(old)
if first and self.args.drop_after > 0:
threading.Timer(self.args.drop_after, self.drop).start()
try:
while True:
b = conn.recv(1)
if not b:
break
if b[0] == 0x00:
self.start_stream() # the filler byte after scan start
continue
if b[0] != 0xDB:
continue
rest = recv_exact(conn, 5)
if rest is None:
break
cmd, fid, _status, count = struct.unpack("<BBBH", rest)
body = recv_exact(conn, count + 1)
if body is None:
break
frame = b"\xDB" + rest + body
if (sum(frame) & 0xFF) == 0xFF:
status, reply = self.handle(cmd, body[:count])
else:
status, reply = 1, b""
out = struct.pack("<BBBBH", 0xDB, cmd | 0x80, fid, status, len(reply)) + reply
out += bytes([(0xFF - sum(out)) & 0xFF])
conn.sendall(out)
except (OSError, ValueError):
pass
finally:
with self.lock:
mine = self.cmd_conn is conn
if mine:
self.cmd_conn = None
if mine:
self.stop_stream(close_scan=True)
close_quietly(conn)
def serve_cmd_port(self, port):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("", port))
srv.listen(4)
while True:
conn, _ = srv.accept()
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
threading.Thread(target=self.serve_cmd, args=(conn,), daemon=True).start()
def main():
ap = argparse.ArgumentParser(description="E-1608 protocol mock")
ap.add_argument("--port", type=int, default=54211)
ap.add_argument("--mac", default="00:80:2f:00:16:08")
ap.add_argument("--code", type=int, default=0, help="connection code the board accepts")
ap.add_argument("--drop-after", type=float, default=0.0, help="close both links once, S s after the first command connection")
ap.add_argument("--overrun-after", type=float, default=0.0, help="raise the overrun bit S s after a scan starts, once")
ap.add_argument("--log", default="", help="append event lines to this file")
args = ap.parse_args()
board = Board(args)
for target in (board.serve_udp, board.serve_scan_port, board.serve_cmd_port):
port = args.port + 1 if target == board.serve_scan_port else args.port
threading.Thread(target=target, args=(port,), daemon=True).start()
print("mock_e1608: listening on port %d (scan %d)" % (args.port, args.port + 1), flush=True)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()
- Step 2: Write the throwaway smoke client
Create /tmp/e1608_smoke.py (not committed). It speaks the protocol independently of the C++ code, so a mock bug and a C++ bug cannot cancel:
import socket, struct, sys, time
port = int(sys.argv[1])
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); u.settimeout(1)
u.sendto(b"D", ("127.0.0.1", port))
d, _ = u.recvfrom(64)
assert d[0:1] == b"D" and struct.unpack("<H", d[7:9])[0] == 0x012F, d
print("discovery ok, mac", d[1:7].hex(":"))
u.sendto(b"C" + struct.pack("<I", 999), ("127.0.0.1", port)); a, _ = u.recvfrom(8)
assert a == b"C\x01", a
u.sendto(b"C" + struct.pack("<I", 0), ("127.0.0.1", port)); a, _ = u.recvfrom(8)
assert a == b"C\x00", a
print("handshake ok (wrong code gets status 1)")
c = socket.create_connection(("127.0.0.1", port)); c.settimeout(2)
fid = 0
def cmd(code, payload=b""):
global fid
fid += 1
f = struct.pack("<BBBBH", 0xDB, code, fid, 0, len(payload)) + payload
f += bytes([(0xFF - sum(f)) & 0xFF]); c.sendall(f)
h = c.recv(6, socket.MSG_WAITALL); n = h[4] | h[5] << 8
body = c.recv(n + 1, socket.MSG_WAITALL)
assert (sum(h + body) & 0xFF) == 0xFF and h[1] == code | 0x80 and h[2] == fid
return h[3], body[:n]
st, cal = cmd(0x40, struct.pack("<HH", 0x20, 32)); assert st == 0 and len(cal) == 32
slope, icpt = struct.unpack("<ff", cal[:8]); print("SE cal r=0:", slope, icpt); assert abs(slope - 1.02) < 1e-4
assert cmd(0x15, bytes([1, 0, 0]))[0] == 0
s = socket.create_connection(("127.0.0.1", port + 1)); s.settimeout(3)
assert cmd(0x11, struct.pack("<IIB", 0, 79999, 0))[0] == 0 # 1 channel at 1 kS/s
c.sendall(b"\x00") # the filler byte
data = b""
while len(data) < 400: data += s.recv(4096)
raw = struct.unpack("<%dH" % (len(data) // 2), data[:len(data) // 2 * 2])
print("scan bytes", len(data), "first raw", raw[:3])
st, sb = cmd(0x52); print("status", struct.unpack("<H", sb)[0]); assert struct.unpack("<H", sb)[0] & 2
assert cmd(0x13, b"\x01")[0] == 0
print("SMOKE OK")
- Step 3: Run the smoke test
chmod +x tools/mock_e1608.py
python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-smoke.log & MOCK=$!
sleep 1; python3 /tmp/e1608_smoke.py 54711; kill $MOCK
cat /tmp/e1608-smoke.log
Expected: discovery ok, handshake ok, SE cal r=0: 1.0199999809265137 300.0, a status that has bit 1 (value 3 or 2), and SMOKE OK; the log holds HANDSHAKE code=999 status=1, HANDSHAKE code=0 status=0, QUEUE 0:0, SCAN_START pacer=79999 options=0 channels=1, STREAM rate=1000.0 channels=1, SCAN_STOP. Any assertion failure is a mock bug: fix the mock, not the client.
- Step 4: Commit
git add tools/mock_e1608.py
git commit -m "tools: a protocol mock of the MCC E-1608, with its unverified commands listed"
Task 2: Link and device layers, plus decode_scans
Files:
- Create:
src/source/e1608/e1608_link.hpp,src/source/e1608/e1608_link.cpp - Create:
src/source/e1608/e1608_device.hpp,src/source/e1608/e1608_device.cpp - Modify:
CMakeLists.txt(aftersrc/source/probe_source.cpp) - Throwaway (not committed):
/tmp/e1608_drv.cpp
Interfaces:
- Consumes: the mock from Task 1 (
tools/mock_e1608.py). - Produces (namespace
udpscope::e1608; no GTK, noDataSourcetypes), used by Task 3:link.hpp:kDefaultPort = 54211,kProductId = 0x012F;class LinkError : std::runtime_errorwithenum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command }andkind();class Fd(RAII);using Mac = std::array<std::uint8_t, 6>;bool parse_mac(const std::string&, Mac&);std::string format_mac(const Mac&);struct DeviceInfo { std::string ip; Mac mac; std::uint16_t product_id; };std::vector<std::string> sweep_targets(const std::string& subnet);std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);class Linkwithconnect(host, port, code, timeout_ms),close(),is_open(),command(cmd, payload = {}, expect = -1) -> std::vector<uint8_t>,open_scan(),close_scan(),scan_open(),send_filler(),read_scan(buf, cap, timeout_ms) -> size_t(0 on timeout; throwsIowhen the board closed the scan socket).device.hpp:enum class Range,full_scale(Range),range_name(Range),parse_range(str, Range&);enum class Trigger { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 },trigger_name,parse_trigger;struct Cal { double slope = 1.0, intercept = 0.0; };struct Calibration { Cal se[4], df[4], ao[2]; bool loaded; double volts(uint16_t raw, Range, bool diff) const; uint16_t dac_code(int ch, double volts) const; };struct QueueEntry { uint8_t wire_channel; Range range; bool diff() const; };void decode_scans(const uint16_t* raw, size_t scans, const std::vector<QueueEntry>&, const Calibration&, std::vector<std::vector<double>>& out);class Device(read_calibration,din,dout,dconf,aout,counter,counter_reset,status,scan_start,scan_stop).
Protocol facts (from the two references; the mock implements the same):
-
UDP on the command port:
D-> a 64-byte reply (D, MAC[1..6], product id LE16 at [7..8], firmware [9..10], name [11..26], port [27..28]);C+ code LE32 ->C+ status (0 ok, 2 ok "code ignored", 1 rejected, 3 busy). -
TCP command frame:
0xDB, cmd, frame id, status 0, count LE16, payload, checksumwithchecksum = 0xFF - (sum of the previous bytes mod 256). A reply iscmd | 0x80, the id echoed, status 0 on success, and the whole frame sums to0xFF. The first frame id is 1. -
Commands:
0x00DIN (1 byte),0x03DOUT write,0x05DCONF (bit set = input),0x11scan start (count LE32 = 0, pacer LE32, options),0x13scan stop (payload1= close),0x15queue write (n, then(wire channel, range)pairs),0x21AOUT write (ch, code LE16),0x30counter read (LE32),0x31counter reset,0x40calibration read (addr LE16, count LE16),0x52status LE16 (bit 0 scan socket open, bit 1 running, bit 2 overrun). -
After scan start, send one
0x00filler byte on the command socket; the stream is TCP onport + 1, little-endianuint16, interleaved in queue order. -
Pacer =
round(80e6 / aggregate - 1)(aggregate = scan rate times channels);0for an external clock. Options byte = the trigger value. -
Calibration memory: float32 LE
(slope, intercept)pairs: DIFF 4 pairs at0x00, SE 4 pairs at0x20, DAC 2 pairs at0x40; 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)), thenclamp(round(nominal*slope + intercept)); clamp the volts to +-10 first. DIFF wire channel = channel + 8. -
Step 1: Write
e1608_link.hpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
namespace udpscope::e1608 {
constexpr int kDefaultPort = 54211;
constexpr std::uint16_t kProductId = 0x012F;
/// Every failure of the link. `Rejected` (a bad connection code) is the only one a retry cannot
/// fix. `Command` is a reply the board answered with a nonzero status, or a reply of the wrong
/// length: the link itself is fine. `Protocol` is a framing error (sync, checksum, id).
class LinkError : public std::runtime_error {
public:
enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command };
LinkError(Kind kind, const std::string& what) : std::runtime_error(what), kind_(kind) {}
Kind kind() const noexcept { return kind_; }
private:
Kind kind_;
};
/// An owned file descriptor.
class Fd {
public:
Fd() = default;
explicit Fd(int fd) : fd_(fd) {}
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
Fd(Fd&& o) noexcept : fd_(o.release()) {}
Fd& operator=(Fd&& o) noexcept {
if (this != &o) reset(o.release());
return *this;
}
~Fd() { reset(); }
int get() const noexcept { return fd_; }
bool valid() const noexcept { return fd_ >= 0; }
int release() noexcept {
const int f = fd_;
fd_ = -1;
return f;
}
/// Closes the current descriptor (if any) and takes `fd`.
void reset(int fd = -1) noexcept;
private:
int fd_ = -1;
};
using Mac = std::array<std::uint8_t, 6>;
/// Six hex bytes separated by `:` or `-`. False (and `out` untouched) on anything else.
bool parse_mac(const std::string& text, Mac& out);
/// Lower-case `aa:bb:cc:dd:ee:ff`.
std::string format_mac(const Mac& mac);
struct DeviceInfo {
std::string ip;
Mac mac{};
std::uint16_t product_id = 0;
};
/// Where to send a discovery `D`: an empty subnet is the limited broadcast; `a.b.c` (an optional
/// trailing dot) is a unicast to hosts .1 to .254; anything else is empty.
std::vector<std::string> sweep_targets(const std::string& subnet);
/// Sends `D` to every target, then collects replies for `wait_ms`. Keeps E-1608s only (product id
/// `kProductId`) and one entry per MAC.
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);
/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`).
/// Not thread safe; the source's producer thread owns it.
class Link {
public:
/// Handshake, then the command connection. Throws `LinkError` (`Rejected` for a bad code).
void connect(const std::string& host, int port, std::uint32_t code, int timeout_ms);
/// Closes the command and scan sockets. Safe to call at any time.
void close();
bool is_open() const noexcept { return cmd_.valid(); }
/// One framed exchange. `expect >= 0` demands a reply payload of exactly that length. Throws
/// `Command` for a nonzero status or a length mismatch, `Protocol` for a bad frame.
std::vector<std::uint8_t> command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload = {},
int expect = -1);
/// Opens the scan stream socket (a 1 MiB receive buffer, set before connecting).
void open_scan();
void close_scan();
bool scan_open() const noexcept { return scan_.valid(); }
/// The single `0x00` byte on the command socket that starts the stream after scan start.
void send_filler();
/// Waits up to `timeout_ms` for scan bytes and reads what is there (at most `cap`). Returns 0 on
/// a timeout. Throws `Io` when the board closed the socket.
std::size_t read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms);
private:
Fd cmd_;
Fd scan_;
std::string host_;
int port_ = kDefaultPort;
int timeout_ms_ = 2000;
std::uint8_t next_id_ = 0;
};
} // namespace udpscope::e1608
- Step 2: Write
e1608_link.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_link.hpp"
#include <algorithm>
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
namespace udpscope::e1608 {
void Fd::reset(int fd) noexcept {
if (fd_ >= 0) ::close(fd_);
fd_ = fd;
}
namespace {
using Clock = std::chrono::steady_clock;
using Kind = LinkError::Kind;
constexpr std::size_t kMaxFrame = 1024;
[[noreturn]] void sys_fail(const std::string& what) {
throw LinkError(Kind::Io, what + ": " + std::strerror(errno));
}
int ms_left(Clock::time_point deadline) {
const auto d = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now());
return d.count() < 0 ? 0 : static_cast<int>(d.count());
}
/// True when `fd` is ready for `events` (or has an error/hang-up to report); false on timeout.
bool wait_fd(int fd, short events, int timeout_ms) {
pollfd p{fd, events, 0};
for (;;) {
const int r = ::poll(&p, 1, timeout_ms);
if (r > 0) return true;
if (r == 0) return false;
if (errno == EINTR) continue;
sys_fail("poll");
}
}
sockaddr_in make_addr(const std::string& host, int port) {
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_port = htons(static_cast<std::uint16_t>(port));
if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) == 1) return a;
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
if (::getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr)
throw LinkError(Kind::Io, "cannot resolve '" + host + "'");
a.sin_addr = reinterpret_cast<const sockaddr_in*>(res->ai_addr)->sin_addr;
::freeaddrinfo(res);
return a;
}
void send_all(int fd, const std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
while (n > 0) {
if (!wait_fd(fd, POLLOUT, ms_left(deadline)))
throw LinkError(Kind::Timeout, "timed out sending to the board");
const ssize_t w = ::send(fd, p, n, MSG_NOSIGNAL | MSG_DONTWAIT);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
sys_fail("send");
}
p += w;
n -= static_cast<std::size_t>(w);
}
}
void read_exact(int fd, std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
while (n > 0) {
if (!wait_fd(fd, POLLIN, ms_left(deadline)))
throw LinkError(Kind::Timeout, "timed out waiting for the board");
const ssize_t r = ::recv(fd, p, n, MSG_DONTWAIT);
if (r == 0) throw LinkError(Kind::Io, "the board closed the connection");
if (r < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
sys_fail("recv");
}
p += r;
n -= static_cast<std::size_t>(r);
}
}
/// A connected, non-blocking TCP socket with TCP_NODELAY. `rcvbuf > 0` sets SO_RCVBUF first.
Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
const sockaddr_in a = make_addr(host, port);
Fd fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0));
if (!fd.valid()) sys_fail("socket");
const int one = 1;
::setsockopt(fd.get(), IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
if (rcvbuf > 0) ::setsockopt(fd.get(), SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
const std::string where = host + ":" + std::to_string(port);
if (::connect(fd.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0 &&
errno != EINPROGRESS)
sys_fail("cannot connect to " + where);
if (!wait_fd(fd.get(), POLLOUT, timeout_ms))
throw LinkError(Kind::Timeout, "timed out connecting to " + where);
int err = 0;
socklen_t len = sizeof err;
::getsockopt(fd.get(), SOL_SOCKET, SO_ERROR, &err, &len);
if (err != 0) {
errno = err;
sys_fail("cannot connect to " + where);
}
return fd;
}
} // namespace
// --- addresses ------------------------------------------------------------------------------
bool parse_mac(const std::string& text, Mac& out) {
Mac m{};
std::size_t pos = 0;
for (std::size_t i = 0; i < 6; ++i) {
if (pos + 2 > text.size()) return false;
unsigned v = 0;
for (std::size_t k = 0; k < 2; ++k) {
const char c = text[pos + k];
v <<= 4;
if (c >= '0' && c <= '9') v |= static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f') v |= static_cast<unsigned>(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') v |= static_cast<unsigned>(c - 'A' + 10);
else return false;
}
m[i] = static_cast<std::uint8_t>(v);
pos += 2;
if (i < 5) {
if (pos >= text.size() || (text[pos] != ':' && text[pos] != '-')) return false;
++pos;
}
}
if (pos != text.size()) return false;
out = m;
return true;
}
std::string format_mac(const Mac& mac) {
char buf[24];
std::snprintf(buf, sizeof buf, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
mac[4], mac[5]);
return buf;
}
std::vector<std::string> sweep_targets(const std::string& subnet) {
std::string s = subnet;
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
while (!s.empty() && (s.back() == ' ' || s.back() == '.')) s.pop_back();
if (s.empty()) return {"255.255.255.255"};
int parts = 0;
std::size_t i = 0;
while (i <= s.size()) {
const std::size_t dot = s.find('.', i);
const std::string part = s.substr(i, dot == std::string::npos ? std::string::npos : dot - i);
if (part.empty() || part.size() > 3) return {};
int v = 0;
for (char c : part) {
if (c < '0' || c > '9') return {};
v = v * 10 + (c - '0');
}
if (v > 255) return {};
++parts;
if (dot == std::string::npos) break;
i = dot + 1;
}
if (parts != 3) return {};
std::vector<std::string> out;
out.reserve(254);
for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h));
return out;
}
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms) {
std::vector<DeviceInfo> found;
Fd u(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
if (!u.valid()) sys_fail("socket");
const int one = 1;
::setsockopt(u.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
const std::uint8_t probe = 'D';
for (const std::string& t : targets) {
sockaddr_in a{};
try {
a = make_addr(t, port);
} catch (const LinkError&) {
continue;
}
// A sweep may hit unreachable hosts; those sends fail and are simply skipped.
(void)::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&a),
sizeof a);
}
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
for (;;) {
if (!wait_fd(u.get(), POLLIN, ms_left(deadline))) break;
std::uint8_t buf[128];
sockaddr_in from{};
socklen_t flen = sizeof from;
const ssize_t n = ::recvfrom(u.get(), buf, sizeof buf, MSG_DONTWAIT,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
break;
}
if (n < 29 || buf[0] != 'D') continue;
DeviceInfo info;
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
if (info.product_id != kProductId) continue;
std::copy(buf + 1, buf + 7, info.mac.begin());
bool dup = false;
for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac;
if (dup) continue;
char ip[INET_ADDRSTRLEN] = {};
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
info.ip = ip;
found.push_back(info);
}
return found;
}
// --- Link -----------------------------------------------------------------------------------
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
close();
host_ = host;
port_ = port;
timeout_ms_ = timeout_ms;
next_id_ = 0;
// The connection-code handshake: a connected UDP socket, so an ICMP refusal surfaces.
{
const sockaddr_in a = make_addr(host, port);
Fd u(::socket(AF_INET, SOCK_DGRAM, 0));
if (!u.valid()) sys_fail("socket");
if (::connect(u.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0)
sys_fail("handshake with " + host);
const std::uint8_t msg[5] = {'C', static_cast<std::uint8_t>(code & 0xFF),
static_cast<std::uint8_t>((code >> 8) & 0xFF),
static_cast<std::uint8_t>((code >> 16) & 0xFF),
static_cast<std::uint8_t>((code >> 24) & 0xFF)};
if (::send(u.get(), msg, sizeof msg, MSG_NOSIGNAL) < 0) sys_fail("handshake with " + host);
if (!wait_fd(u.get(), POLLIN, timeout_ms))
throw LinkError(Kind::Timeout, "no answer from the board at " + host);
std::uint8_t rep[16];
const ssize_t n = ::recv(u.get(), rep, sizeof rep, 0);
if (n < 0) sys_fail("handshake with " + host);
if (n < 2 || rep[0] != 'C') throw LinkError(Kind::Protocol, "unexpected handshake reply");
switch (rep[1]) {
case 0:
case 2:
break;
case 1:
throw LinkError(Kind::Rejected, "the board rejected the connection code");
case 3:
throw LinkError(Kind::Busy, "the board is busy (another client holds it)");
default:
throw LinkError(Kind::Protocol, "unexpected handshake status " + std::to_string(rep[1]));
}
}
cmd_ = tcp_connect(host, port, timeout_ms, 0);
}
void Link::close() {
scan_.reset();
cmd_.reset();
}
std::vector<std::uint8_t> Link::command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload,
int expect) {
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
if (payload.size() + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "command too long");
// Bytes left by an earlier exchange that timed out must not be read as this reply.
{
std::uint8_t junk[256];
while (::recv(cmd_.get(), junk, sizeof junk, MSG_DONTWAIT) > 0) {
}
}
const std::uint8_t id = ++next_id_;
std::vector<std::uint8_t> f;
f.reserve(7 + payload.size());
f.push_back(0xDB);
f.push_back(cmd);
f.push_back(id);
f.push_back(0);
f.push_back(static_cast<std::uint8_t>(payload.size() & 0xFF));
f.push_back(static_cast<std::uint8_t>((payload.size() >> 8) & 0xFF));
f.insert(f.end(), payload.begin(), payload.end());
unsigned sum = 0;
for (std::uint8_t b : f) sum += b;
f.push_back(static_cast<std::uint8_t>(0xFF - (sum & 0xFF)));
const auto deadline = Clock::now() + std::chrono::milliseconds(timeout_ms_);
send_all(cmd_.get(), f.data(), f.size(), deadline);
std::uint8_t h[6];
read_exact(cmd_.get(), h, sizeof h, deadline);
if (h[0] != 0xDB) throw LinkError(Kind::Protocol, "bad sync byte in a reply");
const std::size_t n = static_cast<std::size_t>(h[4] | (h[5] << 8));
if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply");
std::vector<std::uint8_t> body(n + 1);
read_exact(cmd_.get(), body.data(), body.size(), deadline);
unsigned rsum = 0;
for (std::uint8_t b : h) rsum += b;
for (std::uint8_t b : body) rsum += b;
if ((rsum & 0xFF) != 0xFF) throw LinkError(Kind::Protocol, "bad checksum in a reply");
if (h[1] != (cmd | 0x80) || h[2] != id)
throw LinkError(Kind::Protocol, "reply does not match the command");
if (h[3] != 0) {
static const char* const kText[] = {"ok", "protocol error", "parameter error",
"busy", "not ready", "timeout",
"other error"};
const char* t = h[3] < 7 ? kText[h[3]] : "unknown error";
char hex[8];
std::snprintf(hex, sizeof hex, "%02x", cmd);
throw LinkError(Kind::Command, std::string("the board refused command 0x") + hex + ": " + t);
}
body.pop_back();
if (expect >= 0 && body.size() != static_cast<std::size_t>(expect))
throw LinkError(Kind::Command, "reply of " + std::to_string(body.size()) +
" bytes, expected " + std::to_string(expect));
return body;
}
void Link::open_scan() {
close_scan();
scan_ = tcp_connect(host_, port_ + 1, timeout_ms_, 1 << 20);
}
void Link::close_scan() { scan_.reset(); }
void Link::send_filler() {
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
const std::uint8_t z = 0;
send_all(cmd_.get(), &z, 1, Clock::now() + std::chrono::milliseconds(timeout_ms_));
}
std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) {
if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open");
if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0;
const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT);
if (n > 0) return static_cast<std::size_t>(n);
if (n == 0) throw LinkError(Kind::Io, "scan socket closed by the board");
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0;
sys_fail("scan recv");
}
} // namespace udpscope::e1608
- Step 3: Write
e1608_device.hpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/e1608/e1608_link.hpp"
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace udpscope::e1608 {
enum class Range : std::uint8_t { Bip10V = 0, Bip5V = 1, Bip2V = 2, Bip1V = 3 };
double full_scale(Range r); ///< 10, 5, 2, 1
const char* range_name(Range r); ///< "BIP10V" ...
/// Case-insensitive: "BIP10V", "bip5v", ...
bool parse_range(const std::string& text, Range& out);
enum class Trigger : std::uint8_t { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 };
const char* trigger_name(Trigger t); ///< "none", "rising", "falling", "high", "low"
bool parse_trigger(const std::string& text, Trigger& out); ///< case-insensitive
struct Cal {
double slope = 1.0;
double intercept = 0.0;
};
/// The factory calibration: one (slope, intercept) per range for single-ended and differential
/// inputs, one per DAC. Defaults are the nominal (uncalibrated) values.
struct Calibration {
Cal se[4];
Cal df[4];
Cal ao[2];
bool loaded = false;
/// A raw ADC count as calibrated volts.
double volts(std::uint16_t raw, Range range, bool diff) const;
/// The DAC code that makes `ch` output `volts` (clamped to +-10 V).
std::uint16_t dac_code(int ch, double volts) const;
};
/// One entry of the scan queue. DIFF channels are `channel + 8` on the wire.
struct QueueEntry {
std::uint8_t wire_channel;
Range range;
bool diff() const noexcept { return wire_channel >= 8; }
};
/// Turns `scans` interleaved scans of `queue.size()` raw counts into calibrated volts:
/// `out[queue index][scan]`.
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
const Calibration& cal, std::vector<std::vector<double>>& out);
/// Typed commands over a `Link`. Holds a reference: the link must outlive it.
class Device {
public:
explicit Device(Link& link) : link_(link) {}
/// Fills `cal` from the board's calibration memory and sets `cal.loaded`. A refusal from the
/// board (`LinkError::Kind::Command`) leaves the nominal values and returns false; any other
/// error propagates.
bool read_calibration(Calibration& cal);
std::uint8_t din();
void dout(std::uint8_t value);
/// `inputs_mask`: a set bit makes that line an input.
void dconf(std::uint8_t inputs_mask);
/// Writes `volts` (clamped to +-10 V, calibrated) to DAC `ch`; returns the clamped volts.
double aout(int ch, double volts, const Calibration& cal);
std::uint32_t counter();
void counter_reset();
/// Bit 0 scan socket open, bit 1 scan running, bit 2 overrun.
std::uint16_t status();
/// Opens the scan socket, writes the queue, starts the scan and sends the filler byte.
/// `aggregate_hz` is the scan rate times the number of channels. On failure the scan socket
/// is closed again.
void scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
Trigger trigger);
/// Closes the scan socket and tells the board to stop; board errors are ignored.
void scan_stop();
private:
Link& link_;
};
} // namespace udpscope::e1608
- Step 4: Write
e1608_device.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_device.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
namespace udpscope::e1608 {
namespace {
constexpr double kClockHz = 80e6;
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
double clamp_code(double v) { return std::min(65535.0, std::max(0.0, v)); }
float le_f32(const std::uint8_t* p) {
const std::uint32_t u = static_cast<std::uint32_t>(p[0]) | (static_cast<std::uint32_t>(p[1]) << 8) |
(static_cast<std::uint32_t>(p[2]) << 16) |
(static_cast<std::uint32_t>(p[3]) << 24);
float f;
std::memcpy(&f, &u, sizeof f);
return f;
}
void put_le32(std::vector<std::uint8_t>& v, std::uint32_t x) {
for (int i = 0; i < 4; ++i) v.push_back(static_cast<std::uint8_t>((x >> (8 * i)) & 0xFF));
}
/// Reads `count` (slope, intercept) pairs at `addr`.
void read_table(Link& link, std::uint16_t addr, int count, Cal* out) {
const std::uint16_t bytes = static_cast<std::uint16_t>(count * 8);
const std::vector<std::uint8_t> req = {static_cast<std::uint8_t>(addr & 0xFF),
static_cast<std::uint8_t>(addr >> 8),
static_cast<std::uint8_t>(bytes & 0xFF),
static_cast<std::uint8_t>(bytes >> 8)};
const std::vector<std::uint8_t> r = link.command(0x40, req, bytes);
for (int i = 0; i < count; ++i) {
out[i].slope = le_f32(&r[static_cast<std::size_t>(i) * 8]);
out[i].intercept = le_f32(&r[static_cast<std::size_t>(i) * 8 + 4]);
}
}
} // namespace
double full_scale(Range r) {
static const double fs[] = {10.0, 5.0, 2.0, 1.0};
return fs[static_cast<int>(r)];
}
const char* range_name(Range r) {
static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"};
return n[static_cast<int>(r)];
}
bool parse_range(const std::string& text, Range& out) {
const std::string t = lower(text);
for (int i = 0; i < 4; ++i) {
if (t == lower(range_name(static_cast<Range>(i)))) {
out = static_cast<Range>(i);
return true;
}
}
return false;
}
const char* trigger_name(Trigger t) {
switch (t) {
case Trigger::None: return "none";
case Trigger::Rising: return "rising";
case Trigger::Falling: return "falling";
case Trigger::High: return "high";
case Trigger::Low: return "low";
}
return "none";
}
bool parse_trigger(const std::string& text, Trigger& out) {
const std::string t = lower(text);
for (Trigger c : {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, Trigger::Low}) {
if (t == trigger_name(c)) {
out = c;
return true;
}
}
return false;
}
double Calibration::volts(std::uint16_t raw, Range range, bool diff) const {
const Cal& c = (diff ? df : se)[static_cast<int>(range)];
const double code = clamp_code(std::round(static_cast<double>(raw) * c.slope + c.intercept));
return (code - 32768.0) * full_scale(range) / 32768.0;
}
std::uint16_t Calibration::dac_code(int ch, double v) const {
v = std::min(10.0, std::max(-10.0, v));
const double nominal = clamp_code(std::round(v * 32768.0 / 10.0 + 32768.0));
const Cal& c = ao[ch & 1];
return static_cast<std::uint16_t>(clamp_code(std::round(nominal * c.slope + c.intercept)));
}
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
const Calibration& cal, std::vector<std::vector<double>>& out) {
const std::size_t nq = queue.size();
out.resize(nq);
for (std::size_t q = 0; q < nq; ++q) {
out[q].resize(scans);
for (std::size_t s = 0; s < scans; ++s)
out[q][s] = cal.volts(raw[s * nq + q], queue[q].range, queue[q].diff());
}
}
bool Device::read_calibration(Calibration& cal) {
Calibration c;
try {
read_table(link_, 0x00, 4, c.df);
read_table(link_, 0x20, 4, c.se);
read_table(link_, 0x40, 2, c.ao);
} catch (const LinkError& e) {
if (e.kind() != LinkError::Kind::Command) throw;
cal = Calibration{};
return false;
}
c.loaded = true;
cal = c;
return true;
}
std::uint8_t Device::din() { return link_.command(0x00, {}, 1)[0]; }
void Device::dout(std::uint8_t value) { link_.command(0x03, {value}); }
void Device::dconf(std::uint8_t inputs_mask) { link_.command(0x05, {inputs_mask}); }
double Device::aout(int ch, double volts, const Calibration& cal) {
const double v = std::min(10.0, std::max(-10.0, volts));
const std::uint16_t code = cal.dac_code(ch, v);
link_.command(0x21, {static_cast<std::uint8_t>(ch & 1), static_cast<std::uint8_t>(code & 0xFF),
static_cast<std::uint8_t>(code >> 8)});
return v;
}
std::uint32_t Device::counter() {
const std::vector<std::uint8_t> r = link_.command(0x30, {}, 4);
return static_cast<std::uint32_t>(r[0]) | (static_cast<std::uint32_t>(r[1]) << 8) |
(static_cast<std::uint32_t>(r[2]) << 16) | (static_cast<std::uint32_t>(r[3]) << 24);
}
void Device::counter_reset() { link_.command(0x31); }
std::uint16_t Device::status() {
const std::vector<std::uint8_t> r = link_.command(0x52, {}, 2);
return static_cast<std::uint16_t>(r[0] | (r[1] << 8));
}
void Device::scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
Trigger trigger) {
link_.open_scan();
try {
std::vector<std::uint8_t> q;
q.push_back(static_cast<std::uint8_t>(queue.size()));
for (const QueueEntry& e : queue) {
q.push_back(e.wire_channel);
q.push_back(static_cast<std::uint8_t>(e.range));
}
link_.command(0x15, q);
std::uint32_t pacer = 0;
if (!ext_clock) {
const double p = std::round(kClockHz / aggregate_hz - 1.0);
pacer = static_cast<std::uint32_t>(std::min(4294967295.0, std::max(1.0, p)));
}
std::vector<std::uint8_t> s;
put_le32(s, 0); // count 0: scan until stopped
put_le32(s, pacer);
s.push_back(static_cast<std::uint8_t>(trigger));
link_.command(0x11, s);
link_.send_filler();
} catch (...) {
link_.close_scan();
throw;
}
}
void Device::scan_stop() {
link_.close_scan();
try {
link_.command(0x13, {1});
} catch (const LinkError&) {
}
}
} // namespace udpscope::e1608
- Step 5: Add the sources to CMake
In CMakeLists.txt, after the line src/source/probe_source.cpp add:
src/source/e1608/e1608_link.cpp
src/source/e1608/e1608_device.cpp
- Step 6: Write the throwaway driver
/tmp/e1608_drv.cpp
It exercises both layers against the mock and checks the decode against hand-computed volts (no mock needed for that part).
#include "source/e1608/e1608_device.hpp"
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <thread>
using namespace udpscope::e1608;
using namespace std::chrono_literals;
static int fails = 0;
#define CHECK(c) \
do { \
if (!(c)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #c); \
++fails; \
} \
} while (0)
static double mean(const std::vector<double>& v) {
double s = 0;
for (double x : v) s += x;
return s / static_cast<double>(v.size());
}
int main(int argc, char** argv) {
const int port = argc > 1 ? std::atoi(argv[1]) : 54711;
// --- decode against hand-computed volts (needs no board) ---
{
Calibration cal;
cal.se[1] = {2.0, -32768.0}; // BIP5V
cal.df[0] = {1.0, 100.0}; // DIFF BIP10V
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip5V}};
const std::uint16_t raw[4] = {0x8000, 0x4000, 0xC000, 0x6000};
std::vector<std::vector<double>> out;
decode_scans(raw, 2, q, cal, out);
CHECK(out.size() == 2 && out[0].size() == 2);
CHECK(out[0][0] == 0.0 && out[0][1] == 5.0);
CHECK(out[1][0] == -5.0 && out[1][1] == -2.5);
const std::vector<QueueEntry> qd = {{8, Range::Bip10V}};
const std::uint16_t rd[1] = {0x8000};
decode_scans(rd, 1, qd, cal, out);
CHECK(out[0][0] == 0.030517578125);
CHECK(cal.dac_code(0, 0.0) == 32768);
CHECK(cal.dac_code(0, 99.0) == 65535);
Range r;
CHECK(parse_range("bip5v", r) && r == Range::Bip5V && !parse_range("BIP3V", r));
Trigger t;
CHECK(parse_trigger("Rising", t) && t == Trigger::Rising && !parse_trigger("x", t));
Mac m;
CHECK(parse_mac("00:80:2F:00:16:08", m) && format_mac(m) == "00:80:2f:00:16:08");
CHECK(!parse_mac("zz", m) && !parse_mac("00:80:2f:00:16", m) && !parse_mac("00:80:2f:00:16:08:", m));
CHECK(sweep_targets("").size() == 1 && sweep_targets("192.168.1").size() == 254 &&
sweep_targets("192.168.1.").size() == 254 && sweep_targets("bad").empty() &&
sweep_targets("1.2").empty() && sweep_targets("1.2.3.4").empty());
}
// --- no board: a refusal, not a hang ---
try {
Link l;
l.connect("127.0.0.1", port + 7, 0, 300);
CHECK(false);
} catch (const LinkError&) {
}
// --- discovery ---
auto devs = discover({"127.0.0.1"}, port, 500);
CHECK(devs.size() == 1);
if (devs.size() == 1) {
CHECK(format_mac(devs[0].mac) == "00:80:2f:00:16:08");
CHECK(devs[0].product_id == 0x012F);
}
CHECK(discover(sweep_targets("127.0.0"), port, 800).size() == 1);
// --- a wrong connection code ---
try {
Link l;
l.connect("127.0.0.1", port, 999, 1000);
CHECK(false);
} catch (const LinkError& e) {
CHECK(e.kind() == LinkError::Kind::Rejected);
}
// --- a session ---
Link link;
link.connect("127.0.0.1", port, 0, 2000);
CHECK(link.is_open());
Device dev(link);
Calibration cal;
CHECK(dev.read_calibration(cal) && cal.loaded);
CHECK(std::fabs(cal.se[0].slope - 1.02) < 1e-4 && std::fabs(cal.se[0].intercept - 300.0) < 1e-3);
CHECK(std::fabs(cal.df[1].slope - 1.006) < 1e-4);
CHECK(std::fabs(cal.ao[1].intercept - 100.0) < 1e-3);
dev.dconf(0x0F);
dev.dout(0xA0);
CHECK((dev.din() & 0xF0) == 0xA0);
CHECK(dev.aout(0, 2.5, cal) == 2.5);
CHECK(dev.aout(1, 99.0, cal) == 10.0);
const std::uint32_t c1 = dev.counter();
std::this_thread::sleep_for(450ms);
const std::uint32_t c2 = dev.counter();
CHECK(c2 > c1);
dev.counter_reset();
CHECK(dev.counter() <= 1);
// --- a scan: two channels, 1000 S/s each; 200 scans = exactly one 5 Hz period ---
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip10V}};
dev.scan_start(q, 2000.0, false, Trigger::None);
std::vector<std::uint8_t> bytes;
std::uint8_t buf[4096];
const auto t0 = std::chrono::steady_clock::now();
while (bytes.size() < 800 && std::chrono::steady_clock::now() - t0 < 5s) {
const std::size_t n = link.read_scan(buf, sizeof buf, 500);
bytes.insert(bytes.end(), buf, buf + n);
}
CHECK(bytes.size() >= 800);
if (bytes.size() >= 800) {
std::vector<std::uint16_t> raw(400);
std::memcpy(raw.data(), bytes.data(), 800);
std::vector<std::vector<double>> out;
decode_scans(raw.data(), 200, q, cal, out);
std::printf("calibrated means: %.4f %.4f\n", mean(out[0]), mean(out[1]));
CHECK(std::fabs(mean(out[0]) - 0.5) < 0.01);
CHECK(std::fabs(mean(out[1]) - 1.0) < 0.01);
Calibration nominal;
decode_scans(raw.data(), 200, q, nominal, out);
std::printf("uncalibrated mean: %.4f\n", mean(out[0]));
CHECK(std::fabs(mean(out[0]) - 0.5) > 0.05);
}
CHECK((dev.status() & 2) != 0);
dev.scan_stop();
std::this_thread::sleep_for(100ms);
CHECK((dev.status() & 3) == 0);
std::printf(fails == 0 ? "DRIVER OK\n" : "DRIVER FAILED (%d)\n", fails);
return fails == 0 ? 0 : 1;
}
- Step 7: Build the driver, run it against the mock
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
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(declaremake_e1608_kind),src/source/registry.cpp(register after udps),CMakeLists.txt(the two new.cppfiles aftere1608_device.cpp)
Interfaces:
- Consumes (Task 2): everything in
e1608_link.hpp/e1608_device.hpp; framework typesDataSource,SourceSink,Frame,SignalValues,SignalInfo,SourceStats,SourceEvent,SourceConfig,SourceKind,ConfigForm,SourcePanel. - Produces (namespace
udpscope::e1608), used by Tasks 4 and 5:struct E1608Settingswithstatic E1608Settings from(const SourceConfig&),std::string problem() const(empty = connectable),queue(),active_channels(),aggregate(),identity(); fieldsaddress, 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 DataSourcewith the producer-thread-only methodsset_output_bit(int bit, bool on),reset_counter(),set_analog_out(int ch, const AoSettings&), and accessorsreadouts()(std::shared_ptr<E1608Readouts>),settings(),connects()(unsigned, thread-safe),last_message()(std::string, thread-safe).- In
udpscope:std::unique_ptr<SourceKind> make_e1608_kind(); the kind's id is"e1608".
- Config keys (all optional):
address,mac,subnet,port(54211),code(0),timeout_ms(2000),scan_rate(1000),ext_clock(0),trigger(none|rising|falling|high|low),auto_restart(1),calibrate(1),poll_rate(10),mode(se|diff),ai0..ai7(BIP10V|BIP5V|BIP2V|BIP1V|off; absent = off; if noaiNkey 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 machineDown -> Scanning, orHaltedafter a rejected connection code. Every exception is caught insidepoll(); the producer thread never sees one.drain_commands()runs inside the sametry, 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 throughE1608Readouts. -
Every 500 ms the status word is read: an overrun with
auto_restartfiresError("scan overrun; restarting"), restarts the scan, and marks the next framelost = 1; an overrun without it halts. A scan that is not running withauto_restartset 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
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/data_source.hpp"
#include "source/e1608/e1608_device.hpp"
#include "source/e1608/e1608_link.hpp"
#include "source/source_config.hpp"
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope::e1608 {
constexpr double kMaxAggregateHz = 250000.0;
constexpr double kMinAggregateHz = 0.02;
/// Everything a configured E-1608 source needs, parsed once from the flat `SourceConfig`.
struct E1608Settings {
std::string address; ///< host or IP; empty means "find it by MAC"
std::string mac_text; ///< as typed; empty means "any board at `address`"
std::string subnet; ///< `a.b.c` to sweep when looking up a MAC; empty means broadcast
int port = kDefaultPort;
std::uint32_t code = 0;
int timeout_ms = 2000;
double scan_rate = 1000.0; ///< per channel; with an external clock, that clock's rate
bool ext_clock = false;
Trigger trigger = Trigger::None;
bool auto_restart = true;
bool calibrate = true;
double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter
bool diff = false;
bool ai_on[8] = {};
Range ai_range[8] = {};
bool dir_out[8] = {};
bool plot[8] = {};
bool plot_counter = false;
int do_init = 0;
double ao_init[2] = {0.0, 0.0};
static E1608Settings from(const SourceConfig& cfg);
/// The first reason this cannot connect, in words for the user; empty when it can.
std::string problem() const;
int active_channels() const;
std::vector<QueueEntry> queue() const;
double aggregate() const { return scan_rate * active_channels(); }
/// The address, else the MAC; names the session file.
std::string identity() const;
};
/// What the panels read. Written by the producer thread, read by the GTK thread's timer.
struct E1608Readouts {
std::atomic<unsigned> di{0}; ///< the DIN byte as last read
std::atomic<unsigned> do_latch{0}; ///< the DOUT byte as last written
std::atomic<std::uint32_t> counter{0};
std::atomic<double> ao[2]; ///< the last DAC voltage written; NaN before any
std::atomic<bool> gen_on[2];
std::atomic<unsigned> epoch{0}; ///< bumped at each (re)connect: panels re-sync from it
E1608Readouts() {
for (int i = 0; i < 2; ++i) {
ao[i].store(std::nan(""));
gen_on[i].store(false);
}
}
};
enum class AoMode { Level, Sine, Square, Triangle, Ramp };
struct AoSettings {
AoMode mode = AoMode::Level;
double level = 0.0; ///< Level mode, volts
double amplitude = 1.0; ///< waveform modes, volts
double offset = 0.0;
double freq_hz = 1.0; ///< clamped to 0.1 .. 10
bool run = false; ///< waveform modes only
};
class E1608Source final : public DataSource {
public:
explicit E1608Source(const SourceConfig& cfg);
void open(SourceSink& sink) override;
void poll(int timeout_ms) override;
void close() override;
SourceStats stats() const override;
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
// Producer thread only: reach these through `post()`. Each is ignored unless scanning.
void set_output_bit(int bit, bool on);
void reset_counter();
void set_analog_out(int ch, const AoSettings& s);
std::shared_ptr<E1608Readouts> readouts() const { return readouts_; }
const E1608Settings& settings() const { return cfg_; }
unsigned connects() const { return connects_.load(std::memory_order_relaxed); }
std::string last_message() const;
private:
using Clock = std::chrono::steady_clock;
enum class State { Down, Scanning, Halted };
void connect_and_start();
void scan_step(int timeout_ms);
void emit_frame(std::size_t count);
void run_timers();
void slow_read();
void check_status();
void restart_scan();
void generator_tick(Clock::time_point now);
void write_ao(int ch, double volts);
void update_gen_flags();
void fail(const std::string& msg, bool fatal);
void note(const std::string& msg);
int wait_ms(int timeout_ms, Clock::time_point now) const;
E1608Settings cfg_;
SourceSink* sink_ = nullptr;
State state_ = State::Down;
Link link_;
Device dev_{link_};
Calibration cal_;
std::vector<QueueEntry> queue_;
std::vector<SignalInfo> signals_;
std::vector<int> plotted_bits_; ///< the DI bits that are plot channels, ascending
std::size_t block_ = 10;
std::shared_ptr<E1608Readouts> readouts_ = std::make_shared<E1608Readouts>();
std::atomic<bool> connected_{false};
std::atomic<unsigned> connects_{0};
mutable std::mutex msg_mu_;
std::string last_message_;
std::string last_logged_;
std::vector<std::uint8_t> rx_; ///< the read buffer
std::vector<std::uint8_t> pending_; ///< bytes of an incomplete scan
std::vector<std::vector<double>> ready_; ///< decoded, not yet framed: [queue index][scan]
std::vector<std::vector<double>> dec_;
std::vector<std::vector<double>> vals_; ///< the frame being built: [signal][sample]
std::vector<SignalValues> views_;
std::vector<double> held_di_;
double held_counter_ = 0.0;
std::uint8_t do_latch_ = 0;
AoSettings ao_[2];
Clock::time_point gen_t0_[2];
bool any_gen_ = false;
Clock::time_point retry_at_;
Clock::time_point next_slow_, next_status_, next_gen_;
std::uint32_t frame_counter_ = 0;
std::uint32_t pending_lost_ = 0;
std::uint64_t packets_ = 0, frames_ = 0, reconnects_ = 0;
};
} // namespace udpscope::e1608
- Step 2: Write
e1608_source.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_source.hpp"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <stdexcept>
#include <thread>
namespace udpscope::e1608 {
namespace {
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
double clamp(double v, double lo, double hi) { return std::min(hi, std::max(lo, v)); }
double wave(AoMode m, double f) {
switch (m) {
case AoMode::Sine: return std::sin(2.0 * M_PI * f);
case AoMode::Square: return f < 0.5 ? 1.0 : -1.0;
case AoMode::Triangle: return f < 0.5 ? 4.0 * f - 1.0 : 3.0 - 4.0 * f;
case AoMode::Ramp: return 2.0 * f - 1.0;
case AoMode::Level: break;
}
return 0.0;
}
double epoch_seconds() {
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
}
} // namespace
// --- E1608Settings --------------------------------------------------------------------------
E1608Settings E1608Settings::from(const SourceConfig& c) {
E1608Settings s;
s.address = trim(c.get("address"));
s.mac_text = trim(c.get("mac"));
s.subnet = trim(c.get("subnet"));
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
const long code = c.get_int("code", 0);
s.code = static_cast<std::uint32_t>(std::min(4294967295L, std::max(0L, code)));
s.timeout_ms = static_cast<int>(c.get_int("timeout_ms", 2000));
s.scan_rate = c.get_double("scan_rate", 1000.0);
s.ext_clock = c.get_bool("ext_clock", false);
parse_trigger(c.get("trigger", "none"), s.trigger);
s.auto_restart = c.get_bool("auto_restart", true);
s.calibrate = c.get_bool("calibrate", true);
s.poll_rate = c.get_double("poll_rate", 10.0);
s.diff = lower(c.get("mode", "se")) == "diff";
bool any_key = false;
for (int i = 0; i < 8; ++i) {
const std::string key = "ai" + std::to_string(i);
if (!c.has(key)) continue;
any_key = true;
Range r;
if (parse_range(c.get(key), r)) {
s.ai_on[i] = true;
s.ai_range[i] = r;
}
}
if (!any_key) { // a bare `--source-opt address=...` is connectable
s.ai_on[0] = true;
s.ai_range[0] = Range::Bip10V;
}
if (s.diff) {
for (int i = 4; i < 8; ++i) s.ai_on[i] = false;
}
for (int i = 0; i < 8; ++i) {
const std::string d = lower(c.get("dir" + std::to_string(i), "i"));
s.dir_out[i] = d == "o" || d == "out" || d == "output";
s.plot[i] = c.get_bool("plot" + std::to_string(i), false) && !s.dir_out[i];
}
s.plot_counter = c.get_bool("plot_counter", false);
s.do_init = static_cast<int>(c.get_int("do_init", 0) & 0xFF);
for (int i = 0; i < 2; ++i)
s.ao_init[i] = clamp(c.get_double("ao" + std::to_string(i) + "_init", 0.0), -10.0, 10.0);
return s;
}
int E1608Settings::active_channels() const {
int n = 0;
for (int i = 0; i < 8; ++i) n += ai_on[i] ? 1 : 0;
return n;
}
std::vector<QueueEntry> E1608Settings::queue() const {
std::vector<QueueEntry> q;
for (int i = 0; i < 8; ++i) {
if (ai_on[i]) q.push_back({static_cast<std::uint8_t>(diff ? i + 8 : i), ai_range[i]});
}
return q;
}
std::string E1608Settings::identity() const {
if (!address.empty()) return address;
Mac m;
return parse_mac(mac_text, m) ? format_mac(m) : mac_text;
}
std::string E1608Settings::problem() const {
Mac m;
if (address.empty() && mac_text.empty()) return "Enter an address, or a MAC address to look up";
if (!mac_text.empty() && !parse_mac(mac_text, m))
return "The MAC address must be six hex bytes, such as 00:80:2f:00:16:08";
if (address.empty() && !subnet.empty() && sweep_targets(subnet).empty())
return "The subnet must look like 192.168.1";
if (port < 1 || port > 65534) return "The port must be between 1 and 65534";
if (timeout_ms < 100 || timeout_ms > 60000) return "The timeout must be 100 to 60000 ms";
if (!(scan_rate > 0.0)) return "The scan rate must be positive";
if (active_channels() < 1) return "Enable at least one analog input";
if (aggregate() > kMaxAggregateHz) {
char b[96];
std::snprintf(b, sizeof b, "%d channels at %.6g S/s is %.6g kS/s; the board takes 250 kS/s",
active_channels(), scan_rate, aggregate() / 1000.0);
return b;
}
if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s";
if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz";
return {};
}
// --- E1608Source ----------------------------------------------------------------------------
E1608Source::E1608Source(const SourceConfig& cfg) : cfg_(E1608Settings::from(cfg)) {
queue_ = cfg_.queue();
block_ = static_cast<std::size_t>(clamp(std::round(cfg_.scan_rate / 100.0), 1.0, 4096.0));
rx_.resize(65536);
ready_.resize(queue_.size());
auto base = [&](const std::string& name, const char* unit, double lo, double hi) {
SignalInfo s;
s.name = name;
s.unit = unit;
s.type_code = 9; // float64
s.rows = 1;
s.cols = 1;
s.range_min = lo;
s.range_max = hi;
s.time_mode = kTimePacket;
s.sampling_rate = cfg_.scan_rate;
return s;
};
for (int i = 0; i < 8; ++i) {
if (!cfg_.ai_on[i]) continue;
const double fs = full_scale(cfg_.ai_range[i]);
signals_.push_back(base("AI" + std::to_string(i), "V", -fs, fs));
}
for (int b = 0; b < 8; ++b) {
if (!cfg_.plot[b]) continue;
plotted_bits_.push_back(b);
signals_.push_back(base("DI" + std::to_string(b), "", 0.0, 1.0));
}
if (cfg_.plot_counter) signals_.push_back(base("Counter", "counts", 0.0, 1000.0));
held_di_.assign(plotted_bits_.size(), 0.0);
}
std::string E1608Source::last_message() const {
std::lock_guard<std::mutex> lk(msg_mu_);
return last_message_;
}
void E1608Source::open(SourceSink& sink) {
const std::string p = cfg_.problem();
if (!p.empty()) throw std::runtime_error(p);
sink_ = &sink;
state_ = State::Down;
retry_at_ = Clock::now();
}
void E1608Source::close() {
if (state_ == State::Scanning) dev_.scan_stop();
link_.close();
connected_.store(false, std::memory_order_relaxed);
}
SourceStats E1608Source::stats() const {
SourceStats s;
s.packets = packets_;
s.frames = frames_;
s.reconnects = reconnects_;
s.connected = connected_.load(std::memory_order_relaxed);
return s;
}
void E1608Source::note(const std::string& msg) {
{
std::lock_guard<std::mutex> lk(msg_mu_);
last_message_ = msg;
}
if (msg != last_logged_) { // a retry every 2 s must not flood stderr
last_logged_ = msg;
std::fprintf(stderr, "[e1608] %s\n", msg.c_str());
std::fflush(stderr);
}
}
void E1608Source::fail(const std::string& msg, bool fatal) {
link_.close();
const bool was_up = connected_.exchange(false, std::memory_order_relaxed);
note(msg);
if (was_up) sink_->on_event(SourceEvent::Disconnected, msg.c_str());
sink_->on_event(SourceEvent::Error, msg.c_str());
any_gen_ = false;
readouts_->gen_on[0].store(false);
readouts_->gen_on[1].store(false);
if (fatal) {
state_ = State::Halted;
} else {
state_ = State::Down;
retry_at_ = Clock::now() + std::chrono::seconds(2);
}
}
void E1608Source::poll(int timeout_ms) {
if (sink_ == nullptr) return;
try {
drain_commands();
switch (state_) {
case State::Down: {
const auto now = Clock::now();
if (now < retry_at_) {
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(retry_at_ - now);
std::this_thread::sleep_for(std::min(left, std::chrono::milliseconds(timeout_ms)));
return;
}
connect_and_start();
break;
}
case State::Scanning:
scan_step(timeout_ms);
break;
case State::Halted:
std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms));
break;
}
} catch (const LinkError& e) {
fail(e.what(), e.kind() == LinkError::Kind::Rejected);
} catch (const std::exception& e) {
fail(e.what(), false);
}
}
void E1608Source::connect_and_start() {
std::string host = cfg_.address;
Mac want{};
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
if (host.empty() || have_mac) {
const std::vector<std::string> targets =
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
const std::vector<DeviceInfo> found =
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
if (host.empty()) {
for (const DeviceInfo& d : found) {
if (d.mac == want) host = d.ip;
}
if (host.empty())
throw LinkError(LinkError::Kind::Io,
"no E-1608 with MAC " + format_mac(want) + " answered");
} else {
if (found.empty()) throw LinkError(LinkError::Kind::Io, "no E-1608 answered at " + host);
if (found[0].mac != want)
throw LinkError(LinkError::Kind::Protocol, "the board at " + host + " has MAC " +
format_mac(found[0].mac) + ", not " +
format_mac(want));
}
}
link_.connect(host, cfg_.port, cfg_.code, cfg_.timeout_ms);
cal_ = Calibration{};
if (cfg_.calibrate && !dev_.read_calibration(cal_))
note("calibration memory refused; using nominal values");
std::uint8_t inputs = 0;
for (int b = 0; b < 8; ++b) {
if (!cfg_.dir_out[b]) inputs = static_cast<std::uint8_t>(inputs | (1u << b));
}
dev_.dconf(inputs);
do_latch_ = static_cast<std::uint8_t>(cfg_.do_init);
dev_.dout(do_latch_);
readouts_->do_latch.store(do_latch_);
for (int ch = 0; ch < 2; ++ch) {
ao_[ch] = AoSettings{};
ao_[ch].level = cfg_.ao_init[ch];
write_ao(ch, cfg_.ao_init[ch]);
}
any_gen_ = false;
update_gen_flags();
dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger);
pending_.clear();
for (auto& r : ready_) r.clear();
pending_lost_ = 0;
const auto now = Clock::now();
next_slow_ = now;
next_status_ = now + std::chrono::milliseconds(500);
next_gen_ = now + std::chrono::milliseconds(20);
slow_read();
state_ = State::Scanning;
connected_.store(true, std::memory_order_relaxed);
if (connects_.fetch_add(1) > 0) ++reconnects_;
readouts_->epoch.fetch_add(1);
last_logged_.clear();
sink_->on_config(signals_, 1);
sink_->on_event(SourceEvent::Connected, "");
}
int E1608Source::wait_ms(int timeout_ms, Clock::time_point now) const {
Clock::time_point next = std::min(next_slow_, next_status_);
if (any_gen_) next = std::min(next, next_gen_);
const long long d = std::chrono::duration_cast<std::chrono::milliseconds>(next - now).count();
return static_cast<int>(std::max(0LL, std::min<long long>(d, timeout_ms)));
}
void E1608Source::scan_step(int timeout_ms) {
const std::size_t n = link_.read_scan(rx_.data(), rx_.size(), wait_ms(timeout_ms, Clock::now()));
if (n > 0) {
++packets_;
pending_.insert(pending_.end(), rx_.begin(), rx_.begin() + static_cast<std::ptrdiff_t>(n));
const std::size_t nq = queue_.size();
const std::size_t scans = pending_.size() / (2 * nq);
if (scans > 0) {
std::vector<std::uint16_t> raw(scans * nq);
for (std::size_t i = 0; i < raw.size(); ++i)
raw[i] = static_cast<std::uint16_t>(pending_[2 * i] | (pending_[2 * i + 1] << 8));
pending_.erase(pending_.begin(),
pending_.begin() + static_cast<std::ptrdiff_t>(scans * nq * 2));
decode_scans(raw.data(), scans, queue_, cal_, dec_);
for (std::size_t q = 0; q < nq; ++q)
ready_[q].insert(ready_[q].end(), dec_[q].begin(), dec_[q].end());
}
}
while (!ready_.empty() && ready_[0].size() >= block_) emit_frame(block_);
run_timers();
}
void E1608Source::emit_frame(std::size_t count) {
const std::size_t nq = queue_.size();
const std::size_t total = signals_.size();
vals_.resize(total);
views_.resize(total);
for (std::size_t q = 0; q < nq; ++q) {
vals_[q].assign(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
ready_[q].erase(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
}
for (std::size_t k = 0; k < plotted_bits_.size(); ++k) vals_[nq + k].assign(count, held_di_[k]);
if (cfg_.plot_counter) vals_[total - 1].assign(count, held_counter_);
for (std::size_t i = 0; i < total; ++i)
views_[i] = SignalValues{vals_[i].data(), static_cast<std::uint32_t>(count)};
Frame f;
f.counter = frame_counter_++;
f.lost = pending_lost_;
pending_lost_ = 0;
f.late = false;
f.recv_time = epoch_seconds();
f.publish_mode = 1;
f.num_samples = static_cast<std::uint32_t>(count);
f.num_signals = static_cast<std::uint32_t>(total);
f.signals = signals_.data();
f.values = views_.data();
++frames_;
sink_->on_data(f);
}
void E1608Source::run_timers() {
const auto now = Clock::now();
if (now >= next_slow_) {
next_slow_ = now + std::chrono::duration_cast<Clock::duration>(
std::chrono::duration<double>(1.0 / cfg_.poll_rate));
slow_read();
}
if (now >= next_status_) {
next_status_ = now + std::chrono::milliseconds(500);
check_status();
}
if (any_gen_ && now >= next_gen_) {
next_gen_ = now + std::chrono::milliseconds(20);
generator_tick(now);
}
}
void E1608Source::slow_read() {
const std::uint8_t d = dev_.din();
const std::uint32_t c = dev_.counter();
readouts_->di.store(d);
readouts_->counter.store(c);
for (std::size_t k = 0; k < plotted_bits_.size(); ++k)
held_di_[k] = (d >> plotted_bits_[k]) & 1 ? 1.0 : 0.0;
held_counter_ = static_cast<double>(c);
}
void E1608Source::check_status() {
const std::uint16_t st = dev_.status();
if (st & 4) {
if (!cfg_.auto_restart) {
fail("scan overrun; the scan is stopped (auto-restart is off)", true);
return;
}
note("scan overrun; restarting");
sink_->on_event(SourceEvent::Error, "scan overrun; restarting");
restart_scan();
pending_lost_ = 1;
} else if (!(st & 2) && cfg_.auto_restart) {
restart_scan();
}
}
void E1608Source::restart_scan() {
dev_.scan_stop();
pending_.clear();
for (auto& r : ready_) r.clear();
dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger);
}
void E1608Source::write_ao(int ch, double volts) {
readouts_->ao[ch].store(dev_.aout(ch, volts, cal_));
}
void E1608Source::update_gen_flags() {
any_gen_ = false;
for (int ch = 0; ch < 2; ++ch) {
const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run;
readouts_->gen_on[ch].store(on);
any_gen_ = any_gen_ || on;
}
}
void E1608Source::generator_tick(Clock::time_point now) {
for (int ch = 0; ch < 2; ++ch) {
const AoSettings& s = ao_[ch];
if (s.mode == AoMode::Level || !s.run) continue;
const double t = std::chrono::duration<double>(now - gen_t0_[ch]).count();
const double phase = std::fmod(t * s.freq_hz, 1.0);
write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0));
}
}
void E1608Source::set_output_bit(int bit, bool on) {
if (state_ != State::Scanning || bit < 0 || bit > 7 || !cfg_.dir_out[bit]) return;
do_latch_ = static_cast<std::uint8_t>(on ? (do_latch_ | (1u << bit)) : (do_latch_ & ~(1u << bit)));
dev_.dout(do_latch_);
readouts_->do_latch.store(do_latch_);
}
void E1608Source::reset_counter() {
if (state_ != State::Scanning) return;
dev_.counter_reset();
held_counter_ = 0.0;
readouts_->counter.store(0);
}
void E1608Source::set_analog_out(int ch, const AoSettings& s) {
if (state_ != State::Scanning || ch < 0 || ch > 1) return;
ao_[ch] = s;
ao_[ch].freq_hz = clamp(s.freq_hz, 0.1, 10.0);
ao_[ch].amplitude = clamp(s.amplitude, 0.0, 10.0);
ao_[ch].offset = clamp(s.offset, -10.0, 10.0);
if (s.mode == AoMode::Level) {
ao_[ch].run = false;
write_ao(ch, clamp(s.level, -10.0, 10.0));
} else if (s.run) {
gen_t0_[ch] = Clock::now();
}
update_gen_flags();
}
} // namespace udpscope::e1608
Note: M_PI needs <cmath>, already included through the header.
- Step 3: Write
e1608_kind.cpp(the connect form; the panels are Task 4)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_source.hpp"
#include "source/registry.hpp"
#include <adwaita.h>
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
namespace udpscope {
namespace {
using namespace e1608;
using ChangeCb = std::shared_ptr<std::function<void()>>;
constexpr const char* kTriggerNames[] = {"none", "rising", "falling", "high", "low"};
constexpr Trigger kTriggers[] = {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High,
Trigger::Low};
std::string num(double v) {
char b[48];
std::snprintf(b, sizeof b, "%.10g", v);
return b;
}
GtkWidget* entry_row(const char* title) {
GtkWidget* r = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
return r;
}
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* switch_row(const char* title, bool def) {
GtkWidget* r = adw_switch_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_switch_row_set_active(ADW_SWITCH_ROW(r), def);
return r;
}
GtkWidget* combo_row(const char* title, const char* const* items) {
GtkWidget* r = adw_combo_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
return r;
}
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
GtkWidget* e = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
return e;
}
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); }
/// The form's widgets. Held by a `shared_ptr` that every signal closure and every `ConfigForm`
/// closure shares; the raw widget pointers are valid while the chooser keeps the rows.
struct Form {
GtkWidget *address, *mac, *subnet, *port, *code, *timeout;
GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate;
GtkWidget *mode, *ai[8], *ai_group;
GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2];
ChangeCb changed = std::make_shared<std::function<void()>>();
bool updating = false;
bool diff() const { return sel_of(mode) == 1; }
int enabled_ai() const {
int n = 0;
for (int i = 0; i < (diff() ? 4 : 8); ++i) n += sel_of(ai[i]) > 0 ? 1 : 0;
return n;
}
/// In DIFF mode AI rows 4 to 7 are insensitive, not hidden; the group's subtitle shows the load.
void refresh() {
const bool d = diff();
for (int i = 0; i < 8; ++i) gtk_widget_set_sensitive(ai[i], !(d && i >= 4));
const int n = enabled_ai();
char b[96];
std::snprintf(b, sizeof b, "%d channel%s, %.3g of 250 kS/s", n, n == 1 ? "" : "s",
n * value_of(rate) / 1000.0);
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(ai_group), b);
}
SourceConfig read() const {
SourceConfig c;
if (!text_of(address).empty()) c.set("address", text_of(address));
if (!text_of(mac).empty()) c.set("mac", text_of(mac));
if (!text_of(subnet).empty()) c.set("subnet", text_of(subnet));
c.set("port", num(value_of(port)));
c.set("code", num(value_of(code)));
c.set("timeout_ms", num(value_of(timeout)));
c.set("scan_rate", num(value_of(rate)));
c.set("ext_clock", on_of(ext_clock) ? "1" : "0");
c.set("trigger", kTriggerNames[std::min<guint>(sel_of(trigger), 4)]);
c.set("auto_restart", on_of(auto_restart) ? "1" : "0");
c.set("calibrate", on_of(calibrate) ? "1" : "0");
c.set("poll_rate", num(value_of(poll_rate)));
c.set("mode", diff() ? "diff" : "se");
static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"};
for (int i = 0; i < 8; ++i) {
const guint s = sel_of(ai[i]);
if (s > 0 && s <= 4 && !(diff() && i >= 4)) c.set("ai" + std::to_string(i), kRange[s]);
}
for (int i = 0; i < 8; ++i) {
const guint s = sel_of(dir[i]); // 0 input, 1 input plotted, 2 output
c.set("dir" + std::to_string(i), s == 2 ? "o" : "i");
c.set("plot" + std::to_string(i), s == 1 ? "1" : "0");
}
c.set("plot_counter", on_of(plot_counter) ? "1" : "0");
c.set("do_init", num(value_of(do_init)));
c.set("ao0_init", num(value_of(ao_init[0])));
c.set("ao1_init", num(value_of(ao_init[1])));
return c;
}
void write(const SourceConfig& cfg) {
const E1608Settings s = E1608Settings::from(cfg);
updating = true;
gtk_editable_set_text(GTK_EDITABLE(address), s.address.c_str());
gtk_editable_set_text(GTK_EDITABLE(mac), s.mac_text.c_str());
gtk_editable_set_text(GTK_EDITABLE(subnet), s.subnet.c_str());
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
adw_spin_row_set_value(ADW_SPIN_ROW(code), static_cast<double>(s.code));
adw_spin_row_set_value(ADW_SPIN_ROW(timeout), s.timeout_ms);
adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.scan_rate);
adw_switch_row_set_active(ADW_SWITCH_ROW(ext_clock), s.ext_clock);
for (guint i = 0; i < 5; ++i) {
if (kTriggers[i] == s.trigger) adw_combo_row_set_selected(ADW_COMBO_ROW(trigger), i);
}
adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart);
adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate);
adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate);
adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0);
for (int i = 0; i < 8; ++i) {
adw_combo_row_set_selected(
ADW_COMBO_ROW(ai[i]), s.ai_on[i] ? static_cast<guint>(s.ai_range[i]) + 1 : 0);
adw_combo_row_set_selected(ADW_COMBO_ROW(dir[i]), s.dir_out[i] ? 2 : (s.plot[i] ? 1 : 0));
}
adw_switch_row_set_active(ADW_SWITCH_ROW(plot_counter), s.plot_counter);
adw_spin_row_set_value(ADW_SPIN_ROW(do_init), s.do_init);
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[0]), s.ao_init[0]);
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[1]), s.ao_init[1]);
updating = false;
refresh();
}
bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); }
};
using FormPtr = std::shared_ptr<Form>;
void on_notify(GObject*, GParamSpec*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
f.refresh();
if (!f.updating && f.changed && *f.changed) (*f.changed)();
}
void on_edited(GtkEditable*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.updating && f.changed && *f.changed) (*f.changed)();
}
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) {
g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void hook_entry(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
class E1608Kind final : public SourceKind {
public:
std::string id() const override { return "e1608"; }
std::string display_name() const override { return "MCC E-1608"; }
bool has_datagrams() const override { return false; }
std::string identity(const SourceConfig& cfg) const override {
return E1608Settings::from(cfg).identity();
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<E1608Source>(cfg);
}
ConfigForm make_config_form() const override {
auto f = std::make_shared<Form>();
static const char* const kTrig[] = {"None", "Rising edge", "Falling edge", "High level",
"Low level", nullptr};
static const char* const kMode[] = {"Single-ended (8 channels)", "Differential (4 channels)",
nullptr};
static const char* const kAi[] = {"Off", "\u00b110 V", "\u00b15 V", "\u00b12 V", "\u00b11 V",
nullptr};
static const char* const kDir[] = {"Input", "Input, plotted", "Output", nullptr};
f->address = entry_row("_Address");
f->mac = entry_row("_MAC address");
f->subnet = entry_row("_Discovery subnet");
f->port = spin_row("_Port", 1, 65534, 1, kDefaultPort, 0);
f->code = spin_row("Connection _code", 0, 4294967295.0, 1, 0, 0);
f->timeout = spin_row("_Timeout (ms)", 100, 60000, 100, 2000, 0);
f->rate = spin_row("Scan _rate (S/s per channel)", 0.02, 250000, 1, 1000, 2);
f->ext_clock = switch_row("_External clock", false);
f->trigger = combo_row("T_rigger", kTrig);
f->auto_restart = switch_row("_Restart after an overrun", true);
f->calibrate = switch_row("Apply _factory calibration", true);
f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1);
f->mode = combo_row("Input _mode", kMode);
for (int i = 0; i < 8; ++i) {
const std::string title = "Channel " + std::to_string(i);
f->ai[i] = combo_row(title.c_str(), kAi);
adw_combo_row_set_selected(ADW_COMBO_ROW(f->ai[i]), i == 0 ? 1 : 0);
}
for (int i = 0; i < 8; ++i) {
const std::string title = "Bit " + std::to_string(i);
f->dir[i] = combo_row(title.c_str(), kDir);
}
f->plot_counter = switch_row("Plot the _counter", false);
f->do_init = spin_row("Initial output value", 0, 255, 1, 0, 0);
f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3);
f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3);
GtkWidget* conn = expander("Connection", {f->address, f->mac, f->subnet, f->port, f->code,
f->timeout});
GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
f->calibrate, f->poll_rate});
GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3],
f->ai[4], f->ai[5], f->ai[6], f->ai[7]});
GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4],
f->dir[5], f->dir[6], f->dir[7], f->plot_counter,
f->do_init, f->ao_init[0], f->ao_init[1]});
f->ai_group = ai;
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(conn), TRUE);
for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f);
for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->do_init,
f->ao_init[0], f->ao_init[1]})
hook_notify(w, "notify::value", f);
for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter})
hook_notify(w, "notify::active", f);
hook_notify(f->trigger, "notify::selected", f);
hook_notify(f->mode, "notify::selected", f);
for (int i = 0; i < 8; ++i) {
hook_notify(f->ai[i], "notify::selected", f);
hook_notify(f->dir[i], "notify::selected", f);
}
f->refresh();
ConfigForm out;
out.rows = {conn, acq, ai, dio};
out.description =
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)";
out.focus = f->address;
out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); };
out.valid = [f]() { return f->valid(); };
ChangeCb cb = f->changed;
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return out;
}
};
} // namespace
std::unique_ptr<SourceKind> make_e1608_kind() { return std::make_unique<E1608Kind>(); }
} // namespace udpscope
Note: out.write/read/valid capture f (a shared_ptr<Form>) by value, and the signal closures hold their own copies; Form holds no shared_ptr to itself, so there is no cycle. Form::changed is read through f->changed (the same shared_ptr object the on_change lambda captured).
- Step 4: Register the kind and add the sources to CMake
src/source/registry.hpp, in the declarations block:
std::unique_ptr<SourceKind> make_e1608_kind();
(after make_probe_kind()), and update the comment above to (\udps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`)`.
src/source/registry.cpp, in register_builtin_sources() between the udps line and the probe line:
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
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
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
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(overridemake_panels),CMakeLists.txt(adde1608_panels.cppaftere1608_kind.cpp)
Interfaces:
- Consumes (Task 3):
E1608Source::post(inherited),set_output_bit,reset_counter,set_analog_out,readouts(),settings();E1608Readouts;AoMode,AoSettings; frameworkSourcePanel,DataSource. - Produces:
std::vector<SourcePanel> make_e1608_panels(DataSource& source)in namespaceudpscope, two panels: idao(title "Analog out", iconaudio-volume-high-symbolic) and iddio(title "Digital I/O", iconinput-dialpad-symbolic). The app turns the ids into stack pagessource-aoandsource-dio. Task 5's probe finds these widget names (set withgtk_widget_set_nameon the widget itself), and they are a contract:e1608-ao<n>-mode(AdwComboRow),-level,-amp,-offset,-freq(AdwSpinRow),-run(AdwSwitchRow),-last(AdwActionRow), forn= 0, 1;e1608-do<b>(AdwSwitchRow, output bits only),e1608-di<b>(GtkLabel, input bits only), forb= 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
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/source_kind.hpp"
#include <vector>
namespace udpscope {
/// The E-1608's two capability panels, "Analog out" and "Digital I/O". `source` must be an
/// `e1608::E1608Source`. The widgets reach it only through `source.post()`.
std::vector<SourcePanel> make_e1608_panels(DataSource& source);
} // namespace udpscope
- Step 2: Write
e1608_panels.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_panels.hpp"
#include "source/e1608/e1608_source.hpp"
#include <adwaita.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
namespace udpscope {
namespace {
using namespace e1608;
constexpr gint64 kRefreshUs = 100000; // the panels redraw their readouts at 10 Hz
/// A signal closure's argument: the panel's context and the index (DAC or bit) it concerns.
struct Hook {
void* ctx = nullptr;
int idx = 0;
};
GtkWidget* spin(const std::string& name, const char* title, double lo, double hi, double step,
double def, guint digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
gtk_widget_set_name(r, name.c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), digits);
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* switch_row(const std::string& name, const char* title, const char* subtitle) {
GtkWidget* r = adw_switch_row_new();
gtk_widget_set_name(r, name.c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
if (subtitle != nullptr) adw_action_row_set_subtitle(ADW_ACTION_ROW(r), subtitle);
return r;
}
bool due(gint64& last_us) {
const gint64 now = g_get_monotonic_time();
if (now - last_us < kRefreshUs) return false;
last_us = now;
return true;
}
void set_text(GtkWidget* label, const char* text) {
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
}
GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_box_append(GTK_BOX(box), a);
if (b != nullptr) gtk_box_append(GTK_BOX(box), b);
return box;
}
// --- Analog out -------------------------------------------------------------------------------
struct AoWidgets {
GtkWidget *mode = nullptr, *level = nullptr, *amp = nullptr, *offset = nullptr, *freq = nullptr,
*run = nullptr, *last = nullptr;
};
struct AoCtx {
E1608Source* src = nullptr;
std::shared_ptr<E1608Readouts> ro;
AoWidgets w[2];
Hook hooks[2];
bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing
unsigned epoch_seen = 0;
gint64 last_us = 0;
};
void ao_sensitivity(const AoWidgets& w) {
const bool level = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode)) == 0;
gtk_widget_set_sensitive(w.level, level);
for (GtkWidget* x : {w.amp, w.offset, w.freq, w.run}) gtk_widget_set_sensitive(x, !level);
}
/// Back to what the source holds after a (re)connect.
void ao_reset(AoCtx* c) {
c->updating = true;
for (int ch = 0; ch < 2; ++ch) {
const AoWidgets& w = c->w[ch];
adw_combo_row_set_selected(ADW_COMBO_ROW(w.mode), 0);
adw_spin_row_set_value(ADW_SPIN_ROW(w.level), c->src->settings().ao_init[ch]);
adw_spin_row_set_value(ADW_SPIN_ROW(w.amp), 1.0);
adw_spin_row_set_value(ADW_SPIN_ROW(w.offset), 0.0);
adw_spin_row_set_value(ADW_SPIN_ROW(w.freq), 1.0);
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
ao_sensitivity(w);
}
c->updating = false;
}
void ao_push(AoCtx* c, int ch) {
if (c->updating) return;
const AoWidgets& w = c->w[ch];
const guint mode = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode));
if (mode == 0 && adw_switch_row_get_active(ADW_SWITCH_ROW(w.run))) { // Level has no generator
c->updating = true;
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
c->updating = false;
}
ao_sensitivity(w);
AoSettings s;
s.mode = static_cast<AoMode>(mode);
s.level = adw_spin_row_get_value(ADW_SPIN_ROW(w.level));
s.amplitude = adw_spin_row_get_value(ADW_SPIN_ROW(w.amp));
s.offset = adw_spin_row_get_value(ADW_SPIN_ROW(w.offset));
s.freq_hz = adw_spin_row_get_value(ADW_SPIN_ROW(w.freq));
s.run = adw_switch_row_get_active(ADW_SWITCH_ROW(w.run));
E1608Source* src = c->src;
src->post([src, ch, s] { src->set_analog_out(ch, s); });
}
void on_ao_changed(GObject*, GParamSpec*, gpointer user) {
const Hook* h = static_cast<const Hook*>(user);
ao_push(static_cast<AoCtx*>(h->ctx), h->idx);
}
void ao_refresh(AoCtx* c) {
const unsigned ep = c->ro->epoch.load();
if (ep != c->epoch_seen) {
c->epoch_seen = ep;
ao_reset(c);
}
for (int ch = 0; ch < 2; ++ch) {
const double v = c->ro->ao[ch].load();
char b[48];
if (std::isnan(v))
std::snprintf(b, sizeof b, "not written");
else
std::snprintf(b, sizeof b, "%+.4f V", v);
const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->w[ch].last));
if (cur == nullptr || std::strcmp(cur, b) != 0)
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b);
}
}
gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
AoCtx* c = static_cast<AoCtx*>(user);
if (due(c->last_us)) ao_refresh(c);
return G_SOURCE_CONTINUE;
}
GtkWidget* build_ao(E1608Source* src) {
auto* c = new AoCtx;
c->src = src;
c->ro = src->readouts();
GtkWidget* groups[2];
static const char* const kModes[] = {"Level", "Sine", "Square", "Triangle", "Ramp", nullptr};
for (int ch = 0; ch < 2; ++ch) {
const std::string p = "e1608-ao" + std::to_string(ch);
AoWidgets& w = c->w[ch];
w.mode = adw_combo_row_new();
gtk_widget_set_name(w.mode, (p + "-mode").c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.mode), "Mode");
adw_combo_row_set_model(ADW_COMBO_ROW(w.mode), G_LIST_MODEL(gtk_string_list_new(kModes)));
w.level = spin(p + "-level", "Level (V)", -10, 10, 0.1, src->settings().ao_init[ch], 3);
w.amp = spin(p + "-amp", "Amplitude (V)", 0, 10, 0.1, 1.0, 3);
w.offset = spin(p + "-offset", "Offset (V)", -10, 10, 0.1, 0.0, 3);
w.freq = spin(p + "-freq", "Frequency (Hz)", 0.1, 10, 0.1, 1.0, 1);
w.run = switch_row(p + "-run", "Run", nullptr);
w.last = adw_action_row_new();
gtk_widget_set_name(w.last, (p + "-last").c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.last), "Last written");
adw_action_row_set_subtitle(ADW_ACTION_ROW(w.last), "not written");
GtkWidget* g = adw_preferences_group_new();
const std::string title = "DAC " + std::to_string(ch);
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(g), title.c_str());
for (GtkWidget* r : {w.mode, w.level, w.amp, w.offset, w.freq, w.run, w.last})
adw_preferences_group_add(ADW_PREFERENCES_GROUP(g), r);
groups[ch] = g;
c->hooks[ch] = {c, ch};
g_signal_connect_data(w.mode, "notify::selected", G_CALLBACK(on_ao_changed), &c->hooks[ch],
nullptr, static_cast<GConnectFlags>(0));
for (GtkWidget* x : {w.level, w.amp, w.offset, w.freq})
g_signal_connect_data(x, "notify::value", G_CALLBACK(on_ao_changed), &c->hooks[ch], nullptr,
static_cast<GConnectFlags>(0));
g_signal_connect_data(w.run, "notify::active", G_CALLBACK(on_ao_changed), &c->hooks[ch],
nullptr, static_cast<GConnectFlags>(0));
ao_sensitivity(w);
}
GtkWidget* root = panel_root(groups[0], groups[1]);
g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c,
+[](gpointer p) { delete static_cast<AoCtx*>(p); });
gtk_widget_add_tick_callback(root, ao_tick, c, nullptr);
return root;
}
// --- Digital I/O ------------------------------------------------------------------------------
struct DioCtx {
E1608Source* src = nullptr;
std::shared_ptr<E1608Readouts> ro;
GtkWidget* led[8] = {}; ///< input bits
GtkWidget* out[8] = {}; ///< output bits (switch rows)
GtkWidget* counter = nullptr;
Hook hooks[8];
bool updating = false;
unsigned epoch_seen = 0;
gint64 last_us = 0;
};
void set_led(GtkWidget* l, bool on) {
if (gtk_widget_has_css_class(l, "success") == on) return;
if (on) {
gtk_widget_remove_css_class(l, "dim-label");
gtk_widget_add_css_class(l, "success");
} else {
gtk_widget_remove_css_class(l, "success");
gtk_widget_add_css_class(l, "dim-label");
}
}
void dio_refresh(DioCtx* c) {
const unsigned ep = c->ro->epoch.load();
if (ep != c->epoch_seen) { // a (re)connect re-latched the outputs from the form
c->epoch_seen = ep;
const unsigned latch = c->ro->do_latch.load();
c->updating = true;
for (int b = 0; b < 8; ++b) {
if (c->out[b] != nullptr)
adw_switch_row_set_active(ADW_SWITCH_ROW(c->out[b]), ((latch >> b) & 1u) != 0);
}
c->updating = false;
}
const unsigned di = c->ro->di.load();
for (int b = 0; b < 8; ++b) {
if (c->led[b] != nullptr) set_led(c->led[b], ((di >> b) & 1u) != 0);
}
char buf[24];
std::snprintf(buf, sizeof buf, "%u", static_cast<unsigned>(c->ro->counter.load()));
set_text(c->counter, buf);
}
gboolean dio_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
DioCtx* c = static_cast<DioCtx*>(user);
if (due(c->last_us)) dio_refresh(c);
return G_SOURCE_CONTINUE;
}
void on_do_changed(GObject* obj, GParamSpec*, gpointer user) {
const Hook* h = static_cast<const Hook*>(user);
DioCtx* c = static_cast<DioCtx*>(h->ctx);
if (c->updating) return;
const bool on = adw_switch_row_get_active(ADW_SWITCH_ROW(obj));
E1608Source* src = c->src;
const int bit = h->idx;
src->post([src, bit, on] { src->set_output_bit(bit, on); });
}
void on_counter_reset(GtkButton*, gpointer user) {
DioCtx* c = static_cast<DioCtx*>(user);
E1608Source* src = c->src;
src->post([src] { src->reset_counter(); });
}
GtkWidget* build_dio(E1608Source* src) {
auto* c = new DioCtx;
c->src = src;
c->ro = src->readouts();
GtkWidget* lines = adw_preferences_group_new();
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(lines), "Digital lines");
for (int b = 0; b < 8; ++b) {
const std::string title = "Bit " + std::to_string(b);
if (src->settings().dir_out[b]) {
GtkWidget* row = switch_row("e1608-do" + std::to_string(b), title.c_str(), "Output");
c->out[b] = row;
c->hooks[b] = {c, b};
g_signal_connect_data(row, "notify::active", G_CALLBACK(on_do_changed), &c->hooks[b], nullptr,
static_cast<GConnectFlags>(0));
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
} else {
GtkWidget* row = adw_action_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title.c_str());
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), "Input");
GtkWidget* led = gtk_label_new("\u25cf");
gtk_widget_set_name(led, ("e1608-di" + std::to_string(b)).c_str());
gtk_widget_add_css_class(led, "dim-label");
adw_action_row_add_suffix(ADW_ACTION_ROW(row), led);
c->led[b] = led;
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
}
}
GtkWidget* counter_group = adw_preferences_group_new();
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(counter_group), "Counter");
GtkWidget* crow = adw_action_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(crow), "Counts");
c->counter = gtk_label_new("0");
gtk_widget_set_name(c->counter, "e1608-counter");
gtk_widget_add_css_class(c->counter, "numeric");
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), c->counter);
GtkWidget* reset = gtk_button_new_with_label("Reset");
gtk_widget_set_name(reset, "e1608-counter-reset");
gtk_widget_set_valign(reset, GTK_ALIGN_CENTER);
g_signal_connect_data(reset, "clicked", G_CALLBACK(on_counter_reset), c, nullptr,
static_cast<GConnectFlags>(0));
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), reset);
adw_preferences_group_add(ADW_PREFERENCES_GROUP(counter_group), crow);
GtkWidget* root = panel_root(lines, counter_group);
g_object_set_data_full(G_OBJECT(root), "e1608-dio-ctx", c,
+[](gpointer p) { delete static_cast<DioCtx*>(p); });
gtk_widget_add_tick_callback(root, dio_tick, c, nullptr);
return root;
}
} // namespace
std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
E1608Source* src = static_cast<E1608Source*>(&source);
SourcePanel ao;
ao.id = "ao";
ao.title = "Analog out";
ao.icon = "audio-volume-high-symbolic";
ao.widget = build_ao(src);
SourcePanel dio;
dio.id = "dio";
dio.title = "Digital I/O";
dio.icon = "input-dialpad-symbolic";
dio.widget = build_dio(src);
return {ao, dio};
}
} // namespace udpscope
Two rules this code follows, from docs/claude/invariants-gtk-and-chrome.md (read it before editing GTK code): a notify handler is (GObject*, GParamSpec*, gpointer) and a button's clicked is (GtkButton*, gpointer); every heap context is freed by a destroy function (g_object_set_data_full on the panel root), never leaked. The Hook arrays live inside the context, so the pointers handed to the signals stay valid for exactly as long as the widgets.
- Step 3: Make
E1608Kindreturn 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():
std::vector<SourcePanel> make_panels(DataSource& source) const override {
return make_e1608_panels(source);
}
CMakeLists.txt, after src/source/e1608/e1608_kind.cpp:
src/source/e1608/e1608_panels.cpp
- Step 4: Build, and read the warnings against the baseline
cmake --build build -j 2>&1 | tee /tmp/e1608-build4.log | grep -E "warning:|error" | head -20
Expected: no errors, no new warning (baseline: 3 on a clean build, 1 incremental). If adw_action_row_get_subtitle or adw_switch_row_* is flagged as undeclared, the header is <adwaita.h>, already included; a typo in a name is the likelier cause.
- Step 5: Run the app against the mock and look for GTK complaints
The semantic checks come with Task 5's probe; this step only proves the panels build and run without a GTK complaint.
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
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; thee1608sample insource_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 pagessource-aoandsource-dioand the widget names listed under Task 4; app membersa->acq->source(),a->source_panels(SourcePanelPage{name, title, content}),a->side_stack,a->capture_button,a->model(stats(),signals(),channels(),channel_count(),live().channel(i),set_signals_enabled()). - Produces: environment variables
UDPSCOPE_PROBE_E1608=<ms>andUDPSCOPE_PROBE_E1608_RECONNECT=<ms>, and the[e1608]output lines Task 6 documents. Four functions inudpscope::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):
/// 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):
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_probe_reconnect, &app);
}
- Step 3: Write the probes
In src/ui_probe.cpp add #include "source/e1608/e1608_source.hpp" with the other source includes (after #include "source/probe_source.hpp"). Put the code below directly after source_kinds_probe (before pan_probe), inside namespace ui_impl:
namespace {
/// The first widget named `name` at or under `root` (the panels name their controls).
GtkWidget* find_named(GtkWidget* root, const char* name) {
if (root == nullptr) return nullptr;
if (std::strcmp(gtk_widget_get_name(root), name) == 0) return root;
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GtkWidget* f = find_named(c, name)) return f;
}
return nullptr;
}
GtkWidget* source_panel_content(App* a, const char* page) {
for (const SourcePanelPage& p : a->source_panels) {
if (p.name == page) return p.content;
}
return nullptr;
}
e1608::E1608Source* e1608_source(App* a) {
return a->acq != nullptr ? dynamic_cast<e1608::E1608Source*>(a->acq->source()) : nullptr;
}
std::uint64_t g_e1608_frames_before = 0;
/// A fixed raw buffer decoded against volts worked out by hand: single-ended, ch0 on BIP10V with
/// intercept 100 (slope 1), ch1 on BIP5V uncalibrated. `(code - 32768) * fs / 32768`, the code
/// clamped to 0..65535.
bool e1608_decode_check() {
using namespace e1608;
Calibration cal;
cal.se[0] = {1.0, 100.0};
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip5V}};
const std::uint16_t raw[] = {32668, 49152, 49052, 16384, 65535, 0}; // 3 scans x 2 channels
std::vector<std::vector<double>> out;
decode_scans(raw, 3, q, cal, out);
const double want0[] = {0.0, 5.0, 32767.0 * 10.0 / 32768.0};
const double want1[] = {2.5, -2.5, -5.0};
if (out.size() != 2 || out[0].size() != 3 || out[1].size() != 3) return false;
for (int i = 0; i < 3; ++i) {
if (std::fabs(out[0][static_cast<std::size_t>(i)] - want0[i]) > 1e-9) return false;
if (std::fabs(out[1][static_cast<std::size_t>(i)] - want1[i]) > 1e-9) return false;
}
return true;
}
} // namespace
/// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button,
/// the source's state and the decode.
gboolean e1608_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
e1608::E1608Source* src = e1608_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const e1608::E1608Settings& s = src->settings();
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
const std::vector<SignalInfo>& sigs = a->model->signals();
std::size_t plotted = 0;
for (bool p : s.plot) plotted += p ? 1 : 0;
const std::size_t expect =
static_cast<std::size_t>(s.active_channels()) + plotted + (s.plot_counter ? 1 : 0);
std::string names;
for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name;
const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate;
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
const bool stream_ok = up && sigs.size() == expect && std::fabs(rate - s.scan_rate) < 1e-6 &&
frames > 0;
std::fprintf(stderr,
"[e1608] stream: connected=%d signals=%zu expect=%zu names=%s frames=%" G_GUINT64_FORMAT
" rate=%.3f ok=%d\n",
up ? 1 : 0, sigs.size(), expect, names.c_str(), frames, rate, stream_ok ? 1 : 0);
// The mock's AI0 is 0.5 V DC under a 5 Hz sine: two periods average to the DC.
double mean = std::nan("");
if (up && a->model->channel_count() > 0 && !a->model->channels().empty() &&
a->model->channels().front().name == "AI0") {
ChannelBuffer& ch = a->model->live().channel(0);
const u64 n = ch.size();
const u64 want = static_cast<u64>(std::lround(2.0 * s.scan_rate / 5.0));
if (want > 0 && n > want + 50) {
std::vector<f32> v(static_cast<std::size_t>(want));
ch.copy_raw(n - want - 50, n - 50, v.data()); // stay clear of the head the producer is writing
double sum = 0.0;
for (f32 x : v) sum += double(x);
mean = sum / double(v.size());
}
}
std::fprintf(stderr, "[e1608] calibration: ai0_mean=%.4f expect=0.5000 ok=%d\n", mean,
std::fabs(mean - 0.5) < 0.02 ? 1 : 0);
std::fprintf(stderr, "[e1608] capture button: present=%d sensitive=%d\n",
a->capture_button != nullptr ? 1 : 0,
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
std::fprintf(stderr, "[e1608] state: connects=%u message='%s'\n", src->connects(),
src->last_message().c_str());
std::fprintf(stderr, "[e1608] decode: ok=%d\n", e1608_decode_check() ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase 1: the panels, then the three controls (a DAC level, a digital output, the counter reset).
gboolean e1608_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
e1608::E1608Source* src = e1608_source(a);
if (src == nullptr) {
std::fprintf(stderr, "[e1608] no e1608 source (run with --source e1608)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
for (const char* id : {"ao", "dio"}) {
const std::string page = std::string("source-") + id;
GtkWidget* content = source_panel_content(a, page.c_str());
const bool present =
content != nullptr && a->side_stack != nullptr &&
adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page.c_str()) != nullptr;
std::fprintf(stderr, "[e1608] panel %s: present=%d sensitive=%d connected=%d\n", id,
present ? 1 : 0, content != nullptr && gtk_widget_get_sensitive(content) ? 1 : 0,
up ? 1 : 0);
}
if (up) {
// All the stream's signals, in one structural change; the plots do not matter to these readings.
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
GtkWidget* ao = source_panel_content(a, "source-ao");
GtkWidget* dio = source_panel_content(a, "source-dio");
GtkWidget* level = find_named(ao, "e1608-ao0-level");
GtkWidget* out1 = find_named(dio, "e1608-do1");
GtkWidget* reset = find_named(dio, "e1608-counter-reset");
// 2.5 V must differ from the configured initial level, or the spin row notifies nothing.
if (level != nullptr) adw_spin_row_set_value(ADW_SPIN_ROW(level), 2.5);
if (out1 != nullptr) adw_switch_row_set_active(ADW_SWITCH_ROW(out1), TRUE);
if (reset != nullptr) g_signal_emit_by_name(reset, "clicked");
std::fprintf(stderr, "[e1608] actions: ao0_level=%d do1=%d counter_reset=%d\n",
level != nullptr ? 1 : 0, out1 != nullptr ? 1 : 0, reset != nullptr ? 1 : 0);
} else {
std::fprintf(stderr, "[e1608] actions: skipped (not connected)\n");
}
std::fflush(stderr);
g_timeout_add(1000, e1608_probe_report, a);
return G_SOURCE_REMOVE;
}
/// After the mock dropped the link: has the source reconnected, and does the stream move again?
gboolean e1608_probe_resumed(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed);
std::fprintf(stderr, "[e1608] resumed: frames_before=%" G_GUINT64_FORMAT " frames_after=%" G_GUINT64_FORMAT
" ok=%d\n",
g_e1608_frames_before, after, after > g_e1608_frames_before ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean e1608_probe_reconnect(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
e1608::E1608Source* src = e1608_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
g_e1608_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
std::fprintf(stderr, "[e1608] reconnect: connects=%u connected=%d ok=%d\n", src->connects(),
up ? 1 : 0, src->connects() >= 2 && up ? 1 : 0);
std::fflush(stderr);
g_timeout_add(500, e1608_probe_resumed, a);
return G_SOURCE_REMOVE;
}
If the build reports that ChannelBuffer, u64 or f32 are not found in ui_probe.cpp, they are already used unqualified elsewhere in this file (the [gather] probe near line 345 uses u64 and f32); copy that file's own spelling. If copy_raw is not accessible on ChannelBuffer, call it through const plot::data::Series& (it is the base class's public virtual).
- Step 4: Add the
e1608sample tosource_kinds_probe, and guard its click
In source_kinds_probe, in the sample lambda, extend the chain after the probe branch:
} 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:
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
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
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 \
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
2>&1 | tee /tmp/e1608-runA.out | grep -E "\[e1608\]|gtk warnings|Gtk-"
Expected, in this order:
[e1608] <one line, a refusal or a timeout from the handshake; printed once, not every retry>
[e1608] panel ao: present=1 sensitive=0 connected=0
[e1608] panel dio: present=1 sensitive=0 connected=0
[e1608] actions: skipped (not connected)
[e1608] stream: connected=0 signals=0 expect=1 names= frames=0 rate=0.000 ok=0
[e1608] calibration: ai0_mean=nan expect=0.5000 ok=0
[e1608] capture button: present=1 sensitive=0
[e1608] state: connects=0 message='<the same text as the first line>'
[e1608] decode: ok=1
gtk warnings: 0
The point of A: the panels exist and are insensitive with no board (disabled, not hidden), the source retries without flooding stderr, and decode: ok=1 needs no board. Anything else is a defect; sensitive=1 here means apply_source_panels is not reaching the panel, present=0 means make_panels returned nothing.
- Step 7: Run B, the mock: stream, calibration, controls, reconnect
rm -f /tmp/e1608-mock.log
python3 tools/mock_e1608.py --port 54711 --drop-after 3 --log /tmp/e1608-mock.log > /dev/null & MOCK=$!
sleep 1
xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_PROBE_E1608_RECONNECT=8000 \
UDPSCOPE_EXIT_AFTER_MS=10500 \
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
--source-opt scan_rate=1000 --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 \
--source-opt plot_counter=1 --source-opt dir1=o --source-opt ao0_init=1.5 \
2>&1 | tee /tmp/e1608-runB.out | grep -E "\[e1608\]|gtk warnings|Gtk-"
kill $MOCK
(The mock's --drop-after 3 counts from the first command connection; the app first connects within a fraction of a second, so the link drops at about 3 s, is seen at the next status read, and comes back after the 2 s retry, at about 5.5 s, well before the 8 s reconnect probe.)
Expected:
[e1608] panel ao: present=1 sensitive=1 connected=1
[e1608] panel dio: present=1 sensitive=1 connected=1
[e1608] actions: ao0_level=1 do1=1 counter_reset=1
[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=<N> rate=1000.000 ok=1
[e1608] calibration: ai0_mean=0.50xx expect=0.5000 ok=1
[e1608] capture button: present=1 sensitive=0
[e1608] state: connects=1 message=''
[e1608] decode: ok=1
[e1608] <a line about the drop: the link closed / the scan socket closed> (once, near the 3 s mark)
[e1608] reconnect: connects=2 connected=1 ok=1
[e1608] resumed: frames_before=<N> frames_after=<M> ok=1
gtk warnings: 0
(names= lists the enabled channels in signal order, which is AI0,AI1,DI0,Counter. message= is empty at 2.5 s unless the calibration read was refused, in which case it reads calibration memory refused; using nominal values and the calibration line would be ok=0: the mock serves the memory, so that would be a defect.) Then the mock's side:
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
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):
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:
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
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:
- **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:
`--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=1between theudpsandprobeform lines, change the bulletkinds=udps,probetokinds=udps,e1608,probe, and add to the round_trip=0bullet: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:
## `[e1608]`
```
[e1608] panel ao: present=1 sensitive=1 connected=1
[e1608] panel dio: present=1 sensitive=1 connected=1
[e1608] actions: ao0_level=1 do1=1 counter_reset=1
[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=300 rate=1000.000 ok=1
[e1608] calibration: ai0_mean=0.5002 expect=0.5000 ok=1
[e1608] capture button: present=1 sensitive=0
[e1608] state: connects=1 message=''
[e1608] decode: ok=1
[e1608] reconnect: connects=2 connected=1 ok=1
[e1608] resumed: frames_before=2900 frames_after=2950 ok=1
```
`UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and
`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the last two), run as `--source e1608` against `tools/mock_e1608.py`
with the options in `verification.md`. `connected=0` throughout, with `actions: skipped (not connected)`, is the
no-board run; it is a reading and not a failure.
- `panel ...: present=1 sensitive=<connected>`: **disable, don't hide.** `present=0` is a panel that was never
added; `sensitive` that differs from `connected` is `apply_source_panels` acting on the wrong widget.
- `actions: ao0_level=1 do1=1 counter_reset=1`: each control was found by its widget name (`e1608-ao0-level`,
`e1608-do1`, `e1608-counter-reset`). A `0` is a renamed widget, or (`do1`) `dir1=o` missing from the run. The
effects are read off the **mock's log**, not here: a DAC write near 2.5 V (within 0.01), `DOUT 0x02`, `COUNTER_RESET`.
A control that was found but whose effect is missing is a command that `post()` did not deliver or `poll()`
did not run.
- `stream: signals=4 expect=4 rate=1000.000 ok=1`: the enabled channels plus the plotted digital lines plus the
counter, at the scan rate. `signals` below `expect` is a signal the source did not declare; `rate` off by a
factor is the pacer computed from the wrong aggregate.
- `calibration: ai0_mean=0.50xx ok=1`: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through
non-trivial factory coefficients; the mean over two periods lands on 0.5 V only when `decode()` applies them.
About `0.2043` is the **uncalibrated** reading (the calibration read failed, or `calibrate=0`); `nan` is a
run that never got AI0 (not connected, or AI0 not enabled).
- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI disabled, not hidden.
- `state: connects=1 message=''`: a non-empty message at this point is a refusal that happened (for example
`calibration memory refused; using nominal values`), which the stream then ran through.
- `decode: ok=1`: a fixed raw buffer against hand-computed volts (intercept 100, BIP10V and BIP5V, the code
clamp at both ends). It needs no board; `ok=0` is a change to the calibration arithmetic.
- `reconnect: connects=2 connected=1`, `resumed: ok=1`: the mock dropped both links at about 3 s; the source
reported it, retried after 2 s and the frame count moved on. `connects=1` is a source that never noticed the
drop (the status check does not run), `resumed: ok=0` is a reconnect that did not restart the scan.
- Step 5:
README.md, "## Data sources"
(a) After the paragraph ending ... only when \UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-check.`, add:
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
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:
- What was built (the three layers, the two panels, the mock, the probe) and the commit range on
feature/source-e1608. - The deviations from the spec, each with its reason:
- 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.
valid()accepts a MAC with an empty subnet (it means "broadcast discovery"); the spec said a MAC plus a subnet.valid()also requires at least one enabled analog input; with noaiNkey at all,ai0=BIP10Vis the default, so a bare--source-opt address=...connects.- A nonzero reply status is
LinkError::Kind::Command, distinct fromProtocol; the calibration read swallows onlyCommand, so a board that refuses the calibration memory runs on nominal values while a broken link still fails. - Errors are also written to stderr as
[e1608] ..., becauseAcquisition::on_eventignores the detail string and does nothing forError. - The two panels live in
e1608_panels.cpp, not ine1608_kind.cppas the spec's file list says, to keep each file to one responsibility.
- 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.
- That nothing was pushed or merged;
feature/source-e1608is branched from the unmergedfeature/source-plugins.