Web profile plugin — host half + browser half.
> **English**: [README.md](README.md) | **中文**: [README.zh.md](README.zh.md)
---
# DSH Hardware Sandbox
**A 3D hardware sandbox for DSH: build the assembly, wire it up, and run your project's own code against virtual hardware.**
**Install (from GitHub; this package is not published on npm)**
```sh
dsh plugin --profile web add github:Noob-stupid/dsh-breadboard
```
Then reload the page. The sandbox registers into the **`main`** slot (the 3D view) and the
**`sidebar.panellist`** slot (the panels); `skill/SKILL.md` ships with the plugin, so an agent
that has it installed already knows how to drive it.
**Like it?** Open this plugin's page in the Plugins manager — its own bottom-right corner carries
a small, plain-text link to [give it a star on GitHub](https://github.com/Noob-stupid/dsh-breadboard).
It is one transparent line, no box, no banner, and it only ever appears on this plugin's page.
Three things make that one line work — all three are in this repository:
| Requirement | Where | Note |
|---|---|---|
| repo topic `dsh-plugin` | GitHub repository topics | makes the repo discoverable in community lists |
| a `dsh.bundle` manifest | `package.json` → `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` | **without it `dsh plugin add` installs nothing** — and the UI symptom is misleading: the plugin list says "no composition bundle declared", while what actually broke is the assembly, so every `/api/*` returns 404 and the panel simply never appears |
| an exported `apply(ctx)` | `src/index.ts` (host) / `src/client/index.ts` (browser) | host half registers routes, tools and the simulation engine; browser half registers the two slots above |
## What it is / what it solves
Normally the parts of a hardware project you can only *try on the real board* are the wiring
itself, the sensor semantics at register level, and the failure handling. This plugin moves those
into a virtual scene that actually runs:
- **3D assembly** — drop parts from the built-in model library (ESP32, Raspberry Pi 4B, BME280,
HC-SR501 PIR, LED module, MPL3115A2, breadboard), wire them port by port, and get a live
SSOT (single source of truth) assembly state with topology and electrical checks.
- **Project-level hardware-in-the-loop** — a **real Python process** runs **your project's code**.
A Python shim intercepts its `smbus`/I2C access and answers from virtual devices, so the project
under test reads register-level values (e.g. it performs the real BME280 compensation math on
data coming from the virtual chip).
- **Networked-device emulation** (a second, separate class of virtual hardware) — a device that
actively POSTs to **your** external HTTP backend on a real cadence: register → pull config →
periodic report. Verified end-to-end against a real seat-selection system
(device → HTTP → backend state machine → database).
- **Failure injection** — unplug a wire, return NACK, force a reading, take a device offline, and
watch whether the project's error handling actually catches it.
- **Project-level convenience** — scan a real project folder to learn which hardware it needs, and
switch the entire hardware setup in one click.
Requires DSH with the **`web`** profile and `webServer`, `tools` and `subprocess` services. The
host half declares them as **hard `inject` dependencies on purpose**: if any of them is missing the
plugin does not activate at all, rather than activating with tools silently unregistered, a blank
panel, or a simulation engine that cannot spawn.
### See it without a real project
`examples/zhizuo-virtual-esp32/` is a standalone script version of the virtual ESP32 seat sensor.
`hw_list_projects` lists the built-in demo project (**zhizuo-seat-node**) together with whether it
is currently wired end to end; `hw_import_project` puts its parts, wires and network binding in
place in one call.
## UI tour
The interface is currently **Chinese only** — the labels below are the literal ones on screen.
| Panel | What it does |
|---|---|
| **③ Project (one-click hardware swap)** | the main workflow. Per project body: file count (`N files (M skipped while sampling)` when sampling skipped files), **Scan requirements**, **Delete**; plus **+ Import project body (pick folder)** and **+ Import model bundle (pick folder)** |
| **① Add component** | click a model key to drop it into the scene (collapsed by default) |
| **② Re-skin models** | search for a model / import a file, and re-skin one of the model keys above; a model key with no geometry renders as a placeholder box (collapsed by default) |
| **Statistics** | render statistics (draw calls / triangles / FPS) — pure read, off by default |
| **Center 3D view** | placed components with world transforms, **blue cables** between ports, spatial warning boxes drawn on the components that have `warn`/`error` warnings, gizmo/right-click menu (pin, delete), port tooltips |
| **Panel overlay input box** | send a message to the current session without leaving the panel; if the host exposes no `agents` service the box is hidden (`capabilities.chat` is absent) |
Two of those deserve emphasis:
- **Scan requirements is a UI button only.** It is not exposed as an agent tool — the agent side has
no equivalent. What the agent *can* do is everything after that: place parts, wire them, build a
project blueprint, run simulations.
- **Deleting a project only deletes the project definition** (that one JSON). It does **not** touch
the scene, so removing a project never wipes the hardware you have on screen.
## Quick start: three steps
1. **Import the project body** — *+ Import project body (pick folder)*, point it at a **complete
project folder** (frontend + backend + hardware code). It is imported **without** models; the
host reads it to work out which hardware it needs.
2. **Scan what hardware it needs** — the **Scan requirements** button in the ③ panel (UI only, see
above). The report lists each candidate model key with the **evidence** — which files it was seen
in — so you can judge it yourself; when the scan had to sample, it says so instead of implying it
read everything.
3. **Import a model bundle** — *+ Import model bundle (pick folder)*, a folder laid out as
`/model.glb`. Whatever you skip keeps rendering as a placeholder box.
Or skip steps 1–3 entirely: describe the system to the agent and let it call **`hw_save_project`**
to write a blueprint, then **`hw_import_project`** to place every part, wire and network binding in
one go.
## Agent tools: 16 `hw_*` tools
This is the actual registered list (nothing added, nothing omitted). The **when to use** column is
the point of the table: these tools are not interchangeable.
### Projects
| Tool | What it does | When to use it |
|---|---|---|
| `hw_list_projects` | list built-in hardware projects: parts, wires, network config, and whether each is currently wired end to end | **before** hand-placing anything — a blueprint is one call, hand-assembly is four steps and a missed step fails silently |
| `hw_import_project` | import a project: **clears the current assembly first**, then places parts, wires and network bindings | switching the whole hardware set; note it *replaces*, it does not add |
| `hw_save_project` | create or overwrite a project blueprint (stored under `$DSH_HOME/dsh-hardware-sandbox/projects/`) | the user described a system in words — write it down as a blueprint, then import it |
### Assembly
| Tool | What it does | When to use it |
|---|---|---|
| `hw_get_assembly` | read the SSOT summary: revision, components, connections, warnings (with their `message`, not just the code), power summary, virtual devices | **before every edit** — you need the real port ids; port ids are pin identities (`E4`), not silkscreen names (`GPIO23`) |
| `hw_edit_assembly` | one tool over the contract's `ClientAction` union: place / move / rotate / connect / disconnect / remove / pin | any change to the scene. It takes an action object, so read the samples in the tool description first |
### Simulation
| Tool | What it does | When to use it |
|---|---|---|
| `hw_run_simulation` | run a real Python project on virtual hardware; `duration` is **virtual** seconds; returns project output plus virtual/wall elapsed time | the actual hardware-in-the-loop run. `accelerated` (default) finishes far faster than real time; `realtime` paces to the wall clock for observation |
| `hw_get_sim_log` | read the last (or in-progress) run's project output, with an optional `tail` | when `hw_run_simulation`'s output was truncated |
| `hw_inject_fault` | inject `disconnect` / `nack` / `busy` / `set_reading` / `clear` against a device id or a `model:address` target | verifying that the project's error handling really observes the fault (e.g. an `OSError` after a disconnect) instead of assuming it does |
### Networked devices
| Tool | What it does | When to use it |
|---|---|---|
| `hw_network_add` | add a networked device (protocol adapter + endpoint + self-reported device id). **It does not start** | wiring a device emulator into your backend for the first time |
| `hw_network_devices` | list devices: protocol, endpoint, online state, report/failure counts, current readings, server-pushed config | confirming the device is actually connected and actually reporting — an added-but-idle device looks "fine" otherwise |
| `hw_network_control` | `start` / `stop` / `disconnect` (offline fault) / `clear` | **only `start` makes requests go out** — intentional, so nothing has external side effects by default; `disconnect` tests whether the far side notices |
| `hw_network_set_reading` | set a **physical** reading (e.g. `{ ir_front: true, ir_back: true }`, `true` = occluded) | testing whether the protocol layer is right, independently of the hardware path. The protocol translates the reading into a level (`ir_active_high` etc.), so polarity mistakes stay on the protocol side where they belong |
| `hw_set_occlusion` | occlude / un-occlude a placed part and propagate the effect **along the real wires** to a networked device's readings | testing the *hardware* path: if the PIR is not wired, or wired to the wrong pin, **nothing happens** — exactly like the real board |
## Reason codes and what to do about them
Failed assembly operations come back with a reason code (the contract's `WarningCode`, 12 of them,
in `src/contracts/assembly.ts`).
**Read the code. Change that thing. Do not guess-and-retry.**
| Code | Meaning | What to do |
|---|---|---|
| `bad_params` | the action's parameters are unusable (e.g. a rotation angle of `NaN` / `Infinity`) | fix the numbers — the problem is the value, not the component id |
| `direction_conflict` | two current directions fight (output to output / two supplies tied together) | rearrange per current direction: at least one end must become an input |
| `protocol_mismatch` | the two ports carry different signal kinds | use a matching port pair; check which signal that pin actually supports |
| `port_occupied` | **a different** cable already owns that port | unplug the other cable (`disconnect` with its `cableId`), or use another pin |
| `voltage_mismatch` | port voltages are not compatible | connect voltage-compatible ports, or add level shifting |
| `power_exceeded` | the assembly's power demand exceeds its supply budget | add supply or reduce draw |
| `i2c_address_conflict` | two devices on the bus share an I2C address | change one device's address (SDO/AD0 pin) |
| `i2c_pullup_missing` | the I2C bus has no pull-ups | add pull-ups on SDA/SCL |
| `unconnected_port` | a port has no connection at all | connect it as needed — and check you did not forget the *other* wire of a pair |
| `unknown_hardware_model` | no such model key | check the key against `hw_get_assembly`'s existing components |
| `component_pinned` | the component is pinned; the move was **explicitly refused** | unpin it first (right-click menu, or `set_pinned: false`) |
| `already_connected` | **that very pair** of ports is already connected | do not go looking for a cable that does not exist; to re-route, disconnect this one first |
Two notes worth keeping:
- **`port_occupied` and `already_connected` are separate on purpose.** Merged into one code, the user
goes hunting for a cable that does not exist.
- **Multiple *different* ports connecting to the same port is legal** (that is an I2C bus) and does
**not** raise `already_connected`.
- **`ok: true` does not mean the effect happened.** Every write should be followed by reading the
assembly again.
## Model definitions: teaching the plugin a new board
**Appearance and pin data are two different things.** Model libraries (step.parts, GrabCAD,
SnapEDA…) provide **appearance** — the `.glb`. They do **not** provide pin data: even SnapEDA's
symbol library explicitly carries "no bus protocol or voltage", and that semantics is exactly what
the validators check.
```jsonc
{
"key": "stm32f103c8t6", // identity; cannot shadow a built-in
"label": "STM32F103C8T6 (Blue Pill)",
"size": { "x": 0.0533, "y": 0.011, "z": 0.0229 }, // metres
"ports": [
{ "portId": "P1", "name": "VBAT", "protocol": "power", "voltage": 3.3, "direction": "in" }
],
"portLayout": { "edge": "+z", "pitch": 0.00254, "count": 40, "inset": 0.001, "rows": 2 }
}
```
Install it from the panel (**+ 添加型号定义**) or have an agent write it
(`hw_save_model_definition`). Validation happens **before** anything is written, and a valid
definition takes effect immediately without reloading the plugin.
**Start from an authoritative source, not from a pinout image.** `hw_scan_board_pins` reads the
chain PlatformIO itself uses — `boards/.json` → `build.variant` →
`variants//pins_arduino.h`, the file the compiler translates `SDA` into GPIO21 with — and
returns a draft with its provenance. It returns a draft rather than a finished definition on
purpose, because that file cannot tell you which pin sits where on the header, which pin is 5V or
needs a pull-up, or what is printed on the board. Those gaps come back with every draft, and an
agent that cannot source them is told to ask rather than invent them.
**`portId` is identity, `name` is the silkscreen.** A board has several GND pins; naming ports after
their function collides silently. **`voltage: 0` is valid** — that is a ground pin, not a missing
field.
**The rule that matters:** a pin table must come from an authoritative source — the vendor
datasheet, PlatformIO's `variants//pins_arduino.h` (the mapping the compiler actually uses),
or the project's own files (`platformio.ini`, `.ioc`, `sdkconfig`). **A hand-typed pin table is
worse than none**, because it yields validation that looks right and connects the wrong pin, and
nothing can detect it. An agent that cannot find a source is told to stop and ask rather than
invent a plausible pin order.
## Real-device observation: reading a board into the scene
`hw_watch_device` polls an external system (read-only) and maps a real device onto a scene
component. It is the mirror image of `hw_network_add`: that one impersonates a device and sends
requests, this one asks about a real board and sends nothing.
Five states are kept apart, because collapsing them produces plausible but wrong conclusions:
| state | meaning | where to look |
|---|---|---|
| `ok` | seen, data fresh | — |
| `stale` | seen, but the data is old | the board really is offline — check power/network |
| `error` | we cannot reach the system | the URL and the credentials, **not the hardware** |
| `not_found` | the system answers but has no such device | the device id, or it has never been online |
| `idle` | not polled yet | — |
Polling runs on wall-clock time with a two-second floor, which protects the remote server. Only the
host polls; the panel reads the host's cache.
## Model bundle format
The folder you hand to *+ Import model bundle* (and the layout the exporter round-trips through) is:
```
/
model.glb the geometry
meta.json optional metadata
```
- **Only GLB / GLTF are loaded for rendering.** The importer's format detection is
content-based (not extension-based) and accepts glb / gltf / stl / obj / ply / 3mf / dae / fbx /
wrl, but **STEP / IGES is refused outright rather than silently stored** — three.js has no loader
for it, so a stored STEP would never render.
- Internally each stored model is one directory `/{model.bin, meta.json}`, with
`model.bin` holding the geometry bytes (`meta.json` records `format`, `nodes`, `bytes`,
`generator`). Storing the bytes with a neutral name is deliberate: it keeps the layout free of
extension ambiguity and keeps `meta` bound to its bytes. Exported bundles use the
`/model.glb` form above, so export and import are the same layout.
- Shipped with the package (seeded into the user model directory at startup, **only keys that are
missing** — a model you imported is never overwritten):
| modelKey | Origin | In the bundle | Why |
|---|---|---|---|
| `bme280` | self-produced | yes | register-level device model + demo assembly |
| `led-5v` | self-produced | yes | simplest possible output part |
| `hc-sr501` | self-produced | yes | the demo assembly uses 2 of them (PIR) |
| `esp32-seat-sensor` | self-produced | yes | the networked-device demo |
| `rpi-4b` | imported from step.parts (third party) | **no** | third-party geometry — **not redistributed by this plugin**; import it yourself from the UI when you need it |
| `mpl3115a2` | imported from step.parts (third party) | **no** | same |
| `breadboard` | self-produced (no ports, passive) | no geometry | renders as the procedural placeholder box (sized from the contract) |
Why the split: the four self-produced models are ours to ship, so the plugin is **usable out of the
box** (the demo assembly is `esp32-seat-sensor` + 2 × `hc-sr501`, all shipped). The third-party
models are somebody else's assets with their own terms, so the plugin **does not redistribute
them** — the UI gives you search entry points and you import the file yourself. Port *semantics*
(I2C address, protocol, voltage, direction) are always produced here, because no model library
sells them — see `docs/05`.
## Wall-clock trap: why networked devices ignore virtual time
The virtual clock is **accelerating**: `advance(60)` burns 60 virtual seconds in tens of
milliseconds. A passive bus device is fine on it (deterministic, purely synchronous). A **networked
device is not** — it reports periodically on its own initiative, so a single fast-forward would fire
dozens of **real HTTP requests** at somebody's backend and hammer it.
So networked devices **always run on the wall clock, decoupled from virtual time**; their snapshot
carries `wallClockDriven: true`, and accelerated mode means nothing for them. Two consequences:
- **Accelerated mode does not apply to networked devices.** They keep reporting at their real
interval regardless.
- **An added networked device sits in `idle` until you explicitly `start` it** — by default it
produces no external side effects at all. One of the structural rules of this plugin: default
behaviour must not reach outside the machine.
## Build and development
```powershell
# 1. install (also links the framework peers the build needs)
npm install
# 2. build: src/ -> lib/ (needs bash + a DSH source checkout)
$env:DSH_CHECKOUT='D:\dsh\deepseek-harness'; bash scripts/build.sh
# 3. tests and type checks (measured on 2026-10-11)
npm test # node --test, 308 tests, 0 failures
npm run typecheck # tsc -p tsconfig.json --noEmit -> exit 0
npm run typecheck:client # tsc -p tsconfig.client.json --noEmit -> exit 0
```
`scripts/build.sh` does three things beyond compiling: it links the build dependencies
(`scripts/link-deps.mjs`, also wired as `postinstall` because `npm install` wipes junctions it does
not recognize), it copies `project-shim/` into `lib/project-shim/`, and it writes
`lib/build-stamp.json`. That copy is **functional, not cosmetic** — `SimEngine` points the Python
process's `PYTHONPATH` at the shim (`smbus` + `RPi.GPIO` + `sitecustomize`), and since
`package.json`'s `files` only ships `lib`, a build without it publishes a package that cannot run.
`scripts/prepare.mjs` makes the **git install** work: `lib/` is gitignored, so a git install would
have no entry point. It builds only when `lib/index.js` is missing, and **a failed build only warns
(exit 0)** — a build failure usually means the machine lacks bash or devDependencies, and failing the
whole install would blame npm for it.
Release artifact: `npm pack` → a `.tgz` containing `lib`, `cordis.patch.yml`, `skill`, `docs`,
`models` and `scripts`.
### Self-check: is the live build the build you think it is?
```sh
curl http://127.0.0.1:/@noob-stupid/dsh-breadboard/api/capabilities
```
All UI and API live under the route prefix `/@noob-stupid/dsh-breadboard/`. `GET …/api/capabilities`
returns `build` (the build timestamp of the **currently live** code) and `simTools` (the tools that
**actually registered**). Reach for it first when something looks wrong: tool registration failure is
**completely silent** — the model just says it does not have that capability and the UI shows nothing
unusual — and a hot reload can report success while the old module keeps serving.
## Known limitations
Honest list; none of these are hidden behind a default that pretends otherwise.
- **This package is not published on npm.** Install from GitHub. A prebuilt tarball exists as a
release artifact, but there is no `npm install` line to offer.
- **The UI is Chinese only.** There is no locale switch in the panel today, and no English UI
strings to promise.
- **Reading GPIO from the project's Python code is not implemented.** `hw_set_occlusion` propagates
occlusion along the real wires into a networked device's readings, but the reverse direction —
project code calling `RPi.GPIO.input()` through the shim — raises `NotImplementedError`, and the
bridge's `gpio_read` / `gpio_write` are declared in the contract but not implemented
("protocol defined, behaviour model not implemented yet"). So a firmware that polls a pin and then
reports cannot be exercised end to end yet; a firmware that reports over HTTP can.
- **Networked devices are not rendered in the 3D scene yet.** They are in the SSOT
(`transport: 'network'`) and fully drivable by tools, but the front end does not draw them.
- **Only measured / verified paths are claimed.** The networked-device chain was verified against a
real backend; an I2C-shaped *real* project has not been adapted yet — the I2C half is verified by
this repository's own test projects.
- **Model size vs geometric fidelity is an open either/or** (documented in `docs/06` §6): scaling an
imported model to the declared real-board `size` slightly deforms it. Today the plugin keeps real
board dimensions and aligns ports to the deformed pitch.
- **`hw_set_occlusion` only reaches networked devices.** If the occluded part is wired to something
that is not a networked device with a `pinMap`, nothing propagates — and the tool says so in its
`reason` instead of silently succeeding.
- **The port count on some imported models is still converging** (e.g. a handful of Raspberry Pi
header pins land on the PCB rather than on a pin mesh). Ports are usable; a few anchors are off by
fractions of a millimetre.
## Documentation
| File | Contents |
|---|---|
| [docs/00-宿主API核实.md](docs/00-宿主API核实.md) | host API verification (slot tree, route registration, pitfalls) |
| [docs/01-项目定位.md](docs/01-项目定位.md) | project positioning, locked scope |
| [docs/02-步骤①-虚拟时钟内核.md](docs/02-步骤①-虚拟时钟内核.md) | the virtual clock kernel |
| [docs/03-模型库可行性验证.md](docs/03-模型库可行性验证.md) | model-library feasibility study (why not a scraper) |
| [docs/04-失败族-看起来正常实际全错.md](docs/04-失败族-看起来正常实际全错.md) | the "looks fine, actually all wrong" failure family (20 cases) |
| [docs/05-模型来源与导入方案.md](docs/05-模型来源与导入方案.md) | model sources and the import decision |
| [docs/06-状态与交接.md](docs/06-状态与交接.md) | status / handover sheet |
| [docs/07-联网设备-第二类虚拟硬件.md](docs/07-联网设备-第二类虚拟硬件.md) | networked devices, the second class of virtual hardware |
The docs are written in Chinese (they are working documents, and translating them would create two
copies to keep in sync). This README and `README.zh.md` are maintained as a matched pair.
## License
BSD-3-Clause.
---
dsh-breadboard