Noob-stupid/dsh-breadboard ↗★ 0
@noob-stupid/dsh-breadboard
DSH 嵌入式硬件沙盒:3D 装配协作感知 + 项目级硬件在环仿真
Install
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Noob-stupid/dsh-breadboardREADME
Read the full README ↗Web profile plugin — host half + browser half.
English: README.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)
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. 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
- 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.
- 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.
- 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: 13 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_occupiedandalready_connectedare 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: truedoes not mean the effect happened. Every write should be followed by reading the assembly again.
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}, withmodel.binholding the geometry bytes (meta.jsonrecordsformat,nodes,bytes,generator). Storing the bytes with a neutral name is deliberate: it keeps the layout free of extension ambiguity and keepsmetabound to its bytes. Exported bundles use the/model.glbform 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
idleuntil you explicitlystartit — by default it produces no external