From 0e16ea6b0f8fab2b221cdabf8e1feb1c65370d55 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:09 +0200 Subject: [PATCH 01/12] feat(midi): MIDI in/out node across browser, desktop, and Arduino MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single Midi node handling both directions: - browser via Web MIDI (MidiPerformer, the twin of the desktop MidiManager) - desktop via midir (runtime/midi.rs on the actor thread) - Arduino via serial MIDI.h codegen Adds midi_wiring/collect_midi_listeners (no per-topic reconcile — every matching listener receives every message), a MidiSend CloudRequestKind intercepted by each host before the cloud performer, and the NodeEmission shared_declarations/setup/loop dedup primitive for codegen. Validate warns that the serial-MIDI emitter claims the board's primary UART. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 12 + Cargo.lock | 144 +++- apps/web/node-components.json | 11 +- apps/web/src-tauri/Cargo.toml | 1 + apps/web/src-tauri/src/runtime/host.rs | 75 +- apps/web/src-tauri/src/runtime/midi.rs | 142 ++++ .../src/components/flow/nodes/_REGISTRY.ts | 4 + apps/web/src/components/flow/nodes/_TYPES.ts | 2 + .../flow/nodes/_base/_base.types.ts | 3 + .../flow/nodes/midi/midi-note-editor.tsx | 105 +++ .../flow/nodes/midi/midi-song-editor.tsx | 128 +++ .../flow/nodes/midi/midi.constants.ts | 11 + .../components/flow/nodes/midi/midi.schema.ts | 37 + .../src/components/flow/nodes/midi/midi.tsx | 132 +++ .../src/lib/firmata/cloud/cloud-performer.ts | 6 + apps/web/src/lib/firmata/flow-reactor.ts | 24 + .../midi/__tests__/midi-performer.test.ts | 128 +++ .../src/lib/firmata/midi/midi-performer.ts | 132 +++ apps/web/src/lib/runtime/wasm.ts | 9 + apps/web/wire-interface.generated.json | 12 + .../microflow-core/src/codegen/cloud/llm.rs | 1 + .../microflow-core/src/codegen/cloud/midi.rs | 313 ++++++++ .../microflow-core/src/codegen/cloud/mod.rs | 4 + .../microflow-core/src/codegen/cloud/mqtt.rs | 1 + .../src/codegen/cloud/transport.rs | 1 + crates/microflow-core/src/codegen/emit.rs | 30 + crates/microflow-core/src/codegen/mod.rs | 56 ++ crates/microflow-core/src/codegen/parity.rs | 755 +++++++++++++++++- crates/microflow-core/src/codegen/validate.rs | 20 + crates/microflow-core/src/config/midi.rs | 99 +++ crates/microflow-core/src/config/mod.rs | 1 + .../microflow-core/src/runtime/cloud/figma.rs | 4 +- .../microflow-core/src/runtime/cloud/llm.rs | 4 +- .../microflow-core/src/runtime/cloud/midi.rs | 621 ++++++++++++++ .../microflow-core/src/runtime/cloud/mod.rs | 1 + .../microflow-core/src/runtime/cloud/mqtt.rs | 4 +- .../microflow-core/src/runtime/component.rs | 11 + crates/microflow-core/src/runtime/context.rs | 4 + crates/microflow-core/src/runtime/mod.rs | 24 + crates/microflow-core/src/runtime/registry.rs | 1 + .../src/runtime/subscriptions.rs | 14 + crates/microflow-runtime-wasm/src/lib.rs | 16 + 42 files changed, 3076 insertions(+), 27 deletions(-) create mode 100644 apps/web/src-tauri/src/runtime/midi.rs create mode 100644 apps/web/src/components/flow/nodes/midi/midi-note-editor.tsx create mode 100644 apps/web/src/components/flow/nodes/midi/midi-song-editor.tsx create mode 100644 apps/web/src/components/flow/nodes/midi/midi.constants.ts create mode 100644 apps/web/src/components/flow/nodes/midi/midi.schema.ts create mode 100644 apps/web/src/components/flow/nodes/midi/midi.tsx create mode 100644 apps/web/src/lib/firmata/midi/__tests__/midi-performer.test.ts create mode 100644 apps/web/src/lib/firmata/midi/midi-performer.ts create mode 100644 crates/microflow-core/src/codegen/cloud/midi.rs create mode 100644 crates/microflow-core/src/config/midi.rs create mode 100644 crates/microflow-core/src/runtime/cloud/midi.rs diff --git a/CONTEXT.md b/CONTEXT.md index 7e7bafd2..aebca328 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -402,3 +402,15 @@ Bidirectional reconciler between a `FlowDocument` (Y.Doc CRDT) and the [ReactFlo - **`scheduleFlush` / `flush`** — RAF-batched structural writes. Multiple `applyNodeChanges` / `applyEdgeChanges` calls in one frame coalesce into one `transact("local")` and therefore one `UndoManager` entry. `flush()` is public so tests and callers needing a write barrier (e.g. before navigation) can force the flush synchronously. Each invariant is independently unit-testable (28 vitest cases in `__tests__/react-flow-bridge.test.ts`, including a convergence-via-`RecordingSyncAdapter` headline test that proves CRDT replay end-to-end without a React renderer). Drag-during positions are **not** sent over the doc today — the right channel for ephemeral peer state is Yjs awareness, not the doc, and a future enhancement will broadcast `draggingNode: { id, position }` via `RemoteSyncAdapter.updateCursor`-style awareness so live collaborators see smooth drag motion without polluting undo history. + +## Board Bring-Up + +Sans-IO state machine owning the board bring-up policy: probe → flash StandardFirmata if missing → connect → auto-reconnect, and the `disconnected → connecting → flashing → connected → error` phase transitions. Lives in `crates/microflow-core/src/bringup.rs` (value-tested transition table); events in (`PortReady`, `ProbeOk`/`ProbeFailed`, `FlashOk`/`FlashFailed`, `ConnectionLost`, `PortGone`, `DisconnectRequested`) → `Phase` + host actions out (`Probe`, `Flash`, `ClosePort`, `ScheduleRetry`, `Notify`), mirroring the [Effects](#effects)/[EffectsSink](#effectssink) discipline. Both [Runtime Hosts](#runtime-host) are adapters: the browser drives it through `BringUpMachine` in `microflow-firmata-wasm` from `apps/web/src/lib/firmata/board-controller.ts`; the desktop drives it from `src-tauri/src/hardware/mod.rs`. Hosts own I/O and timing only (serial ops, flash transport, boot/settle sleeps, toasts keyed off `Notify` phases); the decisions live in the machine. + +## Flow Role + +The access level a user has on a cloud flow: **Owner** (the `flow.ownerId` user — full control including delete, sharing, and role changes), **Editor** (a `flowCollaborator` row with `role: "editor"` — may edit the document), or **Viewer** (`role: "viewer"` — read-only). Ranked `viewer < editor < owner`. The type and the pure resolution/enforcement helpers (`resolveFlowRole`, `assertFlowRole`) live in `packages/api/src/routers/flow-role.ts`; the access matrix is table-tested in `flow-access.test.ts`. + +## Flow Access seam + +`requireFlowAccess(flowId, userId, minRole)` in `packages/api/src/routers/flow-access.ts` — the single choke point where tRPC flow procedures resolve a [Flow Role](#flow-role) and enforce a minimum. Fetches the flow row, resolves owner-or-collaborator role via `resolveFlowRole`, throws `"Flow not found"` / `"Access denied"`, and returns `{ flow, role }` so procedures never re-implement the check. `flow.get` keeps its richer eager-loaded query but routes role resolution through the same pure helpers, so the two access notions cannot drift. diff --git a/Cargo.lock b/Cargo.lock index 2df5795b..2f614114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_log-sys" version = "0.3.2" @@ -875,6 +897,27 @@ dependencies = [ "libc", ] +[[package]] +name = "coremidi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1fa14fb8c3ca83d0d7f22f4afc9ecfe5f40947f01ce639a638a9377c2662dde" +dependencies = [ + "block2", + "core-foundation", + "core-foundation-sys", + "coremidi-sys", +] + +[[package]] +name = "coremidi-sys" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e" +dependencies = [ + "core-foundation-sys", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2838,12 +2881,13 @@ dependencies = [ [[package]] name = "microflow" -version = "0.10.4" +version = "0.10.5" dependencies = [ "async-trait", "dashmap", "log", "microflow-core", + "midir", "mqtt-endpoint-tokio", "proptest", "rand 0.8.6", @@ -2922,6 +2966,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "midir" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56542e359bb7e4bd1a77cb79042be32d4af0713a9ce58160355eaf72df9db87c" +dependencies = [ + "alsa", + "bitflags 1.3.2", + "coremidi", + "js-sys", + "libc", + "parking_lot", + "wasm-bindgen", + "web-sys", + "windows 0.56.0", +] + [[package]] name = "mime" version = "0.3.17" @@ -5045,7 +5106,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5133,7 +5194,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -5317,7 +5378,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -5403,7 +5464,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5428,7 +5489,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -6501,10 +6562,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -6525,7 +6586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -6575,6 +6636,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -6597,14 +6668,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -6616,8 +6699,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -6634,6 +6717,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6645,6 +6739,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6689,6 +6794,15 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7158,7 +7272,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/apps/web/node-components.json b/apps/web/node-components.json index 8fbde7a6..89b7d052 100644 --- a/apps/web/node-components.json +++ b/apps/web/node-components.json @@ -72,6 +72,10 @@ "name": "Matrix", "impl": "Matrix" }, + { + "name": "Midi", + "impl": "Midi" + }, { "name": "Monitor", "impl": "Monitor" @@ -232,6 +236,11 @@ "category": "output", "requiresHardware": true }, + { + "name": "Midi", + "category": "external", + "requiresHardware": false + }, { "name": "Monitor", "category": "output", @@ -319,4 +328,4 @@ "requiresHardware": false } ] -} +} \ No newline at end of file diff --git a/apps/web/src-tauri/Cargo.toml b/apps/web/src-tauri/Cargo.toml index e346f287..350433ea 100644 --- a/apps/web/src-tauri/Cargo.toml +++ b/apps/web/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ tauri-plugin-process = "2" tauri-plugin-opener = "2" tauri-plugin-deep-link = "2" serialport = "4" +midir = "0.10" tokio = { version = "1", features = ["sync", "rt", "macros", "time", "net"] } mqtt-endpoint-tokio = { version = "0.6", default-features = false, features = ["tls", "ws"] } uuid = { version = "1", features = ["v4"] } diff --git a/apps/web/src-tauri/src/runtime/host.rs b/apps/web/src-tauri/src/runtime/host.rs index 4cf63d2c..24176f99 100644 --- a/apps/web/src-tauri/src/runtime/host.rs +++ b/apps/web/src-tauri/src/runtime/host.rs @@ -73,6 +73,10 @@ pub enum ActorMsg { topic: String, payload: Vec, }, + /// A raw MIDI message from an open `midir` input. The actor fans it out to + /// every `Midi` in-node whose device filter matches `port_name` (via + /// `FlowRuntime::deliver_message`, mirroring the browser host). + MidiMessage { port_name: String, bytes: Vec }, /// An async cloud-node result re-entering the runtime (the `CloudEmitter` /// path) — folded in via `FlowRuntime::inject_event`. Inject { @@ -168,6 +172,9 @@ struct Actor { /// Performs cloud `Effects` (ADR-0009): holds the MQTT/LLM services and the /// in-flight LLM task table. `EffectsSink::perform_cloud` delegates here. cloud: CloudPerformer, + /// Open `midir` connections; inputs reconciled per flow update, outputs + /// opened lazily per `MidiSend`. Thread-confined like the runtime. + midi: crate::runtime::midi::MidiManager, } impl Actor { @@ -199,6 +206,7 @@ impl Actor { start: Instant::now(), timers: HashMap::new(), cloud, + midi: crate::runtime::midi::MidiManager::new(), } } @@ -283,6 +291,8 @@ impl Actor { let effects = self.rt.update_flow(flow); self.apply(effects); let _ = reply.send(self.rt.collect_subscriber_wirings()); + let listeners = self.rt.collect_midi_listeners(); + self.midi.reconcile(&listeners, &self.self_tx); } ActorMsg::Call { id, method, value } => { self.set_now(); @@ -299,6 +309,19 @@ impl Actor { let effects = self.rt.deliver_message(&id, &topic, &payload); self.apply(effects); } + ActorMsg::MidiMessage { port_name, bytes } => { + // Fan out against the runtime's own listener list (always fresh) + // — every matching in-node receives the raw message; parsing + // lives in core's `Midi::receive_raw_message`. + self.set_now(); + let listeners = self.rt.collect_midi_listeners(); + for listener in listeners { + if crate::runtime::midi::device_matches(&port_name, &listener.device_name) { + let effects = self.rt.deliver_message(&listener.node_id, &port_name, &bytes); + self.apply(effects); + } + } + } ActorMsg::Inject { source, handle, value } => { self.set_now(); let effects = self.rt.inject_event(&source, &handle, value); @@ -385,6 +408,12 @@ impl EffectsSink for Actor { /// task table. The ordering (cloud before UI events) is fixed by /// `Effects::apply`; this just supplies the primitive. fn perform_cloud(&mut self, request: &CloudRequest) { + // MIDI is host-peripheral I/O on the actor's own `midir` connections, + // not an async network call — handled here, not by the `CloudPerformer`. + if let CloudRequestKind::MidiSend { device_name, bytes } = &request.kind { + self.midi.send(device_name, bytes); + return; + } self.cloud.perform(request); } @@ -494,6 +523,11 @@ impl CloudPerformer { }); self.llm_tasks.insert(Arc::clone(&request.source), join.abort_handle()); } + // Intercepted by `Actor::perform_cloud` (the actor owns the `midir` + // connections); a request reaching here has no performer. + CloudRequestKind::MidiSend { .. } => { + log::warn!("[cloud] MidiSend reached the CloudPerformer — handled by the actor"); + } } } } @@ -679,7 +713,9 @@ mod tests { assert_eq!(topic, "microflow/uid-1/app/variable/1-2/set"); assert_eq!(payload, b"true"); } - other @ CloudRequestKind::LlmGenerate { .. } => panic!("expected MqttPublish, got {other:?}"), + other @ (CloudRequestKind::LlmGenerate { .. } | CloudRequestKind::MidiSend { .. }) => { + panic!("expected MqttPublish, got {other:?}") + } } } @@ -703,4 +739,41 @@ mod tests { edges: Vec::::new(), }); } + + #[test] + fn midi_flow_reports_listener_sends_and_receives() { + // End-to-end through the runtime the actor drives: an in-node surfaces + // as a listener, an out-node's `send` records a MidiSend, and a + // delivered raw message emits on the in-node's handles. + let mut rt = FlowRuntime::new(); + rt.update_flow(FlowUpdate { + nodes: vec![ + node("m-in", "Midi", serde_json::json!({ "direction": "in", "deviceName": "pad" })), + node( + "m-out", + "Midi", + serde_json::json!({ "direction": "out", "mode": "cc", "control": 7 }), + ), + ], + edges: Vec::::new(), + }); + + let listeners = rt.collect_midi_listeners(); + assert_eq!(listeners.len(), 1, "only the in-node listens"); + assert_eq!(listeners[0].node_id, "m-in"); + assert_eq!(listeners[0].device_name, "pad"); + + let effects = rt.dispatch("m-out", "send", ComponentValue::Number(64.0)); + assert_eq!(effects.cloud_requests.len(), 1); + match &effects.cloud_requests[0].kind { + CloudRequestKind::MidiSend { bytes, .. } => assert_eq!(bytes, &vec![0xB0, 7, 64]), + other => panic!("expected MidiSend, got {other:?}"), + } + + // A note-on for the in-node (default note mode) emits its handles. + let effects = rt.deliver_message("m-in", "Launchpad", &[0x90, 60, 100]); + let handles: Vec<&str> = + effects.component_events.iter().map(|e| e.source_handle.as_ref()).collect(); + assert_eq!(handles, vec!["note", "velocity", "on"]); + } } diff --git a/apps/web/src-tauri/src/runtime/midi.rs b/apps/web/src-tauri/src/runtime/midi.rs new file mode 100644 index 00000000..c7d50dfa --- /dev/null +++ b/apps/web/src-tauri/src/runtime/midi.rs @@ -0,0 +1,142 @@ +//! Desktop MIDI I/O for the runtime actor — the `midir` twin of the browser's +//! Web MIDI `MidiPerformer`. +//! +//! Owns the open `midir` connections, confined to the actor thread like the +//! runtime itself. Routing stays out of here on purpose: an input callback only +//! forwards the raw bytes as [`ActorMsg::MidiMessage`]; the actor fans each +//! message out against `FlowRuntime::collect_midi_listeners()` (always fresh — +//! no listener state to go stale here). Output connections open lazily on the +//! first send to a matching port name. +//! +//! [`ActorMsg::MidiMessage`]: crate::runtime::host::ActorMsg + +use crate::runtime::host::ActorMsg; +use midir::{MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection}; +use microflow_core::runtime::subscriptions::MidiListener; +use std::collections::HashMap; +use tokio::sync::mpsc::UnboundedSender; + +/// Client name shown to the OS MIDI stack. +const CLIENT: &str = "microflow"; + +/// Case-insensitive substring match; an empty filter matches every port. +pub fn device_matches(port_name: &str, filter: &str) -> bool { + filter.is_empty() || port_name.to_lowercase().contains(&filter.to_lowercase()) +} + +pub struct MidiManager { + /// Open input connections keyed by port name. Dropping one closes it. + inputs: HashMap>, + /// Open output connections keyed by port name, opened lazily per send. + outputs: HashMap, +} + +impl MidiManager { + #[must_use] + pub fn new() -> Self { + Self { inputs: HashMap::new(), outputs: HashMap::new() } + } + + /// Reconcile the open input connections against the flow's listeners: open + /// every port some listener's filter matches, close ports no filter matches + /// anymore. Called by the actor after every flow update. + pub fn reconcile(&mut self, listeners: &[MidiListener], tx: &UnboundedSender) { + let wanted = |port_name: &str| { + listeners.iter().any(|l| device_matches(port_name, &l.device_name)) + }; + self.inputs.retain(|name, _| wanted(name)); + if listeners.is_empty() { + return; + } + + let probe = match MidiInput::new(CLIENT) { + Ok(input) => input, + Err(e) => { + log::warn!("[midi] input unavailable: {e}"); + return; + } + }; + for port in probe.ports() { + let Ok(name) = probe.port_name(&port) else { continue }; + if self.inputs.contains_key(&name) || !wanted(&name) { + continue; + } + // One `MidiInput` client per connection — midir consumes it on connect. + let Ok(input) = MidiInput::new(CLIENT) else { continue }; + let tx = tx.clone(); + let port_name = name.clone(); + match input.connect( + &port, + CLIENT, + move |_ts, message, ()| { + let _ = tx.send(ActorMsg::MidiMessage { + port_name: port_name.clone(), + bytes: message.to_vec(), + }); + }, + (), + ) { + Ok(conn) => { + log::info!("[midi] listening on '{name}'"); + self.inputs.insert(name, conn); + } + Err(e) => log::warn!("[midi] failed to open input '{name}': {e}"), + } + } + } + + /// Write one raw message to every output whose port name matches + /// `device_name` ("" = all), opening connections lazily. + pub fn send(&mut self, device_name: &str, bytes: &[u8]) { + let probe = match MidiOutput::new(CLIENT) { + Ok(output) => output, + Err(e) => { + log::warn!("[midi] output unavailable: {e}"); + return; + } + }; + for port in probe.ports() { + let Ok(name) = probe.port_name(&port) else { continue }; + if !device_matches(&name, device_name) { + continue; + } + if !self.outputs.contains_key(&name) { + let Ok(output) = MidiOutput::new(CLIENT) else { continue }; + match output.connect(&port, CLIENT) { + Ok(conn) => { + log::info!("[midi] sending to '{name}'"); + self.outputs.insert(name.clone(), conn); + } + Err(e) => { + log::warn!("[midi] failed to open output '{name}': {e}"); + continue; + } + } + } + if let Some(conn) = self.outputs.get_mut(&name) { + if let Err(e) = conn.send(bytes) { + log::warn!("[midi] send to '{name}' failed: {e}; dropping connection"); + self.outputs.remove(&name); + } + } + } + } +} + +impl Default for MidiManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::device_matches; + + #[test] + fn empty_filter_matches_everything_and_matching_is_case_insensitive() { + assert!(device_matches("Launchpad Mini MK3", "")); + assert!(device_matches("Launchpad Mini MK3", "launchpad")); + assert!(!device_matches("Launchpad Mini MK3", "push")); + } +} diff --git a/apps/web/src/components/flow/nodes/_REGISTRY.ts b/apps/web/src/components/flow/nodes/_REGISTRY.ts index ea39801b..7365dbc1 100644 --- a/apps/web/src/components/flow/nodes/_REGISTRY.ts +++ b/apps/web/src/components/flow/nodes/_REGISTRY.ts @@ -41,6 +41,8 @@ import { Llm } from "./llm/llm"; import { defaults as LlmDefaults } from "./llm/llm.schema"; import { Matrix } from "./matrix/matrix"; import { defaults as MatrixDefaults } from "./matrix/matrix.schema"; +import { Midi } from "./midi/midi"; +import { defaults as MidiDefaults } from "./midi/midi.schema"; import { Monitor } from "./monitor/monitor"; import { defaults as MonitorDefaults } from "./monitor/monitor.schema"; import { Motion } from "./motion/motion"; @@ -117,6 +119,7 @@ export const NODE_REGISTRY = { Led: { component: Led, defaults: LedDefaults as NodeDefaults, adapter: undefined }, Llm: { component: Llm, defaults: LlmDefaults as NodeDefaults, adapter: undefined }, Matrix: { component: Matrix, defaults: MatrixDefaults as NodeDefaults, adapter: undefined }, + Midi: { component: Midi, defaults: MidiDefaults as NodeDefaults, adapter: undefined }, Monitor: { component: Monitor, defaults: MonitorDefaults as NodeDefaults, adapter: undefined }, Motion: { component: Motion, defaults: MotionDefaults as NodeDefaults, adapter: undefined }, Mqtt: { component: Mqtt, defaults: MqttDefaults as NodeDefaults, adapter: MqttAdapter }, @@ -159,6 +162,7 @@ export const NODE_TYPES = { Led, Llm, Matrix, + Midi, Monitor, Motion, Mqtt, diff --git a/apps/web/src/components/flow/nodes/_TYPES.ts b/apps/web/src/components/flow/nodes/_TYPES.ts index fa429803..d82517e9 100644 --- a/apps/web/src/components/flow/nodes/_TYPES.ts +++ b/apps/web/src/components/flow/nodes/_TYPES.ts @@ -1,4 +1,5 @@ import { Matrix } from "./matrix/matrix"; +import { Midi } from "./midi/midi"; import { Monitor } from "./monitor/monitor"; import { Motion } from "./motion/motion"; import { Mqtt } from "./mqtt/mqtt"; @@ -71,6 +72,7 @@ export const NODE_TYPES = { Led: Led, Llm: Llm, Matrix: Matrix, + Midi: Midi, Monitor: Monitor, Motion: Motion, Mqtt: Mqtt, diff --git a/apps/web/src/components/flow/nodes/_base/_base.types.ts b/apps/web/src/components/flow/nodes/_base/_base.types.ts index d61dd811..03fb7c72 100644 --- a/apps/web/src/components/flow/nodes/_base/_base.types.ts +++ b/apps/web/src/components/flow/nodes/_base/_base.types.ts @@ -20,6 +20,7 @@ export const COMPONENT_TYPES = [ "Led", "Llm", "Matrix", + "Midi", "Monitor", "Motion", "Mqtt", @@ -74,6 +75,7 @@ export const COMPONENT_PORTS = { Led: ["true", "false", "toggle", "value"] as const, Llm: ["trigger"] as const, Matrix: ["value", "reset", "reinitialize"] as const, + Midi: ["send"] as const, Monitor: ["value"] as const, Motion: ["read"] as const, Mqtt: ["trigger"] as const, @@ -131,6 +133,7 @@ export const COMPONENT_EMITS = { Led: ["value"] as const, Llm: ["thinking", "value", "done", "error"] as const, Matrix: ["value"] as const, + Midi: ["value", "note", "velocity", "on", "off"] as const, Monitor: ["value"] as const, Motion: ["event", "true", "false", "value"] as const, Mqtt: ["value"] as const, diff --git a/apps/web/src/components/flow/nodes/midi/midi-note-editor.tsx b/apps/web/src/components/flow/nodes/midi/midi-note-editor.tsx new file mode 100644 index 00000000..e139cc0a --- /dev/null +++ b/apps/web/src/components/flow/nodes/midi/midi-note-editor.tsx @@ -0,0 +1,105 @@ +import { type Note } from "./midi.schema"; +import { useState, type PropsWithChildren } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { NoteSelector } from "../piezo/note-selector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { noteDurationToVisualDuation } from "../piezo/helpers"; +import { NOTE_DURATION } from "../piezo/piezo.constants"; +import { Button } from "@/components/ui/button"; + +// Piezo's NodeEditor, forked to carry a per-note velocity (a buzzer has none). +// Reuses Piezo's velocity-agnostic NoteSelector / duration helpers. +export function MidiNoteEditor(props: Props) { + const [internalNote, setInternalNote] = useState(props.note); + const [note, duration, velocity] = internalNote; + + function update(next: Note) { + setInternalNote(next); + props.onSelect?.(next); + } + + return ( + + {props.children} + + update([value, duration, velocity])} + /> + + + + + + ); +} + +type Props = PropsWithChildren & { + note: Note; + onSelect?: (note: Note) => void; + action: Action; +}; + +type Action = { + variant?: + | "link" + | "default" + | "outline" + | "secondary" + | "ghost" + | "destructive" + | null; + label: string; + onClick: (note: Note) => void; +}; diff --git a/apps/web/src/components/flow/nodes/midi/midi-song-editor.tsx b/apps/web/src/components/flow/nodes/midi/midi-song-editor.tsx new file mode 100644 index 00000000..c9c4c6ba --- /dev/null +++ b/apps/web/src/components/flow/nodes/midi/midi-song-editor.tsx @@ -0,0 +1,128 @@ +import { type Note } from "./midi.schema"; +import { DndBadge } from "../piezo/dnd-badge"; +import { noteDurationToVisualDuation } from "../piezo/helpers"; +import { MidiNoteEditor } from "./midi-note-editor"; +import { MusicSheet } from "../piezo/music-sheet"; +import { DEFAULT_NOTE, DEFAULT_NOTE_DURATION } from "../piezo/piezo.constants"; +import { DEFAULT_NOTE_VELOCITY } from "./midi.constants"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { DragAndDropProvider } from "@/providers/drag-and-drop"; +import { uid } from "@/lib/uid"; + +// Piezo's SongEditor, forked for MIDI's 3-slot note (per-note velocity). The +// music sheet only renders pitch/duration, so notes map down to 2-tuples there. +export function MidiSongEditor(props: Props) { + const [editedSong, setEditedSong] = useState( + props.song.map((note) => ({ note, id: uid() })), + ); + + function swapNotes(id: string, hoveredId: string) { + setEditedSong((prev) => { + const leftIndex = prev.findIndex((item) => item.id === id); + const rightIndex = prev.findIndex((item) => item.id === hoveredId); + const newSong = [...prev]; + newSong[leftIndex] = prev[rightIndex]; + newSong[rightIndex] = prev[leftIndex]; + return newSong; + }); + } + + return ( + + + + Edit song + +
+ [note[0], note[1]])} + title={props.title} + /> + +
+ {editedSong?.map(({ note, id }, index) => ( + { + setEditedSong((prev) => { + const newSong = [...prev]; + newSong[index] = { ...newSong[index], note: value }; + return newSong; + }); + }} + action={{ + label: "Delete note", + variant: "destructive", + onClick: () => { + setEditedSong((prev) => { + const newSong = [...prev]; + newSong.splice(index, 1); + return newSong; + }); + }, + }} + > + + {note[0] ?? "Rest"} + + {noteDurationToVisualDuation(note[1])} + {note[0] !== null && ` · v${note[2]}`} + + + + ))} + { + setEditedSong((prev) => [...prev, { note, id: uid() }]); + }, + }} + > + + Add note + + +
+
+ + + + + + +
+
+
+ ); +} + +type Props = { + song: Note[]; + title: string; + onSave: (data: { song: Note[] }) => void; + onClose: () => void; +}; diff --git a/apps/web/src/components/flow/nodes/midi/midi.constants.ts b/apps/web/src/components/flow/nodes/midi/midi.constants.ts new file mode 100644 index 00000000..ca0b3127 --- /dev/null +++ b/apps/web/src/components/flow/nodes/midi/midi.constants.ts @@ -0,0 +1,11 @@ +import { DEFAULT_SONG } from "../piezo/piezo.constants"; + +/** Mezzo-forte default velocity for freshly-added song notes. */ +export const DEFAULT_NOTE_VELOCITY = 100; + +/** + * Reuse the Piezo demo melody, giving every note the default velocity. MIDI + * song notes are `[name | null-for-rest, beats, velocity]`. + */ +export const DEFAULT_MIDI_SONG: [string | null, number, number][] = + DEFAULT_SONG.map(([note, beats]) => [note, beats, DEFAULT_NOTE_VELOCITY]); diff --git a/apps/web/src/components/flow/nodes/midi/midi.schema.ts b/apps/web/src/components/flow/nodes/midi/midi.schema.ts new file mode 100644 index 00000000..269ed1c7 --- /dev/null +++ b/apps/web/src/components/flow/nodes/midi/midi.schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { baseDataSchema } from "../_base/_base.schema"; +import { DEFAULT_MIDI_SONG } from "./midi.constants"; + +export const valueSchema = z.number(); +export type Value = z.infer; + +// MIDI song note: [noteName | null-for-rest, beats, velocity]. Unlike the Piezo +// song, each note carries its own velocity (a buzzer has no dynamics). +const noteSchema = z.tuple([z.string().nullable(), z.number(), z.number()]); +export type Note = z.infer; + +export const dataSchema = baseDataSchema.extend({ + instance: z.literal("Midi").default("Midi"), + direction: z.enum(["in", "out"]).default("in"), + deviceName: z.string().default(""), + channel: z.number().min(0).max(16).default(0), + mode: z.enum(["note", "cc", "song"]).default("note"), + control: z.number().min(0).max(127).default(1), + note: z.number().min(0).max(127).default(60), + velocity: z.number().min(0).max(127).default(127), + // Out + song mode only. Defaulted so a fresh song node plays immediately. + song: z.array(noteSchema).default(DEFAULT_MIDI_SONG), + tempo: z.number().min(40).max(240).default(113), +}); + +export type Data = z.infer; + +export const defaults = { + ...dataSchema.parse({}), + group: "sense", + tags: ["value", "source", "action", "external"], + label: "MIDI", + description: + "Receive notes and knob values from MIDI controllers, or play notes and send control changes to synths", + icon: "KeyboardMusicIcon", +}; diff --git a/apps/web/src/components/flow/nodes/midi/midi.tsx b/apps/web/src/components/flow/nodes/midi/midi.tsx new file mode 100644 index 00000000..26a8c508 --- /dev/null +++ b/apps/web/src/components/flow/nodes/midi/midi.tsx @@ -0,0 +1,132 @@ +import { KeyboardMusicIcon, MusicIcon } from "lucide-react"; +import { button, folder } from "leva"; +import { useMemo, useState } from "react"; +import { Handle as BaseHandle } from "../../handle"; +import { IconWithValue } from "../../icon-with-value"; +import { NodeContainer, useNodeControls, useNodeData, type BaseNode } from "../_base/_base"; +import { MidiSongEditor } from "./midi-song-editor"; +import { DEFAULT_MIDI_SONG } from "./midi.constants"; +import { dataSchema, defaults, type Data } from "./midi.schema"; + +const Handle = BaseHandle<"Midi">; + +export function Midi(props: Props) { + const { direction, mode } = props.data; + return ( + + + + {direction === "out" && ( + + )} + {direction === "in" && mode === "note" && ( + <> + + + + + + )} + {direction === "in" && mode === "cc" && ( + + )} + + ); +} + +function Value() { + const data = useNodeData(); + + const displayValue = useMemo(() => { + const device = data.deviceName || "all devices"; + const what = data.mode === "cc" ? `CC ${data.control}` : data.mode === "song" ? "song" : "notes"; + return `${what} · ${device}`; + }, [data.deviceName, data.mode, data.control]); + + return ( + + ); +} + +function Settings() { + const data = useNodeData(); + const [editorOpened, setEditorOpened] = useState(false); + + // Song is an out-only playback mode (there's nothing to listen to). + const modeOptions = + data.direction === "out" + ? { "note on/off": "note", "control change": "cc", song: "song" } + : { "note on/off": "note", "control change": "cc" }; + + const { render, setNodeData } = useNodeControls( + { + direction: { + value: data.direction, + options: ["in", "out"], + }, + deviceName: { + value: data.deviceName, + label: "device (blank = all)", + }, + channel: { + value: data.channel, + min: data.direction === "in" ? 0 : 1, + max: 16, + step: 1, + label: data.direction === "in" ? "channel (0 = all)" : "channel", + }, + mode: { + value: data.mode, + // Leva options are { [label]: value }; value side must be the + // "note"|"cc"|"song" the schema/runtime expect, not the human label. + options: modeOptions, + }, + ...(data.mode === "cc" && { + control: { value: data.control, min: 0, max: 127, step: 1, label: "cc number" }, + }), + ...(data.direction === "out" && + data.mode === "note" && { + note: { value: data.note, min: 0, max: 127, step: 1 }, + }), + // Velocity drives both a single note and every note in a song. + ...(data.direction === "out" && + (data.mode === "note" || data.mode === "song") && { + velocity: { value: data.velocity, min: 0, max: 127, step: 1 }, + }), + ...(data.direction === "out" && + data.mode === "song" && { + // Folder key must not be "song" — that path would collide with the + // `song` data array (Piezo names its folder "songSettings" for this). + songSettings: folder({ + // Fall back for nodes created before song mode existed. + tempo: { value: data.tempo ?? 113, min: 40, max: 240, step: 1 }, + "edit song": button(() => setEditorOpened(true)), + }), + }), + }, + [data.direction, data.mode], + ); + + return ( + <> + {render()} + {editorOpened && ( + setEditorOpened(false)} + onSave={(saved) => { + setNodeData({ ...data, ...saved }); + setEditorOpened(false); + }} + /> + )} + + ); +} + +type Props = BaseNode; +Midi.defaultProps = { data: defaults }; diff --git a/apps/web/src/lib/firmata/cloud/cloud-performer.ts b/apps/web/src/lib/firmata/cloud/cloud-performer.ts index 13172c22..3197b696 100644 --- a/apps/web/src/lib/firmata/cloud/cloud-performer.ts +++ b/apps/web/src/lib/firmata/cloud/cloud-performer.ts @@ -122,6 +122,12 @@ export class CloudPerformer { void this.runLlm(request); return; } + // Intercepted by the reactor's MidiPerformer before delegation (host + // peripheral, not a network call) — mirrors the desktop actor. + if (request.kind === "midiSend") { + console.warn("[cloud-performer] midiSend reached the CloudPerformer — handled by the reactor"); + return; + } this.publishMqtt(request.brokerId, request.topic, request.payload, request.retain); } diff --git a/apps/web/src/lib/firmata/flow-reactor.ts b/apps/web/src/lib/firmata/flow-reactor.ts index 662f6ac1..1e76a808 100644 --- a/apps/web/src/lib/firmata/flow-reactor.ts +++ b/apps/web/src/lib/firmata/flow-reactor.ts @@ -26,6 +26,8 @@ import { } from "@/lib/runtime/wasm"; import { CloudPerformer, type CloudDeps } from "./cloud/cloud-performer"; import type { ActiveSub } from "./cloud/mqtt-subscriptions"; +import { MidiPerformer } from "./midi/midi-performer"; +import type { MidiListener } from "@/lib/runtime/wasm"; import { applyEffects, type CloudRequest, @@ -54,6 +56,9 @@ export class FlowReactor implements EffectsSink { /** The cloud half (LLM/MQTT/Figma), lifted out of this class (ADR-0009). The * reactor supplies the two runtime re-entry seams the performer needs. */ private readonly cloudPerformer: CloudPerformer; + /** The MIDI half (Web MIDI): the browser twin of the desktop `MidiManager`. + * Inbound messages re-enter via the same `deliverMessage` path MQTT uses. */ + private readonly midiPerformer: MidiPerformer; /** Edges of the flow the runtime is executing — kept from the last * {@link applyFlow} so `dispatchEvent` routes component events onto exactly * the wires the runtime fired them across. */ @@ -82,6 +87,10 @@ export class FlowReactor implements EffectsSink { // binding, so the browser announces identically to the desktop host. figmaAnnounceActions, ); + this.midiPerformer = new MidiPerformer((nodeId, portName, bytes) => { + if (!this.runtime || this.disposed) return; + this.apply(this.runtime.deliverMessage(nodeId, portName, bytes, now())); + }); } /** Instantiate the wasm runtime and seed its pin table from the detection @@ -122,6 +131,7 @@ export class FlowReactor implements EffectsSink { for (const handle of this.timers.values()) clearTimeout(handle); this.timers.clear(); this.cloudPerformer.dispose(); + this.midiPerformer.dispose(); this.runtime = null; } @@ -155,6 +165,14 @@ export class FlowReactor implements EffectsSink { return; } this.cloudPerformer.reconcile(reconciled); + let midiListeners: MidiListener[]; + try { + midiListeners = JSON.parse(this.runtime.midiListeners()) as MidiListener[]; + } catch (error) { + console.error("[flow-reactor] bad midiListeners json:", error); + return; + } + this.midiPerformer.reconcile(midiListeners); } // --- EffectsSink: the browser platform primitives (ADR-0008) --------------- @@ -187,6 +205,12 @@ export class FlowReactor implements EffectsSink { * task table. The ordering (cloud before UI events) is fixed by * {@link applyEffects}; this just supplies the primitive. */ performCloud(request: CloudRequest): void { + // MIDI is host-peripheral I/O, not a network call — the MidiPerformer owns + // it (mirrors the desktop actor intercepting `MidiSend` before delegating). + if (request.kind === "midiSend") { + this.midiPerformer.send(request.deviceName, request.bytes); + return; + } this.cloudPerformer.perform(request); } diff --git a/apps/web/src/lib/firmata/midi/__tests__/midi-performer.test.ts b/apps/web/src/lib/firmata/midi/__tests__/midi-performer.test.ts new file mode 100644 index 00000000..e0340a9c --- /dev/null +++ b/apps/web/src/lib/firmata/midi/__tests__/midi-performer.test.ts @@ -0,0 +1,128 @@ +// MidiPerformer unit tests: a stub MidiAccessFactory, fake input/output ports, +// no runtime — mirroring the cloud-performer tests. The performer only moves +// bytes; parsing/filtering is core's `Midi::receive_raw_message` (Rust tests). + +import { describe, expect, it } from "bun:test"; +import { deviceMatches, MidiPerformer, type MidiAccessLike } from "../midi-performer"; + +type Delivery = { nodeId: string; portName: string; bytes: number[] }; + +function fakeInput(name: string) { + const input = { + name, + onmidimessage: null as ((event: { data: Uint8Array }) => void) | null, + emit(bytes: number[]) { + this.onmidimessage?.({ data: Uint8Array.from(bytes) }); + }, + }; + return input; +} + +function fakeOutput(name: string) { + const sent: number[][] = []; + return { name, sent, send: (bytes: number[]) => sent.push([...bytes]) }; +} + +function setup(inputs: ReturnType[], outputs: ReturnType[] = []) { + const access: MidiAccessLike = { + inputs: new Map(inputs.map((i) => [i.name, i as unknown as MIDIInput])), + outputs: new Map(outputs.map((o) => [o.name, o as unknown as MIDIOutput])), + onstatechange: null, + }; + const deliveries: Delivery[] = []; + const performer = new MidiPerformer( + (nodeId, portName, bytes) => deliveries.push({ nodeId, portName, bytes: [...bytes] }), + () => Promise.resolve(access), + ); + return { access, deliveries, performer }; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("deviceMatches", () => { + it("empty filter matches everything, otherwise case-insensitive substring", () => { + expect(deviceMatches("Launchpad Mini MK3", "")).toBe(true); + expect(deviceMatches("Launchpad Mini MK3", "launchpad")).toBe(true); + expect(deviceMatches("Launchpad Mini MK3", "push")).toBe(false); + }); +}); + +describe("MidiPerformer", () => { + it("attaches to matching inputs and fans a message out to every matching listener", async () => { + const pad = fakeInput("Launchpad Mini"); + const keys = fakeInput("Keystation 49"); + const { deliveries, performer } = setup([pad, keys]); + + performer.reconcile([ + { nodeId: "n1", deviceName: "launchpad" }, + { nodeId: "n2", deviceName: "" }, + ]); + await tick(); + + pad.emit([0x90, 60, 100]); + expect(deliveries).toEqual([ + { nodeId: "n1", portName: "Launchpad Mini", bytes: [0x90, 60, 100] }, + { nodeId: "n2", portName: "Launchpad Mini", bytes: [0x90, 60, 100] }, + ]); + + deliveries.length = 0; + keys.emit([0xb0, 1, 42]); + // Only the "" (all devices) listener matches the Keystation. + expect(deliveries).toEqual([ + { nodeId: "n2", portName: "Keystation 49", bytes: [0xb0, 1, 42] }, + ]); + }); + + it("detaches handlers when the flow has no listeners left", async () => { + const pad = fakeInput("Launchpad Mini"); + const { deliveries, performer } = setup([pad]); + + performer.reconcile([{ nodeId: "n1", deviceName: "" }]); + await tick(); + expect(pad.onmidimessage).not.toBeNull(); + + performer.reconcile([]); + expect(pad.onmidimessage).toBeNull(); + pad.emit([0x90, 60, 100]); + expect(deliveries).toEqual([]); + }); + + it("sends to every matching output and none other", async () => { + const synth = fakeOutput("Micro Synth"); + const drums = fakeOutput("Drum Machine"); + const { performer } = setup([], [synth, drums]); + + performer.send("synth", [0xb0, 7, 127]); + await tick(); + expect(synth.sent).toEqual([[0xb0, 7, 127]]); + expect(drums.sent).toEqual([]); + + performer.send("", [0x90, 60, 100]); + await tick(); + expect(synth.sent).toHaveLength(2); + expect(drums.sent).toEqual([[0x90, 60, 100]]); + }); + + it("drops deliveries after dispose", async () => { + const pad = fakeInput("Launchpad Mini"); + const { deliveries, performer } = setup([pad]); + performer.reconcile([{ nodeId: "n1", deviceName: "" }]); + await tick(); + + performer.dispose(); + pad.emit([0x90, 60, 100]); + expect(deliveries).toEqual([]); + }); + + it("degrades quietly when MIDI access is unavailable", async () => { + const performer = new MidiPerformer( + () => { + throw new Error("must not deliver"); + }, + () => Promise.reject(new Error("Web MIDI unavailable")), + ); + performer.reconcile([{ nodeId: "n1", deviceName: "" }]); + performer.send("", [0x90, 60, 100]); + await tick(); + }); +}); diff --git a/apps/web/src/lib/firmata/midi/midi-performer.ts b/apps/web/src/lib/firmata/midi/midi-performer.ts new file mode 100644 index 00000000..f3e395f5 --- /dev/null +++ b/apps/web/src/lib/firmata/midi/midi-performer.ts @@ -0,0 +1,132 @@ +// The browser MIDI performer: Web MIDI I/O for the flow host — the browser twin +// of the desktop `MidiManager` (src-tauri/src/runtime/midi.rs). +// +// Like the CloudPerformer it is host-free: it never touches the wasm runtime; +// inbound messages re-enter through the injected {@link MidiDeliver} callback +// (the runtime's `deliverMessage`, `topic` = the port name, payload = the raw +// `[status, data1, data2]`). ALL parsing/filtering lives in core's +// `Midi::receive_raw_message` — this module only moves bytes. +// +// Web MIDI is Chromium-only (and needs a user permission grant); on browsers +// without it, reconcile/send log once and do nothing — mirroring how cloud +// nodes degrade without a configured broker. + +import type { MidiListener } from "@/lib/runtime/wasm"; + +/** Route one raw inbound MIDI message to an in-node (`deliverMessage`). */ +export type MidiDeliver = (nodeId: string, portName: string, bytes: Uint8Array) => void; + +/** Case-insensitive substring match; an empty filter matches every port. + * Mirrors the desktop `device_matches` — the two hosts must agree. */ +export function deviceMatches(portName: string, filter: string): boolean { + return filter === "" || portName.toLowerCase().includes(filter.toLowerCase()); +} + +/** The slice of `MIDIAccess` the performer uses — stubbed in tests. The port + * maps are the DOM maplikes' read surface, so a real `MIDIAccess` satisfies it + * structurally. */ +export type MidiAccessLike = { + inputs: { values(): Iterable }; + outputs: { values(): Iterable }; + onstatechange: unknown; +}; + +/** Stubbed in tests; defaults to the real `navigator.requestMIDIAccess`. */ +export type MidiAccessFactory = () => Promise; + +function defaultAccessFactory(): Promise { + if (typeof navigator === "undefined" || navigator.requestMIDIAccess === undefined) { + console.warn("[midi-performer] Web MIDI is not available in this browser"); + return Promise.reject(new Error("Web MIDI unavailable")); + } + return navigator.requestMIDIAccess(); +} + +export class MidiPerformer { + private listeners: MidiListener[] = []; + private access: MidiAccessLike | null = null; + private accessPromise: Promise | null = null; + private disposed = false; + + constructor( + private readonly deliver: MidiDeliver, + private readonly factory: MidiAccessFactory = defaultAccessFactory, + ) {} + + /** Reconcile the flow's MIDI listeners: attach a message handler to every + * input some listener's filter matches, detach the rest. Access is requested + * on the first reconcile with listeners (the browser permission prompt). */ + reconcile(listeners: MidiListener[]): void { + this.listeners = listeners; + if (listeners.length === 0) { + this.detach(); + return; + } + this.ensureAccess() + .then(() => this.attach()) + .catch(() => {}); + } + + /** Write one raw message to every output whose port name matches + * `deviceName` ("" = all). */ + send(deviceName: string, bytes: number[]): void { + this.ensureAccess() + .then((access) => { + if (this.disposed) return; + for (const output of access.outputs.values()) { + if (deviceMatches(output.name ?? "", deviceName)) { + output.send(bytes); + } + } + }) + .catch(() => {}); + } + + dispose(): void { + this.disposed = true; + this.detach(); + } + + private ensureAccess(): Promise { + if (this.accessPromise === null) { + this.accessPromise = this.factory().then((access) => { + this.access = access; + // Hotplug: a device (dis)appearing re-runs handler attachment against + // the current listener set. + access.onstatechange = () => this.attach(); + return access; + }); + this.accessPromise.catch((error: unknown) => { + console.warn("[midi-performer] MIDI access denied:", error); + }); + } + return this.accessPromise; + } + + private attach(): void { + if (!this.access || this.disposed) return; + for (const input of this.access.inputs.values()) { + const name = input.name ?? ""; + const wanted = this.listeners.some((l) => deviceMatches(name, l.deviceName)); + input.onmidimessage = wanted ? (event) => this.onMessage(name, event) : null; + } + } + + /** Fan one inbound message out to every matching listener — several nodes + * listening to one device ALL receive it (no per-topic owner, unlike MQTT). */ + private onMessage(portName: string, event: MIDIMessageEvent): void { + if (this.disposed || !event.data) return; + for (const listener of this.listeners) { + if (deviceMatches(portName, listener.deviceName)) { + this.deliver(listener.nodeId, portName, event.data); + } + } + } + + private detach(): void { + if (!this.access) return; + for (const input of this.access.inputs.values()) { + input.onmidimessage = null; + } + } +} diff --git a/apps/web/src/lib/runtime/wasm.ts b/apps/web/src/lib/runtime/wasm.ts index 95af3758..1dfd5945 100644 --- a/apps/web/src/lib/runtime/wasm.ts +++ b/apps/web/src/lib/runtime/wasm.ts @@ -86,8 +86,17 @@ export type CloudRequest = { source: string } & ( system: string | null; prompt: string; } + | { kind: "midiSend"; deviceName: string; bytes: number[] } ); +/** One MIDI in-node's device interest (matches the Rust `MidiListener` serde + * shape, as returned by `runtime.midiListeners()`). `deviceName` is a + * case-insensitive substring filter on the host port name; "" = every device. */ +export type MidiListener = { + nodeId: string; + deviceName: string; +}; + /** The side effects of one runtime turn (matches the Rust `Effects` serde shape). */ export type Effects = { outboundBytes: number[]; diff --git a/apps/web/wire-interface.generated.json b/apps/web/wire-interface.generated.json index 954e2ed2..b881697c 100644 --- a/apps/web/wire-interface.generated.json +++ b/apps/web/wire-interface.generated.json @@ -176,6 +176,18 @@ "reinitialize" ] }, + "Midi": { + "emits": [ + "value", + "note", + "velocity", + "on", + "off" + ], + "ports": [ + "send" + ] + }, "Monitor": { "emits": [ "value" diff --git a/crates/microflow-core/src/codegen/cloud/llm.rs b/crates/microflow-core/src/codegen/cloud/llm.rs index 61b07567..62713baa 100644 --- a/crates/microflow-core/src/codegen/cloud/llm.rs +++ b/crates/microflow-core/src/codegen/cloud/llm.rs @@ -225,6 +225,7 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { declarations, setup, loop_body, + ..NodeEmission::default() } } diff --git a/crates/microflow-core/src/codegen/cloud/midi.rs b/crates/microflow-core/src/codegen/cloud/midi.rs new file mode 100644 index 00000000..f6c0a38d --- /dev/null +++ b/crates/microflow-core/src/codegen/cloud/midi.rs @@ -0,0 +1,313 @@ +//! Midi emitter — the on-device counterpart of `runtime/cloud/midi.rs`. +//! +//! The live Midi node speaks to host MIDI ports (Web MIDI / `midir`). On-device +//! there is no host: the generated sketch speaks **serial MIDI** (the DIN-5 jack +//! / MIDI shield convention, 31250 baud on the board's primary hardware serial) +//! via the ubiquitous FortySevenEffects Arduino MIDI Library (`MIDI.h`), +//! `MIDI_CREATE_DEFAULT_INSTANCE()`. The node's `deviceName` filter is +//! meaningless here — the jack IS the device — and validation warns that the +//! hardware UART is claimed. +//! +//! Several Midi nodes share ONE `MIDI` instance, one `MIDI.begin`, and one +//! read-pump per scheduler tick — emitted through the assembler's shared-block +//! regions ([`NodeEmission::shared_declarations`] etc.), which de-duplicate by +//! block equality. The pump mirrors the hosts' fan-out: it parses one inbound +//! message per tick into shared rx state that every in-node then filters +//! exactly like the runtime's `receive_raw_message` (channel, mode, control). +//! +//! Like every emitter this is a pure function of the [`FlowNode`]: identical +//! input yields byte-identical output (determinism invariant). + +use crate::codegen::emit::{NodeEmission, NodeToken}; +use crate::codegen::wire::{bind_pulses, CppExpr, NodeInputs, SourceExpr}; +use crate::config::midi::{MidiConfig, MidiDirection, MidiMode}; +use crate::flow::FlowNode; + +/// The shared `MIDI` instance + inbound rx state + the once-per-tick pump. +/// One block, deduplicated across every Midi node in the flow. +const SHARED_DECLS: &str = "\ +MIDI_CREATE_DEFAULT_INSTANCE(); +byte midi_rx_type = 0; +byte midi_rx_channel = 0; +byte midi_rx_data1 = 0; +byte midi_rx_data2 = 0; +bool midi_rx_fresh = false; +void midi_pump() { + midi_rx_fresh = MIDI.read(); + if (midi_rx_fresh) { + midi_rx_type = (byte)MIDI.getType(); + midi_rx_channel = (byte)MIDI.getChannel(); + midi_rx_data1 = (byte)MIDI.getData1(); + midi_rx_data2 = (byte)MIDI.getData2(); + } +}"; + +/// Shared boot: listen omni (each in-node filters its own channel, mirroring +/// the runtime) and disable the library's default soft-thru echo. +const SHARED_SETUP: &str = "\ +MIDI.begin(MIDI_CHANNEL_OMNI); +MIDI.turnThruOff();"; + +/// The pump runs once per tick, before every in-node reads the rx state. +const SHARED_LOOP: &str = "midi_pump();"; + +fn config_of(node: &FlowNode) -> MidiConfig { + serde_json::from_value(node.data.clone()).unwrap_or_default() +} + +/// The send channel: the runtime clamps 1-16 (0/omni is receive-only). +fn send_channel(config: &MidiConfig) -> u8 { + config.channel.clamp(1, 16) +} + +/// Emit C++ for a Midi Node. In-direction filters the shared rx state into +/// node-scoped variables (the exact port of `Midi::receive_raw_message`); +/// out-direction sends one message per new sample on the `send` port (the +/// on-device twin of one dispatch == one `MidiSend`). +#[must_use] +pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { + let config = config_of(node); + let mut e = NodeEmission { + includes: vec!["#include ".to_string()], + shared_declarations: vec![SHARED_DECLS.to_string()], + shared_setup: vec![SHARED_SETUP.to_string()], + ..NodeEmission::default() + }; + + match config.direction { + MidiDirection::In => emit_in(node, &config, &mut e), + MidiDirection::Out => emit_out(node, &config, inputs, &mut e), + } + e +} + +fn emit_in(node: &FlowNode, config: &MidiConfig, e: &mut NodeEmission) { + let token = node.id_token(); + e.shared_loop.push(SHARED_LOOP.to_string()); + + // Channel filter mirrors `Midi::channel_matches` (0 = omni). + let channel_ok = if config.channel == 0 { + String::new() + } else { + format!(" && midi_rx_channel == {}", config.channel) + }; + + match config.mode { + MidiMode::Note => { + e.declarations.extend([ + format!("byte midi_{token}_note = 0;"), + format!("byte midi_{token}_velocity = 0;"), + format!("bool midi_{token}_gate = false;"), + ]); + // NoteOn (0x90) with velocity 0 is a NoteOff by MIDI convention, + // exactly as the runtime treats it. + e.loop_body.extend([ + format!("if (midi_rx_fresh{channel_ok}) {{"), + format!(" if (midi_rx_type == 0x90 && midi_rx_data2 > 0) {{ midi_{token}_note = midi_rx_data1; midi_{token}_velocity = midi_rx_data2; midi_{token}_gate = true; }}"), + format!(" else if (midi_rx_type == 0x80 || midi_rx_type == 0x90) {{ midi_{token}_note = midi_rx_data1; midi_{token}_velocity = 0; midi_{token}_gate = false; }}"), + "}".to_string(), + ]); + } + MidiMode::Cc => { + e.declarations.push(format!("byte midi_{token}_value = 0;")); + e.loop_body.extend([ + format!( + "if (midi_rx_fresh{channel_ok} && midi_rx_type == 0xB0 && midi_rx_data1 == {}) {{", + config.control + ), + format!(" midi_{token}_value = midi_rx_data2;"), + "}".to_string(), + ]); + } + // Song is an out-only playback mode; an in-node has nothing to filter. + MidiMode::Song => {} + } +} + +fn emit_out(node: &FlowNode, config: &MidiConfig, inputs: &NodeInputs, e: &mut NodeEmission) { + let token = node.id_token(); + let channel = send_channel(config); + + // Song playback is sequenced on the host clock and has no generated + // counterpart (same as the Piezo song). On-device a song-configured out-node + // degrades to the base note on the `send` port. + if config.mode == MidiMode::Song { + e.declarations.push( + "// note: MIDI song playback is host-only; on-device the trigger sends the base note" + .to_string(), + ); + } + + let sources = inputs.on("send"); + let binding = bind_pulses(&format!("midi_{token}_send"), sources); + e.declarations.extend(binding.declarations.iter().cloned()); + e.loop_body.extend(binding.loop_lines.iter().cloned()); + if binding.fired.is_empty() { + e.loop_body + .push(format!("// midi Node {token} has no wired send input — nothing to send")); + } + for (fired, source) in binding.fired.iter().zip(sources) { + match config.mode { + // CC: clamp the firing sample to 0-127, mirror of `Midi::encode`. + MidiMode::Cc => e.loop_body.push(format!( + "if ({fired}) {{ MIDI.sendControlChange({}, (byte)constrain((int)({}), 0, 127), {channel}); }}", + config.control, + source.value.as_double_or("0.0"), + )), + // Note (and the song degrade): truthy sample → note-on at the + // configured velocity, falsy → note-off. + MidiMode::Note | MidiMode::Song => e.loop_body.push(format!( + "if ({fired}) {{ if ({}) {{ MIDI.sendNoteOn({}, {}, {channel}); }} else {{ MIDI.sendNoteOff({}, 0, {channel}); }} }}", + source.value.as_bool(), + config.note, + config.velocity, + config.note, + )), + } + } +} + +/// What downstream Nodes read from an in-direction Midi Node, per emit handle — +/// the codegen twin of the runtime's emits. Out-direction exposes nothing. +#[must_use] +pub fn output(node: &FlowNode, handle: &str) -> Option { + let config = config_of(node); + if config.direction == MidiDirection::Out { + return None; + } + let token = node.id_token(); + match (config.mode, handle) { + (MidiMode::Note, "note") => { + Some(SourceExpr::level(CppExpr::number(format!("(double)midi_{token}_note")))) + } + // The runtime keeps `value` = the latest velocity in note mode. + (MidiMode::Note, "velocity" | "value") => { + Some(SourceExpr::level(CppExpr::number(format!("(double)midi_{token}_velocity")))) + } + (MidiMode::Note, "on") => { + Some(SourceExpr::rising(CppExpr::boolean(format!("midi_{token}_gate")))) + } + (MidiMode::Note, "off") => { + Some(SourceExpr::rising(CppExpr::boolean(format!("!midi_{token}_gate")))) + } + (MidiMode::Cc, "value") => { + Some(SourceExpr::level(CppExpr::number(format!("(double)midi_{token}_value")))) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::flow::Position; + use serde_json::json; + + fn midi(id: &str, data: serde_json::Value) -> FlowNode { + FlowNode { + id: id.to_string(), + node_type: Some("Midi".to_string()), + data, + position: Position { x: 0.0, y: 0.0 }, + } + } + + fn send_input(expr: &str) -> NodeInputs { + let mut inputs = NodeInputs::default(); + inputs.add("send", SourceExpr::level(CppExpr::number(expr))); + inputs + } + + #[test] + fn every_direction_shares_one_instance_setup_and_includes() { + for data in [json!({ "direction": "in" }), json!({ "direction": "out" })] { + let e = emit(&midi("m-1", data), &NodeInputs::default()); + assert!(e.includes.iter().any(|i| i.contains("MIDI.h"))); + assert_eq!(e.shared_declarations, vec![SHARED_DECLS.to_string()]); + assert_eq!(e.shared_setup, vec![SHARED_SETUP.to_string()]); + } + } + + #[test] + fn in_note_mode_filters_the_pumped_message_like_the_runtime() { + let e = emit( + &midi("m-1", json!({ "direction": "in", "mode": "note", "channel": 2 })), + &NodeInputs::default(), + ); + let body = e.loop_body.join("\n"); + assert_eq!(e.shared_loop, vec![SHARED_LOOP.to_string()], "in-nodes arm the pump"); + assert!(body.contains("midi_rx_channel == 2"), "channel filter: {body}"); + assert!(body.contains("midi_rx_type == 0x90 && midi_rx_data2 > 0"), "note-on: {body}"); + assert!( + body.contains("midi_rx_type == 0x80 || midi_rx_type == 0x90"), + "note-off incl. velocity-0 note-on: {body}" + ); + } + + #[test] + fn in_omni_channel_has_no_channel_filter() { + let e = emit(&midi("m-1", json!({ "direction": "in", "channel": 0 })), &NodeInputs::default()); + assert!(!e.loop_body.join("\n").contains("midi_rx_channel"), "omni filters nothing"); + } + + #[test] + fn in_cc_mode_latches_only_its_control() { + let e = emit( + &midi("m-1", json!({ "direction": "in", "mode": "cc", "control": 7 })), + &NodeInputs::default(), + ); + let body = e.loop_body.join("\n"); + assert!(body.contains("midi_rx_type == 0xB0 && midi_rx_data1 == 7"), "{body}"); + assert!(body.contains("midi_m_1_value = midi_rx_data2"), "{body}"); + } + + #[test] + fn out_cc_sends_clamped_control_change_per_new_sample() { + let e = emit( + &midi("m-1", json!({ "direction": "out", "mode": "cc", "control": 7, "channel": 2 })), + &send_input("pot_p_1_value"), + ); + let body = e.loop_body.join("\n"); + assert!(body.contains("sendControlChange(7, (byte)constrain((int)("), "clamped CC send: {body}"); + assert!(body.contains("pot_p_1_value"), "sends the wired sample: {body}"); + assert!(body.contains(", 0, 127), 2)"), "clamps to 0-127 on channel 2: {body}"); + assert!(body.contains("!= midi_m_1_send_prev0"), "sends once per new sample: {body}"); + assert!(e.shared_loop.is_empty(), "out-nodes do not arm the pump"); + } + + #[test] + fn out_note_maps_truthy_to_on_and_falsy_to_off() { + let e = emit( + &midi("m-1", json!({ "direction": "out", "mode": "note", "note": 64, "velocity": 90 })), + &send_input("btn_b_1_value"), + ); + let body = e.loop_body.join("\n"); + assert!(body.contains("sendNoteOn(64, 90, 1)"), "{body}"); + assert!(body.contains("sendNoteOff(64, 0, 1)"), "{body}"); + } + + #[test] + fn output_maps_in_handles_and_out_exposes_nothing() { + let note_in = midi("m-1", json!({ "direction": "in", "mode": "note" })); + assert_eq!(output(¬e_in, "note").map(|s| s.value.code), Some("(double)midi_m_1_note".into())); + assert_eq!( + output(¬e_in, "velocity").map(|s| s.value.code), + Some("(double)midi_m_1_velocity".into()) + ); + assert_eq!(output(¬e_in, "on").map(|s| s.value.code), Some("midi_m_1_gate".into())); + assert_eq!(output(¬e_in, "off").map(|s| s.value.code), Some("!midi_m_1_gate".into())); + + let cc_in = midi("m-1", json!({ "direction": "in", "mode": "cc" })); + assert_eq!(output(&cc_in, "value").map(|s| s.value.code), Some("(double)midi_m_1_value".into())); + assert!(output(&cc_in, "note").is_none(), "cc mode exposes only value"); + + let out = midi("m-1", json!({ "direction": "out" })); + assert!(output(&out, "value").is_none()); + } + + #[test] + fn emits_deterministically() { + let n = midi("m-1", json!({ "direction": "out", "mode": "cc" })); + assert_eq!(emit(&n, &send_input("v")), emit(&n, &send_input("v"))); + } +} diff --git a/crates/microflow-core/src/codegen/cloud/mod.rs b/crates/microflow-core/src/codegen/cloud/mod.rs index 83121df9..53bed20a 100644 --- a/crates/microflow-core/src/codegen/cloud/mod.rs +++ b/crates/microflow-core/src/codegen/cloud/mod.rs @@ -19,6 +19,10 @@ pub mod figma; pub mod llm; +// `Midi` lives here as a host-peripheral node alongside the networked cloud +// nodes, but unlike them it needs NO networking on-device — it speaks serial +// MIDI over the board's hardware UART (MIDI.h), so it runs on every board. +pub mod midi; pub mod monitor; pub mod mqtt; pub mod transport; diff --git a/crates/microflow-core/src/codegen/cloud/mqtt.rs b/crates/microflow-core/src/codegen/cloud/mqtt.rs index 11892472..79400f23 100644 --- a/crates/microflow-core/src/codegen/cloud/mqtt.rs +++ b/crates/microflow-core/src/codegen/cloud/mqtt.rs @@ -246,6 +246,7 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { declarations, setup, loop_body, + ..NodeEmission::default() } } diff --git a/crates/microflow-core/src/codegen/cloud/transport.rs b/crates/microflow-core/src/codegen/cloud/transport.rs index 5470bcdb..3996ef06 100644 --- a/crates/microflow-core/src/codegen/cloud/transport.rs +++ b/crates/microflow-core/src/codegen/cloud/transport.rs @@ -221,6 +221,7 @@ impl Transport<'_> { declarations: self.declarations(extra_decls), setup: self.setup(), loop_body, + ..NodeEmission::default() } } } diff --git a/crates/microflow-core/src/codegen/emit.rs b/crates/microflow-core/src/codegen/emit.rs index 744ca6a7..3db67f6f 100644 --- a/crates/microflow-core/src/codegen/emit.rs +++ b/crates/microflow-core/src/codegen/emit.rs @@ -26,6 +26,20 @@ pub struct NodeEmission { pub setup: Vec, /// Statements emitted inside `loop()` (read/write logic). pub loop_body: Vec, + /// Multi-line declaration **blocks shared across nodes of one kind** (e.g. + /// the single `MIDI` instance + rx state several Midi Nodes read). Each + /// element is one whole block; the assembler de-duplicates by exact block + /// equality (first-seen order) and emits them once, before the per-node + /// declarations. Unlike `includes` the blocks are NOT sorted, so a block + /// may span multiple dependent lines. + pub shared_declarations: Vec, + /// Shared `setup()` blocks, de-duplicated like [`shared_declarations`](Self::shared_declarations) + /// and emitted before the per-node setup statements. + pub shared_setup: Vec, + /// Shared `loop()` blocks, de-duplicated and emitted at the top of the + /// scheduled-task region — before every node body — so a shared pump (e.g. + /// one `MIDI.read()` per tick) runs exactly once, ahead of its readers. + pub shared_loop: Vec, } impl NodeEmission { @@ -36,9 +50,25 @@ impl NodeEmission { && self.declarations.is_empty() && self.setup.is_empty() && self.loop_body.is_empty() + && self.shared_declarations.is_empty() + && self.shared_setup.is_empty() + && self.shared_loop.is_empty() } } +/// De-duplicate shared blocks by exact equality, preserving first-seen order. +/// The assembler applies this per shared region across all emissions. +#[must_use] +pub fn dedupe_shared<'a>(blocks: impl Iterator) -> Vec<&'a str> { + let mut out: Vec<&str> = Vec::new(); + for block in blocks { + if !out.contains(&block.as_str()) { + out.push(block); + } + } + out +} + /// Extension methods for turning Flow read-model types into C++-safe tokens. pub trait NodeToken { /// A C++-identifier-safe token derived from the Node `id`. Flow ids may diff --git a/crates/microflow-core/src/codegen/mod.rs b/crates/microflow-core/src/codegen/mod.rs index a084642c..63f1856f 100644 --- a/crates/microflow-core/src/codegen/mod.rs +++ b/crates/microflow-core/src/codegen/mod.rs @@ -323,6 +323,11 @@ fn emit_node(node: &FlowNode, inputs: &NodeInputs, target: &BoardTarget) -> Node Some("Figma") => cloud::figma::emit(node, inputs), Some("Monitor") => cloud::monitor::emit(node, inputs), Some("Llm") => cloud::llm::emit(node, inputs), + // Midi bridges the board's serial MIDI jack (MIDI.h); the host's Web + // MIDI / midir I/O becomes MIDI.read()/sendNoteOn on-device. Several + // Midi nodes share one MIDI instance + read-pump via the assembler's + // shared-block regions. + Some("Midi") => cloud::midi::emit(node, inputs), // AudioPlayer is a browser-only playback Node — it plays audio in the // web UI and has no Arduino hardware equivalent. We route it explicitly // (rather than letting it fall through) so the skip is intentional and @@ -486,6 +491,9 @@ fn output_expression(node: &FlowNode, handle: &str) -> Option { Some("Mqtt") if handle == "value" => cloud::mqtt::output(node), Some("Figma") if handle == "value" || handle == "change" => cloud::figma::output(node), Some("Llm") if handle == "value" => cloud::llm::output(node), + // An in-direction Midi Node surfaces note/velocity/on/off/value; the + // emitter maps each handle (out-direction exposes none). + Some("Midi") => cloud::midi::output(node, handle), _ => None, } } @@ -533,6 +541,16 @@ fn declarations( out.push('\n'); } } + // Cross-node shared blocks (deduplicated) precede per-node declarations so + // every node can reference them. + let shared = emit::dedupe_shared(emissions.iter().flat_map(|e| &e.shared_declarations)); + if !shared.is_empty() { + out.push_str("// shared across nodes (deduplicated)\n"); + for block in shared { + out.push_str(block); + out.push('\n'); + } + } for (node, emission) in order.iter().zip(emissions) { let kind = node.node_type.as_deref().unwrap_or("unknown"); out.push_str(&format!("// node {} ({})\n", node.id, kind)); @@ -560,6 +578,13 @@ fn setup_region( out.push('\n'); } } + for block in emit::dedupe_shared(emissions.iter().flat_map(|e| &e.shared_setup)) { + for line in block.lines() { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + } for line in emissions.iter().flat_map(|e| &e.setup) { out.push_str(" "); out.push_str(line); @@ -584,6 +609,14 @@ if (currentMillis - previousMillis >= interval) {\n \ previousMillis = currentMillis;\n \ // --- Scheduled tasks ---\n", ); + // Shared pumps run once, before every node body that reads their state. + for block in emit::dedupe_shared(emissions.iter().flat_map(|e| &e.shared_loop)) { + for line in block.lines() { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + } for line in permutation.iter().flat_map(|&i| &emissions[i].loop_body) { out.push_str(" "); out.push_str(line); @@ -645,6 +678,29 @@ mod tests { } } + /// Two Midi nodes must share ONE `MIDI` instance, one `MIDI.begin`, and one + /// `midi_pump()` per tick — the assembler deduplicates their shared blocks + /// so the sketch declares/pumps once, not per node. + #[test] + fn multiple_midi_nodes_share_one_instance_and_pump() { + let flow = FlowUpdate { + nodes: vec![ + node_data("m-in", "Midi", json!({ "direction": "in", "mode": "cc", "control": 1 })), + node_data("m-out", "Midi", json!({ "direction": "out", "mode": "note" })), + ], + edges: vec![], + }; + let sketch = generate_sketch_text(&flow); + assert_eq!( + sketch.matches("MIDI_CREATE_DEFAULT_INSTANCE();").count(), + 1, + "one shared MIDI instance for both nodes:\n{sketch}" + ); + assert_eq!(sketch.matches("MIDI.begin(").count(), 1, "one MIDI.begin:\n{sketch}"); + assert_eq!(sketch.matches("midi_pump();").count(), 1, "one read-pump per tick:\n{sketch}"); + assert_eq!(sketch.matches("#include ").count(), 1, "one include:\n{sketch}"); + } + /// An edge over the default `value` handles — the most common wiring. fn edge(source: &str, target: &str) -> FlowEdge { edge_h(source, "value", target, "value") diff --git a/crates/microflow-core/src/codegen/parity.rs b/crates/microflow-core/src/codegen/parity.rs index dcdb65cd..9c20f3f1 100644 --- a/crates/microflow-core/src/codegen/parity.rs +++ b/crates/microflow-core/src/codegen/parity.rs @@ -7,13 +7,18 @@ //! port/emit routing the runtime router uses — but the *behavior* is still //! written twice. //! -//! These tests pin every operation variant (and Counter's ports) to an -//! explicit, EXHAUSTIVE classification: a newly added operation or port won't -//! compile until it is categorized here, forcing a conscious "emit it, or -//! record the limitation" decision. They are the CI replacement for the prose -//! docstrings that previously kept the two sides in sync by hand — the kind of -//! hand-sync that let the Smooth attenuation invert silently (commit -//! `e1e1eb9`). +//! These tests pin every node type to an explicit, EXHAUSTIVE classification +//! (see `classify` at the bottom): each registered type either carries a +//! behavioural parity case — feed inputs, assert the emitted C++ encodes the +//! same transform the runtime applies — or an exemption naming, in one line, +//! why no value transform exists to compare. Per-config enums (operations, +//! waveforms, validators, …) are matched without a wildcard arm, so a newly +//! added variant won't compile until it is categorized here, and a newly +//! registered node type fails the registry-driven guard until classified — +//! forcing a conscious "emit it, or record the limitation" decision. They are +//! the CI replacement for the prose docstrings that previously kept the two +//! sides in sync by hand — the kind of hand-sync that let the Smooth +//! attenuation invert silently (commit `e1e1eb9`). #[cfg(test)] mod tests { @@ -39,6 +44,13 @@ mod tests { inputs } + /// One source wired into one port — the shape most single-driver Nodes see. + fn input(port: &str, expr: CppExpr) -> NodeInputs { + let mut inputs = NodeInputs::default(); + inputs.add(port, SourceExpr::level(expr)); + inputs + } + // ---- Calculate: 11 arithmetic functions ------------------------------ /// EXHAUSTIVE over `CalculateFunction`: the C++ token each variant's fold @@ -177,4 +189,733 @@ mod tests { ); } } + + // ---- Compare: 5 validators ------------------------------------------- + + /// EXHAUSTIVE over `CompareValidator`: the comparison token each variant + /// must emit for a wired numeric input (number=5, range 1..9 fixed), + /// transcribing the runtime's `compare` dispatch. + fn compare_token(v: crate::config::compare::CompareValidator) -> &'static str { + use crate::config::compare::CompareValidator::{Boolean, Number, OddEven, Range, Text}; + match v { + Boolean => "!= 0.0", + Number => "== 5.0", + OddEven => "% 2) == 0", + Range => "> 1.0 && ", + // Text predicates have no on-device string model; the runtime's + // empty-match default is `false`, and the emitter emits exactly that. + Text => "= false;", + } + } + + #[test] + fn compare_emit_covers_every_validator() { + use crate::config::compare::CompareValidator as V; + let all = [ + ("boolean", V::Boolean), + ("number", V::Number), + ("oddeven", V::OddEven), + ("range", V::Range), + ("text", V::Text), + ]; + for (wire, variant) in all { + let e = crate::codegen::transformation::compare::emit( + &node( + "Compare", + json!({ "validator": wire, "number": 5.0, "range": { "min": 1.0, "max": 9.0 } }), + ), + &input("value", CppExpr::number("a")), + ); + let body = e.loop_body.join("\n"); + let token = compare_token(variant); + assert!( + body.contains(token), + "Compare `{wire}` must emit its comparison (`{token}`), got: {body}" + ); + } + } + + // ---- Smooth: 2 smoothing types --------------------------------------- + + /// EXHAUSTIVE over `SmoothType`: the arithmetic each variant must emit, + /// transcribing the runtime's `result = (1 - a) * value + a * previous` + /// (attenuation fixed at 0.9) and the rolling-window mean. + fn smooth_token(t: crate::config::smooth::SmoothType) -> &'static str { + use crate::config::smooth::SmoothType::{MovingAverage, Smooth}; + match t { + Smooth => "(1.0 - 0.9) * ", + MovingAverage => "_sum / ", + } + } + + #[test] + fn smooth_emit_covers_every_type() { + use crate::config::smooth::SmoothType as S; + let all = [("smooth", S::Smooth), ("movingAverage", S::MovingAverage)]; + for (wire, variant) in all { + let e = crate::codegen::transformation::smooth::emit( + &node("Smooth", json!({ "type": wire, "attenuation": 0.9, "windowSize": 4 })), + &input("value", CppExpr::number("a")), + ); + let body = e.loop_body.join("\n"); + let token = smooth_token(variant); + assert!( + body.contains(token), + "Smooth `{wire}` must emit its transform (`{token}`), got: {body}" + ); + } + } + + // ---- Oscillator: 7 waveforms ----------------------------------------- + + /// EXHAUSTIVE over `Waveform`: the sampling math each variant must emit + /// (amplitude fixed at 2.5), the port of the runtime's `calculate_waveform`. + fn waveform_token(w: crate::config::oscillator::Waveform) -> &'static str { + use crate::config::oscillator::Waveform::{ + Perlin, Random, RandomWalk, Sawtooth, Sinus, Square, Triangle, + }; + match w { + Sinus => "sin(", + Square => "? 2.5 : -(2.5)", + Sawtooth => "(2.0 / ", + Triangle => "(4.0 / ", + Random => "random(0, 10000)", + RandomWalk => "(a + (b - a) * f)", + Perlin => "(2.0 / 3.0)", + } + } + + #[test] + fn oscillator_emit_covers_every_waveform() { + use crate::config::oscillator::Waveform as W; + let all = [ + ("sinus", W::Sinus), + ("square", W::Square), + ("sawtooth", W::Sawtooth), + ("triangle", W::Triangle), + ("random", W::Random), + ("randomwalk", W::RandomWalk), + ("perlin", W::Perlin), + ]; + for (wire, variant) in all { + let e = crate::codegen::generator::oscillator::emit( + &node("Oscillator", json!({ "waveform": wire, "amplitude": 2.5 })), + &NodeInputs::default(), + ); + let body = e.loop_body.join("\n"); + let token = waveform_token(variant); + assert!( + body.contains(token), + "Oscillator `{wire}` must emit its waveform math (`{token}`), got: {body}" + ); + } + } + + // ---- Midi: in/out × note/cc ------------------------------------------ + + /// Both directions and both modes must transcribe the runtime's semantics: + /// in-note filters note-on/off out of the shared pump; in-cc latches the + /// configured control; out-note maps truthy→NoteOn / falsy→NoteOff; out-cc + /// clamps and sends a control change on the configured control/channel. + #[test] + fn midi_emit_covers_both_directions_and_modes() { + use crate::codegen::wire::SourceExpr; + + let in_note = crate::codegen::cloud::midi::emit( + &node("Midi", json!({ "direction": "in", "mode": "note", "channel": 3 })), + &NodeInputs::default(), + ); + let body = in_note.loop_body.join("\n"); + assert!(body.contains("midi_rx_channel == 3"), "in-note channel filter: {body}"); + assert!(body.contains("0x90") && body.contains("0x80"), "in-note on/off: {body}"); + + let in_cc = crate::codegen::cloud::midi::emit( + &node("Midi", json!({ "direction": "in", "mode": "cc", "control": 7 })), + &NodeInputs::default(), + ); + assert!( + in_cc.loop_body.join("\n").contains("0xB0 && midi_rx_data1 == 7"), + "in-cc latches its control" + ); + + let mut send = NodeInputs::default(); + send.add("send", SourceExpr::level(CppExpr::number("v"))); + let out_note = crate::codegen::cloud::midi::emit( + &node("Midi", json!({ "direction": "out", "mode": "note", "note": 64, "velocity": 90 })), + &send, + ); + let body = out_note.loop_body.join("\n"); + assert!(body.contains("sendNoteOn(64, 90,"), "out-note on: {body}"); + assert!(body.contains("sendNoteOff(64, 0,"), "out-note off: {body}"); + + let out_cc = crate::codegen::cloud::midi::emit( + &node("Midi", json!({ "direction": "out", "mode": "cc", "control": 7 })), + &send, + ); + assert!( + out_cc.loop_body.join("\n").contains("sendControlChange(7, (byte)constrain"), + "out-cc clamps + sends its control" + ); + } + + // ---- Trigger: 2 behaviours ------------------------------------------- + + /// EXHAUSTIVE over `TriggerBehaviour`: the direction comparison each + /// variant must emit, transcribing `value_changes_in_correct_direction`. + fn behaviour_token(b: crate::config::trigger::TriggerBehaviour) -> &'static str { + use crate::config::trigger::TriggerBehaviour::{Decreasing, Increasing}; + match b { + Increasing => "_diff > 0.0", + Decreasing => "_diff <= 0.0", + } + } + + #[test] + fn trigger_emit_covers_every_behaviour() { + use crate::config::trigger::TriggerBehaviour as B; + let all = [("increasing", B::Increasing), ("decreasing", B::Decreasing)]; + for (wire, variant) in all { + let e = crate::codegen::control::trigger::emit( + &node("Trigger", json!({ "behaviour": wire, "threshold": 7.5 })), + &input("value", CppExpr::number("a")), + ); + let body = e.loop_body.join("\n"); + let token = behaviour_token(variant); + assert!( + body.contains(token), + "Trigger `{wire}` must emit its direction check (`{token}`), got: {body}" + ); + assert!( + body.contains(">= 7.5"), + "Trigger `{wire}` must compare against the configured threshold: {body}" + ); + } + } + + // ---- RangeMap / Delay / Interval / Constant / Function --------------- + + /// The runtime's linear remap plus its span-dependent rounding precision. + #[test] + fn range_map_emits_the_runtime_remap() { + let e = crate::codegen::transformation::range_map::emit( + &node( + "RangeMap", + json!({ "from": { "min": 0.0, "max": 100.0 }, "to": { "min": 0.0, "max": 255.0 } }), + ), + &input("value", CppExpr::number("a")), + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("* (255.0 - 0.0) / (100.0 - 0.0)"), + "RangeMap must emit the runtime's linear remap, got: {body}" + ); + assert!( + body.contains("round(") && body.contains("/ 1.0"), + "output spans > 10 round to whole numbers, got: {body}" + ); + let e = crate::codegen::transformation::range_map::emit( + &node("RangeMap", json!({ "to": { "min": 0.0, "max": 5.0 } })), + &input("value", CppExpr::number("a")), + ); + assert!( + e.loop_body.iter().any(|l| l.contains("/ 10.0")), + "output spans <= 10 keep one decimal place" + ); + } + + /// The configured delay drives the non-blocking deadline; the stored value + /// is re-emitted on fire, like the runtime's delayed `event`. + #[test] + fn delay_plumbs_config_into_the_deadline() { + let e = crate::codegen::control::delay::emit( + &node("Delay", json!({ "delay": 750 })), + &input("trigger", CppExpr::boolean("v")), + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("millis() - ") && body.contains(">= 750UL"), + "Delay must fire on a non-blocking elapsed-time compare, got: {body}" + ); + assert!( + body.contains("delay_p_value = delay_p_stored"), + "Delay must re-emit the stored value on fire, got: {body}" + ); + } + + /// The configured interval drives the tick (clamped to the runtime's 16ms + /// minimum); the payload is the elapsed time since the start window. + #[test] + fn interval_plumbs_config_into_the_tick() { + let e = crate::codegen::control::interval::emit( + &node("Interval", json!({ "interval": 500 })), + &NodeInputs::default(), + ); + let body = e.loop_body.join("\n"); + assert!(body.contains(">= 500UL"), "configured interval drives the tick: {body}"); + assert!( + body.contains("(double)(millis() - interval_p_start)"), + "payload is elapsed ms since the start window, like `now - started_at`: {body}" + ); + let e = crate::codegen::control::interval::emit( + &node("Interval", json!({ "interval": 1 })), + &NodeInputs::default(), + ); + assert!( + e.loop_body.iter().any(|l| l.contains(">= 16UL")), + "intervals below the runtime minimum clamp to 16ms" + ); + } + + /// The configured value lands verbatim in the declaration; no loop work. + #[test] + fn constant_emits_the_configured_value() { + let e = crate::codegen::control::constant::emit(&node("Constant", json!({ "value": 42.0 }))); + assert!(e.declarations.iter().any(|d| d.contains("= 42.0;"))); + assert!(e.loop_body.is_empty(), "a Constant does no per-loop work"); + } + + /// The JS expression subset translates into the same arithmetic; anything + /// outside it emits an explicit note and leaves the runtime's 0.0 initial + /// value — never guessed-at C++. + #[test] + fn function_emits_only_the_supported_subset() { + let e = crate::codegen::transformation::function::emit( + &node("Function", json!({ "code": "return input * 2;" })), + &input("trigger", CppExpr::number("a")), + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("(a)") && body.contains("* 2"), + "translated JS must read its wired input and keep the arithmetic: {body}" + ); + let e = crate::codegen::transformation::function::emit( + &node("Function", json!({ "code": "while (input) {}" })), + &NodeInputs::default(), + ); + assert!(e.loop_body.is_empty(), "unsupported JS must not emit loop code"); + assert!( + e.declarations.iter().any(|d| d.contains("// unsupported")), + "unsupported JS is noted, value stays 0.0" + ); + } + + // ---- Outputs: value-mapping transforms ------------------------------- + + /// Led `value` applies the runtime's `as_u8` clamp and tracks `is_on`. + /// Also covers Vibration, which shares the Led implementation on both sides. + #[test] + fn led_value_port_applies_the_runtime_brightness_clamp() { + let e = crate::codegen::output::led::emit( + &node("Led", json!({})), + &input("value", CppExpr::number("a")), + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("constrain((double)(a), 0.0, 255.0)"), + "Led `value` must clamp like `ComponentValue::as_u8`: {body}" + ); + assert!(body.contains("analogWrite("), "Led `value` is a PWM write: {body}"); + assert!(body.contains("> 0;"), "Led must track the runtime's is_on: {body}"); + } + + /// EXHAUSTIVE over `RelayType`: the digital level "open" writes, + /// transcribing the runtime's NO/NC inversion. + fn relay_open_level(t: crate::config::relay::RelayType) -> &'static str { + use crate::config::relay::RelayType::{NC, NO}; + match t { + NO => "HIGH", + NC => "LOW", + } + } + + #[test] + fn relay_emit_covers_every_type() { + use crate::config::relay::RelayType as R; + let all = [("NO", R::NO), ("NC", R::NC)]; + for (wire, variant) in all { + let e = crate::codegen::output::relay::emit( + &node("Relay", json!({ "type": wire })), + &input("true", CppExpr::boolean("a")), + ); + let body = e.loop_body.join("\n"); + let level = relay_open_level(variant); + assert!( + body.contains(&format!("digitalWrite(relay_p_pin, {level}); relay_p_open = true")), + "Relay `{wire}` open must write {level}, got: {body}" + ); + } + } + + /// EXHAUSTIVE over `ServoType`: the write each variant derives from a + /// wired `value` — the standard clamp vs. the continuous dead-zone map. + fn servo_token(t: crate::config::servo::ServoType) -> &'static str { + use crate::config::servo::ServoType::{Continuous, Standard}; + match t { + Standard => "(int)constrain(", + Continuous => "? 90 : ", + } + } + + #[test] + fn servo_emit_covers_every_type() { + use crate::config::servo::ServoType as S; + let uno = crate::codegen::board::target_by_id("uno").expect("uno is supported"); + let all = [("standard", S::Standard), ("continuous", S::Continuous)]; + for (wire, variant) in all { + let e = crate::codegen::output::servo::emit( + &node("Servo", json!({ "type": wire, "range": { "min": 10, "max": 170 } })), + &input("value", CppExpr::number("a")), + &uno, + ); + let body = e.loop_body.join("\n"); + let token = servo_token(variant); + assert!( + body.contains(token), + "Servo `{wire}` must emit its value mapping (`{token}`), got: {body}" + ); + } + // The standard clamp uses the configured range bounds. + let e = crate::codegen::output::servo::emit( + &node("Servo", json!({ "range": { "min": 10, "max": 170 } })), + &input("value", CppExpr::number("a")), + &uno, + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("(double)10") && body.contains("(double)170"), + "Servo clamp must use the configured range: {body}" + ); + } + + /// Rgb reproduces the runtime's `channel * alpha` intensity math: alpha is + /// a 0..=100 percent clamped to 0..=1, and common-anode inverts the write. + #[test] + fn rgb_emit_applies_the_runtime_color_math() { + let mut inputs = NodeInputs::default(); + inputs.add("red", SourceExpr::level(CppExpr::number("a"))); + inputs.add("alpha", SourceExpr::level(CppExpr::number("b"))); + let e = crate::codegen::output::rgb::emit(&node("Rgb", json!({})), &inputs); + let body = e.loop_body.join("\n"); + assert!( + body.contains("/ 100.0, 0.0, 1.0)"), + "alpha percent must clamp to 0..=1 intensity: {body}" + ); + assert!(body.contains("* rgb_p_a"), "channels must scale by alpha: {body}"); + let e = crate::codegen::output::rgb::emit( + &node("Rgb", json!({ "isAnode": true })), + &input("red", CppExpr::number("a")), + ); + assert!( + e.loop_body.iter().any(|l| l.contains("(255 - ")), + "common-anode inverts the written level" + ); + } + + /// Pixel `value` selects a preset with the runtime's hex parsing and + /// index clamp (`index.min(len - 1)`). + #[test] + fn pixel_value_selects_a_clamped_preset() { + let e = crate::codegen::output::pixel::emit( + &node("Pixel", json!({ "length": 4, "presets": [["#ff0000"], ["#0000ff"]] })), + &input("value", CppExpr::number("a")), + ); + let decls = e.declarations.join("\n"); + assert!( + decls.contains("0xFF0000") && decls.contains("0x0000FF"), + "presets must bake the runtime's parsed hex colors: {decls}" + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("constrain(round(") && body.contains("0.0, 1.0)"), + "the index must clamp to the preset list like the runtime: {body}" + ); + assert!(body.contains("setPixelColor"), "the preset must reach the strip: {body}"); + } + + /// Matrix `value` selects a shape with the runtime's binary-row slicing + /// and index clamp. + #[test] + fn matrix_value_selects_a_clamped_shape() { + let e = crate::codegen::output::matrix::emit( + &node("Matrix", json!({ "shapes": [["10000001"], ["11111111"]] })), + &input("value", CppExpr::number("a")), + ); + let decls = e.declarations.join("\n"); + assert!( + decls.contains("0x81") && decls.contains("0xFF"), + "shapes must bake the runtime's binary-row bytes: {decls}" + ); + let body = e.loop_body.join("\n"); + assert!( + body.contains("constrain(round(") && body.contains("setRow("), + "the clamped index must drive the row writes: {body}" + ); + } + + /// EXHAUSTIVE over `StepperInterface`: the `AccelStepper` constructor + /// each variant must emit, transcribing the runtime's `CMD_CONFIG` — the + /// same interface constant, pin count, and pin order it sends over + /// Firmata (driver = step/dir; two-/four-wire = motor pins 1–2 / 1–4). + /// Whole-step only: the runtime never sets Firmata's half-step bits, so + /// `FULL2WIRE`/`FULL4WIRE`, never the `HALF*` variants. + fn stepper_interface_token(i: crate::config::stepper::StepperInterface) -> &'static str { + use crate::config::stepper::StepperInterface::{Driver, FourWire, TwoWire}; + match i { + Driver => "(AccelStepper::DRIVER, 11, 12)", + TwoWire => "(AccelStepper::FULL2WIRE, 21, 22)", + FourWire => "(AccelStepper::FULL4WIRE, 21, 22, 23, 24)", + } + } + + #[test] + fn stepper_emit_covers_every_interface() { + use crate::config::stepper::StepperInterface as I; + let all = [("driver", I::Driver), ("two_wire", I::TwoWire), ("four_wire", I::FourWire)]; + for (wire, variant) in all { + let e = crate::codegen::output::stepper::emit( + &node( + "Stepper", + json!({ + "interface": wire, + "stepPin": 11, "dirPin": 12, + "motorPin1": 21, "motorPin2": 22, "motorPin3": 23, "motorPin4": 24, + }), + ), + &input("value", CppExpr::number("a")), + ); + let decls = e.declarations.join("\n"); + let token = stepper_interface_token(variant); + assert!( + decls.contains(token), + "Stepper `{wire}` must construct `{token}`, got: {decls}" + ); + // The runtime skips zero-step samples; the emitted move must too. + let body = e.loop_body.join("\n"); + assert!( + body.contains("!= 0") && body.contains(".move("), + "Stepper `value` is a zero-skipping relative move: {body}" + ); + } + let e = crate::codegen::output::stepper::emit( + &node("Stepper", json!({})), + &input("to", CppExpr::number("a")), + ); + assert!( + e.loop_body.iter().any(|l| l.contains(".moveTo((long)")), + "Stepper `to` is an absolute target, like CMD_TO" + ); + } + + /// EXHAUSTIVE over `PiezoType`: buzz maps to the built-in `tone(...)`; + /// song playback is host-only and emits an explicit note (the trigger + /// still buzzes the base frequency). + fn piezo_token(t: crate::config::piezo::PiezoType) -> &'static str { + use crate::config::piezo::PiezoType::{Buzz, Song}; + match t { + Buzz => "tone(piezo_p_pin, 880, 250)", + Song => "song playback", + } + } + + #[test] + fn piezo_emit_covers_every_type() { + use crate::config::piezo::PiezoType as P; + let all = [("buzz", P::Buzz), ("song", P::Song)]; + for (wire, variant) in all { + let e = crate::codegen::output::piezo::emit( + &node("Piezo", json!({ "type": wire, "frequency": 880, "duration": 250 })), + &input("trigger", CppExpr::boolean("a")), + ); + let text = [e.declarations.join("\n"), e.loop_body.join("\n")].join("\n"); + let token = piezo_token(variant); + assert!( + text.contains(token), + "Piezo `{wire}` must emit `{token}`, got: {text}" + ); + } + } + + // ---- I2cDevice: 3 output formats ------------------------------------- + + /// EXHAUSTIVE over `OutputFormat`, driven by the SHARED decode descriptor + /// (`OutputFormat::decode`, the one the runtime interprets via + /// `fold_bytes`): the token the emitted fold must carry per descriptor. + /// Raw has no on-device byte-array value model, so it folds like + /// `UnsignedInt` — the closest single-value approximation, recorded here. + fn i2c_format_token(f: crate::config::i2c_device::OutputFormat) -> &'static str { + use crate::config::i2c_device::ByteDecode; + match f.decode() { + ByteDecode::Raw | ByteDecode::Fold { sign_extend: false } => "<< 8", + ByteDecode::Fold { sign_extend: true } => "= -1", + } + } + + #[test] + fn i2c_emit_covers_every_output_format() { + use crate::config::i2c_device::OutputFormat as F; + let all = [("raw", F::Raw), ("unsigned_int", F::UnsignedInt), ("signed_int", F::SignedInt)]; + for (wire, variant) in all { + let e = crate::codegen::input::i2c_device::emit( + &node("I2cDevice", json!({ "output": wire })), + &NodeInputs::default(), + ); + let body = e.loop_body.join("\n"); + let token = i2c_format_token(variant); + assert!( + body.contains(token), + "I2cDevice `{wire}` must emit its decode (`{token}`), got: {body}" + ); + } + // The signed fold sign-extends from the MSB, like the runtime. + let e = crate::codegen::input::i2c_device::emit( + &node("I2cDevice", json!({ "output": "signed_int" })), + &NodeInputs::default(), + ); + assert!( + e.loop_body.iter().any(|l| l.contains("& 0x80")), + "signed decode must test the sign bit of the first byte" + ); + // The fold cap is part of the descriptor: `fold_bytes` ignores bytes + // past `FOLD_BYTE_CAP`, so a longer read must guard the emitted fold + // too (unguarded, the 32-bit `long` kept the LAST 4 bytes instead). + let cap = crate::config::i2c_device::OutputFormat::FOLD_BYTE_CAP; + let e = crate::codegen::input::i2c_device::emit( + &node("I2cDevice", json!({ "output": "unsigned_int", "readLength": cap + 2 })), + &NodeInputs::default(), + ); + assert!( + e.loop_body.iter().any(|l| l.contains(&format!("< {cap}) {{"))), + "reads past the shared FOLD_BYTE_CAP must cap the fold like the runtime" + ); + } + + // ---- Inputs with a read transform: Button, Switch -------------------- + + /// A pull-up Button reads active-low; the emitted state inverts the raw + /// read so "pressed = true" matches the runtime. + #[test] + fn button_emit_covers_the_pullup_inversion() { + let e = crate::codegen::input::button::emit(&node("Button", json!({ "isPullup": true }))); + assert!(e.setup.iter().any(|s| s.contains("INPUT_PULLUP"))); + assert!( + e.loop_body.iter().any(|l| l.contains("== LOW")), + "pull-up reads are active-low and must invert" + ); + let e = crate::codegen::input::button::emit(&node("Button", json!({}))); + assert!( + e.loop_body.iter().any(|l| l.contains("== HIGH")), + "plain INPUT reads HIGH on press" + ); + } + + /// EXHAUSTIVE over `SwitchType`: the read comparison per contact type, + /// transcribing the runtime's NO/NC inversion. + fn switch_read_token(t: crate::config::switch::SwitchType) -> &'static str { + use crate::config::switch::SwitchType::{NC, NO}; + match t { + NO => "== HIGH", + NC => "== LOW", + } + } + + #[test] + fn switch_emit_covers_every_type() { + use crate::config::switch::SwitchType as S; + let all = [("NO", S::NO), ("NC", S::NC)]; + for (wire, variant) in all { + let e = crate::codegen::input::switch::emit(&node("Switch", json!({ "type": wire }))); + let body = e.loop_body.join("\n"); + let token = switch_read_token(variant); + assert!( + body.contains(token), + "Switch `{wire}` must read `{token}`, got: {body}" + ); + } + } + + // ---- The exhaustive per-node classification -------------------------- + + /// A node type's interpret↔emit parity status: a behavioural case that + /// feeds inputs and asserts the emitted C++ encodes the runtime's + /// transform, or a conscious exemption naming why none exists. + #[cfg(feature = "runtime")] + enum Parity { + Case(fn()), + Exempt(&'static str), + } + + /// EXHAUSTIVE over every name the live `ComponentRegistry` registers (the + /// guard below drives this from `declared()`): a newly registered node + /// type hits the `other =>` arm and fails until the author adds a + /// behavioural case above or records an exemption here — in one place, so + /// the emit-or-explain decision is always conscious. + #[cfg(feature = "runtime")] + fn classify(node_type: &str) -> Parity { + use Parity::{Case, Exempt}; + match node_type { + // transformation + "Calculate" => Case(calculate_emit_covers_every_function), + "Compare" => Case(compare_emit_covers_every_validator), + "Gate" => Case(gate_emit_covers_every_gate), + "RangeMap" => Case(range_map_emits_the_runtime_remap), + "Smooth" => Case(smooth_emit_covers_every_type), + "Function" => Case(function_emits_only_the_supported_subset), + // control / generator + "Counter" => Case(counter_ports_classified_for_codegen), + "Delay" => Case(delay_plumbs_config_into_the_deadline), + "Trigger" => Case(trigger_emit_covers_every_behaviour), + "Constant" => Case(constant_emits_the_configured_value), + "Interval" => Case(interval_plumbs_config_into_the_tick), + "Oscillator" => Case(oscillator_emit_covers_every_waveform), + // output (Vibration shares the Led impl on both sides) + "Led" | "Vibration" => Case(led_value_port_applies_the_runtime_brightness_clamp), + "Relay" => Case(relay_emit_covers_every_type), + "Servo" => Case(servo_emit_covers_every_type), + "Rgb" => Case(rgb_emit_applies_the_runtime_color_math), + "Pixel" => Case(pixel_value_selects_a_clamped_preset), + "Matrix" => Case(matrix_value_selects_a_clamped_shape), + "Stepper" => Case(stepper_emit_covers_every_interface), + "Piezo" => Case(piezo_emit_covers_every_type), + // input + "I2cDevice" => Case(i2c_emit_covers_every_output_format), + "Button" => Case(button_emit_covers_the_pullup_inversion), + "Switch" => Case(switch_emit_covers_every_type), + "Motion" => Exempt("plain digitalRead-HIGH into a state variable — no value transform"), + "Hotkey" => Exempt("host-keyboard source; on-device the state stays false by design"), + "Pn532" => Exempt("no Arduino emitter yet — falls through to the placeholder comment"), + // Plain analogRead sources (Sensor backs the specialised aliases; + // Proximity has its own impl with the same read semantics). + "Sensor" | "Force" | "HallEffect" | "Ldr" | "Potentiometer" | "Tilt" | "Proximity" => { + Exempt("plain analogRead into a state variable — no value transform") + } + "Midi" => Case(midi_emit_covers_both_directions_and_modes), + // cloud + "Monitor" | "Mqtt" | "Figma" | "Llm" => Exempt( + "network transport side-effect — values cross unchanged; bring-up and \ + topic plumbing are pinned by the emitter's own tests", + ), + other => panic!( + "node type `{other}` has no interpret↔emit parity classification — add a \ + behavioural case or a one-line exemption in codegen/parity.rs::classify." + ), + } + } + + /// Drive `classify` from the live registry so the classification can + /// never lag reality: registering a node type (ADR-0007's catalog set) + /// makes this test reach its `classify` arm — or the panic. + #[cfg(feature = "runtime")] + #[test] + fn every_registered_node_type_is_classified() { + let registry = crate::runtime::ComponentRegistry::new(); + let mut names: Vec<&String> = registry.declared().keys().collect(); + names.sort(); + assert!(!names.is_empty(), "the registry must declare its node types"); + for name in names { + match classify(name) { + Parity::Case(case) => case(), + Parity::Exempt(reason) => assert!(!reason.is_empty()), + } + } + } } diff --git a/crates/microflow-core/src/codegen/validate.rs b/crates/microflow-core/src/codegen/validate.rs index b0e7ece2..f1d31a8a 100644 --- a/crates/microflow-core/src/codegen/validate.rs +++ b/crates/microflow-core/src/codegen/validate.rs @@ -165,6 +165,10 @@ fn emitted_pin(node: &FlowNode, kind: &str) -> u8 { fn problem_for(node: &FlowNode, target: &BoardTarget) -> Option { let kind = node.node_type.as_deref(); match kind { + // Midi is a cloud-family node but needs NO networking on-device (serial + // MIDI over the UART), so it is checked before the networking gate — it + // warns instead that it claims the board's primary hardware serial. + Some("Midi") => midi_serial_problem(node, target), Some(k) if CLOUD_NODE_TYPES.contains(&k) => networking_problem(node, k, target), Some(k) if input::sensor::ANALOG_SENSOR_TYPES.contains(&k) => { sensor_pin_problem(node, k, target) @@ -204,6 +208,22 @@ fn networking_problem( )) } +/// A Midi Node's serial-MIDI emitter claims the board's primary hardware UART +/// (`MIDI_CREATE_DEFAULT_INSTANCE()` binds `Serial` at 31250 baud), so the +/// Serial Monitor / USB serial is unavailable while flashed. A warning, never a +/// block: the Author may be using a MIDI shield or a board with a spare UART. +fn midi_serial_problem(node: &FlowNode, _target: &BoardTarget) -> Option { + Some(problem( + node, + "Midi", + ProblemSeverity::Warning, + format!( + "Node {} (Midi) uses serial MIDI on the board's primary hardware serial (31250 baud) — the USB Serial Monitor is unavailable while running, and a DIN-5 MIDI jack or shield is required", + node.id + ), + )) +} + /// A hardware-IO Node's pin should exist in the board's pin map. fn digital_pin_problem( node: &FlowNode, diff --git a/crates/microflow-core/src/config/midi.rs b/crates/microflow-core/src/config/midi.rs new file mode 100644 index 00000000..7673923a --- /dev/null +++ b/crates/microflow-core/src/config/midi.rs @@ -0,0 +1,99 @@ +//! MIDI Node config — shared by the live runtime and the codegen emitter. +//! +//! One node, two directions (mirroring the Mqtt node's publish/subscribe): +//! `direction = "in"` listens to host MIDI inputs, `direction = "out"` sends to +//! host MIDI outputs. `device_name` is a case-insensitive substring filter +//! against the host's MIDI port names ("" = every device); it is meaningless +//! on-device (codegen targets the serial MIDI jack instead). + +use serde::{Deserialize, Serialize}; + +/// One song step: note name (`Some("C4")`, sharps only) or `None` for a rest, +/// its length in beats, and its note-on velocity (0-127). Unlike the Piezo song +/// (a buzzer has no dynamics), each MIDI note carries its own velocity. +pub type SongNote = (Option, f64, u8); + +/// Which way this node speaks MIDI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MidiDirection { + #[default] + In, + Out, +} + +/// Which MIDI messages the node speaks: note-on/off pairs, a control-change, or +/// (out-direction only) an embedded note sequence played back on the host clock. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MidiMode { + #[default] + Note, + Cc, + /// Play the embedded `song` — the MIDI twin of the Piezo "song" type. Only + /// meaningful on an out-direction node; ignored on in. + Song, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MidiConfig { + #[serde(default)] + pub direction: MidiDirection, + /// Substring filter on the host MIDI port name; "" matches every device. + #[serde(default)] + pub device_name: String, + /// 1-16; 0 = omni (in-direction accepts every channel; out clamps to 1). + #[serde(default)] + pub channel: u8, + #[serde(default)] + pub mode: MidiMode, + /// CC number to listen for / send on (cc mode only). + #[serde(default = "default_control")] + pub control: u8, + /// Note number to play (out + note mode only). + #[serde(default = "default_note")] + pub note: u8, + /// Note-on velocity (out + note/song mode only). + #[serde(default = "default_velocity")] + pub velocity: u8, + /// Note sequence for song mode (out + song only); each note carries its + /// own velocity. `velocity` above is the fallback default for new notes. + #[serde(default)] + pub song: Vec, + /// Playback tempo in BPM for song mode. + #[serde(default = "default_tempo")] + pub tempo: u32, +} + +impl Default for MidiConfig { + fn default() -> Self { + Self { + direction: MidiDirection::default(), + device_name: String::new(), + channel: 0, + mode: MidiMode::default(), + control: default_control(), + note: default_note(), + velocity: default_velocity(), + song: Vec::new(), + tempo: default_tempo(), + } + } +} + +/// CC 1 = the mod wheel, the knob most controllers map first. +fn default_control() -> u8 { + 1 +} +/// Middle C. +fn default_note() -> u8 { + 60 +} +fn default_velocity() -> u8 { + 127 +} +/// A brisk-but-common default, matching the Piezo song tempo. +fn default_tempo() -> u32 { + 113 +} diff --git a/crates/microflow-core/src/config/mod.rs b/crates/microflow-core/src/config/mod.rs index 27de7b42..7428ec60 100644 --- a/crates/microflow-core/src/config/mod.rs +++ b/crates/microflow-core/src/config/mod.rs @@ -56,4 +56,5 @@ pub mod smooth; // reach it too — the network I/O is the host's, not the config's) pub mod figma; pub mod llm; +pub mod midi; pub mod mqtt; diff --git a/crates/microflow-core/src/runtime/cloud/figma.rs b/crates/microflow-core/src/runtime/cloud/figma.rs index 6748d462..7a4decf3 100644 --- a/crates/microflow-core/src/runtime/cloud/figma.rs +++ b/crates/microflow-core/src/runtime/cloud/figma.rs @@ -323,7 +323,9 @@ mod tests { CloudRequestKind::MqttPublish { broker_id, topic, payload, retain } => { (broker_id, topic, payload, retain) } - other @ CloudRequestKind::LlmGenerate { .. } => panic!("expected MqttPublish, got {other:?}"), + other @ (CloudRequestKind::LlmGenerate { .. } | CloudRequestKind::MidiSend { .. }) => { + panic!("expected MqttPublish, got {other:?}") + } } } diff --git a/crates/microflow-core/src/runtime/cloud/llm.rs b/crates/microflow-core/src/runtime/cloud/llm.rs index d766c5db..44906068 100644 --- a/crates/microflow-core/src/runtime/cloud/llm.rs +++ b/crates/microflow-core/src/runtime/cloud/llm.rs @@ -158,7 +158,9 @@ mod tests { CloudRequestKind::LlmGenerate { provider_id, model, system, prompt } => { (provider_id, model, system, prompt) } - other @ CloudRequestKind::MqttPublish { .. } => panic!("expected LlmGenerate, got {other:?}"), + other @ (CloudRequestKind::MqttPublish { .. } | CloudRequestKind::MidiSend { .. }) => { + panic!("expected LlmGenerate, got {other:?}") + } } } diff --git a/crates/microflow-core/src/runtime/cloud/midi.rs b/crates/microflow-core/src/runtime/cloud/midi.rs new file mode 100644 index 00000000..e805fabf --- /dev/null +++ b/crates/microflow-core/src/runtime/cloud/midi.rs @@ -0,0 +1,621 @@ +//! MIDI host-peripheral node on core's [`Component`] trait. +//! +//! MIDI is not a cloud service, but it is mechanically the same sans-IO shape +//! (ADR-0009), and — like the Mqtt node — ONE node covers both directions: +//! +//! - **`direction = "in"`**: describes its interest via +//! [`midi_wiring`](Component::midi_wiring) (a device-name filter the host uses +//! to open MIDI inputs). The host feeds every raw 3-byte message through +//! [`receive_raw_message`](Component::receive_raw_message) (`topic` = the host +//! port name, `payload` = `[status, data1, data2]`); ALL parsing/filtering +//! lives here in core so both hosts route bytes identically. +//! - **`direction = "out"`**: `dispatch("send")` records a +//! [`CloudRequestKind::MidiSend`] for the host's `EffectsSink::perform_cloud` +//! to write to the device. +//! +//! [`Component`]: crate::runtime::Component + +use crate::runtime::{ + CloudRequestKind, Component, ComponentBase, ComponentBuilder, ComponentValue, RuntimeContext, + RuntimeError, +}; +use std::borrow::Cow; + +pub use crate::config::midi::{MidiConfig, MidiDirection, MidiMode}; + +/// MIDI status nibbles (high 4 bits of the status byte). +const NOTE_OFF: u8 = 0x80; +const NOTE_ON: u8 = 0x90; +const CONTROL_CHANGE: u8 = 0xB0; + +/// One flattened song step: a note (`None` = rest/silence) held for +/// `duration_ms`, played at its own `velocity`. +#[derive(Debug, Clone, Copy)] +struct SongStep { + note: Option, + duration_ms: u64, + velocity: u8, +} + +/// Map a Piezo-style note name (`"C4"`, `"F#5"`, sharps only) to a MIDI note +/// number, using the C4 = 60 convention (matching the node's default note). +/// Returns `None` for a rest, an unknown letter, or an out-of-range result. +fn note_name_to_midi(name: &str) -> Option { + let bytes = name.trim().as_bytes(); + let semitone = match bytes.first()?.to_ascii_uppercase() { + b'C' => 0, + b'D' => 2, + b'E' => 4, + b'F' => 5, + b'G' => 7, + b'A' => 9, + b'B' => 11, + _ => return None, + }; + let mut idx = 1; + let semitone = if bytes.get(idx) == Some(&b'#') { + idx += 1; + semitone + 1 + } else { + semitone + }; + let octave: i32 = std::str::from_utf8(&bytes[idx..]).ok()?.parse().ok()?; + let midi = (octave + 1) * 12 + semitone; + u8::try_from(midi).ok().filter(|n| *n <= 127) +} + +pub struct Midi { + base: ComponentBase, + config: MidiConfig, + /// Flattened song queue, walked one step per `_note` wakeup. Empty when idle. + steps: Vec, + /// Index of the next step to play. + cursor: usize, + /// The note currently sounding, so the next step can note-off it first. + sounding: Option, + is_playing: bool, +} + +impl Midi { + pub const E_NOTE: &'static str = "note"; + pub const E_VELOCITY: &'static str = "velocity"; + pub const E_ON: &'static str = "on"; + pub const E_OFF: &'static str = "off"; + + #[must_use] + pub fn new(id: String, config: MidiConfig) -> Self { + Self { + base: ComponentBase::new(id, ComponentValue::Number(0.0)), + config, + steps: Vec::new(), + cursor: 0, + sounding: None, + is_playing: false, + } + } + + #[must_use] + pub fn is_out(&self) -> bool { + self.config.direction == MidiDirection::Out + } + + /// Does `status`'s channel nibble pass this node's channel filter (0 = omni)? + fn channel_matches(&self, status: u8) -> bool { + self.config.channel == 0 || self.config.channel - 1 == (status & 0x0F) + } + + /// The status byte for `nibble` on this node's send channel (1-16 → 0-15). + fn status(&self, nibble: u8) -> u8 { + nibble | (self.config.channel.clamp(1, 16) - 1) + } + + /// The 3-byte message one `send` value produces. CC mode maps the number + /// (clamped 0-127) onto the configured control; note mode maps truthy → + /// note-on at the configured velocity, falsy → note-off. + fn encode(&self, args: &ComponentValue) -> Vec { + match self.config.mode { + MidiMode::Cc => { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let n = args.as_number().unwrap_or(0.0).round().clamp(0.0, 127.0) as u8; + vec![self.status(CONTROL_CHANGE), self.config.control, n] + } + MidiMode::Note if args.is_truthy() => { + vec![self.status(NOTE_ON), self.config.note, self.config.velocity] + } + MidiMode::Note => vec![self.status(NOTE_OFF), self.config.note, 0], + // Song playback issues its own messages via the wakeup chain, never + // through `encode` (which is per-sample). Nothing to send here. + MidiMode::Song => Vec::new(), + } + } + + fn emit_number(&mut self, handle: &str, n: u8) { + self.base + .emit_with_value(handle, Cow::Owned(ComponentValue::Number(f64::from(n)))); + } + + /// Record one raw MIDI message for the host to write (ADR-0009). + fn send_bytes(&self, ctx: &mut RuntimeContext, bytes: Vec) { + ctx.request_cloud(CloudRequestKind::MidiSend { + device_name: self.config.device_name.clone(), + bytes, + }); + } + + /// Flatten the configured song into note/rest steps at the current tempo. + fn build_steps(&self) -> Vec { + let beat_ms = 60_000.0 / f64::from(self.config.tempo.max(1)); + self.config + .song + .iter() + .map(|(name, beats, velocity)| { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let duration_ms = (beat_ms * beats).max(0.0) as u64; + let note = name.as_deref().and_then(note_name_to_midi); + SongStep { note, duration_ms, velocity: (*velocity).min(127) } + }) + .collect() + } + + /// (Re)start song playback: silence anything sounding, load the queue, and + /// arm the first `_note` wakeup. The runtime delivers it as + /// `dispatch_internal("note")`, which plays each step and chains the next. + fn start_song(&mut self, ctx: &mut RuntimeContext) -> Result<(), RuntimeError> { + self.stop_song(ctx); + self.steps = self.build_steps(); + if self.steps.is_empty() { + return Ok(()); + } + self.is_playing = true; + self.base.set_value(ComponentValue::Number(1.0)); + ctx.schedule_wakeup("_note", 0); + Ok(()) + } + + /// Note-off whatever is sounding, cancel the outstanding step wakeup, and + /// reset to idle. Safe to call when already stopped. + fn stop_song(&mut self, ctx: &mut RuntimeContext) { + ctx.cancel_wakeup("_note"); + if let Some(note) = self.sounding.take() { + self.send_bytes(ctx, vec![self.status(NOTE_OFF), note, 0]); + } + self.is_playing = false; + self.steps.clear(); + self.cursor = 0; + self.base.set_value(ComponentValue::Number(0.0)); + } + + /// Play the step at `cursor`: note-off the previous note, note-on the current + /// one, and arm the next `_note` wakeup for its duration. Finalize when the + /// queue is exhausted. + fn advance(&mut self, ctx: &mut RuntimeContext) -> Result<(), RuntimeError> { + if !self.is_playing { + return Ok(()); + } + if let Some(note) = self.sounding.take() { + self.send_bytes(ctx, vec![self.status(NOTE_OFF), note, 0]); + } + match self.steps.get(self.cursor).copied() { + Some(step) => { + self.cursor += 1; + if let Some(note) = step.note { + self.send_bytes(ctx, vec![self.status(NOTE_ON), note, step.velocity]); + self.sounding = Some(note); + } + ctx.schedule_wakeup("_note", step.duration_ms); + } + None => { + self.is_playing = false; + self.base.set_value(ComponentValue::Number(0.0)); + } + } + Ok(()) + } +} + +impl ComponentBuilder for Midi { + type Config = MidiConfig; + fn build(id: String, config: MidiConfig) -> Result { + Ok(Self::new(id, config)) + } +} + +impl Component for Midi { + fn ports() -> &'static [&'static str] { + &["send"] + } + + fn emits() -> &'static [&'static str] { + &[ + ComponentBase::VALUE_HANDLE, + Self::E_NOTE, + Self::E_VELOCITY, + Self::E_ON, + Self::E_OFF, + ] + } + + fn base(&self) -> &ComponentBase { + &self.base + } + fn base_mut(&mut self) -> &mut ComponentBase { + &mut self.base + } + fn component_type(&self) -> &'static str { + "Midi" + } + + fn midi_wiring(&self) -> Option { + if self.is_out() { + return None; + } + Some(self.config.device_name.clone()) + } + + fn dispatch( + &mut self, + method: &str, + args: ComponentValue, + ctx: &mut RuntimeContext, + ) -> Result<(), RuntimeError> { + match method { + "send" => { + if !self.is_out() { + return Err(RuntimeError::ComponentError( + "This MIDI node is configured for in, not out".to_string(), + )); + } + // Song mode: a truthy sample (re)starts the sequence, a falsy one + // stops it. Note/CC mode: one sample == one message. + if self.config.mode == MidiMode::Song { + if args.is_truthy() { + return self.start_song(ctx); + } + self.stop_song(ctx); + return Ok(()); + } + self.base.value = args.clone(); + // Sans-IO: record the message for the host to write (ADR-0009). + ctx.request_cloud(CloudRequestKind::MidiSend { + device_name: self.config.device_name.clone(), + bytes: self.encode(&args), + }); + Ok(()) + } + _ => Err(RuntimeError::ComponentError(format!("Unknown method: {method}"))), + } + } + + /// The self-scheduled `_note` wakeup that walks the song queue. + fn dispatch_internal( + &mut self, + method: &str, + _value: ComponentValue, + ctx: &mut RuntimeContext, + ) -> Result<(), RuntimeError> { + match method { + "note" => self.advance(ctx), + _ => Err(RuntimeError::ComponentError(format!( + "Unknown internal method: {method}" + ))), + } + } + + fn destroy(&mut self) { + // No ctx here, so we cannot note-off; just drop playback state. The + // host tears down its MIDI ports on flow teardown. + self.is_playing = false; + self.steps.clear(); + self.cursor = 0; + self.sounding = None; + } + + /// One raw MIDI message from the host (`payload` = `[status, data1, data2]`). + /// Note mode: note-on emits `note` + `velocity` + `on`; note-off (incl. the + /// running-status convention note-on @ velocity 0) emits `note` + `velocity 0` + /// + `off`. CC mode: a matching control emits its value on `value`. + fn receive_raw_message(&mut self, _topic: &str, payload: &[u8]) { + let [status, data1, data2] = *payload else { return }; + if self.is_out() || !self.channel_matches(status) { + return; + } + match (status & 0xF0, self.config.mode) { + (NOTE_ON, MidiMode::Note) if data2 > 0 => { + self.base.value = ComponentValue::Number(f64::from(data2)); + self.emit_number(Self::E_NOTE, data1); + self.emit_number(Self::E_VELOCITY, data2); + self.base + .emit_with_value(Self::E_ON, Cow::Owned(ComponentValue::Bool(true))); + } + (NOTE_ON | NOTE_OFF, MidiMode::Note) => { + self.base.value = ComponentValue::Number(0.0); + self.emit_number(Self::E_NOTE, data1); + self.emit_number(Self::E_VELOCITY, 0); + self.base + .emit_with_value(Self::E_OFF, Cow::Owned(ComponentValue::Bool(true))); + } + (CONTROL_CHANGE, MidiMode::Cc) if data1 == self.config.control => { + self.base.set_value(ComponentValue::Number(f64::from(data2))); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::cloud::test_support::{recorded_cloud_requests, with_test_ctx}; + use crate::runtime::{ComponentEvent, EventSink}; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + fn sink() -> EventSink { + Rc::new(RefCell::new(VecDeque::new())) + } + + fn drain(sink: &EventSink) -> Vec { + sink.borrow_mut().drain(..).collect() + } + + fn note_in(channel: u8) -> Midi { + let mut node = Midi::new( + "in-1".into(), + MidiConfig { channel, mode: MidiMode::Note, ..MidiConfig::default() }, + ); + node.set_sink(sink()); + node + } + + fn handles(events: &[ComponentEvent]) -> Vec<&str> { + events.iter().map(|e| e.source_handle.as_ref()).collect() + } + + #[test] + fn note_on_emits_note_velocity_and_on() { + let mut node = note_in(0); + let s = node.base.sink.clone().expect("sink"); + node.receive_raw_message("dev", &[0x90, 60, 100]); + let events = drain(&s); + assert_eq!(handles(&events), vec!["note", "velocity", "on"]); + assert_eq!(events[0].value, ComponentValue::Number(60.0)); + assert_eq!(events[1].value, ComponentValue::Number(100.0)); + assert_eq!(events[2].value, ComponentValue::Bool(true)); + } + + #[test] + fn note_off_and_zero_velocity_note_on_both_emit_off() { + for msg in [[0x80u8, 60, 64], [0x90, 60, 0]] { + let mut node = note_in(0); + let s = node.base.sink.clone().expect("sink"); + node.receive_raw_message("dev", &msg); + let events = drain(&s); + assert_eq!(handles(&events), vec!["note", "velocity", "off"], "msg {msg:?}"); + assert_eq!(events[1].value, ComponentValue::Number(0.0)); + } + } + + #[test] + fn channel_filter_drops_other_channels_and_omni_accepts_all() { + // Channel 2 filter: status nibble 0x91 = channel 2 passes, 0x90 = channel 1 dropped. + let mut node = note_in(2); + let s = node.base.sink.clone().expect("sink"); + node.receive_raw_message("dev", &[0x90, 60, 100]); + assert!(drain(&s).is_empty(), "channel 1 must not pass a channel-2 filter"); + node.receive_raw_message("dev", &[0x91, 60, 100]); + assert_eq!(drain(&s).len(), 3); + + let mut omni = note_in(0); + let s = omni.base.sink.clone().expect("sink"); + omni.receive_raw_message("dev", &[0x9F, 60, 100]); + assert_eq!(drain(&s).len(), 3, "omni accepts channel 16"); + } + + #[test] + fn cc_mode_emits_matching_control_value_only() { + let mut node = Midi::new( + "in-1".into(), + MidiConfig { mode: MidiMode::Cc, control: 7, ..MidiConfig::default() }, + ); + node.set_sink(sink()); + let s = node.base.sink.clone().expect("sink"); + node.receive_raw_message("dev", &[0xB0, 1, 99]); + assert!(drain(&s).is_empty(), "non-matching control is ignored"); + node.receive_raw_message("dev", &[0xB0, 7, 99]); + let events = drain(&s); + assert_eq!(handles(&events), vec!["value"]); + assert_eq!(events[0].value, ComponentValue::Number(99.0)); + } + + #[test] + fn in_node_reports_its_device_filter_and_out_node_reports_none() { + let in_node = Midi::new( + "in-1".into(), + MidiConfig { device_name: "Launchpad".into(), ..MidiConfig::default() }, + ); + assert_eq!(in_node.midi_wiring(), Some("Launchpad".to_string())); + + let out_node = Midi::new( + "out-1".into(), + MidiConfig { direction: MidiDirection::Out, ..MidiConfig::default() }, + ); + assert_eq!(out_node.midi_wiring(), None); + } + + #[test] + fn in_node_rejects_send() { + let mut node = note_in(0); + let err = with_test_ctx("in-1", |ctx| { + node.dispatch("send", ComponentValue::Bool(true), ctx) + .expect_err("in-direction should refuse send") + }); + assert!(err.to_string().contains("in, not out")); + } + + #[test] + fn out_node_ignores_inbound_messages() { + let mut node = Midi::new( + "out-1".into(), + MidiConfig { direction: MidiDirection::Out, mode: MidiMode::Cc, ..MidiConfig::default() }, + ); + node.set_sink(sink()); + let s = node.base.sink.clone().expect("sink"); + node.receive_raw_message("dev", &[0xB0, 1, 99]); + assert!(drain(&s).is_empty(), "an out node must not re-emit inbound messages"); + } + + #[test] + fn cc_send_records_clamped_control_change() { + let mut node = Midi::new( + "out-1".into(), + MidiConfig { + direction: MidiDirection::Out, + device_name: "Synth".into(), + channel: 2, + mode: MidiMode::Cc, + control: 7, + ..MidiConfig::default() + }, + ); + let mut reqs = recorded_cloud_requests("out-1", |ctx| { + node.dispatch("send", ComponentValue::Number(300.0), ctx).expect("dispatch ok"); + }); + assert_eq!(reqs.len(), 1); + match reqs.remove(0) { + CloudRequestKind::MidiSend { device_name, bytes } => { + assert_eq!(device_name, "Synth"); + assert_eq!(bytes, vec![0xB1, 7, 127], "channel 2 status, clamped value"); + } + other => panic!("expected MidiSend, got {other:?}"), + } + } + + #[test] + fn note_send_maps_truthy_to_on_and_falsy_to_off() { + let mut node = Midi::new( + "out-1".into(), + MidiConfig { + direction: MidiDirection::Out, + mode: MidiMode::Note, + note: 64, + velocity: 90, + ..MidiConfig::default() + }, + ); + let reqs = recorded_cloud_requests("out-1", |ctx| { + node.dispatch("send", ComponentValue::Bool(true), ctx).expect("on ok"); + node.dispatch("send", ComponentValue::Number(0.0), ctx).expect("off ok"); + }); + let bytes: Vec> = reqs + .into_iter() + .map(|kind| match kind { + CloudRequestKind::MidiSend { bytes, .. } => bytes, + other => panic!("expected MidiSend, got {other:?}"), + }) + .collect(); + assert_eq!(bytes, vec![vec![0x90, 64, 90], vec![0x80, 64, 0]]); + } + + #[test] + fn note_name_to_midi_maps_scientific_pitch() { + assert_eq!(note_name_to_midi("C4"), Some(60), "middle C"); + assert_eq!(note_name_to_midi("A4"), Some(69), "A440"); + assert_eq!(note_name_to_midi("C#5"), Some(73)); + assert_eq!(note_name_to_midi("c4"), Some(60), "case-insensitive letter"); + assert_eq!(note_name_to_midi("H4"), None, "unknown letter"); + assert_eq!(note_name_to_midi(""), None); + } + + #[test] + fn song_mode_plays_note_on_then_off_through_the_wakeup_chain() { + let mut node = Midi::new( + "out-1".into(), + MidiConfig { + direction: MidiDirection::Out, + mode: MidiMode::Song, + tempo: 120, + song: vec![(Some("C4".into()), 1.0, 90)], + ..MidiConfig::default() + }, + ); + // start (dispatch send=truthy) then walk the queue via two `_note` wakeups: + // advance #1 sounds C4, advance #2 releases it and finds the queue empty. + let reqs = recorded_cloud_requests("out-1", |ctx| { + node.dispatch("send", ComponentValue::Bool(true), ctx).expect("start"); + node.dispatch_internal("note", ComponentValue::default(), ctx).expect("advance 1"); + node.dispatch_internal("note", ComponentValue::default(), ctx).expect("advance 2"); + }); + let bytes: Vec> = reqs + .into_iter() + .map(|kind| match kind { + CloudRequestKind::MidiSend { bytes, .. } => bytes, + other => panic!("expected MidiSend, got {other:?}"), + }) + .collect(); + assert_eq!(bytes, vec![vec![0x90, 60, 90], vec![0x80, 60, 0]]); + } + + #[test] + fn falsy_send_stops_a_playing_song_with_a_note_off() { + let mut node = Midi::new( + "out-1".into(), + MidiConfig { + direction: MidiDirection::Out, + mode: MidiMode::Song, + song: vec![(Some("C4".into()), 4.0, 100)], + ..MidiConfig::default() + }, + ); + let reqs = recorded_cloud_requests("out-1", |ctx| { + node.dispatch("send", ComponentValue::Bool(true), ctx).expect("start"); + node.dispatch_internal("note", ComponentValue::default(), ctx).expect("sound C4"); + node.dispatch("send", ComponentValue::Bool(false), ctx).expect("stop"); + }); + let bytes: Vec> = reqs + .into_iter() + .map(|kind| match kind { + CloudRequestKind::MidiSend { bytes, .. } => bytes, + other => panic!("expected MidiSend, got {other:?}"), + }) + .collect(); + assert_eq!(bytes, vec![vec![0x90, 60, 100], vec![0x80, 60, 0]], "on then off on stop"); + } + + #[test] + fn each_song_note_sounds_at_its_own_velocity() { + let mut node = Midi::new( + "out-1".into(), + MidiConfig { + direction: MidiDirection::Out, + mode: MidiMode::Song, + tempo: 120, + song: vec![(Some("C4".into()), 1.0, 40), (Some("E4".into()), 1.0, 120)], + ..MidiConfig::default() + }, + ); + let reqs = recorded_cloud_requests("out-1", |ctx| { + node.dispatch("send", ComponentValue::Bool(true), ctx).expect("start"); + for _ in 0..3 { + node.dispatch_internal("note", ComponentValue::default(), ctx).expect("advance"); + } + }); + let bytes: Vec> = reqs + .into_iter() + .map(|kind| match kind { + CloudRequestKind::MidiSend { bytes, .. } => bytes, + other => panic!("expected MidiSend, got {other:?}"), + }) + .collect(); + // C4@40 on, C4 off + E4@120 on, E4 off. + assert_eq!( + bytes, + vec![ + vec![0x90, 60, 40], + vec![0x80, 60, 0], + vec![0x90, 64, 120], + vec![0x80, 64, 0], + ] + ); + } +} diff --git a/crates/microflow-core/src/runtime/cloud/mod.rs b/crates/microflow-core/src/runtime/cloud/mod.rs index ff4fe176..2ff92445 100644 --- a/crates/microflow-core/src/runtime/cloud/mod.rs +++ b/crates/microflow-core/src/runtime/cloud/mod.rs @@ -15,6 +15,7 @@ pub mod figma; pub mod llm; +pub mod midi; pub mod mqtt; #[cfg(test)] diff --git a/crates/microflow-core/src/runtime/cloud/mqtt.rs b/crates/microflow-core/src/runtime/cloud/mqtt.rs index b911c900..4482be1a 100644 --- a/crates/microflow-core/src/runtime/cloud/mqtt.rs +++ b/crates/microflow-core/src/runtime/cloud/mqtt.rs @@ -190,7 +190,9 @@ mod tests { assert_eq!(payload, b"42"); assert!(retain); } - other @ CloudRequestKind::LlmGenerate { .. } => panic!("expected MqttPublish, got {other:?}"), + other @ (CloudRequestKind::LlmGenerate { .. } | CloudRequestKind::MidiSend { .. }) => { + panic!("expected MqttPublish, got {other:?}") + } } } diff --git a/crates/microflow-core/src/runtime/component.rs b/crates/microflow-core/src/runtime/component.rs index f332f87d..ef227b2c 100644 --- a/crates/microflow-core/src/runtime/component.rs +++ b/crates/microflow-core/src/runtime/component.rs @@ -144,6 +144,17 @@ pub trait Component { Vec::new() } + /// The MIDI-input interest this component declares: `Some(device filter)` + /// (a case-insensitive substring of the host port name, "" = every device) + /// for a MIDI listener, `None` for everything else. Unlike + /// [`subscriber_wiring`](Component::subscriber_wiring) there is no + /// one-owner-per-topic reconcile — every listener whose filter matches a + /// device receives every message from it (via + /// [`receive_raw_message`](Component::receive_raw_message)). + fn midi_wiring(&self) -> Option { + None + } + /// Board-wide reconcile votes this component contributes — the desired /// sampling interval, I2C read-delay, and continuous read, each targeting a /// single global Firmata setting the runtime reconciles across all components diff --git a/crates/microflow-core/src/runtime/context.rs b/crates/microflow-core/src/runtime/context.rs index 729112cc..73453d08 100644 --- a/crates/microflow-core/src/runtime/context.rs +++ b/crates/microflow-core/src/runtime/context.rs @@ -66,6 +66,10 @@ pub enum CloudRequestKind { system: Option, prompt: String, }, + /// Fire-and-forget raw MIDI message (the `MidiOut` node) to every host MIDI + /// output whose port name contains `device_name` ("" = all). Nothing + /// re-enters the runtime. + MidiSend { device_name: String, bytes: Vec }, } /// Everything the host must do after one runtime turn. Bytes go to the serial diff --git a/crates/microflow-core/src/runtime/mod.rs b/crates/microflow-core/src/runtime/mod.rs index b42ea4d1..65d102a3 100644 --- a/crates/microflow-core/src/runtime/mod.rs +++ b/crates/microflow-core/src/runtime/mod.rs @@ -582,6 +582,30 @@ impl FlowRuntime { out } + /// Every active MIDI listener: `(node id, device-name filter)`. Unlike MQTT + /// there is no one-owner-per-topic reconcile — the host opens the union of + /// matching devices and routes every message to every matching listener via + /// [`deliver_message`](Self::deliver_message) (`topic` = the host port name, + /// `payload` = the raw `[status, data1, data2]`). Sorted by node id so an + /// unchanged flow yields an identical list (zero host churn). + #[must_use] + pub fn collect_midi_listeners(&self) -> Vec { + let mut out: Vec<_> = self + .components + .iter() + .filter_map(|(id, component)| { + component.midi_wiring().map(|device_name| { + crate::runtime::subscriptions::MidiListener { + node_id: id.clone(), + device_name, + } + }) + }) + .collect(); + out.sort_by(|a, b| a.node_id.cmp(&b.node_id)); + out + } + // --- Inbound decode ------------------------------------------------------ /// Diff the codec's pin table against the last-seen values and push a diff --git a/crates/microflow-core/src/runtime/registry.rs b/crates/microflow-core/src/runtime/registry.rs index c2208244..f9307087 100644 --- a/crates/microflow-core/src/runtime/registry.rs +++ b/crates/microflow-core/src/runtime/registry.rs @@ -136,6 +136,7 @@ impl ComponentRegistry { self.register::("Mqtt"); self.register::("Llm"); self.register::("Figma"); + self.register::("Midi"); } } } diff --git a/crates/microflow-core/src/runtime/subscriptions.rs b/crates/microflow-core/src/runtime/subscriptions.rs index ef3e4687..7d07a32d 100644 --- a/crates/microflow-core/src/runtime/subscriptions.rs +++ b/crates/microflow-core/src/runtime/subscriptions.rs @@ -109,6 +109,20 @@ pub fn reconcile_desired(wirings: &[(String, SubscriberWiring)]) -> Vec Result { + serde_json::to_string(&self.inner.collect_midi_listeners()) + .map_err(|e| JsError::new(&format!("failed to serialize midi listeners: {e}"))) + } + /// Deliver an inbound broker payload (MQTT / Figma) to subscribe component /// `id`, then return the cascade `Effects`. Mirrors the desktop /// `ActorMsg::Deliver` path; the browser host calls this from its WSS message From 6841077e247a4f9b913af8a35cf1e7ad874c3c78 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:22 +0200 Subject: [PATCH 02/12] feat(bringup): shared sans-IO board bring-up state machine Lift the bring-up POLICY (probe -> flash-if-missing -> connect -> reconnect, and the disconnected/connecting/flashing/connected/error transitions) into microflow_core::bringup, shared by both hosts. Desktop (hardware/mod.rs) and browser (board-controller.ts) become thin adapters that feed the machine Web-Serial/serial events and perform the actions it returns. web-serial.ts is reduced to transport primitives (probeFirmata / probeAfterFlash); the wasm BringUpMachine binding exposes the machine to the browser. Co-Authored-By: Claude Opus 4.8 --- apps/web/src-tauri/src/hardware/events.rs | 5 - apps/web/src-tauri/src/hardware/mod.rs | 195 ++++---- apps/web/src/lib/firmata/board-controller.ts | 338 +++++++++----- apps/web/src/lib/firmata/wasm.ts | 40 +- apps/web/src/lib/firmata/web-serial.ts | 105 ++--- crates/microflow-core/src/bringup.rs | 455 +++++++++++++++++++ crates/microflow-core/src/lib.rs | 1 + crates/microflow-firmata-wasm/src/lib.rs | 61 +++ 8 files changed, 913 insertions(+), 287 deletions(-) create mode 100644 crates/microflow-core/src/bringup.rs diff --git a/apps/web/src-tauri/src/hardware/events.rs b/apps/web/src-tauri/src/hardware/events.rs index b09ba5c1..6738a335 100644 --- a/apps/web/src-tauri/src/hardware/events.rs +++ b/apps/web/src-tauri/src/hardware/events.rs @@ -85,11 +85,6 @@ impl EventEmitter { }); } - /// Emit error for port without Firmata - pub fn no_firmata_error(&self, port_name: &str) { - self.board_error(&format!("No Firmata detected on {port_name}")); - } - // ======================================================================== // Port Events // ======================================================================== diff --git a/apps/web/src-tauri/src/hardware/mod.rs b/apps/web/src-tauri/src/hardware/mod.rs index 94bcd84b..5720c2dc 100644 --- a/apps/web/src-tauri/src/hardware/mod.rs +++ b/apps/web/src-tauri/src/hardware/mod.rs @@ -30,8 +30,9 @@ pub use events::{BoardStateObserver, EventEmitter}; pub use port_monitor::{PortMonitor, SerialPortInfo}; pub use types::BoardState; -use crate::flasher::{BoardConfig, Flasher}; +use crate::flasher::{BoardConfig, BoardType, Flasher}; use crate::runtime::host::BoardLink; +use microflow_core::bringup::{Action as BringUpAction, BringUp, Event as BringUpEvent, Phase}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -125,12 +126,25 @@ impl Drop for HardwareService { // Monitor Loop - Background orchestration // ============================================================================ -/// Encapsulates the monitoring loop and board lifecycle management +/// Encapsulates the monitoring loop and board lifecycle management. +/// +/// The bring-up *policy* (probe → flash-if-missing → connect → reconnect and +/// the `BoardState` transitions) lives in [`microflow_core::bringup`], shared +/// with the browser host; this loop is the desktop adapter that performs its +/// actions with real serial/flasher I/O. struct HardwareMonitorLoop { events: EventEmitter, monitoring: Arc, board: BoardLink, known_devices: HashMap, + bringup: BringUp, + /// Port name of the current bring-up attempt / live connection. + attempt_port: Option, + /// Recognised board type of the current attempt (flash target). + attempt_board: Option, + /// Full `Connected` payload from the latest successful probe, published + /// when the machine says `Phase::Connected`. + last_connected: Option, } impl HardwareMonitorLoop { @@ -145,6 +159,10 @@ impl HardwareMonitorLoop { monitoring, board, known_devices: HashMap::new(), + bringup: BringUp::new(), + attempt_port: None, + attempt_board: None, + last_connected: None, } } @@ -197,8 +215,9 @@ impl HardwareMonitorLoop { for id in stale { if let Some(port) = self.known_devices.remove(&id) { log::info!("Board on {} lost connection without USB disconnect, re-detecting", port.port_name); - self.board.disconnect(); - self.events.board_disconnected(); + // ScheduleRetry is a no-op here: this poll loop *is* the + // retry (the port re-enters `handle_port_connected` next tick). + self.drive(BringUpEvent::ConnectionLost); } } } @@ -245,114 +264,120 @@ impl HardwareMonitorLoop { return; } - // Check if this is a known board type before processing - let is_known_board = port + let board_type = port .usb_ids() - .and_then(|(vid, pid)| BoardConfig::detect_from_usb(vid, pid)) - .is_some(); + .and_then(|(vid, pid)| BoardConfig::detect_from_usb(vid, pid)); - // Only process USB ports for board detection - let board_state = if port.is_usb() { - self.process_usb_port(&port) - } else { + // Only USB ports are bring-up candidates; and while a board is live the + // machine ignores new ports (it must not steal the connection). + if port.is_usb() && !self.bringup.is_connected() { + log::info!("Bring-up candidate on {} (board: {board_type:?})", port.port_name); + self.attempt_port = Some(port.port_name.clone()); + self.attempt_board = board_type; + self.drive(BringUpEvent::PortReady { + board: board_type.map(|b| b.as_str().to_string()), + // The desktop always flashes recognised boards with no Firmata, + // and surfaces their failures (a recognised board that won't + // connect is worth an error; a random serial device is not). + auto_flash: board_type.is_some(), + explicit: board_type.is_some(), + }); + } else if !port.is_usb() { log::debug!("Skipping non-USB port: {}", port.port_name); - None - }; + } - port.has_firmata = Some(board_state.is_some()); - self.known_devices.insert(device_id.clone(), port.clone()); + port.has_firmata = Some(self.bringup.is_connected()); + self.known_devices.insert(device_id, port.clone()); - // Emit events log::info!( "Port connected: {} (Firmata: {})", port.port_name, port.has_firmata.unwrap_or(false) ); self.events.port_connected(&port); - - match board_state { - Some(state) => self.events.board_state(&state), - // Only emit error for known boards that failed - unknown USB devices - // without Firmata are just ignored (no board_disconnected spam) - None if is_known_board => self.events.no_firmata_error(&port.port_name), - None => {} // Don't emit board_disconnected for unknown devices - } } - fn handle_port_disconnected(&self, port: &SerialPortInfo) { + fn handle_port_disconnected(&mut self, port: &SerialPortInfo) { log::info!("Port disconnected: {}", port.port_name); self.events.port_disconnected(port); if port.has_firmata == Some(true) { - // Disconnect the shared board handle - self.board.disconnect(); - self.events.board_disconnected(); + self.drive(BringUpEvent::PortGone); } } - /// Process a USB port: detect board, flash if needed, detect Firmata - fn process_usb_port(&self, port: &SerialPortInfo) -> Option { - // Try to identify board by USB IDs - let board_type = port - .usb_ids() - .and_then(|(vid, pid)| BoardConfig::detect_from_usb(vid, pid)); - - if let Some(bt) = board_type { - log::info!("Detected {:?} on {} by USB IDs", bt, port.port_name); - return self.handle_known_board(port, bt); + /// Feed one event into the shared bring-up machine and perform the actions + /// it returns. Actions that resolve synchronously (probe, flash) feed their + /// result straight back in, so one `PortReady` drives the whole bring-up. + fn drive(&mut self, event: BringUpEvent) { + for action in self.bringup.handle(event) { + self.perform(action); } - - // Unknown USB device - just try Firmata detection - log::info!("Unknown USB device on {}, trying Firmata", port.port_name); - self.events.board_connecting(); - firmata::detect_and_connect(&port.port_name, &self.board) } - /// Handle a known Arduino board type - fn handle_known_board( - &self, - port: &SerialPortInfo, - board_type: crate::flasher::BoardType, - ) -> Option { - // First check if Firmata is already running - self.events.board_connecting(); - if let Some(state) = firmata::detect_and_connect(&port.port_name, &self.board) { - log::info!("Firmata already running on {board_type:?}"); - return Some(state); + fn perform(&mut self, action: BringUpAction) { + let port_name = self.attempt_port.clone().unwrap_or_default(); + match action { + BringUpAction::Probe { after_flash } => { + if after_flash { + // Wait for the board to reboot into the fresh sketch. + thread::sleep(Duration::from_millis(2500)); + } + match firmata::detect_and_connect(&port_name, &self.board) { + Some(state) => { + self.last_connected = Some(state); + self.drive(BringUpEvent::ProbeOk); + } + None => self.drive(BringUpEvent::ProbeFailed), + } + } + BringUpAction::Flash { board } => { + // Small delay for port readiness after the failed probe. + thread::sleep(Duration::from_millis(500)); + let board_type = self + .attempt_board + .or_else(|| BoardType::from_id(&board)); + let result = match board_type { + Some(bt) => Flasher::flash_standard_firmata(&port_name, bt) + .map_err(|e| e.to_string()), + None => Err(format!("unknown board type '{board}'")), + }; + match result { + Ok(result) => { + log::info!("Flash successful: {}", result.message); + self.drive(BringUpEvent::FlashOk); + } + Err(detail) => { + log::error!("Flash failed: {detail}"); + self.drive(BringUpEvent::FlashFailed { detail }); + } + } + } + BringUpAction::ClosePort => self.board.disconnect(), + // The 250ms poll loop is the desktop's retry mechanism already. + BringUpAction::ScheduleRetry => {} + // The desktop flasher reports no incremental progress today. + BringUpAction::NotifyFlashProgress { .. } => {} + BringUpAction::Notify { phase } => self.publish(&phase), } - - // No Firmata - flash StandardFirmata - log::info!("No Firmata on {board_type:?}, flashing..."); - self.flash_and_detect(port, board_type) } - /// Flash `StandardFirmata` and detect - fn flash_and_detect( - &self, - port: &SerialPortInfo, - board_type: crate::flasher::BoardType, - ) -> Option { - self.events.board_flashing(&port.port_name, board_type.as_str()); - - // Small delay for port readiness - thread::sleep(Duration::from_millis(500)); - - match Flasher::flash_standard_firmata(&port.port_name, board_type) { - Ok(result) => { - log::info!("Flash successful: {}", result.message); - - // Wait for board reset - thread::sleep(Duration::from_millis(2500)); - - // Detect Firmata and connect - self.events.board_connecting(); - firmata::detect_and_connect(&port.port_name, &self.board) - } - Err(e) => { - log::error!("Flash failed: {e}"); - self.events.board_error(&format!("Flash failed: {e}")); - None + /// Map a machine [`Phase`] onto the `board-state` event, filling host-side + /// payloads (probe result, port name). `Error.detail` passes through + /// verbatim — the UI shows it in full (commit `7c8f7e2`). + fn publish(&self, phase: &Phase) { + match phase { + Phase::Connecting => self.events.board_connecting(), + Phase::Flashing { board } => { + let port = self.attempt_port.as_deref().unwrap_or_default(); + self.events.board_flashing(port, board); } + Phase::Connected => match &self.last_connected { + Some(state) => self.events.board_state(state), + None => self.events.board_error("probe succeeded but produced no board state"), + }, + Phase::Disconnected => self.events.board_disconnected(), + Phase::Error { detail } => self.events.board_error(detail), } } } diff --git a/apps/web/src/lib/firmata/board-controller.ts b/apps/web/src/lib/firmata/board-controller.ts index 631ae130..e92a9cdc 100644 --- a/apps/web/src/lib/firmata/board-controller.ts +++ b/apps/web/src/lib/firmata/board-controller.ts @@ -1,18 +1,18 @@ -// Browser board orchestration — the web counterpart to the desktop hardware -// monitor (`apps/web/src-tauri/src/hardware/mod.rs`). The desktop polls serial -// ports in a background thread and, for any recognised board, probes Firmata → -// flashes StandardFirmata if missing → connects, all with zero clicks. The -// browser cannot poll arbitrary ports (Web Serial requires a user gesture + -// picker to *authorise* a device), but once a device is granted it behaves much -// like the desktop: +// Browser board bring-up adapter — the web counterpart to the desktop hardware +// monitor (`apps/web/src-tauri/src/hardware/mod.rs`). The bring-up POLICY — +// probe → flash StandardFirmata if missing → connect → auto-reconnect, plus the +// disconnected→connecting→flashing→connected→error transitions — lives once in +// the shared sans-IO `microflow_core::bringup` state machine (via the firmata +// wasm crate); both hosts drive the same machine. This module only: // -// • on load → `getPorts()` reconnects a granted board (no picker) -// • on plug-in → the `connect` event reconnects it -// • on unplug / reset → tear down and go disconnected -// • connect (gesture) → probe → flash-if-missing → connect, one action +// • feeds Web Serial happenings in as machine events (plug/unplug, gesture, +// probe/flash results), and +// • performs the actions the machine returns (serial probe, flash via the +// shared codec, Zustand store updates, toasts). // -// So the only irreducible manual step is the first-time authorise per device. -// Everything here drives the shared wasm codec/flasher via ./web-serial. +// Web Serial cannot poll arbitrary ports (a user gesture + picker authorises a +// device), so the only irreducible manual step is the first-time authorise per +// device; granted boards auto-reconnect on load / plug-in / reset like desktop. import { toast } from "sonner"; import { track } from "@/lib/analytics"; @@ -23,15 +23,27 @@ import { useFigmaStore } from "@/stores/figma"; import { useLlmProviderStore } from "@/stores/llm-provider"; import { useMqttBrokerStore } from "@/stores/mqtt-broker"; import { - bringUpBoard, + connectedState, detectBoard, + flashPort, isWebSerialSupported, listGrantedPorts, onSerialConnectivity, + portLabel, + probeAfterFlash, + probeFirmata, requestBoardPort, type BoardConnection, + type ProbeHooks, type WebSerialPort, } from "./web-serial"; +import { + createBringUp, + type BringUpAction, + type BringUpEvent, + type BringUpMachine, + type BringUpPhase, +} from "./wasm"; import { FlowReactor, type CloudDeps } from "./flow-reactor"; /** Cloud lookups the reactor needs to perform cloud requests (ADR-0009). Read @@ -56,12 +68,15 @@ const cloudDeps: CloudDeps = { }, }; -/** The single active browser board connection (the desktop owns its own). */ +/** The single active browser board connection — the adapter's I/O handle; every + * DECISION about it comes from the shared bring-up machine. */ let active: BoardConnection | null = null; /** The wasm flow-runtime host for the active connection. */ let reactor: FlowReactor | null = null; /** Latest core `FlowUpdate`, applied when a board attaches. */ let latestFlow: CoreFlowUpdate | null = null; +/** The shared bring-up policy machine (lazy: wasm loads on first use). */ +let machinePromise: Promise | null = null; let started = false; // Serialise every port operation so connect / auto-reconnect / plug events never // race to open the same port. @@ -81,14 +96,6 @@ function setBoard(state: BoardState): void { useBoardStore.getState().setBoard(state); } -async function teardownActive(): Promise { - reactor?.dispose(); - reactor = null; - const connection = active; - active = null; - await connection?.disconnect(); -} - /** * Push the latest flow graph to the runtime. Called by `WasmFlowUpdateSender` * on every graph change; stored so a board connecting later starts on the @@ -99,98 +106,197 @@ export function pushFlowUpdate(flow: CoreFlowUpdate): void { reactor?.applyFlow(flow); } -/** Build the bring-up callbacks, wiring board state + a single flashing toast. */ -function makeBringUp() { - let flashToast: string | number | undefined; - let flashedBoard: string | undefined; - const options = { - onState: (state: BoardState) => { - setBoard(state); - if (state.state === "flashing") { - flashedBoard = state.board; - if (flashToast === undefined) flashToast = toast.loading("Flashing StandardFirmata…"); +// --- Machine adapter -------------------------------------------------------- + +/** Host-side bookkeeping for the bring-up attempt in flight (toast ids, + * analytics facts) — presentation only; the machine owns the decisions. */ +type Attempt = { + port: WebSerialPort; + explicit: boolean; + startedAt: number; + board: string; + flashed: boolean; + flashToast?: string | number; +}; +let attempt: Attempt | null = null; + +function trackData(a: Attempt) { + return { + via: a.explicit ? "gesture" : "auto", + board: a.board, + flashed: a.flashed, + seconds: Math.round((performance.now() - a.startedAt) / 1000), + }; +} + +/** Feed one event into the shared machine and perform the returned actions. */ +async function dispatch(event: BringUpEvent): Promise { + machinePromise ??= createBringUp(); + const machine = await machinePromise; + const actions = JSON.parse(machine.handle(JSON.stringify(event))) as BringUpAction[]; + for (const action of actions) { + await perform(action); + } +} + +/** The probe hooks: raw bytes feed the flow runtime; an unexpected read-loop + * end while connected re-enters the machine as `connectionLost`. */ +function probeHooks(): ProbeHooks { + return { + onBytes: (bytes) => reactor?.feedBytes(bytes), + onClosed: () => void run(() => dispatch({ type: "connectionLost" })), + }; +} + +async function perform(action: BringUpAction): Promise { + switch (action.type) { + case "probe": { + const a = attempt; + if (!a) return; + const probe = action.afterFlash ? probeAfterFlash : probeFirmata; + const connection = await probe(a.port, probeHooks()).catch(() => null); + if (connection) { + active = connection; + await dispatch({ type: "probeOk" }); + } else { + await dispatch({ type: "probeFailed" }); } - }, - onBytes: (bytes: Uint8Array) => { - // Raw inbound bytes drive the wasm flow runtime (it owns its own decode). - reactor?.feedBytes(bytes); - }, - onProgress: (done: number, total: number) => { - const pct = total > 0 ? Math.round((done / total) * 100) : 0; - flashToast = toast.loading(`Flashing StandardFirmata… ${pct}%`, { id: flashToast }); - }, - onClosed: () => { - // The board reset or was unplugged mid-session — drop to disconnected, - // then try to recover. A reset keeps the USB device present (no `connect` - // event fires), so rescanning granted ports re-detects it, mirroring the - // desktop poll's implicit-disconnect handling. + break; + } + case "flash": { + const a = attempt; + if (!a) return; + try { + await flashPort(a.port, { + onProgress: (done, total) => void dispatch({ type: "flashProgress", done, total }), + }); + await dispatch({ type: "flashOk" }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await dispatch({ type: "flashFailed", detail }); + } + break; + } + case "closePort": { + reactor?.dispose(); + reactor = null; + const connection = active; + active = null; + await connection?.disconnect(); + break; + } + case "scheduleRetry": + // A reset keeps the USB device present (no `connect` event fires), so + // rescanning granted ports re-detects it — the browser's retry primitive. + void run(reconnectGranted); + break; + case "notifyFlashProgress": { + if (attempt?.flashToast !== undefined) { + attempt.flashToast = toast.loading(`Flashing StandardFirmata… ${action.percent}%`, { + id: attempt.flashToast, + }); + } + break; + } + case "notify": + await applyPhase(action.phase); + break; + } +} + +/** Map a machine phase onto the board store + toasts (presentation only). */ +async function applyPhase(phase: BringUpPhase): Promise { + const a = attempt; + switch (phase.kind) { + case "connecting": + setBoard({ state: "connecting" }); + break; + case "flashing": { + if (a) { + a.flashed = true; + a.board = phase.board; + a.flashToast ??= toast.loading("Flashing StandardFirmata…"); + } + setBoard({ + state: "flashing", + port: a ? portLabel(a.port.getInfo()) : "Serial port", + board: phase.board, + }); + break; + } + case "connected": { + if (active) setBoard(connectedState(active.port, active.session)); + if (a?.flashToast !== undefined) { + toast.success(`Flashed StandardFirmata to ${a.board}.`, { id: a.flashToast }); + a.flashToast = undefined; + } + // Stand up the wasm flow runtime for this connection and apply the + // current flow. A reactor failure (e.g. wasm load) must not fail the + // connection — the board is still up; the flow just won't run. + reactor?.dispose(); + reactor = null; if (active) { - reactor?.dispose(); - reactor = null; - active = null; - setBoard({ state: "disconnected" }); - void run(reconnectGranted); + try { + reactor = await FlowReactor.attach(active, cloudDeps); + if (latestFlow) reactor.applyFlow(latestFlow); + } catch (reactorError) { + console.error("[board-controller] flow reactor attach failed:", reactorError); + reactor = null; + } } - }, - }; - const settle = (ok: boolean) => { - if (flashToast === undefined) return; - if (ok) toast.success(`Flashed StandardFirmata to ${flashedBoard ?? "board"}.`, { id: flashToast }); - else toast.dismiss(flashToast); - }; - return { options, settle }; + if (a) { + track("board_connected", trackData(a)); + attempt = null; + } + break; + } + case "disconnected": + if (a?.flashToast !== undefined) { + toast.dismiss(a.flashToast); + a.flashToast = undefined; + } + if (a) { + // A bring-up attempt ended quietly (background probe miss). + track("board_connect_failed", { ...trackData(a), error: "no firmata" }); + attempt = null; + } + setBoard({ state: "disconnected" }); + break; + case "error": + if (a?.flashToast !== undefined) { + toast.dismiss(a.flashToast); + a.flashToast = undefined; + } + if (a) { + track("board_connect_failed", { ...trackData(a), error: phase.detail.slice(0, 80) }); + attempt = null; + } + // Full detail reaches the store + toast (do not collapse it — 7c8f7e2). + setBoard({ state: "error", error: phase.detail }); + toast.error(phase.detail); + break; + } } +/** Start a bring-up attempt for `port`; the machine takes it from here. */ async function bringUp( port: WebSerialPort, flags: { autoFlash: boolean; explicit: boolean }, ): Promise { - setBoard({ state: "connecting" }); - const { options, settle } = makeBringUp(); - // Capture flash/board facts as they stream through onState so the analytics - // event can say *which* board connected and whether it needed a flash. - const startedAt = performance.now(); - const meta = { flashed: false, board: "unknown" }; - const onState = options.onState; - options.onState = (state: BoardState) => { - if (state.state === "flashing") meta.flashed = true; - if ("board" in state && typeof state.board === "string") meta.board = state.board; - onState(state); + const board = await detectBoard(port).catch(() => undefined); + attempt = { + port, + explicit: flags.explicit, + startedAt: performance.now(), + board: board ?? "unknown", + flashed: false, }; - const trackData = () => ({ - via: flags.explicit ? "gesture" : "auto", - board: meta.board, - flashed: meta.flashed, - seconds: Math.round((performance.now() - startedAt) / 1000), + await dispatch({ + type: "portReady", + board: board ?? null, + autoFlash: flags.autoFlash, + explicit: flags.explicit, }); - try { - active = await bringUpBoard(port, options, { autoFlash: flags.autoFlash }); - // Stand up the wasm flow runtime for this connection and apply the current - // flow. A reactor failure (e.g. wasm load) must not fail the connection — - // the board is still up; the flow just won't run. - reactor?.dispose(); - try { - reactor = await FlowReactor.attach(active, cloudDeps); - if (latestFlow) reactor.applyFlow(latestFlow); - } catch (reactorError) { - console.error("[board-controller] flow reactor attach failed:", reactorError); - reactor = null; - } - settle(true); - track("board_connected", trackData()); - } catch (error) { - settle(false); - active = null; - const message = error instanceof Error ? error.message : String(error); - track("board_connect_failed", { ...trackData(), error: message.slice(0, 80) }); - if (flags.explicit) { - setBoard({ state: "error", error: message }); - toast.error(message); - } else { - // Background path (auto-reconnect / plug-in): stay quietly disconnected. - setBoard({ state: "disconnected" }); - } - } } /** Cheap pre-check so auto paths don't handshake unrelated granted serial devices. */ @@ -203,9 +309,9 @@ async function looksLikeBoard(port: WebSerialPort): Promise { } /** - * Connect from a user gesture: pick a port, then probe → flash-if-missing → - * connect. `requestPort` must fire synchronously inside the gesture, so it runs - * *before* the serialised task — only the bring-up is queued. + * Connect from a user gesture: pick a port, then let the machine run probe → + * flash-if-missing → connect. `requestPort` must fire synchronously inside the + * gesture, so it runs *before* the serialised task — only the bring-up is queued. */ export function connect(): Promise { if (!isWebSerialSupported()) return Promise.resolve(); @@ -230,17 +336,9 @@ export function connect(): Promise { export function disconnect(): Promise { track("board_disconnected", { via: "gesture" }); - return run(async () => { - await teardownActive(); - setBoard({ state: "disconnected" }); - }); + return run(() => dispatch({ type: "disconnectRequested" })); } -/** - * Start the background orchestration once: reconnect any already-granted board - * on load, and watch for plug/unplug of granted devices. Idempotent; a no-op - * outside Chromium. - */ /** * Reconnect a granted board — recognised boards only, so we never hang * handshaking an unrelated serial device the user once authorised. Shared by the @@ -255,6 +353,11 @@ async function reconnectGranted(): Promise { } } +/** + * Start the background orchestration once: reconnect any already-granted board + * on load, and watch for plug/unplug of granted devices. Idempotent; a no-op + * outside Chromium. + */ export function start(): void { if (started || !isWebSerialSupported()) return; started = true; @@ -273,8 +376,7 @@ export function start(): void { onDisconnect: (port) => void run(async () => { if (active && active.port === port) { - await teardownActive(); - setBoard({ state: "disconnected" }); + await dispatch({ type: "portGone" }); } }), }); diff --git a/apps/web/src/lib/firmata/wasm.ts b/apps/web/src/lib/firmata/wasm.ts index f4005d5a..8ed4432c 100644 --- a/apps/web/src/lib/firmata/wasm.ts +++ b/apps/web/src/lib/firmata/wasm.ts @@ -9,6 +9,7 @@ // ./web-serial.ts, because WASM cannot block on the Web Serial Promises. import init, { + BringUpMachine, FirmataSession, FlashSession, parseHex as wasmParseHex, @@ -20,7 +21,44 @@ import init, { // loads correctly in dev and after a production build with no extra Vite plugin. import wasmUrl from "./generated/microflow_firmata_wasm_bg.wasm?url"; -export { FirmataSession, FlashSession }; +export { BringUpMachine, FirmataSession, FlashSession }; + +// --- Bring-up policy (microflow_core::bringup, shared with the desktop) ----- + +/** An event fed into the shared bring-up state machine. */ +export type BringUpEvent = + | { type: "portReady"; board: string | null; autoFlash: boolean; explicit: boolean } + | { type: "probeOk" } + | { type: "probeFailed" } + | { type: "flashProgress"; done: number; total: number } + | { type: "flashOk" } + | { type: "flashFailed"; detail: string } + | { type: "connectionLost" } + | { type: "portGone" } + | { type: "disconnectRequested" }; + +/** A UI-facing bring-up phase; the adapter maps it onto `BoardState`. */ +export type BringUpPhase = + | { kind: "disconnected" } + | { kind: "connecting" } + | { kind: "flashing"; board: string } + | { kind: "connected" } + | { kind: "error"; detail: string }; + +/** An action the machine tells the host to perform, in order. */ +export type BringUpAction = + | { type: "probe"; afterFlash: boolean } + | { type: "flash"; board: string } + | { type: "closePort" } + | { type: "scheduleRetry" } + | { type: "notify"; phase: BringUpPhase } + | { type: "notifyFlashProgress"; percent: number }; + +/** Create the shared bring-up state machine (the policy lives in Rust). */ +export async function createBringUp(): Promise { + await ensureFirmataReady(); + return new BringUpMachine(); +} /** Create a flashing session for a board id + raw flash image. */ export async function createFlashSession( diff --git a/apps/web/src/lib/firmata/web-serial.ts b/apps/web/src/lib/firmata/web-serial.ts index f77ed930..83bed6be 100644 --- a/apps/web/src/lib/firmata/web-serial.ts +++ b/apps/web/src/lib/firmata/web-serial.ts @@ -93,13 +93,9 @@ export type BoardConnection = { port: WebSerialPort; }; -type ConnectOptions = { - /** Called whenever the board's connection state changes. */ - onState: (state: BoardState) => void; +export type ProbeHooks = { /** Called for each pin value change the board reports. */ onPinChange?: PinChangeHandler; - /** Flash progress (done..total) while auto-flashing during bring-up. */ - onProgress?: FlashProgress; /** The board's read loop ended unexpectedly (reset / unplug mid-session). */ onClosed?: () => void; /** @@ -111,16 +107,6 @@ type ConnectOptions = { onBytes?: (bytes: Uint8Array) => void; }; -/** - * Prompt for a serial port and bring the board fully online. Must be called from - * a user gesture (the browser shows its port picker). Auto-flashes StandardFirmata - * if the board has none, mirroring the desktop orchestrator. - */ -export async function connectBoard(options: ConnectOptions): Promise { - const port = await requestBoardPort(); - return bringUpBoard(port, options, { autoFlash: true, onProgress: options.onProgress }); -} - /** Obtain a port via the browser picker. Must run inside a user gesture. */ export async function requestBoardPort(): Promise { const serial = getSerial(); @@ -136,64 +122,42 @@ export async function listGrantedPorts(): Promise { return serial.getPorts(); } -/** Run the firmware/capability handshake at each supported baud (fresh session). */ -async function tryHandshake( +/** + * Firmata probe: run the firmware/capability handshake at each supported baud + * (fresh session). Returns the live connection, or null if no Firmata answered + * (the probe tears its own I/O down). The bring-up *policy* — when to probe, + * flash, retry, or give up — lives in the shared `microflow_core::bringup` + * machine; this is just the transport primitive its `probe` action runs. + */ +export async function probeFirmata( port: WebSerialPort, - options: ConnectOptions, + hooks: ProbeHooks, ): Promise { const session = await createSession(); for (const baud of BAUD_RATES) { - const connection = await tryConnectAtBaud(port, baud, session, options); + const connection = await tryConnectAtBaud(port, baud, session, hooks); if (connection) return connection; } return null; } /** - * Bring a board online on an already-obtained port, mirroring the desktop - * orchestrator (`hardware::process_usb_port`): probe Firmata; if absent and the - * board is recognised, flash StandardFirmata on the same port and reconnect. - * - * `autoFlash` gates the flashing branch — background paths pass `false` so a - * transient probe miss never reflashes; the explicit user connect passes `true`. + * Post-flash probe (`probe { afterFlash: true }`): wait for the board to reboot + * into the freshly-flashed sketch, then handshake. The original handle returns + * in application mode; if the board re-enumerated (AVR109 boards come back as a + * new USB device, which Web Serial models as a different granted port), fall + * back to scanning granted ports newest-first — a platform serial quirk, so it + * stays host-side. */ -export async function bringUpBoard( +export async function probeAfterFlash( port: WebSerialPort, - options: ConnectOptions, - opts: { autoFlash?: boolean; onProgress?: FlashProgress } = {}, -): Promise { - // 1. Steady state: the board already speaks Firmata — just connect. - const existing = await tryHandshake(port, options); - if (existing) return existing; - - // 2. No Firmata. Identify the board; only recognised boards can be flashed. - const board = await detectBoard(port); - if (!board) { - throw new Error("No Firmata firmware responded and the board could not be identified."); - } - if (!opts.autoFlash) { - throw new Error(`No Firmata on ${board}.`); - } - - // 3. Flash StandardFirmata on the same granted port (no second picker). - options.onState({ state: "flashing", port: portLabel(port.getInfo()), board }); - await flashPort(port, { onProgress: opts.onProgress ?? options.onProgress }); - - // 4. The board reboots into the freshly-flashed sketch. Give it a moment, then - // handshake again — the original handle returns in application mode; if it - // re-enumerated (AVR109), fall back to the newest granted port. + hooks: ProbeHooks, +): Promise { await sleep(POST_FLASH_RESET_MS); - const direct = await tryHandshake(port, options).catch(() => null); - const reconnected = direct ?? (await reconnectAfterFlash(options)); - if (reconnected) return reconnected; - throw new Error(`Flashed ${board}, but it did not come back up with Firmata.`); -} - -/** After a flash that re-enumerated the device, find it among granted ports. */ -async function reconnectAfterFlash(options: ConnectOptions): Promise { - const ports = await listGrantedPorts(); - for (const port of ports.slice().reverse()) { - const connection = await tryHandshake(port, options).catch(() => null); + const direct = await probeFirmata(port, hooks).catch(() => null); + if (direct) return direct; + for (const granted of (await listGrantedPorts()).slice().reverse()) { + const connection = await probeFirmata(granted, hooks).catch(() => null); if (connection) return connection; } return null; @@ -230,7 +194,7 @@ async function tryConnectAtBaud( port: WebSerialPort, baud: number, session: FirmataSession, - options: ConnectOptions, + options: ProbeHooks, ): Promise { await port.open({ baudRate: baud, bufferSize: 1024 }); @@ -325,8 +289,6 @@ async function tryConnectAtBaud( await sleep(100); } - options.onState(connectedState(port, session)); - return { write, session, disconnect: teardown, port }; } @@ -336,7 +298,7 @@ function pinCount(session: FirmataSession): number { } /** Build the `connected` BoardState from the session + the port's USB ids. */ -function connectedState(port: WebSerialPort, session: FirmataSession): BoardState { +export function connectedState(port: WebSerialPort, session: FirmataSession): BoardState { const pins = JSON.parse(session.pinsJson()) as PinInfo[]; return { state: "connected", @@ -348,7 +310,7 @@ function connectedState(port: WebSerialPort, session: FirmataSession): BoardStat } /** A human-ish label for the port (Web Serial exposes no device path). */ -function portLabel(info: WebSerialPortInfo): string { +export function portLabel(info: WebSerialPortInfo): string { if (info.usbVendorId !== undefined && info.usbProductId !== undefined) { const vid = info.usbVendorId.toString(16).padStart(4, "0"); const pid = info.usbProductId.toString(16).padStart(4, "0"); @@ -378,19 +340,6 @@ function concat(a: Uint8Array, b: Uint8Array): return out; } -/** - * Flash StandardFirmata onto a board over Web Serial. Prompts for a port, - * identifies the board from its USB id, picks the embedded firmware + bootloader - * protocol, and runs the shared sans-IO driver. Resolves with the flashed board - * id, or rejects with a readable error. Must be called from a user gesture. - */ -export async function flashStandardFirmata(opts: { - onProgress?: FlashProgress; -}): Promise { - const port = await requestBoardPort(); - return flashPort(port, opts); -} - /** * Flash StandardFirmata onto an already-granted port (no picker). Identifies the * board from its USB id, picks the embedded firmware + bootloader protocol, and diff --git a/crates/microflow-core/src/bringup.rs b/crates/microflow-core/src/bringup.rs new file mode 100644 index 00000000..790a999a --- /dev/null +++ b/crates/microflow-core/src/bringup.rs @@ -0,0 +1,455 @@ +//! Board bring-up policy — one sans-IO state machine shared by both Runtime +//! Hosts (browser `board-controller.ts`, desktop `hardware/mod.rs`). +//! +//! The policy — probe for Firmata → flash `StandardFirmata` if missing → +//! re-probe → connected, plus auto-reconnect on reset/unplug and the +//! disconnected→connecting→flashing→connected→error transitions — lives here +//! once, as a value. Mirrors the runtime's `Effects`/`EffectsSink` discipline +//! (ADR-0006/0008): the machine takes [`Event`]s and returns [`Action`]s; the +//! hosts own all I/O, timers, toasts, and stores. +//! +//! Bring-up is *pre-runtime* hardware setup: this module is independent of the +//! flow engine and ungated, so the lean `microflow-firmata-wasm` crate can ship +//! it to the browser. + +use serde::{Deserialize, Serialize}; + +/// What happened in the host, fed into [`BringUp::handle`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Event { + /// A candidate port is ready to bring up (user gesture, load scan, plug-in + /// event, or the desktop poll finding a new port). + /// + /// `board` is the recognised board id (from USB vid/pid), if any. + /// `auto_flash` allows flashing `StandardFirmata` when the probe misses. + /// `explicit` means someone is watching this attempt: failures surface as + /// `Error` (browser: a user gesture; desktop: any recognised board). + PortReady { + board: Option, + auto_flash: bool, + explicit: bool, + }, + /// The Firmata probe/handshake succeeded (the host holds the connection). + ProbeOk, + /// The probe found no Firmata (the host tore its probe I/O down itself). + ProbeFailed, + /// Flash progress, in flash-driver units. + FlashProgress { done: u32, total: u32 }, + /// Flashing finished successfully. + FlashOk, + /// Flashing failed; `detail` is the driver's error text. + FlashFailed { detail: String }, + /// The live connection dropped mid-session (board reset; port still there). + ConnectionLost, + /// The port physically disappeared (USB unplug). + PortGone, + /// The user asked to disconnect. + DisconnectRequested, +} + +/// UI-facing bring-up phase. Hosts map this onto their `BoardState` payload +/// (the desktop fills in port/pins/firmware from its probe result). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Phase { + Disconnected, + Connecting, + Flashing { board: String }, + Connected, + /// `detail` must reach the user verbatim (commit `7c8f7e2` — board error + /// details surfaced in the UI); hosts must not collapse it to a bare label. + Error { detail: String }, +} + +/// What the host must do next, in order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Action { + /// Run the Firmata probe/handshake on the attempt's port, then feed back + /// `ProbeOk` / `ProbeFailed`. `after_flash: true` ⇒ the board just got + /// flashed: wait for its reboot and tolerate USB re-enumeration. + Probe { after_flash: bool }, + /// Flash `StandardFirmata` for `board` onto the attempt's port, then feed + /// back `FlashOk` / `FlashFailed` (progress via `FlashProgress`). + Flash { board: String }, + /// Tear down the live connection / close the port. + ClosePort, + /// Try to recover: rescan for the board and feed `PortReady` again. + ScheduleRetry, + /// Publish the new phase to the UI. + Notify { phase: Phase }, + /// Update flash-progress UI (single toast / progress line). + NotifyFlashProgress { percent: u8 }, +} + +/// One bring-up attempt's facts, captured at `PortReady`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Attempt { + board: Option, + auto_flash: bool, + explicit: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum State { + Idle, + Probing(Attempt), + Flashing(Attempt), + /// Post-flash probe: the board was just flashed and is rebooting. + Reprobing(Attempt), + Connected, +} + +/// The bring-up state machine. No I/O, no timers, no clock — feed it +/// [`Event`]s, perform the returned [`Action`]s. +#[derive(Debug)] +pub struct BringUp { + state: State, +} + +impl Default for BringUp { + fn default() -> Self { + Self::new() + } +} + +impl BringUp { + #[must_use] + pub fn new() -> Self { + Self { state: State::Idle } + } + + /// True once a probe succeeded and nothing has torn the connection down. + #[must_use] + pub fn is_connected(&self) -> bool { + self.state == State::Connected + } + + /// Advance the machine. Returns the actions the host must perform, in + /// order. Events that don't apply to the current state (stale probe + /// results, a port appearing while already connected) return no actions. + #[must_use] + pub fn handle(&mut self, event: Event) -> Vec { + // Teardown events share one shape regardless of state. + match &event { + Event::DisconnectRequested => { + let was_idle = self.state == State::Idle; + self.state = State::Idle; + return if was_idle { + vec![notify(Phase::Disconnected)] + } else { + vec![Action::ClosePort, notify(Phase::Disconnected)] + }; + } + Event::PortGone => { + return match core::mem::replace(&mut self.state, State::Idle) { + // Nothing in flight (e.g. the port of a failed attempt was + // unplugged) — keep whatever phase the UI shows (an error + // must not be overwritten by `disconnected`). + State::Idle => { + vec![] + } + State::Connected => vec![Action::ClosePort, notify(Phase::Disconnected)], + // Mid-attempt the probe/flash owns its own port teardown. + _ => vec![notify(Phase::Disconnected)], + }; + } + Event::ConnectionLost => { + if self.state == State::Connected { + self.state = State::Idle; + // The port is still present after a reset — retry, mirroring + // the desktop poll re-detect and the browser granted rescan. + return vec![ + Action::ClosePort, + notify(Phase::Disconnected), + Action::ScheduleRetry, + ]; + } + return vec![]; + } + _ => {} + } + + match core::mem::replace(&mut self.state, State::Idle) { + State::Idle => match event { + Event::PortReady { board, auto_flash, explicit } => { + self.state = State::Probing(Attempt { board, auto_flash, explicit }); + vec![notify(Phase::Connecting), Action::Probe { after_flash: false }] + } + _ => vec![], + }, + State::Probing(attempt) => match event { + Event::ProbeOk => { + self.state = State::Connected; + vec![notify(Phase::Connected)] + } + Event::ProbeFailed => match attempt.board.clone() { + Some(board) if attempt.auto_flash => { + self.state = State::Flashing(attempt); + vec![ + notify(Phase::Flashing { board: board.clone() }), + Action::Flash { board }, + ] + } + board if attempt.explicit => vec![notify(Phase::Error { + detail: no_firmata_detail(board.as_deref()), + })], + // Background attempt (auto-reconnect / plug-in / unknown + // device): stay quietly disconnected, no error spam. + _ => vec![notify(Phase::Disconnected)], + }, + other => stay(&mut self.state, State::Probing(attempt), &other), + }, + State::Flashing(attempt) => match event { + Event::FlashProgress { done, total } => { + self.state = State::Flashing(attempt); + vec![Action::NotifyFlashProgress { percent: percent(done, total) }] + } + Event::FlashOk => { + self.state = State::Reprobing(attempt); + vec![notify(Phase::Connecting), Action::Probe { after_flash: true }] + } + Event::FlashFailed { detail } => vec![notify(Phase::Error { + detail: format!("Flash failed: {detail}"), + })], + other => stay(&mut self.state, State::Flashing(attempt), &other), + }, + State::Reprobing(attempt) => match event { + Event::ProbeOk => { + self.state = State::Connected; + vec![notify(Phase::Connected)] + } + Event::ProbeFailed => { + let board = attempt.board.as_deref().unwrap_or("board"); + vec![notify(Phase::Error { + detail: format!( + "Flashed {board}, but it did not come back up with Firmata." + ), + })] + } + other => stay(&mut self.state, State::Reprobing(attempt), &other), + }, + State::Connected => { + // Already connected: a new port appearing must not steal the + // live connection; stale probe/flash results are ignored. + self.state = State::Connected; + vec![] + } + } + } +} + +/// Restore `state` for an event that doesn't apply to it; no actions. +fn stay(slot: &mut State, state: State, _event: &Event) -> Vec { + *slot = state; + vec![] +} + +fn notify(phase: Phase) -> Action { + Action::Notify { phase } +} + +fn percent(done: u32, total: u32) -> u8 { + if total == 0 { + return 0; + } + ((u64::from(done) * 100 + u64::from(total) / 2) / u64::from(total)).min(100) as u8 +} + +fn no_firmata_detail(board: Option<&str>) -> String { + match board { + Some(board) => format!("No Firmata on {board}."), + None => "No Firmata firmware responded and the board could not be identified.".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn port_ready(board: Option<&str>, auto_flash: bool, explicit: bool) -> Event { + Event::PortReady { + board: board.map(str::to_string), + auto_flash, + explicit, + } + } + + #[test] + fn missing_firmware_flashes_then_connects() { + let mut m = BringUp::new(); + assert_eq!( + m.handle(port_ready(Some("nano"), true, true)), + vec![notify(Phase::Connecting), Action::Probe { after_flash: false }] + ); + assert_eq!( + m.handle(Event::ProbeFailed), + vec![ + notify(Phase::Flashing { board: "nano".into() }), + Action::Flash { board: "nano".into() } + ] + ); + assert_eq!( + m.handle(Event::FlashOk), + vec![notify(Phase::Connecting), Action::Probe { after_flash: true }] + ); + assert_eq!(m.handle(Event::ProbeOk), vec![notify(Phase::Connected)]); + assert!(m.is_connected()); + } + + #[test] + fn flash_failure_surfaces_error_with_detail() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), true, true)); + let _ = m.handle(Event::ProbeFailed); + assert_eq!( + m.handle(Event::FlashFailed { detail: "sync failed after 3 attempts".into() }), + vec![notify(Phase::Error { detail: "Flash failed: sync failed after 3 attempts".into() })] + ); + assert!(!m.is_connected()); + } + + #[test] + fn post_flash_probe_failure_names_the_board() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("nano"), true, true)); + let _ = m.handle(Event::ProbeFailed); + let _ = m.handle(Event::FlashOk); + assert_eq!( + m.handle(Event::ProbeFailed), + vec![notify(Phase::Error { + detail: "Flashed nano, but it did not come back up with Firmata.".into() + })] + ); + } + + #[test] + fn reset_mid_session_reconnects() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), false, false)); + let _ = m.handle(Event::ProbeOk); + assert!(m.is_connected()); + // Board reset: connection dropped, port still present → retry. + assert_eq!( + m.handle(Event::ConnectionLost), + vec![Action::ClosePort, notify(Phase::Disconnected), Action::ScheduleRetry] + ); + // The retry finds the board again. + assert_eq!( + m.handle(port_ready(Some("uno"), false, false)), + vec![notify(Phase::Connecting), Action::Probe { after_flash: false }] + ); + assert_eq!(m.handle(Event::ProbeOk), vec![notify(Phase::Connected)]); + } + + #[test] + fn unplug_while_flashing_goes_disconnected() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("nano"), true, true)); + let _ = m.handle(Event::ProbeFailed); + assert_eq!(m.handle(Event::PortGone), vec![notify(Phase::Disconnected)]); + // The flash's eventual failure report is stale — ignored. + assert_eq!(m.handle(Event::FlashFailed { detail: "port closed".into() }), vec![]); + } + + #[test] + fn user_disconnect_during_connect_ignores_stale_probe() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(None, false, true)); + assert_eq!( + m.handle(Event::DisconnectRequested), + vec![Action::ClosePort, notify(Phase::Disconnected)] + ); + // A late probe success must not resurrect the connection. + assert_eq!(m.handle(Event::ProbeOk), vec![]); + assert!(!m.is_connected()); + } + + #[test] + fn background_probe_failure_stays_quiet() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), false, false)); + assert_eq!(m.handle(Event::ProbeFailed), vec![notify(Phase::Disconnected)]); + } + + #[test] + fn explicit_probe_failure_details_the_error() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(None, true, true)); + assert_eq!( + m.handle(Event::ProbeFailed), + vec![notify(Phase::Error { + detail: "No Firmata firmware responded and the board could not be identified." + .into() + })] + ); + + let _ = m.handle(port_ready(Some("mega"), false, true)); + assert_eq!( + m.handle(Event::ProbeFailed), + vec![notify(Phase::Error { detail: "No Firmata on mega.".into() })] + ); + } + + #[test] + fn second_port_while_connected_is_ignored() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), false, false)); + let _ = m.handle(Event::ProbeOk); + assert_eq!(m.handle(port_ready(Some("nano"), true, true)), vec![]); + assert!(m.is_connected()); + } + + #[test] + fn unplug_after_failed_attempt_keeps_error_phase() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), true, true)); + let _ = m.handle(Event::ProbeFailed); + let _ = m.handle(Event::FlashFailed { detail: "boom".into() }); + // Unplugging the failed board must not overwrite the error the user is + // reading with `disconnected`. + assert_eq!(m.handle(Event::PortGone), vec![]); + } + + #[test] + fn flash_progress_maps_to_rounded_percent() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), true, true)); + let _ = m.handle(Event::ProbeFailed); + assert_eq!( + m.handle(Event::FlashProgress { done: 1, total: 3 }), + vec![Action::NotifyFlashProgress { percent: 33 }] + ); + assert_eq!( + m.handle(Event::FlashProgress { done: 3, total: 3 }), + vec![Action::NotifyFlashProgress { percent: 100 }] + ); + assert_eq!(percent(0, 0), 0); + } + + #[test] + fn unexpected_disconnect_from_usb_gone_stops_retry() { + let mut m = BringUp::new(); + let _ = m.handle(port_ready(Some("uno"), false, false)); + let _ = m.handle(Event::ProbeOk); + // Physical unplug: no retry (a future plug-in feeds PortReady itself). + assert_eq!( + m.handle(Event::PortGone), + vec![Action::ClosePort, notify(Phase::Disconnected)] + ); + } + + #[test] + fn wire_shapes_are_camel_case_tagged() { + let event: Event = + serde_json::from_str(r#"{"type":"portReady","board":"nano","autoFlash":true,"explicit":true}"#) + .expect("event json"); + assert_eq!(event, port_ready(Some("nano"), true, true)); + let json = serde_json::to_string(&Action::Notify { + phase: Phase::Flashing { board: "nano".into() }, + }) + .expect("action json"); + assert_eq!(json, r#"{"type":"notify","phase":{"kind":"flashing","board":"nano"}}"#); + } +} diff --git a/crates/microflow-core/src/lib.rs b/crates/microflow-core/src/lib.rs index f632353b..e36d5607 100644 --- a/crates/microflow-core/src/lib.rs +++ b/crates/microflow-core/src/lib.rs @@ -30,6 +30,7 @@ clippy::manual_let_else )] +pub mod bringup; pub mod codegen; pub mod config; pub mod firmata; diff --git a/crates/microflow-firmata-wasm/src/lib.rs b/crates/microflow-firmata-wasm/src/lib.rs index b26db893..3a1f9d0c 100644 --- a/crates/microflow-firmata-wasm/src/lib.rs +++ b/crates/microflow-firmata-wasm/src/lib.rs @@ -27,6 +27,7 @@ clippy::cast_possible_wrap )] +use microflow_core::bringup::{Action as BringUpAction, BringUp, Event as BringUpEvent}; use microflow_core::firmata::{FirmataClient, Message}; use microflow_core::flasher::firmware::standard_firmata_hex; use microflow_core::flasher::{hex, new_driver, BoardConfig, BoardType, FlashDriver, FlashStep}; @@ -423,6 +424,53 @@ fn step_json(step: &FlashStep) -> Result { serde_json::to_string(step).map_err(|e| JsError::new(&format!("failed to serialize step: {e}"))) } +// --- Bring-up policy (shared with the desktop hardware monitor) -------------- + +/// The sans-IO board bring-up state machine ([`microflow_core::bringup`]): the +/// probe → flash-if-missing → connect → auto-reconnect policy the desktop +/// hardware monitor runs natively. Feed it a JSON `BringUpEvent`; perform the +/// returned JSON `BringUpAction[]`. The browser owns all I/O and UI. +#[wasm_bindgen] +pub struct BringUpMachine { + inner: BringUp, +} + +#[wasm_bindgen] +impl BringUpMachine { + #[wasm_bindgen(constructor)] + #[must_use] + pub fn new() -> Self { + Self { inner: BringUp::new() } + } + + /// True once a probe succeeded and nothing has torn the connection down. + #[wasm_bindgen(js_name = isConnected)] + #[must_use] + pub fn is_connected(&self) -> bool { + self.inner.is_connected() + } + + /// Advance the machine with one JSON `BringUpEvent`; returns the JSON + /// `BringUpAction[]` the caller must perform, in order. + /// + /// # Errors + /// Returns a `JsError` if the event JSON is malformed or the actions fail + /// to serialize. + pub fn handle(&mut self, event_json: &str) -> Result { + let event: BringUpEvent = serde_json::from_str(event_json) + .map_err(|e| JsError::new(&format!("invalid bring-up event: {e}")))?; + let actions: Vec = self.inner.handle(event); + serde_json::to_string(&actions) + .map_err(|e| JsError::new(&format!("failed to serialize actions: {e}"))) + } +} + +impl Default for BringUpMachine { + fn default() -> Self { + Self::new() + } +} + #[cfg(test)] mod tests { use super::*; @@ -511,6 +559,19 @@ mod tests { // a non-wasm host (it panics: "cannot call wasm-bindgen imported functions // on non-wasm targets"), so it is only exercisable in the browser. + #[test] + fn bring_up_machine_round_trips_json() { + let mut m = BringUpMachine::new(); + let actions = m + .handle(r#"{"type":"portReady","board":"nano","autoFlash":true,"explicit":true}"#) + .expect("handle ok"); + assert!(actions.contains(r#"{"type":"notify","phase":{"kind":"connecting"}}"#), "got: {actions}"); + assert!(actions.contains(r#"{"type":"probe","afterFlash":false}"#), "got: {actions}"); + let actions = m.handle(r#"{"type":"probeOk"}"#).expect("handle ok"); + assert!(actions.contains(r#""kind":"connected""#), "got: {actions}"); + assert!(m.is_connected()); + } + #[test] fn standard_firmata_hex_available_for_known_boards() { assert!(standard_firmata_hex_for("nano").is_some()); From 71d72477a6fd857985410cfbf73dd53ca1ba0a51 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:36 +0200 Subject: [PATCH 03/12] feat(oscillator): add randomwalk and perlin waveforms Both waveforms use the same sin-hash lattice in the live runtime and the generated sketch, so the device matches the live preview sample-for-sample. Co-Authored-By: Claude Opus 4.8 --- .../nodes/oscillator/oscillator.schema.ts | 10 ++++- .../flow/nodes/oscillator/oscillator.tsx | 8 +++- .../src/codegen/generator/oscillator.rs | 45 ++++++++++++++++--- .../microflow-core/src/config/oscillator.rs | 4 ++ .../src/runtime/generator/oscillator.rs | 40 ++++++++++++++++- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/flow/nodes/oscillator/oscillator.schema.ts b/apps/web/src/components/flow/nodes/oscillator/oscillator.schema.ts index 49989d80..c37068a0 100644 --- a/apps/web/src/components/flow/nodes/oscillator/oscillator.schema.ts +++ b/apps/web/src/components/flow/nodes/oscillator/oscillator.schema.ts @@ -1,7 +1,15 @@ import { z } from "zod"; import { baseDataSchema } from "../_base/_base.schema"; -export const waveformTypeSchema = z.enum(["sinus", "square", "sawtooth", "triangle", "random"]); +export const waveformTypeSchema = z.enum([ + "sinus", + "square", + "sawtooth", + "triangle", + "random", + "randomwalk", + "perlin", +]); export type WaveformType = z.infer; export const valueSchema = z.number(); diff --git a/apps/web/src/components/flow/nodes/oscillator/oscillator.tsx b/apps/web/src/components/flow/nodes/oscillator/oscillator.tsx index 6fd085e9..c29e0309 100644 --- a/apps/web/src/components/flow/nodes/oscillator/oscillator.tsx +++ b/apps/web/src/components/flow/nodes/oscillator/oscillator.tsx @@ -2,11 +2,13 @@ import { useMemo } from "react"; import { NodeHandles } from "../_base/node-handles"; import { NodeContainer, useNodeControls, useNodeData, type BaseNode } from "../_base/_base"; import { + ActivityIcon, AudioWaveformIcon, DicesIcon, SquareIcon, TriangleIcon, TriangleRightIcon, + WavesIcon, type LucideIcon, } from "lucide-react"; import { IconWithValue } from "../../icon-with-value"; @@ -45,6 +47,10 @@ function Value() { return SquareIcon; case "random": return DicesIcon; + case "randomwalk": + return ActivityIcon; + case "perlin": + return WavesIcon; default: return AudioWaveformIcon; } @@ -58,7 +64,7 @@ function Settings() { const { render } = useNodeControls({ waveform: { value: data.waveform, - options: ["sinus", "triangle", "sawtooth", "square", "random"], + options: ["sinus", "triangle", "sawtooth", "square", "random", "randomwalk", "perlin"], }, period: { value: data.period, diff --git a/crates/microflow-core/src/codegen/generator/oscillator.rs b/crates/microflow-core/src/codegen/generator/oscillator.rs index 17ae9d23..43747892 100644 --- a/crates/microflow-core/src/codegen/generator/oscillator.rs +++ b/crates/microflow-core/src/codegen/generator/oscillator.rs @@ -51,6 +51,19 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { let mut sample_lines = vec![ format!("double {t} = (double)(millis() - {start}) + {phase};"), ]; + // Per-node C++ helper functions (hash01/value-noise) for the noise + // waveforms. hash01 mirrors the runtime's sin-hash exactly, so the device + // signal matches live mode sample-for-sample (unlike `Random`, which uses + // the Arduino `random()` and only matches in distribution). + let hash = format!("oscillator_{token}_hash01"); + let vnoise = format!("oscillator_{token}_vnoise"); + let hash_fn = format!( + "double {hash}(double n) {{ double v = sin(n * 12.9898) * 43758.5453; return fabs(v - trunc(v)); }}" + ); + let vnoise_fn = format!( + "double {vnoise}(double t) {{ double i = floor(t); double f = t - i; double u = f * f * (3.0 - 2.0 * f); double a = {hash}(i); double b = {hash}(i + 1.0); return a + (b - a) * u; }}" + ); + let mut helper_decls: Vec = Vec::new(); let loop_body = &mut sample_lines; let sample = match config.waveform { Waveform::Square => { @@ -73,6 +86,25 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { // Runtime: (shift + amplitude) * rand in [0,1). format!("{value} = ({shift} + {amplitude}) * ((double)random(0, 10000) / 10000.0);") } + Waveform::RandomWalk => { + // Runtime `random_walk`: lerp between per-period lattice hashes. + helper_decls.push(hash_fn.clone()); + loop_body.push(format!("double tt = {t} / {period};")); + loop_body.push("double i0 = floor(tt);".to_string()); + loop_body.push("double f = tt - i0;".to_string()); + loop_body.push(format!("double a = {hash}(i0);")); + loop_body.push(format!("double b = {hash}(i0 + 1.0);")); + format!("{value} = ({shift} + {amplitude}) * (a + (b - a) * f);") + } + Waveform::Perlin => { + // Runtime `perlin`: two octaves of smoothstep value noise. + helper_decls.push(hash_fn.clone()); + helper_decls.push(vnoise_fn.clone()); + loop_body.push(format!("double tt = {t} / {period};")); + format!( + "{value} = ({shift} + {amplitude}) * ({vnoise}(tt) * (2.0 / 3.0) + {vnoise}(tt * 2.0 + 57.0) * (1.0 / 3.0));" + ) + } // Sinus is the runtime default. Waveform::Sinus => format!( "{value} = {amplitude} * sin({t} * (2.0 * PI / {period})) + {shift};" @@ -80,12 +112,15 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { }; loop_body.push(sample); + let mut declarations = vec![ + format!("unsigned long {start} = 0;"), + format!("double {value} = 0.0;"), + format!("bool {running} = {};", config.auto_start), + ]; + // hash01 precedes vnoise (vnoise calls it; no forward declarations emitted). + declarations.extend(helper_decls); let mut e = NodeEmission { - declarations: vec![ - format!("unsigned long {start} = 0;"), - format!("double {value} = 0.0;"), - format!("bool {running} = {};", config.auto_start), - ], + declarations, setup: vec![format!("{start} = millis();")], ..NodeEmission::default() }; diff --git a/crates/microflow-core/src/config/oscillator.rs b/crates/microflow-core/src/config/oscillator.rs index b6f7c1c3..564c7d1a 100644 --- a/crates/microflow-core/src/config/oscillator.rs +++ b/crates/microflow-core/src/config/oscillator.rs @@ -11,6 +11,10 @@ pub enum Waveform { Sawtooth, Triangle, Random, + /// Bounded random walk: linear drift to a new random target each period. + RandomWalk, + /// Smooth organic noise: two octaves of smoothstep-faded value noise. + Perlin, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/microflow-core/src/runtime/generator/oscillator.rs b/crates/microflow-core/src/runtime/generator/oscillator.rs index 7d0fc0ec..1c85b68c 100644 --- a/crates/microflow-core/src/runtime/generator/oscillator.rs +++ b/crates/microflow-core/src/runtime/generator/oscillator.rs @@ -72,6 +72,8 @@ fn calculate_waveform(config: &OscillatorConfig, timestamp: f64) -> f64 { Waveform::Sawtooth => sawtooth(config, timestamp), Waveform::Triangle => triangle(config, timestamp), Waveform::Random => random(config, timestamp), + Waveform::RandomWalk => random_walk(config, timestamp), + Waveform::Perlin => perlin(config, timestamp), } } @@ -136,10 +138,46 @@ fn triangle(config: &OscillatorConfig, timestamp: f64) -> f64 { /// Pseudo-random in [0, shift+amplitude). Sin-hash of the timestamp instead of /// the `rand` crate, so the core stays free of `getrandom` (wasm-clean). fn random(config: &OscillatorConfig, timestamp: f64) -> f64 { - let r = ((timestamp * 12.9898).sin() * 43758.5453).fract().abs(); + let r = hash01(timestamp); (config.shift + config.amplitude) * r } +/// Deterministic hash of `n` into [0, 1). Same sin-hash as `random`, applied to +/// lattice indices so both hosts and the generated sketch share one sequence. +fn hash01(n: f64) -> f64 { + ((n * 12.9898).sin() * 43758.5453).fract().abs() +} + +/// Bounded random walk in [0, shift+amplitude): linear interpolation between a +/// random lattice value per period — the output drifts to a new random target +/// every `period` ms instead of jumping every sample like `Random`. +fn random_walk(config: &OscillatorConfig, timestamp: f64) -> f64 { + let t = (timestamp + config.phase) / config.period; + let i = t.floor(); + let f = t - i; + let a = hash01(i); + let b = hash01(i + 1.0); + (config.shift + config.amplitude) * (a + (b - a) * f) +} + +/// Smoothstep-faded value noise in [0, 1) with `period` as the wavelength. +fn value_noise(t: f64) -> f64 { + let i = t.floor(); + let f = t - i; + let u = f * f * (3.0 - 2.0 * f); + let a = hash01(i); + let b = hash01(i + 1.0); + a + (b - a) * u +} + +/// Perlin-style noise in [0, shift+amplitude): two octaves of value noise for +/// an organic drift smoother than `RandomWalk` (no corners at period bounds). +fn perlin(config: &OscillatorConfig, timestamp: f64) -> f64 { + let t = (timestamp + config.phase) / config.period; + let n = value_noise(t) * (2.0 / 3.0) + value_noise(t * 2.0 + 57.0) * (1.0 / 3.0); + (config.shift + config.amplitude) * n +} + impl Component for Oscillator { fn ports() -> &'static [&'static str] { &["start", "stop", "reset"] From 385c241794ee23399afabd58f0acf34bb7458fe8 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:36 +0200 Subject: [PATCH 04/12] refactor(i2c): single-source byte-decode descriptor Move the I2C reply fold arithmetic into one shared ByteDecode descriptor in config::i2c_device (fold_bytes); the runtime interprets it and the sketch emitter transcribes it to C++, with codegen/parity.rs pinning the two. No behavioural change. Co-Authored-By: Claude Opus 4.8 --- .../src/codegen/input/i2c_device.rs | 43 ++++++-- .../microflow-core/src/config/i2c_device.rs | 99 ++++++++++++++++--- .../src/runtime/input/i2c_device.rs | 40 +++----- 3 files changed, 134 insertions(+), 48 deletions(-) diff --git a/crates/microflow-core/src/codegen/input/i2c_device.rs b/crates/microflow-core/src/codegen/input/i2c_device.rs index c07e373a..e182fcb9 100644 --- a/crates/microflow-core/src/codegen/input/i2c_device.rs +++ b/crates/microflow-core/src/codegen/input/i2c_device.rs @@ -10,7 +10,7 @@ use crate::codegen::emit::{NodeEmission, NodeToken}; use crate::codegen::wire::{bind_pulses, NodeInputs}; -use crate::config::i2c_device::{I2cDeviceConfig, OutputFormat}; +use crate::config::i2c_device::{ByteDecode, I2cDeviceConfig, OutputFormat}; use crate::flow::FlowNode; /// The C++ `long` variable name holding this device's latest decoded reading. @@ -37,7 +37,12 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { let addr = config.address; let register = effective_register(&config); let read_length = config.read_length.max(1); - let signed = config.output == OutputFormat::SignedInt; + // The shared decode descriptor this emitter TRANSCRIBES (the runtime + // interprets the same one via `fold_bytes`). Raw has no on-device + // byte-array value model, so it folds like unsigned — recorded in + // `codegen/parity.rs`. + let sign_extend = + matches!(config.output.decode(), ByteDecode::Fold { sign_extend: true }); let acc = format!("i2c_{token}_acc"); let i = format!("i2c_{token}_i"); @@ -62,11 +67,24 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { format!("for (uint8_t {i} = 0; {i} < {read_length} && Wire.available(); {i}++) {{"), format!(" uint8_t {b} = Wire.read();"), ]); - if signed { - // Sign-extend from the most-significant byte, big-endian, like the runtime. - loop_body.push(format!(" if ({i} == 0 && ({b} & 0x80)) {{ {acc} = -1; }}")); + // The fold transcribed from the descriptor: big-endian shift-or, optionally + // sign-extended from bit 7 of the first byte, capped at `FOLD_BYTE_CAP` + // bytes. A longer read still drains the Wire buffer, but only the FIRST cap + // bytes fold in — exactly `fold_bytes`' `take(4)`. (The old uncapped fold + // kept the LAST 4 bytes of a long read; the runtime keeps the first 4.) + let mut fold = Vec::new(); + if sign_extend { + fold.push(format!("if ({i} == 0 && ({b} & 0x80)) {{ {acc} = -1; }}")); } - loop_body.push(format!(" {acc} = ({acc} << 8) | (long){b};")); + fold.push(format!("{acc} = ({acc} << 8) | (long){b};")); + if usize::from(read_length) > OutputFormat::FOLD_BYTE_CAP { + let cap = OutputFormat::FOLD_BYTE_CAP; + fold = std::iter::once(format!("if ({i} < {cap}) {{")) + .chain(fold.into_iter().map(|l| format!(" {l}"))) + .chain(std::iter::once("}".to_string())) + .collect(); + } + loop_body.extend(fold.into_iter().map(|l| format!(" {l}"))); loop_body.push("}".to_string()); loop_body.push(format!("{value} = {acc};")); @@ -114,6 +132,7 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { declarations, setup, loop_body, + ..NodeEmission::default() } } @@ -264,6 +283,18 @@ mod tests { assert!(e.loop_body.iter().any(|l| l.contains("0x80")), "signed must sign-extend"); } + #[test] + fn i2c_folds_at_most_four_bytes_like_the_runtime() { + // Drift fix: the old fold folded ALL bytes, so a >4-byte read kept the + // LAST 4 bytes in the 32-bit `long` while the runtime keeps the FIRST 4 + // (`fold_bytes`). The emitted fold must be guarded at the shared cap. + let e = emit(&i2c("d-1", json!({ "readLength": 6 })), &NodeInputs::default()); + assert!(e.loop_body.iter().any(|l| l.contains("< 4) {")), "long reads must cap the fold"); + // Reads within the cap need no guard — the loop bound already limits them. + let e = emit(&i2c("d-1", json!({ "readLength": 4 })), &NodeInputs::default()); + assert!(!e.loop_body.iter().any(|l| l.contains("< 4) {")), "no guard within the cap"); + } + #[test] fn i2c_unsigned_does_not_sign_extend() { let e = emit(&i2c("d-1", json!({ "output": "unsigned_int" })), &NodeInputs::default()); diff --git a/crates/microflow-core/src/config/i2c_device.rs b/crates/microflow-core/src/config/i2c_device.rs index dd67e210..87d4eec1 100644 --- a/crates/microflow-core/src/config/i2c_device.rs +++ b/crates/microflow-core/src/config/i2c_device.rs @@ -28,21 +28,14 @@ use serde::{Deserialize, Serialize}; /// How the raw I2C reply bytes are decoded into a value. /// -/// # Decode contract (the single authority both interpreters transcribe) -/// This enum is the shared spec for a decode that necessarily exists twice — once -/// as a Rust fold in the live runtime (`runtime/input/i2c_device.rs::convert_bytes`, -/// producing a `ComponentValue`) and once as a C++ fold the sketch emitter writes +/// # Decode contract (the single authority both interpreters consume) +/// Each variant maps to one [`ByteDecode`] descriptor via [`OutputFormat::decode`]. +/// The live runtime *interprets* the descriptor over received bytes +/// (`runtime/input/i2c_device.rs` → [`fold_bytes`], producing a `ComponentValue`) +/// and the sketch emitter *transcribes* it to a C++ fold /// (`codegen/input/i2c_device.rs`, producing a `long`). The two languages cannot -/// share executable code, so they share this contract instead — keep both folds -/// matching it: -/// - **`Raw`** — every byte preserved, in order (runtime: an array; sketch: n/a). -/// - **`UnsignedInt`** — big-endian, at most the first **4** bytes folded (`u32`). -/// - **`SignedInt`** — big-endian, at most **4** bytes, two's-complement -/// **sign-extended from the most-significant byte** (bit 7 of `data[0]`). -/// -/// A change to any bullet above is a change both folds must make together; the -/// mirrored `convert_bytes` tests and the codegen `i2c_sign_extends_*` tests guard -/// that they stay in lockstep. +/// share executable code, so the arithmetic/branching lives here ONCE and the +/// `codegen/parity.rs` `I2cDevice` case pins the emitted C++ to the descriptor. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum OutputFormat { @@ -58,6 +51,62 @@ pub enum OutputFormat { SignedInt, } +/// The decode an [`OutputFormat`] applies to the raw reply bytes — the shared +/// descriptor both consumers derive from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ByteDecode { + /// Every byte preserved, in order. The runtime yields an array; the sketch + /// has no byte-array value model and folds like `Fold { sign_extend: false }` + /// — the closest single-value approximation, recorded in `codegen/parity.rs`. + Raw, + /// Big-endian shift-or of at most [`OutputFormat::FOLD_BYTE_CAP`] bytes into + /// a 32-bit value; trailing bytes are read off the bus but ignored. + /// `sign_extend` seeds the accumulator two's-complement from bit 7 of the + /// FIRST (most-significant) byte. + Fold { sign_extend: bool }, +} + +impl OutputFormat { + /// At most this many reply bytes fold into the value — the 32-bit + /// accumulator width, on BOTH targets (runtime `u32`/`i32`, sketch `long`). + pub const FOLD_BYTE_CAP: usize = 4; + + /// The decode descriptor this format performs. + #[must_use] + pub fn decode(self) -> ByteDecode { + match self { + Self::Raw => ByteDecode::Raw, + Self::UnsignedInt => ByteDecode::Fold { sign_extend: false }, + Self::SignedInt => ByteDecode::Fold { sign_extend: true }, + } + } +} + +/// Interpret a [`ByteDecode::Fold`] over received bytes — the runtime's live +/// decode, and the reference the emitted C++ fold transcribes: big-endian +/// shift-or of the first [`OutputFormat::FOLD_BYTE_CAP`] bytes, optionally +/// sign-extended from bit 7 of the first byte. Empty input decodes to 0. +#[must_use] +pub fn fold_bytes(sign_extend: bool, data: &[u8]) -> f64 { + let data = &data[..data.len().min(OutputFormat::FOLD_BYTE_CAP)]; + if sign_extend { + let mut value: i32 = match data.first() { + Some(&msb) if msb & 0x80 != 0 => -1, + _ => 0, + }; + for &byte in data { + value = (value << 8) | i32::from(byte); + } + f64::from(value) + } else { + let mut value: u32 = 0; + for &byte in data { + value = (value << 8) | u32::from(byte); + } + f64::from(value) + } +} + /// Deserialized configuration for the generic `I2cDevice` node. Ungated (no /// `runtime` feature) so both the interpreter and the emitter read one struct. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -213,6 +262,28 @@ pub fn is_no_hold_sht2x(device: &str) -> bool { mod tests { use super::*; + #[test] + #[allow(clippy::float_cmp)] // every folded value is an exact integer in f64 + fn fold_bytes_is_big_endian_capped_and_sign_extends() { + // Big-endian: MSB first (0x0102 = 258). + assert_eq!(fold_bytes(false, &[0x01, 0x02]), 258.0); + // High bit set stays positive unsigned, sign-extends signed. + assert_eq!(fold_bytes(false, &[0xFF, 0xFF]), 65535.0); + assert_eq!(fold_bytes(true, &[0xFF, 0xFF]), -1.0); + assert_eq!(fold_bytes(true, &[0x00, 0x2A]), 42.0); + // Empty decodes to 0, signed or not. + assert_eq!(fold_bytes(true, &[]), 0.0); + // Only the FIRST `FOLD_BYTE_CAP` bytes fold; trailing bytes are ignored. + assert_eq!(fold_bytes(false, &[1, 2, 3, 4, 5]), f64::from(0x0102_0304_u32)); + } + + #[test] + fn every_format_maps_to_its_decode_descriptor() { + assert_eq!(OutputFormat::Raw.decode(), ByteDecode::Raw); + assert_eq!(OutputFormat::UnsignedInt.decode(), ByteDecode::Fold { sign_extend: false }); + assert_eq!(OutputFormat::SignedInt.decode(), ByteDecode::Fold { sign_extend: true }); + } + #[test] fn normalizes_stale_device_label_to_preset_id() { // Pre-fix flows persisted the leva label; it must map to the preset id. diff --git a/crates/microflow-core/src/runtime/input/i2c_device.rs b/crates/microflow-core/src/runtime/input/i2c_device.rs index 4fec3976..412b91dd 100644 --- a/crates/microflow-core/src/runtime/input/i2c_device.rs +++ b/crates/microflow-core/src/runtime/input/i2c_device.rs @@ -14,7 +14,7 @@ use crate::runtime::{ BoardWiring, Component, ComponentBase, ComponentBuilder, ComponentValue, HardwareComponent, I2cContinuousRead, ListenerWiring, RuntimeContext, RuntimeError, }; -use crate::config::i2c_device::{I2cDeviceConfig, OutputFormat}; +use crate::config::i2c_device::{fold_bytes, ByteDecode, I2cDeviceConfig, OutputFormat}; pub struct I2cDevice { base: ComponentBase, @@ -68,35 +68,18 @@ impl I2cDevice { } } -/// Decode raw I2C reply bytes into a `ComponentValue` per the output format. -/// A pure function (no `self`) so the big-endian unsigned/signed folding is -/// unit-testable in isolation — the decode the runtime applies live, mirrored -/// as C++ by `codegen/input/i2c_device.rs`. +/// Decode raw I2C reply bytes into a `ComponentValue` by INTERPRETING the +/// shared decode descriptor ([`OutputFormat::decode`] → [`fold_bytes`]). The +/// arithmetic lives once in `config::i2c_device`; the sketch emitter +/// (`codegen/input/i2c_device.rs`) transcribes the same descriptor to C++, and +/// `codegen/parity.rs` pins the two. Only the `Raw` array shape is runtime-own +/// (`ComponentValue` doesn't exist on-device). fn convert_bytes(output: OutputFormat, data: &[u8]) -> ComponentValue { - match output { - OutputFormat::Raw => ComponentValue::Array( + match output.decode() { + ByteDecode::Raw => ComponentValue::Array( data.iter().map(|&b| ComponentValue::Number(f64::from(b))).collect(), ), - OutputFormat::UnsignedInt => { - // Big-endian unsigned integer from up to 4 bytes - let mut value: u32 = 0; - for &byte in data.iter().take(4) { - value = (value << 8) | u32::from(byte); - } - ComponentValue::Number(f64::from(value)) - } - OutputFormat::SignedInt => { - // Big-endian signed integer (two's complement) from up to 4 bytes - let len = data.len().min(4); - if len == 0 { - return ComponentValue::Number(0.0); - } - let mut value: i32 = if data[0] & 0x80 != 0 { -1 } else { 0 }; - for &byte in data.iter().take(len) { - value = (value << 8) | i32::from(byte); - } - ComponentValue::Number(f64::from(value)) - } + ByteDecode::Fold { sign_extend } => ComponentValue::Number(fold_bytes(sign_extend, data)), } } @@ -435,7 +418,8 @@ mod tests { assert_eq!(cfg.sample_interval_ms, 50); } - // --- convert_bytes: the numeric decode, now a pure fn tested in isolation --- + // --- convert_bytes: the descriptor interpretation, pinned end-to-end here + // (the fold arithmetic itself is unit-tested in `config::i2c_device`) --- #[test] fn convert_bytes_raw_preserves_every_byte() { From bc55cdd8a3f64226d16551003e3a066c35a2d9d2 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:37 +0200 Subject: [PATCH 05/12] feat(stepper): stepper config updates; hide unsupported template Hide the stepperPosition template: flashed StandardFirmata has no AccelStepper support, so the motor never moves. Co-Authored-By: Claude Opus 4.8 --- .../flow/nodes/stepper/stepper.schema.ts | 6 +- .../components/flow/nodes/stepper/stepper.tsx | 3 +- apps/web/src/lib/templates/index.ts | 3 +- .../src/codegen/output/stepper.rs | 71 +++++++++++++++++-- crates/microflow-core/src/config/stepper.rs | 4 ++ 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/flow/nodes/stepper/stepper.schema.ts b/apps/web/src/components/flow/nodes/stepper/stepper.schema.ts index 6049b918..77a68763 100644 --- a/apps/web/src/components/flow/nodes/stepper/stepper.schema.ts +++ b/apps/web/src/components/flow/nodes/stepper/stepper.schema.ts @@ -25,7 +25,11 @@ export type Data = z.infer; export const defaults = { ...dataSchema.parse({}), - group: "express", + // Hidden from the node picker: the runtime speaks AccelStepper sysex (0x62), + // but the firmware we flash is plain StandardFirmata, which ignores it — the + // motor never moves. Restore group: "express" once we bundle a firmware with + // AccelStepper (ConfigurableFirmata) and decode its 0x62 position replies. + group: "internal", tags: ["action", "value"], label: "Stepper", icon: "CogIcon", diff --git a/apps/web/src/components/flow/nodes/stepper/stepper.tsx b/apps/web/src/components/flow/nodes/stepper/stepper.tsx index 9c7e43e7..7bc1e989 100644 --- a/apps/web/src/components/flow/nodes/stepper/stepper.tsx +++ b/apps/web/src/components/flow/nodes/stepper/stepper.tsx @@ -159,7 +159,8 @@ type Props = BaseNode; Stepper.defaultProps = { data: { ...dataSchema.parse({}), - group: "express", + // Keep in sync with stepper.schema.ts — hidden until firmware supports AccelStepper. + group: "internal", tags: ["action", "value"], label: "Stepper", icon: "CogIcon", diff --git a/apps/web/src/lib/templates/index.ts b/apps/web/src/lib/templates/index.ts index 097a6a5a..80892153 100644 --- a/apps/web/src/lib/templates/index.ts +++ b/apps/web/src/lib/templates/index.ts @@ -572,7 +572,8 @@ export const TEMPLATES: Template[] = [ lightMonitor, servoSweep, rgbMoodLamp, - stepperPosition, + // stepperPosition — hidden with the Stepper node (see stepper.schema.ts: + // flashed StandardFirmata has no AccelStepper support, so the motor never moves) // Communication mqttButton, sensorToFigma, diff --git a/crates/microflow-core/src/codegen/output/stepper.rs b/crates/microflow-core/src/codegen/output/stepper.rs index d30661d7..a51efdee 100644 --- a/crates/microflow-core/src/codegen/output/stepper.rs +++ b/crates/microflow-core/src/codegen/output/stepper.rs @@ -6,14 +6,15 @@ //! target), `stop`, `zero` (reset the current position to 0), and `enable` //! (energize/de-energize the outputs, truthy ⇢ enabled). The generated sketch //! uses the Arduino `AccelStepper` library directly with the matching port -//! semantics: `value` issues `move(steps)` on each new sample, `to` targets +//! semantics: the constructor mirrors the runtime's `CMD_CONFIG` interface + +//! pins, `value` issues `move(steps)` on each new sample, `to` targets //! `moveTo(position)`, and `run()` is called every loop — it steps at most //! once per call and returns immediately, so motion is fully non-blocking and //! other Nodes keep ticking. use crate::codegen::emit::{cpp_double, NodeEmission, NodeToken}; use crate::codegen::wire::{bind_pulses, extra_sources_note, NodeInputs}; -use crate::config::stepper::StepperConfig; +use crate::config::stepper::{StepperConfig, StepperInterface}; use crate::flow::FlowNode; /// Emit C++ for a Stepper Node. Unwired, the motor parks at zero. @@ -22,24 +23,45 @@ pub fn emit(node: &FlowNode, inputs: &NodeInputs) -> NodeEmission { let token = node.id_token(); let obj = format!("stepper_{token}"); let config: StepperConfig = serde_json::from_value(node.data.clone()).unwrap_or_default(); - let step_pin = config.step_pin; - let dir_pin = config.dir_pin; // Runtime stores speed/acceleration as floats; widen to the f64 the C++ // literal formatter expects. let speed = cpp_double(f64::from(config.speed)); let acceleration = cpp_double(f64::from(config.acceleration)); + // The constructor per interface, with the same pins (and order) the + // runtime's Firmata `CMD_CONFIG` sends: driver = step/dir, two-wire = + // motor pins 1–2, four-wire = motor pins 1–4. Whole-step only — the + // runtime never sets Firmata's half-step bits, so `FULL2WIRE`/`FULL4WIRE`, + // never the `HALF*` variants. + let constructor = match config.interface { + StepperInterface::Driver => format!( + "AccelStepper {obj}(AccelStepper::DRIVER, {}, {});", + config.step_pin, config.dir_pin + ), + StepperInterface::TwoWire => format!( + "AccelStepper {obj}(AccelStepper::FULL2WIRE, {}, {});", + config.motor_pin1, config.motor_pin2 + ), + StepperInterface::FourWire => format!( + "AccelStepper {obj}(AccelStepper::FULL4WIRE, {}, {}, {}, {});", + config.motor_pin1, config.motor_pin2, config.motor_pin3, config.motor_pin4 + ), + }; + let mut e = NodeEmission { includes: vec!["#include ".to_string()], - declarations: vec![format!( - "AccelStepper {obj}(AccelStepper::DRIVER, {step_pin}, {dir_pin});" - )], + declarations: vec![constructor], setup: vec![ format!("{obj}.setMaxSpeed({speed});"), format!("{obj}.setAcceleration({acceleration});"), ], ..NodeEmission::default() }; + // The runtime appends the enable pin to CMD_CONFIG when configured; + // AccelStepper's twin is setEnablePin (driven by the `enable` port below). + if let Some(enable_pin) = config.enable_pin { + e.setup.push(format!("{obj}.setEnablePin({enable_pin});")); + } // value: one relative move per new sample; zero steps are skipped. let value_sources = inputs.on("value"); @@ -136,6 +158,41 @@ mod tests { assert!(e.declarations.iter().any(|d| d.contains("AccelStepper stepper_st_1(AccelStepper::DRIVER, 2, 3)"))); } + #[test] + fn two_wire_interface_uses_full2wire_and_motor_pins() { + let e = emit( + &stepper("st-1", json!({ "interface": "two_wire", "motorPin1": 4, "motorPin2": 5 })), + &NodeInputs::default(), + ); + assert!(e + .declarations + .iter() + .any(|d| d.contains("AccelStepper stepper_st_1(AccelStepper::FULL2WIRE, 4, 5)"))); + } + + #[test] + fn four_wire_interface_uses_full4wire_and_motor_pins_in_order() { + let e = emit( + &stepper( + "st-1", + json!({ "interface": "four_wire", "motorPin1": 4, "motorPin2": 5, "motorPin3": 6, "motorPin4": 7 }), + ), + &NodeInputs::default(), + ); + assert!(e + .declarations + .iter() + .any(|d| d.contains("AccelStepper stepper_st_1(AccelStepper::FULL4WIRE, 4, 5, 6, 7)"))); + } + + #[test] + fn configured_enable_pin_is_set_in_setup() { + let e = emit(&stepper("st-1", json!({ "enablePin": 8 })), &NodeInputs::default()); + assert!(e.setup.iter().any(|s| s.contains(".setEnablePin(8)"))); + let e = emit(&stepper("st-1", json!({})), &NodeInputs::default()); + assert!(!e.setup.iter().any(|s| s.contains("setEnablePin")), "no enable pin by default"); + } + #[test] fn stepper_sets_speed_and_acceleration() { let e = emit(&stepper("st-1", json!({ "speed": 800, "acceleration": 200 })), &NodeInputs::default()); diff --git a/crates/microflow-core/src/config/stepper.rs b/crates/microflow-core/src/config/stepper.rs index ef9dd088..4f88c10b 100644 --- a/crates/microflow-core/src/config/stepper.rs +++ b/crates/microflow-core/src/config/stepper.rs @@ -12,7 +12,11 @@ pub enum StepperInterface { FourWire, } +// The web stores camelCase keys (`stepPin`, `motorPin1`, …); without +// `rename_all` every multi-word field silently fell back to its default — +// masked only because the web defaults coincide with the Rust ones. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct StepperConfig { // Driver mode pins (step/dir) #[serde(default = "default_step_pin", deserialize_with = "serde_utils::deserialize_pin_u8")] From e3b6cb7ecc1249494397cc3fc640f5450f19366a Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:50 +0200 Subject: [PATCH 06/12] refactor(cloud-sync): unify capability sync, drop per-provider hooks Replace use-llm-sync/use-mqtt-sync with a single cloud-capability-sync module reading one HostSnapshot (brokers/providers/figma); provider status now lives on the store. The FlowUpdateDispatcher reads the shared snapshot. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/hooks/use-llm-sync.ts | 28 ---- apps/web/src/hooks/use-mqtt-sync.ts | 69 --------- apps/web/src/routes/configuration/llm.tsx | 3 +- apps/web/src/routes/configuration/mqtt.tsx | 3 +- .../__tests__/cloud-capability-sync.test.ts | 145 ++++++++++++++++++ apps/web/src/session/cloud-capabilities.ts | 114 ++++++++++++++ apps/web/src/session/cloud-capability-sync.ts | 70 +++++++++ .../src/session/use-flow-update-dispatcher.ts | 13 +- apps/web/src/stores/llm-provider.ts | 4 + apps/web/src/stores/mqtt-broker.ts | 7 + 10 files changed, 343 insertions(+), 113 deletions(-) delete mode 100644 apps/web/src/hooks/use-llm-sync.ts delete mode 100644 apps/web/src/hooks/use-mqtt-sync.ts create mode 100644 apps/web/src/session/__tests__/cloud-capability-sync.test.ts create mode 100644 apps/web/src/session/cloud-capabilities.ts create mode 100644 apps/web/src/session/cloud-capability-sync.ts diff --git a/apps/web/src/hooks/use-llm-sync.ts b/apps/web/src/hooks/use-llm-sync.ts deleted file mode 100644 index 93e908eb..00000000 --- a/apps/web/src/hooks/use-llm-sync.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect } from "react"; -import { useLlmProviderStore } from "@/stores/llm-provider"; -import { invokeCommand } from "@/lib/ipc"; -import { isDesktop } from "@/lib/platform"; - -export function useLlmSync() { - const providers = useLlmProviderStore((s) => s.providers); - const setStatus = useLlmProviderStore((s) => s.setStatus); - - useEffect(() => { - if (!isDesktop()) return; - - invokeCommand({ - type: "llm_sync_providers", - providers: providers.map((p) => ({ id: p.id, name: p.name, base_url: p.baseUrl, api_key: p.apiKey })), - }); - - for (const p of providers) { - setStatus(p.id, "testing"); - invokeCommand({ type: "llm_test_provider", baseUrl: p.baseUrl, apiKey: p.apiKey }) - .then((result) => setStatus(p.id, result.success ? "ok" : "error")); - } - }, [providers, setStatus]); -} - -export function useProviderStatus(providerId: string) { - return useLlmProviderStore((s) => s.statuses[providerId] ?? "idle"); -} diff --git a/apps/web/src/hooks/use-mqtt-sync.ts b/apps/web/src/hooks/use-mqtt-sync.ts deleted file mode 100644 index 2dda9e7f..00000000 --- a/apps/web/src/hooks/use-mqtt-sync.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useMqttBrokerStore, type ConnectionStatus } from "@/stores/mqtt-broker"; -import { invokeCommand, useListen, type BrokerStatusPayload } from "@/lib/ipc"; -import { isDesktop } from "@/lib/platform"; - -/** - * Hook that syncs MQTT broker configs to the Tauri backend. - * - On mount: syncs all brokers and connects to them - * - On broker changes: syncs updated configs - * - Listens for status updates from backend - */ -export function useMqttSync() { - const brokers = useMqttBrokerStore((s) => s.brokers); - const setStatuses = useMqttBrokerStore((s) => s.setStatuses); - const initialSyncDone = useRef(false); - - // Sync brokers to backend whenever they change - useEffect(() => { - if (!isDesktop()) return; - - const syncBrokers = async () => { - const result = await invokeCommand< - { type: "mqtt_sync_brokers"; brokers: typeof brokers }, - { data?: BrokerStatusPayload[] } - >({ - type: "mqtt_sync_brokers", - brokers: brokers.map((b) => ({ - id: b.id, - name: b.name, - url: b.url, - username: b.username, - password: b.password, - isDefault: b.isDefault - })), - }); - - if (result.success && result.data) { - const statusMap: Record = {}; - for (const status of result.data as unknown as BrokerStatusPayload[]) { - statusMap[status.id] = status.status; - } - setStatuses(statusMap); - } - - initialSyncDone.current = true; - }; - - syncBrokers(); - }, [brokers, setStatuses]); - - // Listen for status updates from backend - useListen({ - type: "mqtt-broker-status", - handler: (event) => { - const statusMap: Record = {}; - for (const status of event.payload) { - statusMap[status.id] = status.status; - } - setStatuses(statusMap); - }, - }); -} - -/** - * Get the connection status for a specific broker - */ -export function useBrokerStatus(brokerId: string): ConnectionStatus { - return useMqttBrokerStore((s) => s.statuses[brokerId] ?? "disconnected"); -} diff --git a/apps/web/src/routes/configuration/llm.tsx b/apps/web/src/routes/configuration/llm.tsx index cd880b94..1b6a2807 100644 --- a/apps/web/src/routes/configuration/llm.tsx +++ b/apps/web/src/routes/configuration/llm.tsx @@ -7,9 +7,8 @@ import { CircleIcon, CheckCircleIcon, XCircleIcon, Loader2Icon, BotIcon, } from "lucide-react"; -import { useLlmProviderStore, type LlmProviderConfig } from "@/stores/llm-provider"; +import { useLlmProviderStore, useProviderStatus, type LlmProviderConfig } from "@/stores/llm-provider"; import { track } from "@/lib/analytics"; -import { useProviderStatus } from "@/hooks/use-llm-sync"; import { invokeCommand } from "@/lib/ipc"; import { isDesktop } from "@/lib/platform"; import { Button } from "@/components/ui/button"; diff --git a/apps/web/src/routes/configuration/mqtt.tsx b/apps/web/src/routes/configuration/mqtt.tsx index 213d61c1..d44330f5 100644 --- a/apps/web/src/routes/configuration/mqtt.tsx +++ b/apps/web/src/routes/configuration/mqtt.tsx @@ -14,9 +14,8 @@ import { CircleIcon, } from "lucide-react"; -import { useMqttBrokerStore, type MqttBrokerConfig, type ConnectionStatus } from "@/stores/mqtt-broker"; +import { useMqttBrokerStore, useBrokerStatus, type MqttBrokerConfig, type ConnectionStatus } from "@/stores/mqtt-broker"; import { track } from "@/lib/analytics"; -import { useBrokerStatus } from "@/hooks/use-mqtt-sync"; import { Button } from "@/components/ui/button"; import { Card, diff --git a/apps/web/src/session/__tests__/cloud-capability-sync.test.ts b/apps/web/src/session/__tests__/cloud-capability-sync.test.ts new file mode 100644 index 00000000..f4fc6a45 --- /dev/null +++ b/apps/web/src/session/__tests__/cloud-capability-sync.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { + assembleHostSnapshot, + startCloudCapabilitySync, + type CloudCapability, +} from "../cloud-capability-sync"; +import type { HostSnapshot } from "../flow-update-dispatcher"; + +/** Minimal zustand-like store: a slice reference plus change notifications. */ +function makeFakeStore(initial: T) { + let slice = initial; + const listeners = new Set<() => void>(); + return { + read: () => slice, + subscribe: (onChange: () => void) => { + listeners.add(onChange); + return () => listeners.delete(onChange); + }, + /** Replace the slice (new reference) and notify, like a config edit. */ + setSlice: (next: T) => { + slice = next; + for (const l of listeners) l(); + }, + /** Notify without changing the slice reference, like a status update. */ + touch: () => { + for (const l of listeners) l(); + }, + get listenerCount() { + return listeners.size; + }, + }; +} + +function makeCapability(name: string, store: ReturnType>) { + const pushes: T[] = []; + const capability: CloudCapability = { + name, + sync: { + read: store.read, + subscribe: store.subscribe, + push: () => pushes.push(store.read()), + }, + snapshot: () => ({}), + }; + return { capability, pushes }; +} + +describe("startCloudCapabilitySync", () => { + test("pushes each capability once on start", () => { + const brokers = makeFakeStore([{ id: "b1" }]); + const providers = makeFakeStore([{ id: "p1" }]); + const mqtt = makeCapability("mqtt", brokers); + const llm = makeCapability("llm", providers); + + const stop = startCloudCapabilitySync([mqtt.capability, llm.capability]); + + expect(mqtt.pushes).toEqual([[{ id: "b1" }]]); + expect(llm.pushes).toEqual([[{ id: "p1" }]]); + stop(); + }); + + test("re-pushes when the config slice reference changes", () => { + const store = makeFakeStore([{ id: "b1" }]); + const { capability, pushes } = makeCapability("mqtt", store); + const stop = startCloudCapabilitySync([capability]); + + store.setSlice([{ id: "b1" }, { id: "b2" }]); + + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual([{ id: "b1" }, { id: "b2" }]); + stop(); + }); + + test("ignores store churn that keeps the same slice reference (status updates)", () => { + const store = makeFakeStore([{ id: "b1" }]); + const { capability, pushes } = makeCapability("mqtt", store); + const stop = startCloudCapabilitySync([capability]); + + store.touch(); + store.touch(); + + expect(pushes).toHaveLength(1); // only the initial push + stop(); + }); + + test("cleanup unsubscribes; later changes no longer push", () => { + const store = makeFakeStore([{ id: "b1" }]); + const { capability, pushes } = makeCapability("mqtt", store); + const stop = startCloudCapabilitySync([capability]); + + stop(); + store.setSlice([]); + + expect(pushes).toHaveLength(1); + expect(store.listenerCount).toBe(0); + }); + + test("starts and cleans up listen channels; snapshot-only capabilities need no sync", () => { + let listening = false; + const withListen: CloudCapability = { + name: "mqtt", + listen: () => { + listening = true; + return () => { + listening = false; + }; + }, + snapshot: () => ({}), + }; + const snapshotOnly: CloudCapability = { + name: "figma", + snapshot: () => ({ figma: { uniqueId: "u1" } }), + }; + + const stop = startCloudCapabilitySync([withListen, snapshotOnly]); + expect(listening).toBe(true); + stop(); + expect(listening).toBe(false); + }); +}); + +describe("assembleHostSnapshot", () => { + test("merges each capability's contribution into one HostSnapshot", () => { + const capabilities: CloudCapability[] = [ + { name: "mqtt", snapshot: () => ({ brokers: [] }) }, + { + name: "llm", + snapshot: () => ({ + providers: [ + { id: "p1", name: "openai", baseUrl: "https://x", apiKey: "k", isDefault: true }, + ], + }), + }, + { name: "figma", snapshot: () => ({ figma: { uniqueId: "sander" } }) }, + ]; + + const snapshot: HostSnapshot = assembleHostSnapshot(capabilities); + + expect(snapshot).toEqual({ + brokers: [], + providers: [{ id: "p1", name: "openai", baseUrl: "https://x", apiKey: "k", isDefault: true }], + figma: { uniqueId: "sander" }, + }); + }); +}); diff --git a/apps/web/src/session/cloud-capabilities.ts b/apps/web/src/session/cloud-capabilities.ts new file mode 100644 index 00000000..b596052c --- /dev/null +++ b/apps/web/src/session/cloud-capabilities.ts @@ -0,0 +1,114 @@ +import { useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { useMqttBrokerStore, type ConnectionStatus } from "@/stores/mqtt-broker"; +import { useLlmProviderStore } from "@/stores/llm-provider"; +import { useFigmaStore } from "@/stores/figma"; +import { invokeCommand, type BrokerStatusPayload } from "@/lib/ipc"; +import { isDesktop } from "@/lib/platform"; +import { + assembleHostSnapshot, + startCloudCapabilitySync, + type CloudCapability, +} from "./cloud-capability-sync"; +import type { HostSnapshot } from "./flow-update-dispatcher"; + +// Production cloud-capability registry: each entry owns its store slice, its +// push to the runtime host's Service Registry, and its HostSnapshot field. +// The driver + snapshot assembly live in `cloud-capability-sync.ts`. + +function toStatusMap(statuses: BrokerStatusPayload[]): Record { + const map: Record = {}; + for (const status of statuses) { + map[status.id] = status.status; + } + return map; +} + +const mqtt: CloudCapability = { + name: "mqtt", + sync: { + read: () => useMqttBrokerStore.getState().brokers, + subscribe: (onChange) => useMqttBrokerStore.subscribe(onChange), + push: async () => { + const { brokers, setStatuses } = useMqttBrokerStore.getState(); + const result = await invokeCommand< + { type: "mqtt_sync_brokers"; brokers: typeof brokers }, + { data?: BrokerStatusPayload[] } + >({ + type: "mqtt_sync_brokers", + brokers: brokers.map((b) => ({ + id: b.id, + name: b.name, + url: b.url, + username: b.username, + password: b.password, + isDefault: b.isDefault, + })), + }); + if (result.success && result.data) { + setStatuses(toStatusMap(result.data as unknown as BrokerStatusPayload[])); + } + }, + }, + // Runtime→store feedback: connection status pushed by the backend. + listen: () => { + const listener = listen("mqtt-broker-status", (event) => { + useMqttBrokerStore.getState().setStatuses(toStatusMap(event.payload)); + }); + return () => { + listener.then((unlisten) => unlisten()).catch((error) => console.error(error)); + }; + }, + snapshot: () => ({ brokers: useMqttBrokerStore.getState().brokers }), +}; + +const llm: CloudCapability = { + name: "llm", + sync: { + read: () => useLlmProviderStore.getState().providers, + subscribe: (onChange) => useLlmProviderStore.subscribe(onChange), + push: () => { + const { providers, setStatus } = useLlmProviderStore.getState(); + invokeCommand({ + type: "llm_sync_providers", + providers: providers.map((p) => ({ + id: p.id, + name: p.name, + base_url: p.baseUrl, + api_key: p.apiKey, + })), + }); + for (const p of providers) { + setStatus(p.id, "testing"); + invokeCommand({ type: "llm_test_provider", baseUrl: p.baseUrl, apiKey: p.apiKey }).then( + (result) => setStatus(p.id, result.success ? "ok" : "error"), + ); + } + }, + }, + snapshot: () => ({ providers: useLlmProviderStore.getState().providers }), +}; + +const figma: CloudCapability = { + name: "figma", + // No push: figma config reaches the runtime through the Figma node's Host + // Adapter `prepareData` patch in `buildFlowUpdate`, not a sync command. + snapshot: () => ({ figma: { uniqueId: useFigmaStore.getState().uniqueId } }), +}; + +export const CLOUD_CAPABILITIES: readonly CloudCapability[] = [mqtt, llm, figma]; + +/** `HostSnapshotProvider` for the `FlowUpdateDispatcher`, assembled from the + * same registry that drives the sync. */ +export function readHostSnapshot(): HostSnapshot { + return assembleHostSnapshot(CLOUD_CAPABILITIES); +} + +/** Mount the config→runtime sync driver for every cloud capability. Desktop + * only — the browser resolves cloud config live from the stores (CloudDeps). */ +export function useCloudCapabilitySync(): void { + useEffect(() => { + if (!isDesktop()) return; + return startCloudCapabilitySync(CLOUD_CAPABILITIES); + }, []); +} diff --git a/apps/web/src/session/cloud-capability-sync.ts b/apps/web/src/session/cloud-capability-sync.ts new file mode 100644 index 00000000..f190c8b6 --- /dev/null +++ b/apps/web/src/session/cloud-capability-sync.ts @@ -0,0 +1,70 @@ +import type { HostSnapshot } from "./flow-update-dispatcher"; + +/** + * One cloud capability (MQTT brokers, LLM providers, Figma) as the session + * layer sees it. The production entries live in `cloud-capabilities.ts`; this + * module stays store/IPC-free so the driver is testable in isolation (same + * discipline as the injected `NodeAdapterRegistry` on the dispatcher). + * + * - `sync` — the config→runtime driver: `read()` a reference-stable config + * slice from the owning zustand store, `subscribe` to that store, and + * `push()` the current config to the runtime host's Service Registry. + * - `listen` — optional runtime→store feedback channel (e.g. broker + * connection status events). Returns a cleanup. + * - `snapshot` — this capability's contribution to the dispatcher's + * `HostSnapshot`. + * + * Adding a capability = one store + one entry here + one `HostSnapshot` field. + */ +export type CloudCapability = { + name: string; + sync?: { + read(): unknown; + subscribe(onChange: () => void): () => void; + push(): void; + }; + listen?: () => () => void; + snapshot(): Partial; +}; + +/** + * Start the sync driver for every capability: push once on start, then + * re-push whenever the capability's config slice changes. Returns a cleanup + * that unsubscribes everything. + */ +export function startCloudCapabilitySync( + capabilities: readonly CloudCapability[], +): () => void { + const cleanups: Array<() => void> = []; + for (const { sync, listen } of capabilities) { + if (sync) { + let last = sync.read(); + sync.push(); + cleanups.push( + sync.subscribe(() => { + const next = sync.read(); + // Config slices are reference-stable in the stores; unrelated state + // churn (e.g. a status update) keeps the same reference → no re-push. + if (Object.is(next, last)) return; + last = next; + sync.push(); + }), + ); + } + if (listen) cleanups.push(listen()); + } + return () => { + for (const cleanup of cleanups) cleanup(); + }; +} + +/** Assemble the dispatcher's `HostSnapshot` from the capability registry — + * the same registry that drives the sync, so the two can't drift. */ +export function assembleHostSnapshot( + capabilities: readonly CloudCapability[], +): HostSnapshot { + return Object.assign( + {}, + ...capabilities.map((cap) => cap.snapshot()), + ) as HostSnapshot; +} diff --git a/apps/web/src/session/use-flow-update-dispatcher.ts b/apps/web/src/session/use-flow-update-dispatcher.ts index a03bd017..ab7e4127 100644 --- a/apps/web/src/session/use-flow-update-dispatcher.ts +++ b/apps/web/src/session/use-flow-update-dispatcher.ts @@ -1,13 +1,10 @@ import { useEffect, useState } from "react"; import { Debouncer } from "@tanstack/react-pacer"; import { NODE_REGISTRY } from "@/components/flow/nodes/_REGISTRY"; -import { useMqttBrokerStore } from "@/stores/mqtt-broker"; -import { useFigmaStore } from "@/stores/figma"; -import { useLlmProviderStore } from "@/stores/llm-provider"; +import { readHostSnapshot } from "./cloud-capabilities"; import { FlowUpdateDispatcher, type DispatchScheduler, - type HostSnapshot, } from "./flow-update-dispatcher"; import { TauriFlowUpdateSender } from "./tauri-flow-update-sender"; import { WasmFlowUpdateSender } from "./wasm-flow-update-sender"; @@ -33,14 +30,6 @@ class DebounceScheduler implements DispatchScheduler { } } -function readHostSnapshot(): HostSnapshot { - return { - brokers: useMqttBrokerStore.getState().brokers, - providers: useLlmProviderStore.getState().providers, - figma: { uniqueId: useFigmaStore.getState().uniqueId }, - }; -} - /** * Mount one `FlowUpdateDispatcher` for the active `FlowSession`. Caller is * responsible for `isDesktop()` gating — the dispatcher itself contains diff --git a/apps/web/src/stores/llm-provider.ts b/apps/web/src/stores/llm-provider.ts index ed457bb7..c1140892 100644 --- a/apps/web/src/stores/llm-provider.ts +++ b/apps/web/src/stores/llm-provider.ts @@ -60,3 +60,7 @@ export const useLlmProviderStore = create()( { name: "microflow-llm-providers", partialize: (s) => ({ providers: s.providers }) } ) ); + +export function useProviderStatus(providerId: string) { + return useLlmProviderStore((s) => s.statuses[providerId] ?? "idle"); +} diff --git a/apps/web/src/stores/mqtt-broker.ts b/apps/web/src/stores/mqtt-broker.ts index 053c21ca..8b678da6 100644 --- a/apps/web/src/stores/mqtt-broker.ts +++ b/apps/web/src/stores/mqtt-broker.ts @@ -100,3 +100,10 @@ export const useMqttBrokerStore = create()( } ) ); + +/** + * Get the connection status for a specific broker + */ +export function useBrokerStatus(brokerId: string): ConnectionStatus { + return useMqttBrokerStore((s) => s.statuses[brokerId] ?? "disconnected"); +} From 7e30f58326cdf95c7415a25b532bf1f7924f419c Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:17:50 +0200 Subject: [PATCH 07/12] feat(collab): flow-access roles and auth-client session helpers Add flow-access/flow-role routers (per-flow access checks) and extract getSession/getCustomerState helpers on the auth client, wiring them through the flow/login/profile routes. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/lib/auth-client.ts | 23 +++++ apps/web/src/routes/__root.tsx | 6 +- apps/web/src/routes/flow/$flowId.tsx | 8 +- apps/web/src/routes/flow/$flowId/circuit.tsx | 4 +- apps/web/src/routes/flow/$flowId/code.tsx | 4 +- apps/web/src/routes/login.tsx | 4 +- apps/web/src/routes/profile.tsx | 4 +- packages/api/src/routers/flow-access.test.ts | 50 ++++++++++ packages/api/src/routers/flow-access.ts | 42 +++++++++ packages/api/src/routers/flow-role.ts | 28 ++++++ packages/api/src/routers/flow.ts | 97 +++++++------------- 11 files changed, 188 insertions(+), 82 deletions(-) create mode 100644 packages/api/src/routers/flow-access.test.ts create mode 100644 packages/api/src/routers/flow-access.ts create mode 100644 packages/api/src/routers/flow-role.ts diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 23f33fd2..e4c5af66 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -27,3 +27,26 @@ export const authClient = createAuthClient({ }, plugins: [polarClient(), emailOTPClient()], }); + +/** + * getSession that never throws on network failure. When no server is reachable + * (offline / no server found) better-auth's fetch rejects with a raw TypeError; + * treat that as "no session" so route beforeLoads fall through to local/login + * instead of crashing the whole app with an unhandled route error. + */ +export async function getSession() { + try { + return await authClient.getSession(); + } catch { + return { data: null, error: null }; + } +} + +/** customer.state that resolves to null when the server is unreachable. */ +export async function getCustomerState() { + try { + return await authClient.customer.state(); + } catch { + return { data: null }; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 82d2a622..8aaae1ed 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,8 +19,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { useBoardEvents } from "@/stores/board"; -import { useMqttSync } from "@/hooks/use-mqtt-sync"; -import { useLlmSync } from "@/hooks/use-llm-sync"; +import { useCloudCapabilitySync } from "@/session/cloud-capabilities"; import { useUpdater } from "@/hooks/use-updater"; import { useDeepLink } from "@/hooks/use-deep-link"; import { useFigmaUniqueId, useFigmaStore } from "@/stores/figma"; @@ -106,8 +105,7 @@ function RootComponent() { function Board() { useBoardEvents(); - useMqttSync(); - useLlmSync(); + useCloudCapabilitySync(); useUpdater(); useBackendLogs(); useDeepLink(); diff --git a/apps/web/src/routes/flow/$flowId.tsx b/apps/web/src/routes/flow/$flowId.tsx index 157de1fa..98fc7b84 100644 --- a/apps/web/src/routes/flow/$flowId.tsx +++ b/apps/web/src/routes/flow/$flowId.tsx @@ -1,4 +1,4 @@ -import { authClient } from "@/lib/auth-client"; +import { getSession, getCustomerState } from "@/lib/auth-client"; import { useAppStore } from "@/stores/app"; import { useCircuitStore } from "@/stores/circuit-store"; import { @@ -68,10 +68,10 @@ function CircuitBuildListener() { export const Route = createFileRoute("/flow/$flowId")({ component: RouteComponent, beforeLoad: async ({ params }) => { - const session = await authClient.getSession(); + const session = await getSession(); if (params.flowId === "local") { - const { data: customerState } = await authClient.customer.state(); + const { data: customerState } = await getCustomerState(); return { session, customerState }; } @@ -82,7 +82,7 @@ export const Route = createFileRoute("/flow/$flowId")({ }); } - const { data: customerState } = await authClient.customer.state(); + const { data: customerState } = await getCustomerState(); return { session, customerState }; }, }); diff --git a/apps/web/src/routes/flow/$flowId/circuit.tsx b/apps/web/src/routes/flow/$flowId/circuit.tsx index fed9e38f..fb871144 100644 --- a/apps/web/src/routes/flow/$flowId/circuit.tsx +++ b/apps/web/src/routes/flow/$flowId/circuit.tsx @@ -1,9 +1,9 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { authClient } from "@/lib/auth-client"; +import { getSession } from "@/lib/auth-client"; export const Route = createFileRoute("/flow/$flowId/circuit")({ beforeLoad: async ({ params }) => { - const session = await authClient.getSession(); + const session = await getSession(); if (params.flowId === "local") { return { session }; diff --git a/apps/web/src/routes/flow/$flowId/code.tsx b/apps/web/src/routes/flow/$flowId/code.tsx index c0ee0d1b..57c60c85 100644 --- a/apps/web/src/routes/flow/$flowId/code.tsx +++ b/apps/web/src/routes/flow/$flowId/code.tsx @@ -1,9 +1,9 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { authClient } from "@/lib/auth-client"; +import { getSession } from "@/lib/auth-client"; export const Route = createFileRoute("/flow/$flowId/code")({ beforeLoad: async ({ params }) => { - const session = await authClient.getSession(); + const session = await getSession(); if (params.flowId === "local") { return { session }; diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index e30ec554..87b2b54e 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -1,11 +1,11 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { SignInForm } from "@/components/sign-in-form"; -import { authClient } from "@/lib/auth-client"; +import { getSession } from "@/lib/auth-client"; export const Route = createFileRoute("/login")({ beforeLoad: async () => { - const session = await authClient.getSession(); + const session = await getSession(); if (session.data) { throw redirect({ to: "/" }); } diff --git a/apps/web/src/routes/profile.tsx b/apps/web/src/routes/profile.tsx index 331160b9..4bb575ff 100644 --- a/apps/web/src/routes/profile.tsx +++ b/apps/web/src/routes/profile.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { trpc } from "@/lib/trpc"; -import { authClient } from "@/lib/auth-client"; +import { getSession } from "@/lib/auth-client"; import { Card, CardAction, @@ -45,7 +45,7 @@ const COLLAB_ICONS: IconName[] = [ export const Route = createFileRoute("/profile")({ beforeLoad: async () => { - const session = await authClient.getSession(); + const session = await getSession(); if (!session.data?.user) { throw redirect({ to: "/login" }); } diff --git a/packages/api/src/routers/flow-access.test.ts b/packages/api/src/routers/flow-access.test.ts new file mode 100644 index 00000000..67747397 --- /dev/null +++ b/packages/api/src/routers/flow-access.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { + assertFlowRole, + resolveFlowRole, + type FlowRole, +} from "./flow-role"; + +const OWNER = "user-owner"; +const OTHER = "user-other"; +const flowRecord = { ownerId: OWNER }; + +describe("resolveFlowRole", () => { + test("owner wins regardless of collaborator role", () => { + expect(resolveFlowRole(flowRecord, OWNER, undefined)).toBe("owner"); + expect(resolveFlowRole(flowRecord, OWNER, "viewer")).toBe("owner"); + }); + + test("non-owner gets their collaborator role, or null", () => { + expect(resolveFlowRole(flowRecord, OTHER, "editor")).toBe("editor"); + expect(resolveFlowRole(flowRecord, OTHER, "viewer")).toBe("viewer"); + expect(resolveFlowRole(flowRecord, OTHER, undefined)).toBeNull(); + expect(resolveFlowRole(flowRecord, OTHER, null)).toBeNull(); + }); +}); + +describe("assertFlowRole access matrix", () => { + const cases: Array<[FlowRole | null, FlowRole, boolean]> = [ + // [actual role, required role, allowed] + ["owner", "owner", true], + ["owner", "editor", true], + ["owner", "viewer", true], + ["editor", "owner", false], + ["editor", "editor", true], + ["editor", "viewer", true], + ["viewer", "owner", false], + ["viewer", "editor", false], + ["viewer", "viewer", true], + [null, "viewer", false], + [null, "editor", false], + [null, "owner", false], + ]; + + test.each(cases)("role=%p minRole=%p → allowed=%p", (role, minRole, allowed) => { + if (allowed) { + expect(assertFlowRole(role, minRole)).toBe(role as FlowRole); + } else { + expect(() => assertFlowRole(role, minRole)).toThrow("Access denied"); + } + }); +}); diff --git a/packages/api/src/routers/flow-access.ts b/packages/api/src/routers/flow-access.ts new file mode 100644 index 00000000..18662277 --- /dev/null +++ b/packages/api/src/routers/flow-access.ts @@ -0,0 +1,42 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "@microflow/db"; +import { flow, flowCollaborator } from "@microflow/db/schema/flow"; +import { assertFlowRole, resolveFlowRole, type FlowRole } from "./flow-role"; + +export { assertFlowRole, resolveFlowRole, type FlowRole } from "./flow-role"; + +/** + * Fetch a flow and enforce that `userId` has at least `minRole` on it. + * Throws "Flow not found" / "Access denied"; returns the row + resolved role. + */ +export async function requireFlowAccess( + flowId: string, + userId: string, + minRole: FlowRole +) { + const flowRecord = await db.query.flow.findFirst({ + where: eq(flow.id, flowId), + }); + + if (!flowRecord) { + throw new Error("Flow not found"); + } + + let collaboratorRole: FlowRole | undefined; + if (flowRecord.ownerId !== userId) { + const collaborator = await db.query.flowCollaborator.findFirst({ + where: and( + eq(flowCollaborator.flowId, flowId), + eq(flowCollaborator.userId, userId) + ), + }); + collaboratorRole = collaborator?.role as FlowRole | undefined; + } + + const role = assertFlowRole( + resolveFlowRole(flowRecord, userId, collaboratorRole), + minRole + ); + + return { flow: flowRecord, role }; +} diff --git a/packages/api/src/routers/flow-role.ts b/packages/api/src/routers/flow-role.ts new file mode 100644 index 00000000..273955dd --- /dev/null +++ b/packages/api/src/routers/flow-role.ts @@ -0,0 +1,28 @@ +export type FlowRole = "viewer" | "editor" | "owner"; + +const RANK: Record = { viewer: 0, editor: 1, owner: 2 }; + +/** + * Resolve the role a user has on a flow. The single source of truth for + * "who counts as what" — every procedure routes through this, whether it + * fetched the flow itself (get) or via requireFlowAccess. + */ +export function resolveFlowRole( + flowRecord: { ownerId: string }, + userId: string, + collaboratorRole: FlowRole | null | undefined +): FlowRole | null { + if (flowRecord.ownerId === userId) return "owner"; + return collaboratorRole ?? null; +} + +/** Throw unless `role` is at least `minRole`. Returns the role for convenience. */ +export function assertFlowRole( + role: FlowRole | null, + minRole: FlowRole +): FlowRole { + if (!role || RANK[role] < RANK[minRole]) { + throw new Error("Access denied"); + } + return role; +} diff --git a/packages/api/src/routers/flow.ts b/packages/api/src/routers/flow.ts index 099f9d2c..6380793f 100644 --- a/packages/api/src/routers/flow.ts +++ b/packages/api/src/routers/flow.ts @@ -5,6 +5,12 @@ import { flow, flowCollaborator, flowInvite } from "@microflow/db/schema/flow"; import { user } from "@microflow/db/schema/auth"; import { userSettings } from "@microflow/db/schema/user-settings"; import { protectedProcedure, router } from "../index"; +import { + assertFlowRole, + requireFlowAccess, + resolveFlowRole, + type FlowRole, +} from "./flow-access"; import { FlowDocument } from "@microflow/collab/server"; import { sendEmail } from "@microflow/auth/email"; import { env } from "@microflow/env/server"; @@ -137,16 +143,17 @@ export const flowRouter = router({ throw new Error("Flow not found"); } - // Check access - const isOwner = flowRecord.ownerId === userId; - const isCollaborator = flowRecord.collaborators.some( - ({ user }) => user.id === userId + const role = assertFlowRole( + resolveFlowRole( + flowRecord, + userId, + flowRecord.collaborators.find((c) => c.user.id === userId)?.role as + | FlowRole + | undefined + ), + "viewer" ); - if (!isOwner && !isCollaborator) { - throw new Error("Access denied"); - } - // Fetch collabColor and collabIcon for owner and all collaborators const userIds = [ flowRecord.owner.id, @@ -195,10 +202,8 @@ export const flowRouter = router({ nodes, edges, ydocBase64, - isOwner, - role: isOwner - ? "owner" - : flowRecord.collaborators.find((c) => c.user.id === userId)?.role, + isOwner: role === "owner", + role, }; }), @@ -293,13 +298,7 @@ export const flowRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const flowRecord = await db.query.flow.findFirst({ - where: eq(flow.id, input.id), - }); - - if (!flowRecord || flowRecord.ownerId !== ctx.session.user.id) { - throw new Error("Flow not found or access denied"); - } + await requireFlowAccess(input.id, ctx.session.user.id, "owner"); const updatedFlow = await db .update(flow) @@ -320,13 +319,11 @@ export const flowRouter = router({ delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - const flowRecord = await db.query.flow.findFirst({ - where: eq(flow.id, input.id), - }); - - if (!flowRecord || flowRecord.ownerId !== ctx.session.user.id) { - throw new Error("Flow not found or access denied"); - } + const { flow: flowRecord } = await requireFlowAccess( + input.id, + ctx.session.user.id, + "owner" + ); await db.delete(flow).where(eq(flow.id, input.id)); @@ -345,17 +342,7 @@ export const flowRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const flowRecord = await db.query.flow.findFirst({ - where: eq(flow.id, input.flowId), - }); - - if(!flowRecord) { - throw new Error("Flow not found"); - } - - if (flowRecord.ownerId !== ctx.session.user.id) { - throw new Error("Access denied"); - } + await requireFlowAccess(input.flowId, ctx.session.user.id, "owner"); const id = uid(); await db.insert(flowCollaborator).values({ @@ -379,17 +366,7 @@ export const flowRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const flowRecord = await db.query.flow.findFirst({ - where: eq(flow.id, input.flowId), - }); - - if(!flowRecord) { - throw new Error("Flow not found"); - } - - if (flowRecord.ownerId !== ctx.session.user.id) { - throw new Error("Access denied"); - } + await requireFlowAccess(input.flowId, ctx.session.user.id, "owner"); await db .delete(flowCollaborator) @@ -437,13 +414,11 @@ export const flowRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const flowRecord = await db.query.flow.findFirst({ - where: eq(flow.id, input.flowId), - }); - - if (!flowRecord || flowRecord.ownerId !== ctx.session.user.id) { - throw new Error("Flow not found or access denied"); - } + const { flow: flowRecord } = await requireFlowAccess( + input.flowId, + ctx.session.user.id, + "owner" + ); // Find user by email const targetUser = await db.query.user.findFirst({ @@ -529,17 +504,7 @@ export const flowRouter = router({ updateCollaboratorRole: protectedProcedure .input(z.object({ flowId: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]).default("viewer") })) .mutation(async ({ ctx, input }) => { - const flowRecord = await db.query.flow.findFirst({ - where: eq(flow.id, input.flowId), - }); - - if(!flowRecord) { - throw new Error("Flow not found"); - } - - if(flowRecord.ownerId !== ctx.session.user.id) { - throw new Error("Access denied"); - } + await requireFlowAccess(input.flowId, ctx.session.user.id, "owner"); const collaborator = await db.query.flowCollaborator.findFirst({ where: and(eq(flowCollaborator.flowId, input.flowId), eq(flowCollaborator.userId, input.userId)), From ad33add22d8931c45abd9b215e9378ad31b5a9a3 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:18:25 +0200 Subject: [PATCH 08/12] fix(desktop): stop the actor's wake loop from starving serial reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-threaded runtime actor owns the FlowRuntime and the serial port. Its loop drained ALL queued ActorMsgs before reading the port once, and each outbound write blocked on flush() (macOS tcdrain). With a 60fps oscillator arming a _tick every ~16ms, per-wake processing exceeded the tick period, so the message queue never emptied, pump_port() never ran, and feed_bytes was never called — every input (buttons, I2C, NFC) went silent while outbound flooded. Fix: read the port after each handled message (no message volume can starve reads), and drop the per-write flush (write_all sends in order; synchronous drain isn't needed). Note: mid-debug — buttons recover but I2C streaming is still under investigation; kept as its own commit for easy revert/iteration. Co-Authored-By: Claude Opus 4.8 --- apps/web/src-tauri/src/runtime/host.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/src-tauri/src/runtime/host.rs b/apps/web/src-tauri/src/runtime/host.rs index 24176f99..cfc28005 100644 --- a/apps/web/src-tauri/src/runtime/host.rs +++ b/apps/web/src-tauri/src/runtime/host.rs @@ -217,21 +217,29 @@ impl Actor { fn run(mut self, mut rx: UnboundedReceiver) { loop { - // Drain every queued message first. + // Drain queued messages — but read the serial port after EACH one so a + // relentless outbound wake loop can never starve inbound reads. A 60fps + // oscillator arms a `_tick` every ~16ms; if we drained the WHOLE queue + // before reading (as this once did) and processing a wake takes longer + // than that period, the queue never empties, `pump_port` never runs, + // and every input (buttons, I2C, NFC) goes silent while outbound floods. loop { match rx.try_recv() { Ok(msg) => { if !self.handle(msg) { return; } + if self.port.is_some() { + self.pump_port(); + } } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => return, } } - // Connected: interleave a short serial read with message draining. - // Disconnected: block until the next message (no busy spin). + // Idle: keep reading while connected; block for the next message when + // disconnected (no busy spin). if self.port.is_some() { self.pump_port(); } else { @@ -375,7 +383,12 @@ impl Actor { impl EffectsSink for Actor { fn write_bytes(&mut self, bytes: &[u8]) { if let Some(port) = self.port.as_mut() { - if let Err(e) = port.write_all(bytes).and_then(|()| port.flush()) { + // No `flush()`: on macOS it blocks (`tcdrain`) until the bytes finish + // transmitting, so flushing every outbound batch made a 60fps + // oscillator's write take longer than its tick period and starve the + // serial read (see `run`). `write_all` hands the bytes to the OS in + // order; the kernel transmits them — synchronous drain isn't needed. + if let Err(e) = port.write_all(bytes) { log::warn!("[actor] serial write error: {e}; dropping board"); self.port = None; self.connected.store(false, Ordering::Release); From 107c32017a748bb2507df71f3d26178f58ca9bb0 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:19:20 +0200 Subject: [PATCH 09/12] feat(midi): declare desktop runtime midi module Wire the desktop MidiManager (runtime/midi.rs) into the actor's module tree. Co-Authored-By: Claude Opus 4.8 --- apps/web/src-tauri/src/runtime/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src-tauri/src/runtime/mod.rs b/apps/web/src-tauri/src/runtime/mod.rs index 3902d9d0..a47d80c5 100644 --- a/apps/web/src-tauri/src/runtime/mod.rs +++ b/apps/web/src-tauri/src/runtime/mod.rs @@ -24,4 +24,5 @@ pub mod commands; pub mod host; +pub mod midi; pub mod services; From 1a92115a50e0b6a05f628e67aad23ae08cb357a9 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:44:46 +0200 Subject: [PATCH 10/12] ci: install libasound2-dev on linux for alsa-sys (midir) The MIDI node pulls midir, whose Linux backend (alsa-sys) needs the ALSA headers to build. All three Linux CI jobs (clippy, test, build-tauri) failed on `failed to run custom build command for alsa-sys`; add the dev package alongside the existing libudev-dev. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 3 ++- .github/workflows/release.yml | 3 ++- .github/workflows/rust.yml | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f9c199a5..08659a77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,7 +71,8 @@ jobs: libappindicator3-dev \ librsvg2-dev \ patchelf \ - libudev-dev + libudev-dev \ + libasound2-dev - name: Install dependencies run: bun install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 891e7ba2..1c71928a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,7 +149,8 @@ jobs: libappindicator3-dev \ librsvg2-dev \ patchelf \ - libudev-dev + libudev-dev \ + libasound2-dev - name: Install dependencies run: bun install diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b5243dc8..d9c21a5b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,7 +32,8 @@ jobs: libappindicator3-dev \ librsvg2-dev \ patchelf \ - libudev-dev + libudev-dev \ + libasound2-dev # Whole workspace: app_lib plus the extracted microflow-core and # microflow-codegen-wasm crates (the browser code generator). @@ -62,7 +63,8 @@ jobs: libappindicator3-dev \ librsvg2-dev \ patchelf \ - libudev-dev + libudev-dev \ + libasound2-dev - name: Test run: cargo test --workspace --lib --tests From ef0ef6230b8a4d4847fbf4998a00578a35853dd5 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 21:44:46 +0200 Subject: [PATCH 11/12] fix(clippy): satisfy 1.97 pedantic lints CI clippy (rust 1.97) is stricter than local (1.95): - backtick FortySevenEffects in the midi emitter doc (doc_markdown) - if let/else over a two-arm match in the midi step sequencer (single_match) - allow many_single_char_names on the noise-lattice fns (conventional t/i/f/a/b) Co-Authored-By: Claude Opus 4.8 --- .../microflow-core/src/codegen/cloud/midi.rs | 2 +- .../microflow-core/src/runtime/cloud/midi.rs | 21 ++++++++----------- .../src/runtime/generator/oscillator.rs | 2 ++ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/microflow-core/src/codegen/cloud/midi.rs b/crates/microflow-core/src/codegen/cloud/midi.rs index f6c0a38d..2b79f85a 100644 --- a/crates/microflow-core/src/codegen/cloud/midi.rs +++ b/crates/microflow-core/src/codegen/cloud/midi.rs @@ -3,7 +3,7 @@ //! The live Midi node speaks to host MIDI ports (Web MIDI / `midir`). On-device //! there is no host: the generated sketch speaks **serial MIDI** (the DIN-5 jack //! / MIDI shield convention, 31250 baud on the board's primary hardware serial) -//! via the ubiquitous FortySevenEffects Arduino MIDI Library (`MIDI.h`), +//! via the ubiquitous `FortySevenEffects` Arduino MIDI Library (`MIDI.h`), //! `MIDI_CREATE_DEFAULT_INSTANCE()`. The node's `deviceName` filter is //! meaningless here — the jack IS the device — and validation warns that the //! hardware UART is claimed. diff --git a/crates/microflow-core/src/runtime/cloud/midi.rs b/crates/microflow-core/src/runtime/cloud/midi.rs index e805fabf..417dcf00 100644 --- a/crates/microflow-core/src/runtime/cloud/midi.rs +++ b/crates/microflow-core/src/runtime/cloud/midi.rs @@ -195,19 +195,16 @@ impl Midi { if let Some(note) = self.sounding.take() { self.send_bytes(ctx, vec![self.status(NOTE_OFF), note, 0]); } - match self.steps.get(self.cursor).copied() { - Some(step) => { - self.cursor += 1; - if let Some(note) = step.note { - self.send_bytes(ctx, vec![self.status(NOTE_ON), note, step.velocity]); - self.sounding = Some(note); - } - ctx.schedule_wakeup("_note", step.duration_ms); - } - None => { - self.is_playing = false; - self.base.set_value(ComponentValue::Number(0.0)); + if let Some(step) = self.steps.get(self.cursor).copied() { + self.cursor += 1; + if let Some(note) = step.note { + self.send_bytes(ctx, vec![self.status(NOTE_ON), note, step.velocity]); + self.sounding = Some(note); } + ctx.schedule_wakeup("_note", step.duration_ms); + } else { + self.is_playing = false; + self.base.set_value(ComponentValue::Number(0.0)); } Ok(()) } diff --git a/crates/microflow-core/src/runtime/generator/oscillator.rs b/crates/microflow-core/src/runtime/generator/oscillator.rs index 1c85b68c..092cdd1b 100644 --- a/crates/microflow-core/src/runtime/generator/oscillator.rs +++ b/crates/microflow-core/src/runtime/generator/oscillator.rs @@ -151,6 +151,7 @@ fn hash01(n: f64) -> f64 { /// Bounded random walk in [0, shift+amplitude): linear interpolation between a /// random lattice value per period — the output drifts to a new random target /// every `period` ms instead of jumping every sample like `Random`. +#[allow(clippy::many_single_char_names)] // t/i/f/a/b are the conventional noise-lattice names fn random_walk(config: &OscillatorConfig, timestamp: f64) -> f64 { let t = (timestamp + config.phase) / config.period; let i = t.floor(); @@ -161,6 +162,7 @@ fn random_walk(config: &OscillatorConfig, timestamp: f64) -> f64 { } /// Smoothstep-faded value noise in [0, 1) with `period` as the wavelength. +#[allow(clippy::many_single_char_names)] // t/i/f/u/a/b are the conventional noise-lattice names fn value_noise(t: f64) -> f64 { let i = t.floor(); let f = t - i; From e7cf8324054526893c4c5c8b4952562317bc115e Mon Sep 17 00:00:00 2001 From: xiduzo Date: Sat, 18 Jul 2026 22:01:37 +0200 Subject: [PATCH 12/12] fix(clippy): add must_use to desktop device_matches The alsa fix let CI clippy compile the desktop crate on Linux for the first time, surfacing one more pedantic lint (must_use_candidate) that macOS/local builds also have but CI now enforces workspace-wide. Co-Authored-By: Claude Opus 4.8 --- apps/web/src-tauri/src/runtime/midi.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src-tauri/src/runtime/midi.rs b/apps/web/src-tauri/src/runtime/midi.rs index c7d50dfa..8cd9cc91 100644 --- a/apps/web/src-tauri/src/runtime/midi.rs +++ b/apps/web/src-tauri/src/runtime/midi.rs @@ -20,6 +20,7 @@ use tokio::sync::mpsc::UnboundedSender; const CLIENT: &str = "microflow"; /// Case-insensitive substring match; an empty filter matches every port. +#[must_use] pub fn device_matches(port_name: &str, filter: &str) -> bool { filter.is_empty() || port_name.to_lowercase().contains(&filter.to_lowercase()) }