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 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/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-tauri/src/runtime/host.rs b/apps/web/src-tauri/src/runtime/host.rs index 4cf63d2c..cfc28005 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(), } } @@ -209,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 { @@ -283,6 +299,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 +317,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); @@ -352,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); @@ -385,6 +421,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 +536,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 +726,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 +752,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..8cd9cc91 --- /dev/null +++ b/apps/web/src-tauri/src/runtime/midi.rs @@ -0,0 +1,143 @@ +//! 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. +#[must_use] +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-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; 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/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/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/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/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/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/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/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/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/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/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/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/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/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"); +} 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/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/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..2b79f85a --- /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/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/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/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/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/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/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/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/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/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")] 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-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..417dcf00 --- /dev/null +++ b/crates/microflow-core/src/runtime/cloud/midi.rs @@ -0,0 +1,618 @@ +//! 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]); + } + 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(()) + } +} + +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/generator/oscillator.rs b/crates/microflow-core/src/runtime/generator/oscillator.rs index 7d0fc0ec..092cdd1b 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,48 @@ 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`. +#[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(); + 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. +#[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; + 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"] 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() { 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(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()); diff --git a/crates/microflow-runtime-wasm/src/lib.rs b/crates/microflow-runtime-wasm/src/lib.rs index 99090590..e0798452 100644 --- a/crates/microflow-runtime-wasm/src/lib.rs +++ b/crates/microflow-runtime-wasm/src/lib.rs @@ -178,6 +178,22 @@ impl FlowRuntime { .map_err(|e| JsError::new(&format!("failed to serialize subscriptions: {e}"))) } + /// The active `MidiIn` nodes' device interests: a JSON array of + /// `{ nodeId, deviceName }` (`deviceName` = case-insensitive substring + /// filter, "" = every device). No reconcile — every listener whose filter + /// matches a port receives its messages; the browser host opens matching + /// Web MIDI inputs and routes each raw `[status, data1, data2]` back via + /// [`deliver_message`](FlowRuntime::deliver_message) with the port name as + /// `topic`. + /// + /// # Errors + /// `JsError` only if the listener list fails to serialize. + #[wasm_bindgen(js_name = midiListeners)] + pub fn midi_listeners(&self) -> 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 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)),