Skip to content

Add simulated bench and release v0.2.0 - #1

Merged
techmanual-ai merged 4 commits into
mainfrom
feat/simulated-bench
Sep 17, 2026
Merged

techmanual-ai merged 4 commits into
mainfrom
feat/simulated-bench

Conversation

@techmanual-ai

@techmanual-ai techmanual-ai commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Ships a two-instrument simulated bench so LabLink can be evaluated with no hardware, and cuts v0.2.0.

lablink-sim starts one shared Bench behind four real protocol front-ends at once:

FG-100   SCPI    TCPIP0::127.0.0.1::5025::SOCKET
DAQ-8    SCPI    TCPIP0::127.0.0.1::5026::SOCKET
DAQ-8    REST    http://127.0.0.1:8080/api/v1
DAQ-8    serial  /dev/ttysNNN

wiring   FG-100:CH1 --coax--> DAQ-8:CH0

Nothing is mocked. The simulator speaks real protocols on real sockets, so the visa, rest and serial drivers reach it with ordinary configs and no special-case code path — what an evaluator runs is the shipped code. Nothing in lablink/ outside lablink/demo/ is aware the simulator exists.

The patch cable is what makes system_topology demonstrable without hardware. Driven end to end through the MCP tools, connect() reports the wiring and its safety constraint before a single command is sent:

"topology_context": {
  "links": [{"from_port": "sim_fgen:CH1", "to_port": "sim_daq:CH0",
             "constraints": [{"severity": "warning", "limit": "amplitude <= 10.0"}]}],
  "neighbors": ["sim_daq"]
}
MEAS:VOLT? (@0)  →  -1.729084E-04     (noise floor)
SOUR1:FREQ 1000, SOUR1:VOLT 2.0, OUTP1 ON
MEAS:VOLT? (@0)  →  -6.594200E-01     (driven)
READ?            →  CH0 -1.235E+00, CH1..CH7 all sub-millivolt

State is shared across protocols: a POST to the REST generator endpoint is immediately visible to SOUR1:FUNC? over SCPI and to MEAS:VOLT? over the serial pty.

Changes

  • lablink/demo/ — instruments.py (Bench, DAQ, WaveformGen, Patch), scpi.py, rest.py, serial_port.py, cli.py
  • New demo extra — no dependencies, stdlib only
  • New lablink-sim console script
  • examples/topology_sim.toml — mirrors the default patch
  • README "Try it with no hardware" quickstart
  • ARCHITECTURE.md §16 documenting the module and its no-special-casing constraint
  • tests/test_demo_bench.py — 27 integration tests over real sockets
  • v0.2.0 in pyproject.toml / server.json; CHANGELOG cut

Two bugs found and fixed after the first push

77938a7 — race between generator writes and DAQ reads (caught by CI). The instruments are served on separate connections, each on its own handler thread, so a write-only call returned before the write was necessarily applied and a read on the other connection could overtake it. CI saw the noise floor instead of the driven waveform. Fixed by round-tripping a query before crossing connections — *OPC?, the SCPI idiom for exactly this — which converts an ordering assumption into an ordering guarantee.

9baf2fd — unkillable process on shutdown (caught by running the demo). SerialServer.stop() closed the pty master while the reader thread was blocked in os.read() on that fd, leaving the process in an uninterruptible wait that neither SIGTERM nor SIGKILL reaps — so lablink-sim could not be stopped with Ctrl-C. Fixed by polling with select() so the reader returns on its own, and joining it before closing either fd.

Realism

Proper SCPI error queue with standard codes (-222 out of range, -113 undefined header, -224 illegal parameter, cleared on read), *RST / *CLS / *OPC?, input-range clamping, output clipping, per-channel history, and a Gaussian noise floor.

Testing

349 passed (322 existing + 27 new), green on 3.10 / 3.11 / 3.12 / 3.13. Verified end to end through the LabLink MCP tools, the CLI (lablink visa query, lablink rest get, lablink topology show), raw pyvisa, pyserial and curl. Demo-bench tests run 10/10 clean unloaded and 5/5 under 16-core saturation. SIGTERM and SIGINT both confirmed to exit cleanly with no leftover processes. Wheel build confirmed to include lablink/demo/ and the lablink-sim entry point.

Notes

  • serial_port.py is POSIX only; it degrades gracefully on Windows but is untested there.
  • examples/topology_sim.toml mirrors Bench.patches — change one and change the other.

🤖 Generated with Claude Code

techmanual-ai and others added 4 commits September 16, 2026 18:19
Ship a two-instrument simulated bench behind four real protocol front-ends
so LabLink can be evaluated without a lab. An FG-100 generator and a DAQ-8
acquisition module share one Bench, served over SCPI/TCP, a JSON API, a pty,
and direct import.

The simulator is not a mock: it speaks real protocols on real sockets, so the
visa, rest and serial drivers reach it with ordinary configs and no
special-case code path. The generator's CH1 is patched into the DAQ's CH0, so
setting a waveform changes what the DAQ measures — which gives
system_topology something to describe on a bench with no cables in it.

Installed via the new `demo` extra, which has no dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut the first release since 0.1.0, covering system_topology, Home Assistant
support via the rest driver, event-log credential redaction, and the new
simulated bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The simulated instruments are served on separate connections, each handled
on its own thread. A write-only call returned as soon as the bytes were sent,
so a read issued against the other instrument could race ahead of the write
being applied — the VISA test saw the noise floor instead of the driven
waveform on a loaded CI runner.

Round-trip a query before crossing to the other connection, which is the
SCPI idiom for this: _scpi() now appends *OPC? when a call contains no query,
and the VISA test asserts OUTP1? before polling the DAQ. Both turn an
ordering assumption into an ordering guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SerialServer.stop() closed the pty master while the reader thread was blocked
in os.read() on that same fd. On macOS this leaves the process in an
uninterruptible wait that neither SIGTERM nor SIGKILL reaps, so `lablink-sim`
could not be stopped with Ctrl-C — on the onboarding path, of all places.

Poll with select() so the reader observes the stop event and returns on its
own, and join it in stop() before closing either fd. Verified that SIGTERM
and SIGINT both now exit cleanly with no leftover processes, and added a
regression test asserting the reader is dead before the fds close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@techmanual-ai
techmanual-ai merged commit 998ccd6 into main Sep 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant