diff --git a/lp-app/lpa-studio-core/src/app/node/face/mod.rs b/lp-app/lpa-studio-core/src/app/node/face/mod.rs index f3c9520a1..d6f91d041 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/mod.rs @@ -6,24 +6,33 @@ //! hand-built per node kind; only the front-panel metadata (which slots are //! on the panel, widget kind, range/unit) is data-driven from slot shapes. //! -//! Faces exist for shader, fixture, and playlist nodes, derived from the -//! finished section DTOs in `node_face_builder` (project layer) so a panel -//! control and its backing slot row can never disagree. Every other kind -//! gets `None` and renders today's generic sections — the universal -//! fallback, also always available inside the advanced drawer. +//! Faces exist for shader, fixture, playlist, button, and output nodes, +//! derived from the finished section DTOs in `node_face_builder` (project +//! layer) so a panel control and its backing slot row can never disagree. +//! Every other kind gets `None` and renders today's generic sections — the +//! universal fallback, also always available inside the advanced drawer. +//! +//! The button and output faces are the runtime-command channel's faces: +//! their affordance is not an edit at all but a poke at the live runtime +//! (simulate a press; drive a diagnostic pattern), so they carry a node +//! ADDRESS and action constructors rather than slot addresses. +mod ui_button_face; mod ui_fixture_face; mod ui_fixture_power; mod ui_node_face; +mod ui_output_face; mod ui_panel_control; mod ui_panel_widget; mod ui_playlist_entry; mod ui_playlist_face; mod ui_shader_face; +pub use ui_button_face::UiButtonFace; pub use ui_fixture_face::UiFixtureFace; pub use ui_fixture_power::UiFixturePower; pub use ui_node_face::UiNodeFace; +pub use ui_output_face::UiOutputFace; pub use ui_panel_control::UiPanelControl; pub use ui_panel_widget::UiPanelWidget; pub use ui_playlist_entry::UiPlaylistEntry; diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_button_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_button_face.rs new file mode 100644 index 000000000..d3f9d17c3 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_button_face.rs @@ -0,0 +1,107 @@ +//! The button card's permanent face. + +use lpc_wire::WireButtonEvent; + +use crate::{ButtonEventOp, ControllerId, ProjectController, ProjectNodeAddress, UiAction}; + +/// Permanent face for a button node card. +/// +/// The face is one affordance — simulate a press from the Studio, so a show +/// can be rehearsed (and a binding proven) without reaching for the physical +/// button. The endpoint and message id ride along as the button's identity; +/// everything else stays in the advanced drawer's slot view. +/// +/// The face does NOT carry a ready-made action, because a hold's `press_id` +/// is minted per gesture in the view layer. It carries the ADDRESS and the +/// three constructors below, so the op type and its controller routing stay +/// in core while the timing (press window, renewal cadence) stays where the +/// pointer events are. +#[derive(Clone, Debug, PartialEq)] +pub struct UiButtonFace { + /// Stable address of the button node the simulate-press control pokes. + pub node: ProjectNodeAddress, + /// Authored hardware endpoint (`button:gpio:D9`), when the def's row + /// resolved — the button's physical identity, shown beside the control. + pub endpoint: Option, + /// Authored stable message id the button publishes under, when the + /// def's row resolved. + pub id: Option, +} + +impl UiButtonFace { + /// A tap: the minimal down-then-up pair, no hold to manage. + pub fn click_action(&self) -> UiAction { + self.action(WireButtonEvent::Click) + } + + /// Begin — or RENEW — a sustained hold. Renewals re-send this same + /// action (same `press_id`); the runtime auto-releases at `ttl_ms` if + /// they stop, which is what makes an unmounted card safe. + pub fn press_action(&self, press_id: u32, ttl_ms: u32) -> UiAction { + self.action(WireButtonEvent::Press { press_id, ttl_ms }) + } + + /// End the hold started with `press_id`. + pub fn release_action(&self, press_id: u32) -> UiAction { + self.action(WireButtonEvent::Release { press_id }) + } + + fn action(&self, event: WireButtonEvent) -> UiAction { + UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + ButtonEventOp { + node: self.node.clone(), + event, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn face() -> UiButtonFace { + UiButtonFace { + node: ProjectNodeAddress::parse("/demo.project/panel.button").unwrap(), + endpoint: Some("button:gpio:D9".to_string()), + id: Some(1), + } + } + + #[test] + fn every_event_targets_the_project_controller_with_the_faces_address() { + let face = face(); + + for action in [ + face.click_action(), + face.press_action(7, 5000), + face.release_action(7), + ] { + assert!(action.is_for_node(ProjectController::NODE_ID)); + let op = action + .op_as::() + .expect("a button event op rides the action"); + assert_eq!(op.node, face.node); + } + } + + #[test] + fn a_renewal_rebuilds_the_identical_press() { + let face = face(); + assert_eq!( + face.press_action(7, 5000).op_as::().unwrap(), + face.press_action(7, 5000).op_as::().unwrap(), + ); + assert_eq!( + face.press_action(7, 5000) + .op_as::() + .unwrap() + .event, + WireButtonEvent::Press { + press_id: 7, + ttl_ms: 5000, + }, + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs index 1a5ad89f3..971ee060c 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs @@ -1,6 +1,6 @@ //! The kind-specific face variants a node card can render. -use crate::{UiFixtureFace, UiPlaylistFace, UiShaderFace}; +use crate::{UiButtonFace, UiFixtureFace, UiOutputFace, UiPlaylistFace, UiShaderFace}; /// Kind-specific permanent face for a node card. /// @@ -16,4 +16,9 @@ pub enum UiNodeFace { /// Playlist card: entry strip; the active child's real card renders /// below via the existing [`crate::UiNodeChild`]. Playlist(UiPlaylistFace), + /// Button card: the simulate-press control (a runtime poke, not an + /// edit). + Button(UiButtonFace), + /// Output card: the test-pattern toggle (a runtime poke, not an edit). + Output(UiOutputFace), } diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_output_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_output_face.rs new file mode 100644 index 000000000..2004b1078 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_output_face.rs @@ -0,0 +1,118 @@ +//! The output card's permanent face. + +use lpc_wire::WireOutputTestPattern; + +use crate::{ + ControllerId, OutputTestPatternOp, ProjectController, ProjectNodeAddress, UiAction, + UiProducedProduct, +}; + +/// Permanent face for an output node card. +/// +/// The face is one affordance — drive the strip with a diagnostic pattern — +/// answering the first question anyone asks of hardware: are these LEDs +/// wired, addressed, and alive at all? The endpoint rides along as the +/// output's identity; everything else stays in the advanced drawer's slot +/// view. +/// +/// Like [`crate::UiButtonFace`] the face carries the ADDRESS plus the two +/// constructors below rather than a ready-made action: the op type and its +/// controller routing stay in core, the renewal cadence stays where the +/// timers are. +#[derive(Clone, Debug, PartialEq)] +pub struct UiOutputFace { + /// Stable address of the output node the test pattern is aimed at. + pub node: ProjectNodeAddress, + /// Authored hardware endpoint (`ws281x:rmt:D10`), when the def's row + /// resolved — the output's physical identity. + pub endpoint: Option, + /// What the output is currently being fed, when the node publishes a + /// control product to preview. + pub preview: Option, +} + +impl UiOutputFace { + /// Start — or RENEW — full-white test output. The runtime restores the + /// graph's own frames at `ttl_ms` if the renewals stop, which is what + /// makes an unmounted card (or a closed tab) safe. + pub fn test_pattern_action(&self, ttl_ms: u32) -> UiAction { + self.action( + WireOutputTestPattern::Solid { + r: 255, + g: 255, + b: 255, + }, + ttl_ms, + ) + } + + /// End test-pattern mode now, without waiting for the TTL. + pub fn clear_action(&self) -> UiAction { + self.action(WireOutputTestPattern::Clear, 0) + } + + fn action(&self, pattern: WireOutputTestPattern, ttl_ms: u32) -> UiAction { + UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + OutputTestPatternOp { + node: self.node.clone(), + pattern, + ttl_ms, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn face() -> UiOutputFace { + UiOutputFace { + node: ProjectNodeAddress::parse("/demo.project/strip.output").unwrap(), + endpoint: Some("ws281x:rmt:D10".to_string()), + preview: None, + } + } + + #[test] + fn both_sends_target_the_project_controller_with_the_faces_address() { + let face = face(); + + for action in [face.test_pattern_action(2000), face.clear_action()] { + assert!(action.is_for_node(ProjectController::NODE_ID)); + let op = action + .op_as::() + .expect("a test-pattern op rides the action"); + assert_eq!(op.node, face.node); + } + } + + #[test] + fn the_pattern_is_full_white_and_clearing_carries_no_ttl() { + let face = face(); + + let on = face + .test_pattern_action(2000) + .op_as::() + .unwrap() + .clone(); + assert_eq!( + on.pattern, + WireOutputTestPattern::Solid { + r: 255, + g: 255, + b: 255, + } + ); + assert_eq!(on.ttl_ms, 2000); + + let off = face + .clear_action() + .op_as::() + .unwrap() + .clone(); + assert_eq!(off.pattern, WireOutputTestPattern::Clear); + assert_eq!(off.ttl_ms, 0); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/mod.rs b/lp-app/lpa-studio-core/src/app/node/mod.rs index 8ff97e7f3..f694158ee 100644 --- a/lp-app/lpa-studio-core/src/app/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/mod.rs @@ -38,8 +38,8 @@ mod ui_slot_unit; mod ui_slot_value; pub use face::{ - UiFixtureFace, UiFixturePower, UiNodeFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry, - UiPlaylistFace, UiShaderFace, + UiButtonFace, UiFixtureFace, UiFixturePower, UiNodeFace, UiOutputFace, UiPanelControl, + UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiShaderFace, }; pub use ui_asset_editor::UiAssetEditor; pub use ui_binding_authoring::{UiBindingAuthoring, UiBindingAuthoringDirection, UiChannelChoice}; diff --git a/lp-app/lpa-studio-core/src/app/project/mod.rs b/lp-app/lpa-studio-core/src/app/project/mod.rs index e14cf6907..bf8928b0c 100644 --- a/lp-app/lpa-studio-core/src/app/project/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/mod.rs @@ -57,10 +57,10 @@ pub use asset::{ pub use dirty_summary::DirtySummary; pub use loaded_project_choice::LoadedProjectChoice; pub use node::{ - NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, - NodeRevertOp, PlaylistActivateOp, ProjectNodeAddress, ProjectNodeTarget, - ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, UiAttachTarget, - UiNodeRemovePreflight, + ButtonEventOp, NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, + NodeRemoveOp, NodeRevertOp, OutputTestPatternOp, PlaylistActivateOp, ProjectNodeAddress, + ProjectNodeTarget, ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, + UiAttachTarget, UiNodeRemovePreflight, }; pub use node_card_ui_state::{NodeCardDrawer, NodeCardUiState, NodeUiOp}; pub use project_connect_result::ProjectConnectResult; diff --git a/lp-app/lpa-studio-core/src/app/project/node/button_event_op.rs b/lp-app/lpa-studio-core/src/app/project/node/button_event_op.rs new file mode 100644 index 000000000..c303328d4 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/button_event_op.rs @@ -0,0 +1,155 @@ +//! Synthetic button-event operation (the runtime command channel's +//! simulate-press consumer). + +use core::any::Any; + +use lpc_wire::WireButtonEvent; + +use crate::{ + ActionClass, ActionMeta, ActionPriority, ControllerOp, PROJECT_EDITOR_ACTION_DEADLINE, + ProjectNodeAddress, +}; + +/// Poke a button node's runtime with a SYNTHETIC event, via +/// `WireProjectCommand::NodeCommand` → `ButtonNode` (the button face's +/// simulate-press control). A runtime poke, not an edit: nothing is staged +/// in the overlay, nothing shows in the Save panel, and the event lands on +/// the engine's next frame exactly as a real GPIO transition would. +/// +/// Dispatched to `ProjectController::NODE_ID` like [`crate::SlotEditOp`]; +/// the controller resolves the button's CURRENT runtime `NodeId` from the +/// stable authored address at dispatch time, so a queued click can never +/// address a stale runtime id across a project reload. +/// +/// A sustained hold ([`WireButtonEvent::Press`]) is a REPEATED op, not a +/// long-running one: the face re-sends the same `press_id` on a renewal +/// cadence while held, and the device-side TTL clears the hold on its own +/// if the renewals stop (tab closed, card unmounted). There is therefore no +/// background action class here — every event is the same foreground poke. +#[derive(Clone, Debug, PartialEq)] +pub struct ButtonEventOp { + /// Stable address of the button node the event is addressed to. + pub node: ProjectNodeAddress, + /// The synthetic event: a click, a press (begin/renew), or a release. + pub event: WireButtonEvent, +} + +impl ButtonEventOp { + /// Human label for the event, used in the action meta and in the + /// rejection notice ("Couldn't … the button: …"). + fn verb(&self) -> &'static str { + match self.event { + WireButtonEvent::Click => "Press", + WireButtonEvent::Press { .. } => "Hold button", + WireButtonEvent::Release { .. } => "Release button", + } + } +} + +impl ControllerOp for ButtonEventOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new( + self.verb(), + "Send a synthetic event to this button's runtime now.", + ActionPriority::Primary, + ) + } + + fn action_class(&self) -> ActionClass { + // Same editor foreground class as the playlist activate poke: a + // press should feel immediate, and a rejection surfaces as a notice. + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn op(event: WireButtonEvent) -> ButtonEventOp { + ButtonEventOp { + node: ProjectNodeAddress::parse("/demo.project/panel.button").unwrap(), + event, + } + } + + #[test] + fn button_events_are_editor_foreground_class() { + let click = op(WireButtonEvent::Click); + + assert_eq!( + click.action_class(), + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + ); + assert_eq!(click.default_action_meta().label, "Press"); + assert_eq!( + click.default_action_meta().priority, + ActionPriority::Primary + ); + } + + #[test] + fn press_and_release_label_the_hold_they_drive() { + assert_eq!( + op(WireButtonEvent::Press { + press_id: 9, + ttl_ms: 5000, + }) + .default_action_meta() + .label, + "Hold button" + ); + assert_eq!( + op(WireButtonEvent::Release { press_id: 9 }) + .default_action_meta() + .label, + "Release button" + ); + } + + #[test] + fn press_ids_distinguish_otherwise_identical_ops() { + // Renewals re-send the SAME op (equal), a new gesture does not — + // the queue must never coalesce two distinct holds. + assert_eq!( + op(WireButtonEvent::Press { + press_id: 1, + ttl_ms: 5000 + }), + op(WireButtonEvent::Press { + press_id: 1, + ttl_ms: 5000 + }) + ); + assert_ne!( + op(WireButtonEvent::Press { + press_id: 1, + ttl_ms: 5000 + }), + op(WireButtonEvent::Press { + press_id: 2, + ttl_ms: 5000 + }) + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/node/mod.rs b/lp-app/lpa-studio-core/src/app/project/node/mod.rs index 9eb62ff53..3178fb6a1 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/mod.rs @@ -5,6 +5,7 @@ //! [`ProjectNodeTarget`] adds the current runtime `NodeId` for actions that //! need to talk back to the server. +pub mod button_event_op; pub mod node_controller; pub mod node_create_op; pub(in crate::app::project) mod node_face_builder; @@ -13,11 +14,13 @@ pub mod node_remove_op; pub mod node_remove_preflight; pub mod node_revert_op; pub mod node_share_op; +pub mod output_test_pattern_op; pub mod playlist_activate_op; pub mod project_node_address; pub mod project_node_target; pub mod ui_add_node_menu; +pub use button_event_op::ButtonEventOp; pub(in crate::app::project) use node_controller::root_slot_key; pub use node_controller::{NodeController, NodeControllerState, ProjectProductSubscriptionIntent}; pub use node_create_op::{NodeCreateOp, UiAttachTarget}; @@ -25,6 +28,7 @@ pub use node_remove_op::NodeRemoveOp; pub use node_remove_preflight::UiNodeRemovePreflight; pub use node_revert_op::NodeRevertOp; pub use node_share_op::{NodeCopyOp, NodePasteOp}; +pub use output_test_pattern_op::OutputTestPatternOp; pub use playlist_activate_op::PlaylistActivateOp; pub use project_node_address::ProjectNodeAddress; pub use project_node_target::ProjectNodeTarget; diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs index 522cc5caf..fb13f1bba 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs @@ -27,21 +27,29 @@ //! preview snapshot, node-select action). Deriving the face ALSO //! enforces the sibling invariant: the children list keeps ONLY the //! active entry's child (see [`kind_face`]). +//! - **Button** / **Output**: the runtime-poke faces (node-actions P4). +//! Their affordance is a command at the LIVE runtime, not a slot edit, so +//! they derive from the node's stable ADDRESS and always exist; the +//! section DTOs only supply the readouts beside the control (endpoint, +//! message id, the control product the output is fed). Every op the +//! controls dispatch is built by the face DTO itself (`UiButtonFace`, +//! `UiOutputFace`) so the view layer owns only the timing. //! - Every other kind returns `None` — the card keeps today's generic //! sections. use lpc_model::{ - FixtureDef, LpValue, PlaylistDef, ShaderDef, ShaderValueShapeRef, shader_panel_step, + ButtonDef, FixtureDef, LpValue, OutputDef, PlaylistDef, ShaderDef, ShaderValueShapeRef, + shader_panel_step, }; use crate::app::project::format_lp_value; use crate::{ ControllerId, PlaylistActivateOp, ProjectController, ProjectNodeAddress, ProjectSlotAddress, - UiAction, UiAssetEditor, UiAssetEditorKind, UiConfigSlot, UiConfigSlotBody, UiFixtureFace, - UiFixturePower, UiNodeChild, UiNodeFace, UiNodeSection, UiPanelControl, UiPanelWidget, - UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, UiProductKind, UiProductPreview, - UiShaderFace, UiSlotAspect, UiSlotAspectKind, UiSlotEditorHint, UiSlotSourceState, UiSlotValue, - UiSlotValueKind, + UiAction, UiAssetEditor, UiAssetEditorKind, UiButtonFace, UiConfigSlot, UiConfigSlotBody, + UiFixtureFace, UiFixturePower, UiNodeChild, UiNodeFace, UiNodeSection, UiOutputFace, + UiPanelControl, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, + UiProductKind, UiProductPreview, UiShaderFace, UiSlotAspect, UiSlotAspectKind, + UiSlotEditorHint, UiSlotSourceState, UiSlotValue, UiSlotValueKind, }; /// Build the kind-specific face for a node's card from its projected @@ -82,6 +90,8 @@ pub(in crate::app::project) fn kind_face( // ACTIVE placard is the active-ness presentation. Some(UiNodeFace::Playlist(face)) } + ButtonDef::KIND => Some(UiNodeFace::Button(button_face(address, sections))), + OutputDef::KIND => Some(UiNodeFace::Output(output_face(address, sections))), // Unknown kinds stay on the generic fallback permanently. _ => None, } @@ -137,6 +147,63 @@ fn fixture_power(sections: &[UiNodeSection]) -> Option { }) } +// -- runtime-poke faces (button, output) -------------------------------------- + +/// The button card's face: the simulate-press control, plus the button's +/// hardware identity. Unlike the shader/fixture faces this needs nothing +/// from the section DTOs to exist — the affordance is a poke at the runtime, +/// addressed by the node's own stable address — so it always derives and the +/// endpoint/id readouts degrade to `None` on their own. +fn button_face(address: &ProjectNodeAddress, sections: &[UiNodeSection]) -> UiButtonFace { + UiButtonFace { + node: address.clone(), + endpoint: top_level_string(sections, "endpoint"), + id: top_level_u32(sections, "id"), + } +} + +/// The output card's face: the test-pattern toggle, the endpoint, and the +/// control product the output is being fed (when one is projected). Always +/// derives, same reasoning as [`button_face`]. +fn output_face(address: &ProjectNodeAddress, sections: &[UiNodeSection]) -> UiOutputFace { + UiOutputFace { + node: address.clone(), + endpoint: top_level_string(sections, "endpoint"), + preview: product_of_kind(sections, UiProductKind::Control), + } +} + +/// A top-level config row's string reading, keyed EXACTLY (unlike +/// [`string_field`], which matches a record field by its `.` suffix). +fn top_level_string(sections: &[UiNodeSection], key: &str) -> Option { + match &config_rows(sections) + .into_iter() + .find(|row| row.key == key)? + .body + { + UiConfigSlotBody::Value(UiSlotValue { + kind: UiSlotValueKind::String(value), + .. + }) => Some(value.clone()), + _ => None, + } +} + +/// A top-level config row's u32 reading, keyed exactly. +fn top_level_u32(sections: &[UiNodeSection], key: &str) -> Option { + match &config_rows(sections) + .into_iter() + .find(|row| row.key == key)? + .body + { + UiConfigSlotBody::Value(UiSlotValue { + kind: UiSlotValueKind::U32(value), + .. + }) => Some(*value), + _ => None, + } +} + /// First produced product row of the wanted kind; an `Empty`-kind row (the /// output exists but nothing resolved yet) is the stable-face fallback. fn product_of_kind(sections: &[UiNodeSection], kind: UiProductKind) -> Option { @@ -885,6 +952,65 @@ mod tests { ); } + #[test] + fn button_face_carries_the_nodes_address_and_hardware_identity() { + let sections = vec![UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "endpoint", + "Endpoint", + UiSlotValue::string("button:gpio:D9"), + ), + UiConfigSlot::value("id", "Id", UiSlotValue::u32(3)), + UiConfigSlot::value("stable_ms", "Stable ms", UiSlotValue::u32(30)), + ])]; + + let Some(UiNodeFace::Button(face)) = + kind_face("button", &test_address(), §ions, &mut Vec::new()) + else { + panic!("expected a button face"); + }; + assert_eq!(face.node, test_address(), "the poke is address-addressed"); + assert_eq!(face.endpoint.as_deref(), Some("button:gpio:D9")); + assert_eq!(face.id, Some(3)); + } + + #[test] + fn output_face_carries_the_address_endpoint_and_fed_product() { + let sections = vec![ + UiNodeSection::ProducedProducts(vec![UiProducedProduct::control("Output")]), + UiNodeSection::ConfigSlots(vec![UiConfigSlot::value( + "endpoint", + "Endpoint", + UiSlotValue::string("ws281x:rmt:D10"), + )]), + ]; + + let Some(UiNodeFace::Output(face)) = + kind_face("output", &test_address(), §ions, &mut Vec::new()) + else { + panic!("expected an output face"); + }; + assert_eq!(face.node, test_address()); + assert_eq!(face.endpoint.as_deref(), Some("ws281x:rmt:D10")); + assert_eq!( + face.preview.as_ref().map(|product| product.kind), + Some(UiProductKind::Control) + ); + } + + #[test] + fn poke_faces_derive_even_with_nothing_to_read_out() { + // The affordance is a runtime command addressed by the node's own + // address: a def whose rows have not projected yet still gets its + // control (unlike shader/fixture, which need their preview row). + for kind in ["button", "output"] { + assert!( + kind_face(kind, &test_address(), &[], &mut Vec::new()).is_some(), + "{kind} face derives from the address alone" + ); + } + } + #[test] fn other_kinds_and_faceless_sections_stay_generic() { assert_eq!( diff --git a/lp-app/lpa-studio-core/src/app/project/node/output_test_pattern_op.rs b/lp-app/lpa-studio-core/src/app/project/node/output_test_pattern_op.rs new file mode 100644 index 000000000..376b1a2b1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/output_test_pattern_op.rs @@ -0,0 +1,146 @@ +//! Output test-pattern operation (the runtime command channel's +//! "is this strip alive?" consumer). + +use core::any::Any; + +use lpc_wire::WireOutputTestPattern; + +use crate::{ + ActionClass, ActionMeta, ActionPriority, ControllerOp, PROJECT_EDITOR_ACTION_DEADLINE, + ProjectNodeAddress, +}; + +/// Put an output node's runtime into (or out of) test-pattern mode, via +/// `WireProjectCommand::NodeCommand` → `OutputNode` (the output face's +/// test-pattern toggle). A runtime poke, not an edit: nothing is staged in +/// the overlay, nothing shows in the Save panel, and the graph's own frames +/// resume the moment the pattern clears. +/// +/// Dispatched to `ProjectController::NODE_ID` like [`crate::SlotEditOp`]; +/// the controller resolves the output's CURRENT runtime `NodeId` from the +/// stable authored address at dispatch time, so a queued toggle can never +/// address a stale runtime id across a project reload. +/// +/// A sustained pattern is a REPEATED op, not a long-running one: the face +/// re-sends it on a renewal cadence while the toggle is on, and the +/// device-side TTL restores normal output on its own if the renewals stop +/// (tab closed, card unmounted). There is therefore no background action +/// class here — every send is the same foreground poke. +#[derive(Clone, Debug, PartialEq)] +pub struct OutputTestPatternOp { + /// Stable address of the output node the pattern is addressed to. + pub node: ProjectNodeAddress, + /// The pattern to hold (or [`WireOutputTestPattern::Clear`] to end it). + pub pattern: WireOutputTestPattern, + /// Auto-expiry for a sustained pattern, in milliseconds. Ignored by the + /// runtime for `Clear`. + pub ttl_ms: u32, +} + +impl OutputTestPatternOp { + /// Human label, used in the action meta and the rejection notice. + fn verb(&self) -> &'static str { + match self.pattern { + WireOutputTestPattern::Clear => "Clear test pattern", + WireOutputTestPattern::Solid { .. } => "Test pattern", + } + } +} + +impl ControllerOp for OutputTestPatternOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new( + self.verb(), + "Drive this output with a diagnostic pattern instead of the graph.", + ActionPriority::Primary, + ) + } + + fn action_class(&self) -> ActionClass { + // Same editor foreground class as the playlist activate poke: the + // toggle should feel immediate, and a rejection (never-rendered + // output) surfaces as a notice. + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn op(pattern: WireOutputTestPattern, ttl_ms: u32) -> OutputTestPatternOp { + OutputTestPatternOp { + node: ProjectNodeAddress::parse("/demo.project/strip.output").unwrap(), + pattern, + ttl_ms, + } + } + + #[test] + fn test_pattern_is_editor_foreground_class_with_pattern_meta() { + let solid = op( + WireOutputTestPattern::Solid { + r: 255, + g: 255, + b: 255, + }, + 2000, + ); + + assert_eq!( + solid.action_class(), + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + ); + assert_eq!(solid.default_action_meta().label, "Test pattern"); + assert_eq!( + solid.default_action_meta().priority, + ActionPriority::Primary + ); + } + + #[test] + fn clearing_labels_itself_as_the_way_out() { + assert_eq!( + op(WireOutputTestPattern::Clear, 0) + .default_action_meta() + .label, + "Clear test pattern" + ); + } + + #[test] + fn renewals_are_equal_ops_and_clear_is_not() { + let solid = || { + op( + WireOutputTestPattern::Solid { + r: 255, + g: 255, + b: 255, + }, + 2000, + ) + }; + assert_eq!(solid(), solid(), "a renewal re-sends the identical op"); + assert_ne!(solid(), op(WireOutputTestPattern::Clear, 0)); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 219a789fd..82134ea73 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -15,17 +15,18 @@ use crate::app::project::slot::{ use crate::app::studio::refresh_cadence::{VERDICT_CHASE_INTERVAL, VERDICT_CHASE_TICKS}; use crate::core::notice::UiNotices; use crate::{ - AssetEditOp, Controller, ControllerId, DirtySummary, LoadedProjectChoice, MAX_ASSET_BODY_BYTES, - NodeCardUiState, NodeUiOp, PendingAssetEdit, PendingEdit, PendingEditOp, PendingEditPhase, - PlaylistActivateOp, ProgressState, ProjectConnectResult, ProjectEditorOp, ProjectEditorTarget, - ProjectEditorView, ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, - ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, ProjectSlotAddress, ProjectSlotRoot, - ProjectSnapshot, ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, - ProjectSyncSummary, SlotEditOp, StudioOverlayMutation, StudioProjectReadOutcome, - StudioServerClient, UiAction, UiAssetContent, UiAssetContentBody, UiAssetEditor, UiError, - UiIssue, UiLogDraft, UiLogLevel, UiLogOrigin, UiMetric, UiNodeView, UiNotice, UiPaneAction, - UiPaneView, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiProductRef, UiResult, - UiShaderError, UiShaderUniform, UiSlotAsset, UiStatus, UiViewContent, UxUpdateSink, + AssetEditOp, ButtonEventOp, Controller, ControllerId, DirtySummary, LoadedProjectChoice, + MAX_ASSET_BODY_BYTES, NodeCardUiState, NodeUiOp, OutputTestPatternOp, PendingAssetEdit, + PendingEdit, PendingEditOp, PendingEditPhase, PlaylistActivateOp, ProgressState, + ProjectConnectResult, ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, + ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeTreeItem, + ProjectNodeTreeView, ProjectOp, ProjectSlotAddress, ProjectSlotRoot, ProjectSnapshot, + ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, SlotEditOp, + StudioOverlayMutation, StudioProjectReadOutcome, StudioServerClient, UiAction, UiAssetContent, + UiAssetContentBody, UiAssetEditor, UiError, UiIssue, UiLogDraft, UiLogLevel, UiLogOrigin, + UiMetric, UiNodeView, UiNotice, UiPaneAction, UiPaneView, UiPendingEdit, UiPendingEditKind, + UiPendingEditPhase, UiProductRef, UiResult, UiShaderError, UiShaderUniform, UiSlotAsset, + UiStatus, UiViewContent, UxUpdateSink, }; use lpc_model::slot::SlotPersistence; use lpc_model::{ @@ -38,8 +39,8 @@ use lpc_model::{ }; use lpc_view::ProjectView; use lpc_wire::{ - WireCreateNodeRequest, WireCreateNodeResponse, WireNodeCommand, WireNodeCommandResponse, - WireRemoveNodeRequest, WireRemoveNodeResponse, + WireButtonEvent, WireCreateNodeRequest, WireCreateNodeResponse, WireNodeCommand, + WireNodeCommandResponse, WireOutputTestPattern, WireRemoveNodeRequest, WireRemoveNodeResponse, }; use super::node::node_naming::{file_stem, node_kind_slug, sanitize_node_name, unique_node_name}; @@ -2581,6 +2582,95 @@ impl ProjectController { }) } + /// Execute a [`ButtonEventOp`]: dispatch a synthetic button event to the + /// button's live runtime (simulate-press on the button card's face). + /// + /// A hold is a repeated op, not a long-running one — the face re-sends + /// the same `press_id` on its renewal cadence and the device-side TTL + /// cleans up if the renewals stop — so every event takes the same + /// foreground path as the playlist activate poke. + pub async fn send_button_event( + &mut self, + server: &mut StudioServerClient, + op: ButtonEventOp, + ) -> Result { + let what = match op.event { + WireButtonEvent::Click => "simulate a button press", + WireButtonEvent::Press { .. } => "hold the button", + WireButtonEvent::Release { .. } => "release the button", + }; + self.dispatch_node_command( + server, + &op.node, + what, + WireNodeCommand::ButtonEvent { event: op.event }, + ) + .await + } + + /// Execute an [`OutputTestPatternOp`]: drive (or stop driving) an output + /// node's runtime with a diagnostic pattern instead of the graph's + /// frames. + /// + /// A sustained pattern is a repeated op — the face re-sends it while the + /// toggle is on and the device-side TTL restores normal output if the + /// renewals stop — so it takes the same foreground path as the playlist + /// activate poke. + pub async fn send_output_test_pattern( + &mut self, + server: &mut StudioServerClient, + op: OutputTestPatternOp, + ) -> Result { + let what = match op.pattern { + WireOutputTestPattern::Clear => "clear the test pattern", + WireOutputTestPattern::Solid { .. } => "start the test pattern", + }; + self.dispatch_node_command( + server, + &op.node, + what, + WireNodeCommand::OutputTestPattern { + pattern: op.pattern, + ttl_ms: op.ttl_ms, + }, + ) + .await + } + + /// Shared dispatch for the runtime command channel (the non-overlay + /// path): resolve the node's CURRENT runtime `NodeId` from the stable + /// authored address HERE, at dispatch time, so a queued poke never + /// addresses a stale runtime id across a reload; then map the response + /// tiers. Acceptance is quiet but borrows the verdict-chase tightened + /// ticks so the effect is visible in UI-time; rejection surfaces as a + /// warning notice reading "Couldn't ``: ``". + async fn dispatch_node_command( + &mut self, + server: &mut StudioServerClient, + node: &ProjectNodeAddress, + what: &str, + command: WireNodeCommand, + ) -> Result { + let handle_id = self.ready_handle_id()?; + let node_id = self + .node(node) + .map(|node| node.target().node_id) + .ok_or_else(|| UiError::Project(format!("no node at {node} to {what} on")))?; + let run = server.node_command(handle_id, node_id, command).await?; + let notices = match run.response { + WireNodeCommandResponse::Accepted => { + self.verdict_chase_ticks = VERDICT_CHASE_TICKS; + UiNotices::new() + } + WireNodeCommandResponse::Rejected { reason } => UiNotices::new() + .with_notice(UiNotice::warning(format!("Couldn't {what}: {reason}"))), + }; + Ok(ProjectEditRun { + notices, + logs: run.logs, + }) + } + /// Commit the pending-edit overlay (persisted edits are written back to /// def artifacts; transient edits stay pending) and re-sync the overlay /// mirror from a follow-up read. diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index 87ddd93c5..57d1b6d2e 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -22,14 +22,14 @@ use crate::app::studio::ui_console_view::UiConsoleView; use crate::core::log::{LogClock, LogFilter, LogRing}; use crate::core::notice::UiNotices; use crate::{ - AssetContentFetchOp, AssetEditOp, ConnectFlowState, Controller, ControllerContext, - DeviceController, DeviceOp, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, - PlaylistActivateOp, ProjectConnectResult, ProjectController, ProjectEditRun, ProjectOp, - ProjectRefreshOutcome, ProjectState, ProjectSyncRun, RuntimePayload, RuntimePool, - ServerFailureKind, ServerSnapshot, ServerState, SlotEditOp, StudioSnapshot, UiAction, - UiActions, UiActivityView, UiError, UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, UiNotice, - UiPaneView, UiProgress, UiResult, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, - UxUpdate, UxUpdateSink, + AssetContentFetchOp, AssetEditOp, ButtonEventOp, ConnectFlowState, Controller, + ControllerContext, DeviceController, DeviceOp, NodeCopyOp, NodeCreateOp, NodePasteOp, + NodeRemoveOp, NodeRevertOp, OutputTestPatternOp, PlaylistActivateOp, ProjectConnectResult, + ProjectController, ProjectEditRun, ProjectOp, ProjectRefreshOutcome, ProjectState, + ProjectSyncRun, RuntimePayload, RuntimePool, ServerFailureKind, ServerSnapshot, ServerState, + SlotEditOp, StudioSnapshot, UiAction, UiActions, UiActivityView, UiError, UiLogDraft, + UiLogEntry, UiLogLevel, UiLogOrigin, UiNotice, UiPaneView, UiProgress, UiResult, UiStatus, + UiStudioView, UiViewContent, UxActivityTarget, UxUpdate, UxUpdateSink, }; /// How often the quiet PortHeld retry re-attempts the granted attach @@ -2003,6 +2003,14 @@ impl StudioController { let op = action.into_op::()?; return self.execute_playlist_activate_op(op).await; } + if action.op_as::().is_some() { + let op = action.into_op::()?; + return self.execute_button_event_op(op).await; + } + if action.op_as::().is_some() { + let op = action.into_op::()?; + return self.execute_output_test_pattern_op(op).await; + } if action.op_as::().is_some() { let op = action.into_op::()?; return self.execute_node_create_op(op).await; @@ -3134,6 +3142,28 @@ impl StudioController { self.record_project_edit_run(run) } + /// Button face simulate-press: dispatch a synthetic button event to the + /// node's live runtime. Quiet on acceptance (the button's own produced + /// state follows via the tightened refresh ticks); a rejection comes + /// back as a warning notice. + async fn execute_button_event_op(&mut self, op: ButtonEventOp) -> UiResult { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project.send_button_event(server, op).await + }; + self.record_project_edit_run(run) + } + + /// Output face test-pattern toggle: dispatch (or clear) the diagnostic + /// pattern on the output's live runtime. + async fn execute_output_test_pattern_op(&mut self, op: OutputTestPatternOp) -> UiResult { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project.send_output_test_pattern(server, op).await + }; + self.record_project_edit_run(run) + } + async fn execute_node_create_op(&mut self, op: NodeCreateOp) -> UiResult { let run = { let server = self.pool.lens_session_mut()?.client_mut()?; diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 72617f836..d80102b3d 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -44,16 +44,16 @@ pub use app::home::{ }; pub use app::node::{ UiAssetEditor, UiAssetEditorKind, UiBindingAuthoring, UiBindingAuthoringDirection, - UiBindingEndpoint, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, - UiControlSampleFormat, UiFixtureFace, UiFixturePower, UiNodeChild, UiNodeDirtyState, - UiNodeFace, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, - UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, UiProducedBindings, - UiProducedProduct, UiProducedValue, UiProductKind, UiProductPreview, UiProductPreviewFrame, - UiProductRef, UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotAffordance, - UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, UiSlotAsset, UiSlotComposite, - UiSlotEditorHint, UiSlotEnumComposite, UiSlotFieldState, UiSlotMapComposite, UiSlotMapKeyKind, - UiSlotOption, UiSlotOptionality, UiSlotRecord, UiSlotShape, UiSlotShapeField, - UiSlotSourceState, UiSlotUnit, UiSlotValue, UiSlotValueKind, + UiBindingEndpoint, UiButtonFace, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, + UiControlProductPreview, UiControlSampleFormat, UiFixtureFace, UiFixturePower, UiNodeChild, + UiNodeDirtyState, UiNodeFace, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, + UiNodeView, UiOutputFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, + UiProducedBinding, UiProducedBindings, UiProducedProduct, UiProducedValue, UiProductKind, + UiProductPreview, UiProductPreviewFrame, UiProductRef, UiProductTrackingState, UiShaderFace, + UiShaderUniform, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, + UiSlotAsset, UiSlotComposite, UiSlotEditorHint, UiSlotEnumComposite, UiSlotFieldState, + UiSlotMapComposite, UiSlotMapKeyKind, UiSlotOption, UiSlotOptionality, UiSlotRecord, + UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiSlotValueKind, }; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] pub use app::preview_host::{PreviewHost, PreviewSlotHandle}; @@ -62,13 +62,14 @@ pub use app::preview_host::{ PreviewTier, }; pub use app::project::{ - AgentEngineStatus, AssetContentFetchOp, AssetEditOp, DirtySummary, LoadedProjectChoice, - MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeController, NodeControllerState, - NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, NodeUiOp, PendingAssetEdit, - PendingEdit, PendingEditOp, PendingEditPhase, PlaylistActivateOp, ProjectAssetContentRun, - ProjectConnectResult, ProjectController, ProjectEditRun, ProjectEditorOp, ProjectEditorTarget, - ProjectEditorView, ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, - ProjectNodeStatusView, ProjectNodeTarget, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, + AgentEngineStatus, AssetContentFetchOp, AssetEditOp, ButtonEventOp, DirtySummary, + LoadedProjectChoice, MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeController, + NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, + NodeUiOp, OutputTestPatternOp, PendingAssetEdit, PendingEdit, PendingEditOp, PendingEditPhase, + PlaylistActivateOp, ProjectAssetContentRun, ProjectConnectResult, ProjectController, + ProjectEditRun, ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, + ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeStatusView, + ProjectNodeTarget, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, ProjectProductSubscriptionIntent, ProjectRefreshOutcome, ProjectRuntimeSummary, ProjectSlotAddress, ProjectSlotRoot, ProjectSnapshot, ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, SlotController, SlotControllerState, diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 5c816465d..cc7989282 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -1535,6 +1535,9 @@ .tw\:text-\[0\.7rem\] { font-size: 0.7rem; } + .tw\:text-\[0\.8rem\] { + font-size: 0.8rem; + } .tw\:text-\[0\.64rem\] { font-size: 0.64rem; } @@ -1544,6 +1547,9 @@ .tw\:text-\[0\.68rem\] { font-size: 0.68rem; } + .tw\:text-\[0\.75rem\] { + font-size: 0.75rem; + } .tw\:text-\[0\.78rem\] { font-size: 0.78rem; } diff --git a/lp-app/lpa-studio-web/src/app/node/button_face_stories.rs b/lp-app/lpa-studio-web/src/app/node/button_face_stories.rs new file mode 100644 index 000000000..b70b9be2a --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/button_face_stories.rs @@ -0,0 +1,50 @@ +//! Stories for the button card face (node-actions P4). +//! +//! The face is one affordance — simulate the transition a finger would +//! make — over the button's hardware identity. Stories are static by +//! definition, so the held state is mounted directly rather than gestured +//! into: the real one is reached by holding past the 300 ms window, and it +//! renews on the wire until pointer-up. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::node::face_story_fixtures::{button_face, button_node_view}; +use crate::app::node::{ButtonFace, NodePane}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ButtonCardCanvas(children: Element) -> Element { + rsx! { + div { class: "tw:w-full tw:max-w-md", {children} } + } +} + +#[story( + description = "Button card at rest: the press control — a skeuomorphic momentary button in the knob family — beside the endpoint and message id, over the collapsed settings drawer. A tap sends a click; holding past ~300 ms becomes a real hold that renews until release." +)] +fn default() -> Element { + rsx! { + ButtonCardCanvas { + NodePane { + view: button_node_view(), + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Held: the pointer stayed down past the window, so the button is sustaining a real press on the runtime (renewed every second, auto-released by the device if this tab stops asking). The cap sits depressed with a live-blue ring — the same family as the playlist's ACTIVE placard, something happening in the runtime right now." +)] +fn held() -> Element { + rsx! { + ButtonCardCanvas { + ButtonFace { + face: button_face(), + held_initially: true, + on_action: move |_| {}, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/face/button_face.rs b/lp-app/lpa-studio-web/src/app/node/face/button_face.rs new file mode 100644 index 000000000..5da752060 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/face/button_face.rs @@ -0,0 +1,332 @@ +//! The button card's permanent face: the press control (a skeuomorphic momentary button, knob-family). +//! +//! A button node's whole job is a transition someone's finger makes. Studio +//! needs to make that transition without the finger — to rehearse a show, or +//! to prove a binding before the hardware is even on the desk — so the face +//! is one control that behaves like the physical button it stands in for. +//! +//! The gesture is WINDOWED, and the window is what makes one control cover +//! both events the runtime distinguishes: +//! +//! - pointer-up inside [`PRESS_WINDOW_MS`] → `Click` (the minimal +//! down-then-up pair — a tap); +//! - the window elapsing with the pointer still down → `Press`, re-sent +//! every [`RENEWAL_MS`] while held, then `Release` on pointer-up. +//! +//! The renewal cadence and the [`HOLD_TTL_MS`] TTL are a pair: the runtime +//! auto-releases a hold whose renewals stop, so a closed tab, a crashed +//! renderer, or a card that unmounts mid-hold cannot leave a button stuck +//! down. That is why teardown does NOTHING here — the renewal task is +//! scope-owned and simply dies with the component; chasing a guaranteed +//! `Release` on unmount would be a worse mechanism than the one already +//! guarding the wire. +//! +//! Everything in this file is the TIMING half of the affordance. The ops +//! themselves — and which controller they route to — are built by +//! [`lpa_studio_core::UiButtonFace`], so a synthetic press cannot drift from +//! the op that carries it. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use dioxus::prelude::*; +use gloo_timers::future::TimeoutFuture; +use lpa_studio_core::{UiAction, UiButtonFace as UiButtonFaceData}; + +use crate::app::node::NodeCardSection; +use crate::app::node::slot_fields::capture_field_pointer; + +/// How long the pointer must stay down before the gesture becomes a HOLD +/// instead of a tap. +const PRESS_WINDOW_MS: u32 = 300; + +/// Renewal cadence for a sustained hold. Comfortably inside +/// [`HOLD_TTL_MS`], so an ordinary render hitch never drops the hold. +const RENEWAL_MS: u32 = 1000; + +/// A hold's device-side auto-release. The safety net for every teardown +/// path: nothing this component does on unmount, and nothing it could do, +/// matters as long as the renewals stop. +const HOLD_TTL_MS: u32 = 5000; + +/// Per-gesture press id. A hold is identified by this id on the wire, so +/// renewals of the SAME hold repeat it and a new gesture never adopts an +/// old one. Tab-local is the right scope: renewals only ever come from the +/// tab that started the hold, and a reload's stale hold self-clears on TTL. +static NEXT_PRESS_ID: AtomicU32 = AtomicU32::new(1); + +fn next_press_id() -> u32 { + NEXT_PRESS_ID.fetch_add(1, Ordering::Relaxed) +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ButtonFace( + face: UiButtonFaceData, + /// Mount already looking held (stories render the pressed state + /// without a pointer or a timer). + #[props(default = false)] + held_initially: bool, + #[props(default)] on_action: Option>, +) -> Element { + // The live gesture's press id — `None` between gestures. Clearing it is + // ALSO how the renewal task is told to stop: the task compares the id + // it started with on every tick, so a release, a cancel, or a fresh + // gesture all retire the previous loop without any extra channel. + let mut gesture = use_signal(|| None::); + // True once the window has elapsed: the control looks held and the + // pointer-up will be a Release rather than a Click. + let mut held = use_signal(|| held_initially); + let wired = on_action.is_some(); + + let identity = button_identity(&face); + let press_face = face.clone(); + let up_face = face.clone(); + let cancel_face = face; + + rsx! { + NodeCardSection { label: "controls", first: true, + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-3 tw:px-4 tw:py-3", + button { + class: press_button_class(wired), + r#type: "button", + aria_pressed: "{held()}", + disabled: !wired, + title: "Press and hold for a real hold; tap for a click. Reaches the runtime exactly like the physical button.", + onpointerdown: move |event| { + let Some(handler) = on_action else { + return; + }; + event.stop_propagation(); + // Capture so the pointer-up lands here even if the + // finger slides off the control mid-hold. + capture_field_pointer(&event); + let press_id = next_press_id(); + gesture.set(Some(press_id)); + held.set(false); + let face = press_face.clone(); + spawn(async move { + TimeoutFuture::new(PRESS_WINDOW_MS).await; + // Released inside the window (or superseded): + // this gesture was a tap, and the pointer-up + // already sent its Click. + if *gesture.peek() != Some(press_id) { + return; + } + held.set(true); + // Renewal loop. Scope-owned: an unmount drops + // it mid-hold and the TTL does the rest. + while *gesture.peek() == Some(press_id) { + handler.call(face.press_action(press_id, HOLD_TTL_MS)); + TimeoutFuture::new(RENEWAL_MS).await; + } + }); + }, + onpointerup: move |event| { + event.stop_propagation(); + let (Some(press_id), Some(handler)) = (*gesture.peek(), on_action) else { + return; + }; + let was_held = *held.peek(); + gesture.set(None); + held.set(false); + if was_held { + handler.call(up_face.release_action(press_id)); + } else { + handler.call(up_face.click_action()); + } + }, + onpointercancel: move |_| { + // A cancelled gesture is not a tap, so no Click — + // but an established hold must still be let go. + let (Some(press_id), Some(handler)) = (*gesture.peek(), on_action) else { + return; + }; + let was_held = *held.peek(); + gesture.set(None); + held.set(false); + if was_held { + handler.call(cancel_face.release_action(press_id)); + } + }, + // The physical anatomy, knob-family: a housing ring, a + // radial-gradient cap that visibly travels down while the + // pointer is on it, and the live-blue ring only once the + // hold is real on the runtime. Depression is mechanical + // truth (your finger is on it); blue is runtime truth. + svg { + class: "tw:block", + width: "48", + height: "48", + view_box: "0 0 48 48", + defs { + radialGradient { + id: "lp-press-cap-gradient", + cx: "35%", + cy: "30%", + r: "80%", + stop { + offset: "0%", + stop_color: "var(--studio-color-surface-raised-strong)", + } + stop { + offset: "100%", + stop_color: "var(--studio-color-surface-raised)", + } + } + } + // Housing: the panel-mount ring the cap sits in. + circle { + cx: "24", + cy: "24", + r: "16", + fill: "none", + stroke: press_ring_stroke(held()), + stroke_width: if held() { "2" } else { "1.5" }, + } + // Cap: down and slightly smaller while the pointer is + // on it — a momentary button, mid-travel. + circle { + cx: "24", + cy: if gesture().is_some() || held() { "24.8" } else { "24" }, + r: if gesture().is_some() || held() { "11.75" } else { "12.5" }, + fill: "url(#lp-press-cap-gradient)", + stroke: press_ring_stroke(held()), + } + // Rest-state highlight dies while pressed: the cap + // face tips out of the light. + if gesture().is_none() && !held() { + circle { + cx: "20.5", + cy: "20", + r: "6", + fill: "var(--studio-color-surface-raised-strong)", + opacity: "0.35", + } + } + } + } + span { class: if held() { "tw:text-[0.75rem] tw:font-medium tw:text-status-live-foreground" } else { "tw:text-[0.75rem] tw:font-medium tw:text-strong-foreground" }, + "Press" + } + if let Some(identity) = identity { + span { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-[0.7rem] tw:text-dim-foreground", + "{identity}" + } + } + } + } + } +} + +/// The button's hardware identity beside the control: endpoint, message id, +/// or both. `None` when the def rows have not projected yet — the control +/// is the face, the readout is garnish. +fn button_identity(face: &UiButtonFaceData) -> Option { + match (face.endpoint.as_deref(), face.id) { + (Some(endpoint), Some(id)) => Some(format!("{endpoint} · id {id}")), + (Some(endpoint), None) => Some(endpoint.to_string()), + (None, Some(id)) => Some(format!("id {id}")), + (None, None) => None, + } +} + +/// Ring/cap stroke. A held button takes the LIVE family (blue) — the same +/// family the playlist strip's ACTIVE placard wears, because both say the +/// same thing: something is happening in the runtime right now. Deliberately +/// not green (green is good/valid, never state) and not violet (violet is +/// bound/bus). At rest it sits in the neutral knob-body family. +fn press_ring_stroke(held: bool) -> &'static str { + if held { + "var(--studio-status-live-border)" + } else { + "var(--studio-color-border-strong)" + } +} + +/// The button element is bare chrome — the SVG is the whole visual — so the +/// class only carries interaction affordances. +fn press_button_class(wired: bool) -> String { + let cursor = if wired { + " tw:cursor-pointer" + } else { + " tw:opacity-60" + }; + format!( + "tw:inline-flex tw:flex-none tw:touch-none tw:select-none tw:appearance-none \ + tw:items-center tw:rounded-full tw:border-none tw:bg-transparent tw:p-0 \ + tw:outline-none tw:focus-visible:outline tw:focus-visible:outline-1 \ + tw:focus-visible:outline-border-strong{cursor}" + ) +} + +#[cfg(test)] +mod tests { + use lpa_studio_core::{ProjectNodeAddress, UiButtonFace as UiButtonFaceData}; + + use super::{ + HOLD_TTL_MS, PRESS_WINDOW_MS, RENEWAL_MS, button_identity, press_button_class, + press_ring_stroke, + }; + + fn face(endpoint: Option<&str>, id: Option) -> UiButtonFaceData { + UiButtonFaceData { + node: ProjectNodeAddress::parse("/demo.project/panel.button").unwrap(), + endpoint: endpoint.map(str::to_string), + id, + } + } + + #[test] + fn renewals_stay_well_inside_the_hold_ttl() { + // The whole teardown story rests on this: if a renewal can be later + // than the TTL, an ordinary hitch reads as a release. + assert!( + RENEWAL_MS * 2 < HOLD_TTL_MS, + "a hold must survive a missed renewal" + ); + assert!( + PRESS_WINDOW_MS < RENEWAL_MS, + "the hold must be established before the first renewal is due" + ); + } + + #[test] + fn identity_degrades_one_piece_at_a_time() { + assert_eq!( + button_identity(&face(Some("button:gpio:D9"), Some(3))).as_deref(), + Some("button:gpio:D9 · id 3") + ); + assert_eq!( + button_identity(&face(Some("button:gpio:D9"), None)).as_deref(), + Some("button:gpio:D9") + ); + assert_eq!( + button_identity(&face(None, Some(3))).as_deref(), + Some("id 3") + ); + assert_eq!(button_identity(&face(None, None)), None); + } + + #[test] + fn held_never_borrows_the_bound_or_good_families() { + let held = press_ring_stroke(true); + assert!( + held.contains("status-live"), + "held reads as a live runtime state" + ); + assert!( + !held.contains("status-bound"), + "violet is the binding/bus convention, not a diagnostic one" + ); + assert!( + !held.contains("status-good"), + "green means good/valid, never state" + ); + assert!( + press_ring_stroke(false).contains("border-strong"), + "at rest the button sits in the neutral knob-body family" + ); + assert!(press_button_class(true).contains("cursor-pointer")); + assert!(!press_button_class(false).contains("cursor-pointer")); + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/face/mod.rs b/lp-app/lpa-studio-web/src/app/node/face/mod.rs index 3b7d25a1e..858226cac 100644 --- a/lp-app/lpa-studio-web/src/app/node/face/mod.rs +++ b/lp-app/lpa-studio-web/src/app/node/face/mod.rs @@ -13,17 +13,21 @@ use lpa_studio_core::{NodeUiOp, ProjectEditorOp, ProjectEditorTarget, UiAction}; +mod button_face; mod fixture_face; mod node_card_drawers; mod node_card_section; mod node_face_body; +mod output_face; mod playlist_face; mod shader_face; +pub use button_face::ButtonFace; pub use fixture_face::FixtureFace; pub use node_card_drawers::NodeCardDrawers; pub use node_card_section::NodeCardSection; pub use node_face_body::NodeFaceBody; +pub use output_face::OutputFace; pub use playlist_face::PlaylistFace; pub use shader_face::ShaderFace; diff --git a/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs b/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs index 9223779d4..335906bee 100644 --- a/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs +++ b/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs @@ -2,8 +2,8 @@ //! plus the drawers under it. //! //! [`super::super::NodePane`] renders this when `UiNodeView.face` is `Some` -//! (shader/fixture/playlist today); nodes without a face keep the generic -//! tab/section body. The body owns the full-bleed container: faces and +//! (shader/fixture/playlist/button/output today); nodes without a face keep +//! the generic tab/section body. The body owns the full-bleed container: faces and //! drawers emit flat //! [`super::NodeCardSection`]s that span the card edge-to-edge, divided by //! 1px hairlines — one surface, no inner boxes. Children (the playlist's @@ -22,7 +22,7 @@ use lpa_studio_core::{ use crate::app::node::NodeDirtyTint; use crate::base::Platform; -use super::{FixtureFace, NodeCardDrawers, PlaylistFace, ShaderFace}; +use super::{ButtonFace, FixtureFace, NodeCardDrawers, OutputFace, PlaylistFace, ShaderFace}; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] @@ -105,6 +105,30 @@ pub fn NodeFaceBody( on_action, } }, + UiNodeFace::Button(button) => rsx! { + ButtonFace { face: button, on_action } + NodeCardDrawers { + node, + sections, + advanced_open: card_ui.advanced_open, + platform, + pending_edits, + dirty_tint, + on_action, + } + }, + UiNodeFace::Output(output) => rsx! { + OutputFace { face: output, on_action } + NodeCardDrawers { + node, + sections, + advanced_open: card_ui.advanced_open, + platform, + pending_edits, + dirty_tint, + on_action, + } + }, }} } } diff --git a/lp-app/lpa-studio-web/src/app/node/face/output_face.rs b/lp-app/lpa-studio-web/src/app/node/face/output_face.rs new file mode 100644 index 000000000..747397f12 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/face/output_face.rs @@ -0,0 +1,166 @@ +//! The output card's permanent face: the test-pattern toggle. +//! +//! The first question anyone asks of an LED output is not "what is it +//! showing" but "is it wired, addressed, and alive at all". The toggle +//! answers that directly: while it is on, the runtime drives every pixel +//! full white instead of the graph's frames, so a dark strip is a wiring +//! fault rather than an empty show. +//! +//! Sustained the same way a button hold is: the pattern carries +//! [`PATTERN_TTL_MS`] and is re-sent every [`RENEWAL_MS`] while on, so a +//! closed tab or an unmounted card restores normal output within a couple +//! of seconds without this component doing anything on teardown. The +//! renewal task is scope-owned and dies with the component. +//! +//! Visual: the on state takes the ATTENTION family, deliberately not violet +//! (violet is the bound/bus convention) and not green (green means +//! good/valid, never state). Attention is the honest reading — the output +//! is being overridden, which is a temporary abnormal condition someone +//! needs to remember to undo. + +use dioxus::prelude::*; +use gloo_timers::future::TimeoutFuture; +use lpa_studio_core::{UiAction, UiOutputFace as UiOutputFaceData, UiProductKind}; + +use crate::app::node::produced_product_view::ProductPreview; +use crate::app::node::{NodeCardSection, map_view::MapViewOptions}; + +/// Renewal cadence while the pattern is on. +const RENEWAL_MS: u32 = 1000; + +/// The pattern's device-side auto-expiry. Short on purpose: an abandoned +/// override should not outlive the tab that set it by more than a blink. +const PATTERN_TTL_MS: u32 = 2000; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn OutputFace( + face: UiOutputFaceData, + /// Mount with the toggle already on (stories render the overridden + /// state without running a renewal loop). + #[props(default = false)] + pattern_initially_on: bool, + #[props(default)] on_action: Option>, +) -> Element { + let mut on = use_signal(|| pattern_initially_on); + // Retires the previous renewal loop when the toggle is flipped: a loop + // runs only while it still owns the current epoch, so a fast off/on + // cannot leave two loops renewing the same pattern. + let mut epoch = use_signal(|| 0_u32); + let wired = on_action.is_some(); + + let preview = face.preview.clone(); + let endpoint = face.endpoint.clone(); + let toggle_face = face; + + rsx! { + NodeCardSection { label: "test", first: true, + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-3 tw:px-4 tw:py-3", + button { + class: pattern_button_class(on(), wired), + r#type: "button", + role: "switch", + aria_checked: "{on()}", + disabled: !wired, + title: "Drive every pixel white instead of the graph — the wiring check. Auto-clears a couple of seconds after this tab stops asking.", + onclick: move |event| { + event.stop_propagation(); + let Some(handler) = on_action else { + return; + }; + let next = !*on.peek(); + let mine = *epoch.peek() + 1; + epoch.set(mine); + // Optimistic: the toggle follows the click, and a + // refusal (never-rendered output) arrives as the + // op layer's warning notice while the TTL puts the + // runtime back on its own. + on.set(next); + if !next { + handler.call(toggle_face.clear_action()); + return; + } + let face = toggle_face.clone(); + spawn(async move { + while *epoch.peek() == mine && *on.peek() { + handler.call(face.test_pattern_action(PATTERN_TTL_MS)); + TimeoutFuture::new(RENEWAL_MS).await; + } + }); + }, + if on() { "Test pattern on" } else { "Test pattern" } + } + if let Some(endpoint) = endpoint { + span { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-[0.7rem] tw:text-dim-foreground", + "{endpoint}" + } + } + } + if let Some(preview) = preview { + ProductPreview { + kind: UiProductKind::Control, + preview: preview.preview.clone(), + tracking: preview.tracking, + frame: preview.frame, + focus_action: None, + on_action, + map_view: MapViewOptions::default(), + } + } + } + } +} + +/// Toggle chrome. On takes the ATTENTION family: the output is overridden, +/// which is a temporary abnormal condition, not a fault (error/warning) and +/// not a good state (green). Violet stays reserved for bound/bus. +fn pattern_button_class(on: bool, wired: bool) -> String { + let surface = if on { + "tw:border-status-attention-border tw:bg-status-attention-bg tw:text-status-attention-foreground" + } else { + "tw:border-border-strong tw:bg-card-subtle tw:text-strong-foreground tw:hover:bg-card-muted" + }; + let cursor = if wired { + " tw:cursor-pointer" + } else { + " tw:opacity-60" + }; + format!( + "tw:inline-flex tw:flex-none tw:select-none tw:appearance-none tw:items-center \ + tw:rounded-sm tw:border tw:px-3 tw:py-1.5 tw:text-[0.8rem] tw:font-medium \ + tw:transition-colors tw:motion-reduce:transition-none {surface}{cursor}" + ) +} + +#[cfg(test)] +mod tests { + use super::{PATTERN_TTL_MS, RENEWAL_MS, pattern_button_class}; + + #[test] + fn the_pattern_outlives_a_missed_renewal_but_not_the_tab() { + assert!( + RENEWAL_MS < PATTERN_TTL_MS, + "a renewal must land before the pattern expires" + ); + assert!( + PATTERN_TTL_MS <= 2 * RENEWAL_MS + RENEWAL_MS, + "an abandoned override must clear within a blink, not a minute" + ); + } + + #[test] + fn on_reads_as_a_diagnostic_override_not_a_binding_or_a_blessing() { + let on = pattern_button_class(true, true); + assert!(on.contains("status-attention")); + assert!( + !on.contains("status-bound"), + "violet is the binding/bus convention" + ); + assert!( + !on.contains("status-good"), + "green means good/valid, never state" + ); + assert!(pattern_button_class(false, true).contains("cursor-pointer")); + assert!(!pattern_button_class(false, false).contains("cursor-pointer")); + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs index 70cd3baff..c51495582 100644 --- a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs @@ -10,11 +10,11 @@ use lpa_studio_core::{ ArtifactLocation, ControllerId, ProjectEditorOp, ProjectNodeAddress, ProjectSlotAddress, ProjectSlotRoot, SlotPath, UiAction, UiAgentAvailability, UiAgentStatus, UiAgentToolRow, UiAgentTurn, UiAgentUsage, UiAgentView, UiAssetContent, UiAssetEditor, UiAssetEditorKind, - UiBindingEndpoint, UiConfigSlot, UiFixtureFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, - UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, UiPanelControl, UiPanelWidget, - UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, UiProductPreview, UiProductPreviewFrame, - UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotFieldState, UiSlotSourceState, - UiSlotUnit, UiSlotValue, UiStatus, + UiBindingEndpoint, UiButtonFace, UiConfigSlot, UiFixtureFace, UiNodeChild, UiNodeDirtyState, + UiNodeFace, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, UiOutputFace, UiPanelControl, + UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, UiProductPreview, + UiProductPreviewFrame, UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotFieldState, + UiSlotSourceState, UiSlotUnit, UiSlotValue, UiStatus, }; use crate::app::node::node_story_fixtures::{ @@ -448,6 +448,79 @@ pub(crate) fn fixture_node_view_with_face(face: UiFixtureFace) -> UiNodeView { view } +/// The button face: the simulate-press control plus the button's hardware +/// identity. +pub(crate) fn button_face() -> UiButtonFace { + UiButtonFace { + node: ProjectNodeAddress::parse("/fyeah_sign.show/mode.button") + .expect("valid story node address"), + endpoint: Some("button:gpio:D9".to_string()), + id: Some(1), + } +} + +/// Advanced-drawer sections for the button card. +pub(crate) fn button_sections() -> Vec { + vec![UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "endpoint", + "Endpoint", + UiSlotValue::string("button:gpio:D9"), + ), + UiConfigSlot::value("id", "Id", UiSlotValue::u32(1)), + UiConfigSlot::value("stable_ms", "Stable ms", UiSlotValue::u32(30)), + ])] +} + +/// A full button node card view with the face installed. +pub(crate) fn button_node_view() -> UiNodeView { + let header = UiNodeHeader::new("Mode", "Button", "/fyeah_sign.show/mode.button") + .with_source("mode.json") + .with_status(UiStatus::good("Running")) + .with_summary("D9"); + let mut view = UiNodeView::new(header, vec![UiNodeTab::main(button_sections())]) + .with_node_id("button-mode"); + view.face = Some(UiNodeFace::Button(button_face())); + view +} + +/// The output face: the test-pattern toggle, the endpoint, and the control +/// product the output is currently being fed. +pub(crate) fn output_face() -> UiOutputFace { + UiOutputFace { + node: ProjectNodeAddress::parse("/fyeah_sign.show/strip.output") + .expect("valid story node address"), + endpoint: Some("ws281x:rmt:D10".to_string()), + preview: Some(control_preview_product("output")), + } +} + +/// Advanced-drawer sections for the output card. +pub(crate) fn output_sections() -> Vec { + vec![UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "endpoint", + "Endpoint", + UiSlotValue::string("ws281x:rmt:D10"), + ), + UiConfigSlot::value("input", "Input", UiSlotValue::string("Halo ring")).with_source( + UiSlotSourceState::Bound(UiBindingEndpoint::new("bus:control.out")), + ), + ])] +} + +/// A full output node card view with the face installed. +pub(crate) fn output_node_view() -> UiNodeView { + let header = UiNodeHeader::new("Strip", "Output", "/fyeah_sign.show/strip.output") + .with_source("strip.json") + .with_status(UiStatus::good("Running")) + .with_summary("241 LEDs"); + let mut view = UiNodeView::new(header, vec![UiNodeTab::main(output_sections())]) + .with_node_id("output-strip"); + view.face = Some(UiNodeFace::Output(output_face())); + view +} + /// Playlist entries: three timed entries plus one cue entry; Aurora (key 1) /// is playing. Every entry carries the child-select action the P4 /// derivation attaches (clicking a chip focuses the entry's child node). diff --git a/lp-app/lpa-studio-web/src/app/node/mod.rs b/lp-app/lpa-studio-web/src/app/node/mod.rs index cd4cf83a0..09dad7057 100644 --- a/lp-app/lpa-studio-web/src/app/node/mod.rs +++ b/lp-app/lpa-studio-web/src/app/node/mod.rs @@ -12,6 +12,8 @@ mod binding_authoring_section; #[cfg(feature = "stories")] pub(crate) mod binding_authoring_stories; mod binding_chip; +#[cfg(feature = "stories")] +pub(crate) mod button_face_stories; mod config_slot_row; #[cfg(feature = "stories")] pub(crate) mod config_slot_row_stories; @@ -33,6 +35,8 @@ mod node_pane; pub(crate) mod node_stories; #[cfg(feature = "stories")] pub(crate) mod node_story_fixtures; +#[cfg(feature = "stories")] +pub(crate) mod output_face_stories; mod panel; #[cfg(feature = "stories")] pub(crate) mod playlist_face_stories; @@ -84,7 +88,8 @@ pub(crate) use binding_authoring_section::BindingAuthoringSection; pub(crate) use binding_chip::{BindingChip, BindingChipDirection}; pub use config_slot_row::ConfigSlotRow; pub use face::{ - FixtureFace, NodeCardDrawers, NodeCardSection, NodeFaceBody, PlaylistFace, ShaderFace, + ButtonFace, FixtureFace, NodeCardDrawers, NodeCardSection, NodeFaceBody, OutputFace, + PlaylistFace, ShaderFace, }; pub use node_children::NodeChildren; pub(crate) use node_detail_popover::{NodeDetailPopover, node_status_label_class}; diff --git a/lp-app/lpa-studio-web/src/app/node/output_face_stories.rs b/lp-app/lpa-studio-web/src/app/node/output_face_stories.rs new file mode 100644 index 000000000..4598349be --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/output_face_stories.rs @@ -0,0 +1,50 @@ +//! Stories for the output card face (node-actions P4). +//! +//! One diagnostic affordance — drive every pixel white instead of the +//! graph — over the lamp preview of what the output is actually being fed. +//! The on state takes the attention family: an override is a temporary +//! abnormal condition, not a fault and not a blessing. Violet stays the +//! bound/bus convention and green stays good/valid. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::node::face_story_fixtures::{output_face, output_node_view}; +use crate::app::node::{NodePane, OutputFace}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn OutputCardCanvas(children: Element) -> Element { + rsx! { + div { class: "tw:w-full tw:max-w-md", {children} } + } +} + +#[story( + description = "Output card at rest: the test-pattern toggle beside the endpoint, over the lamp preview of what the graph is feeding this output, over the collapsed settings drawer." +)] +fn default() -> Element { + rsx! { + OutputCardCanvas { + NodePane { + view: output_node_view(), + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Test pattern on: the output is overridden with full white, re-sent every second while the toggle is on. Attention-coloured because it is an override someone has to remember to undo — and if they forget, the device's ~2 s TTL undoes it for them." +)] +fn pattern_on() -> Element { + rsx! { + OutputCardCanvas { + OutputFace { + face: output_face(), + pattern_initially_on: true, + on_action: move |_| {}, + } + } + } +} diff --git a/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__lg.png new file mode 100644 index 000000000..106de2dff Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__md.png b/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__md.png new file mode 100644 index 000000000..bba877648 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__sm.png new file mode 100644 index 000000000..7caf99cad Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__button-face__default__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__lg.png new file mode 100644 index 000000000..2ddca44b3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__md.png b/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__md.png new file mode 100644 index 000000000..28485547e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__sm.png new file mode 100644 index 000000000..a087d1d26 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__button-face__held__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__lg.png new file mode 100644 index 000000000..9d95c55ff Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__md.png b/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__md.png new file mode 100644 index 000000000..0e9b22312 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__sm.png new file mode 100644 index 000000000..bf483ed14 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__output-face__default__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__lg.png new file mode 100644 index 000000000..0ede00e6b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__md.png b/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__md.png new file mode 100644 index 000000000..5f6e24b84 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__sm.png new file mode 100644 index 000000000..50c23fdc4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__output-face__pattern-on__sm.png differ diff --git a/lp-core/lpc-engine/src/nodes/button/button_node.rs b/lp-core/lpc-engine/src/nodes/button/button_node.rs index 865c331f8..f02acc84e 100644 --- a/lp-core/lpc-engine/src/nodes/button/button_node.rs +++ b/lp-core/lpc-engine/src/nodes/button/button_node.rs @@ -9,12 +9,22 @@ use lpc_model::{ ButtonDefView, ButtonState, ControlMessage, HwEndpointSpec, MapSlot, Revision, SlotAccess, SlotPath, SlotShapeRegistry, SlotShapeRegistryError, }; +use lpc_wire::{WireButtonEvent, WireNodeCommand}; use crate::node::{ DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, RuntimeStateShape, TickContext, }; +/// How many synthetic presses a single button node holds at once. +/// +/// Bounded on purpose: the set lives in a fixed array so a misbehaving (or +/// simply chatty) client cannot grow node state on an embedded target. +const SYNTHETIC_PRESS_CAPACITY: usize = 4; + +/// TTL applied when a `Press` command asks for `ttl_ms == 0`. +const DEFAULT_PRESS_TTL_MS: u32 = 5000; + /// Runtime node for `kind = "Button"` artifacts. pub struct ButtonNode { state: ButtonState, @@ -23,6 +33,24 @@ pub struct ButtonNode { opened: Option, held_id_seq: Option<(u32, u32)>, fallback_now_ms: u64, + /// Last debounced hardware level, latched from `poll` edges. + hw_pressed: bool, + /// Merged state published on the previous produce; `down`/`up` edges + /// derive from CHANGES against it. + effective_pressed: bool, + /// Node-owned message sequence, bumped once per effective edge. The + /// hardware event's own sequence is not forwarded any more: with two + /// sources feeding one logical button, only a node-owned counter stays + /// collision-free for consumers that dedup by `(id, seq)`. + edge_seq: u32, + /// Clicks queued by `WireButtonEvent::Click`, applied one produce at a + /// time (queue in `handle_command`, merge in `produce`). + pending_clicks: u8, + /// True while the frame that a queued click made effective is the most + /// recent one, so the next produce releases it. + click_active: bool, + /// Sustained synthetic presses, keyed by client-generated `press_id`. + presses: [Option; SYNTHETIC_PRESS_CAPACITY], } impl ButtonNode { @@ -34,6 +62,12 @@ impl ButtonNode { opened: None, held_id_seq: None, fallback_now_ms: 0, + hw_pressed: false, + effective_pressed: false, + edge_seq: 0, + pending_clicks: 0, + click_active: false, + presses: [None; SYNTHETIC_PRESS_CAPACITY], } } @@ -68,7 +102,12 @@ impl ButtonNode { .map_err(|error| NodeError::msg(format!("open button {}: {error}", config.endpoint)))?; self.input = Some(input); self.opened = Some(opened); + // A reopened endpoint starts from a clean edge state; synthetic + // presses are client-owned and survive, so a live hold re-arms with a + // fresh down edge on the next produce. self.held_id_seq = None; + self.hw_pressed = false; + self.effective_pressed = false; Ok(()) } @@ -80,6 +119,142 @@ impl ButtonNode { self.fallback_now_ms = self.fallback_now_ms.saturating_add(1); self.fallback_now_ms } + + /// One frame of the hardware/synthetic merge. + /// + /// There is ONE logical button: the hardware level, the click phase, and + /// the synthetic press set fold into a single `effective_pressed`, and the + /// `down`/`held`/`up` slots derive from CHANGES in it. Overlapping sources + /// therefore add no spurious edges, and neither source's release ends the + /// other's hold. + /// + /// Split out of [`NodeRuntime::produce`] so the merge rule is unit + /// testable without a live [`TickContext`]. + fn advance_frame( + &mut self, + revision: Revision, + id: u32, + now_ms: u64, + hw_event: Option, + ) { + if let Some(kind) = hw_event { + self.hw_pressed = matches!(kind, ButtonEventKind::Pressed); + } + self.sweep_presses(now_ms); + let click_pressed = self.advance_click_phase(); + let effective = self.hw_pressed || click_pressed || self.has_active_press(); + + let mut down = MapSlot::default(); + let mut up = MapSlot::default(); + if effective != self.effective_pressed { + self.effective_pressed = effective; + self.edge_seq = self.edge_seq.wrapping_add(1); + if effective { + self.held_id_seq = Some((id, self.edge_seq)); + down = one_message_map(revision, id, self.edge_seq); + } else { + self.held_id_seq = None; + up = one_message_map(revision, id, self.edge_seq); + } + } + let held = self + .held_id_seq + .map(|(held_id, seq)| one_message_map(revision, held_id, seq)) + .unwrap_or_default(); + + self.state.down = down; + self.state.held = held; + self.state.up = up; + } + + /// Expire (and lazily stamp) synthetic presses against the produce clock. + /// + /// `handle_command` cannot stamp deadlines itself: its `time_s` is the + /// engine's frame clock, a different domain from the [`TickContext`] time + /// provider `produce` polls the debouncer with. Entries therefore arrive + /// with a TTL budget and get their deadline on the first produce that + /// sees them — which is also what makes a renewal simply clear the stamp. + fn sweep_presses(&mut self, now_ms: u64) { + for slot in &mut self.presses { + let Some(press) = slot.as_mut() else { + continue; + }; + match press.expires_at_ms { + None => { + press.expires_at_ms = Some(now_ms.saturating_add(u64::from(press.ttl_ms))); + } + Some(deadline) if now_ms >= deadline => *slot = None, + Some(_) => {} + } + } + } + + fn has_active_press(&self) -> bool { + self.presses.iter().any(Option::is_some) + } + + /// Click phasing: a queued click is effective for exactly one produce and + /// released on the next, so queued clicks serialize into distinct down/up + /// pairs instead of merging into one long hold. If another source holds + /// the button meanwhile, the click simply dissolves into that hold. + fn advance_click_phase(&mut self) -> bool { + if self.click_active { + self.click_active = false; + return false; + } + if self.pending_clicks == 0 { + return false; + } + self.pending_clicks -= 1; + self.click_active = true; + true + } + + /// Insert or renew a sustained synthetic press. + fn begin_press(&mut self, press_id: u32, ttl_ms: u32) -> Result<(), NodeError> { + let ttl_ms = if ttl_ms == 0 { + DEFAULT_PRESS_TTL_MS + } else { + ttl_ms + }; + if let Some(press) = self + .presses + .iter_mut() + .filter_map(Option::as_mut) + .find(|press| press.press_id == press_id) + { + press.ttl_ms = ttl_ms; + press.expires_at_ms = None; + return Ok(()); + } + let Some(slot) = self.presses.iter_mut().find(|slot| slot.is_none()) else { + return Err(NodeError::msg(format!( + "button already holds {SYNTHETIC_PRESS_CAPACITY} synthetic presses" + ))); + }; + *slot = Some(SyntheticPress { + press_id, + ttl_ms, + expires_at_ms: None, + }); + Ok(()) + } + + /// End a sustained synthetic press. An unknown id is a normal data-level + /// rejection — a Release that lost the race with its own TTL lands here. + fn end_press(&mut self, press_id: u32) -> Result<(), NodeError> { + let Some(slot) = self + .presses + .iter_mut() + .find(|slot| slot.is_some_and(|press| press.press_id == press_id)) + else { + return Err(NodeError::msg(format!( + "button has no synthetic press {press_id}" + ))); + }; + *slot = None; + Ok(()) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -95,6 +270,18 @@ struct OpenedButton { stable_ms: u64, } +/// One sustained synthetic press held by the node. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SyntheticPress { + /// Client-generated id, scoped to this node, pairing Press with Release. + press_id: u32, + /// Lifetime granted by the latest Press command for this id. + ttl_ms: u32, + /// Deadline in the produce clock's domain; `None` until the next produce + /// stamps it (see [`ButtonNode::sweep_presses`]). + expires_at_ms: Option, +} + impl NodeRuntime for ButtonNode { fn produce( &mut self, @@ -105,44 +292,50 @@ impl NodeRuntime for ButtonNode { self.ensure_input(&config, ctx)?; let now_ms = self.next_now_ms(ctx); - let mut down = MapSlot::default(); - let mut up = MapSlot::default(); - let mut held = self - .held_id_seq - .map(|(id, seq)| one_message_map(ctx.revision(), id, seq)) - .unwrap_or_default(); - - if let Some(event) = self + // Poll every frame regardless of synthetic state: the debouncer is a + // sampler, and skipping it would stall the hardware edge detector. + let hw_event = self .input .as_mut() .ok_or_else(|| NodeError::msg("button input missing after open"))? .poll(now_ms) - { - let seq = event.sequence(); - match event.kind() { - ButtonEventKind::Pressed => { - self.held_id_seq = Some((config.id, seq)); - down = one_message_map(ctx.revision(), config.id, seq); - held = one_message_map(ctx.revision(), config.id, seq); - } - ButtonEventKind::Released => { - self.held_id_seq = None; - held = MapSlot::default(); - up = one_message_map(ctx.revision(), config.id, seq); - } - } - } + .map(|event| event.kind()); - self.state.down = down; - self.state.held = held; - self.state.up = up; + self.advance_frame(ctx.revision(), config.id, now_ms, hw_event); Ok(ProduceResult::Produced) } + /// Synthetic button events (the wire runtime command channel): queue the + /// event here, merge it into the one logical button state in `produce`. + /// + /// Writing `down`/`held`/`up` directly would be lost — `produce` rebuilds + /// all three from the poll each frame — so nothing is stamped here. + /// Sustained presses carry a TTL rather than trusting a Release to + /// arrive: a dropped Release would otherwise wedge the button held + /// forever. + fn handle_command(&mut self, command: &WireNodeCommand, _time_s: f32) -> Result<(), NodeError> { + let WireNodeCommand::ButtonEvent { event } = command else { + return Err(NodeError::msg("button does not support this command")); + }; + match event { + WireButtonEvent::Click => { + self.pending_clicks = self.pending_clicks.saturating_add(1); + Ok(()) + } + WireButtonEvent::Press { press_id, ttl_ms } => self.begin_press(*press_id, *ttl_ms), + WireButtonEvent::Release { press_id } => self.end_press(*press_id), + } + } + fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { self.input = None; self.opened = None; self.held_id_seq = None; + self.hw_pressed = false; + self.effective_pressed = false; + self.pending_clicks = 0; + self.click_active = false; + self.presses = [None; SYNTHETIC_PRESS_CAPACITY]; Ok(()) } @@ -183,3 +376,302 @@ pub fn button_held_path() -> SlotPath { pub fn button_up_path() -> SlotPath { SlotPath::parse("up").expect("button up path") } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + + #[test] + fn click_fires_down_and_held_on_one_frame_then_up_on_the_next() { + let mut node = ButtonNode::new(); + node.handle_command(&click(), 0.0).expect("click accepted"); + + frame(&mut node, 0, None); + assert_eq!(down_seq(&node), Some(1)); + assert_eq!(held_seq(&node), Some(1)); + assert!(node.state.up.is_empty()); + + frame(&mut node, 10, None); + assert!(node.state.down.is_empty()); + assert!(node.state.held.is_empty()); + assert_eq!(up_seq(&node), Some(2)); + + frame(&mut node, 20, None); + assert!(node.state.down.is_empty()); + assert!(node.state.held.is_empty()); + assert!(node.state.up.is_empty()); + } + + #[test] + fn queued_clicks_serialize_into_separate_edge_pairs() { + let mut node = ButtonNode::new(); + node.handle_command(&click(), 0.0).expect("first click"); + node.handle_command(&click(), 0.0).expect("second click"); + + frame(&mut node, 0, None); + assert!(!node.state.down.is_empty(), "first click down"); + frame(&mut node, 10, None); + assert!(!node.state.up.is_empty(), "first click up"); + frame(&mut node, 20, None); + assert!(!node.state.down.is_empty(), "second click down"); + frame(&mut node, 30, None); + assert!(!node.state.up.is_empty(), "second click up"); + frame(&mut node, 40, None); + assert!(node.state.down.is_empty()); + assert!(node.state.up.is_empty()); + } + + #[test] + fn press_holds_across_frames_and_release_emits_the_up_edge() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 1000), 0.0) + .expect("press accepted"); + + frame(&mut node, 0, None); + assert_eq!(down_seq(&node), Some(1)); + assert_eq!(held_seq(&node), Some(1)); + + for now_ms in [10, 20, 30] { + frame(&mut node, now_ms, None); + assert!(node.state.down.is_empty(), "no repeat down at {now_ms}"); + assert_eq!(held_seq(&node), Some(1), "still held at {now_ms}"); + assert!(node.state.up.is_empty(), "no up at {now_ms}"); + } + + node.handle_command(&release(1), 0.0).expect("release"); + frame(&mut node, 40, None); + assert!(node.state.held.is_empty()); + assert_eq!(up_seq(&node), Some(2)); + } + + #[test] + fn press_auto_releases_at_ttl_expiry() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 100), 0.0).expect("press"); + + frame(&mut node, 0, None); + assert_eq!(down_seq(&node), Some(1)); + frame(&mut node, 99, None); + assert_eq!(held_seq(&node), Some(1)); + + frame(&mut node, 100, None); + assert!(node.state.held.is_empty(), "TTL expiry releases the hold"); + assert_eq!(up_seq(&node), Some(2)); + } + + #[test] + fn renewing_a_press_extends_its_ttl() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 100), 0.0).expect("press"); + frame(&mut node, 0, None); + + node.handle_command(&press(1, 100), 0.0).expect("renewal"); + frame(&mut node, 50, None); + assert!(node.state.down.is_empty(), "renewal is not a new edge"); + + frame(&mut node, 100, None); + assert_eq!(held_seq(&node), Some(1), "original deadline was extended"); + assert!(node.state.up.is_empty()); + + frame(&mut node, 150, None); + assert!(node.state.held.is_empty(), "renewed deadline expires"); + assert_eq!(up_seq(&node), Some(2)); + } + + #[test] + fn press_with_zero_ttl_uses_the_default_ttl() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 0), 0.0).expect("press"); + + frame(&mut node, 0, None); + assert_eq!( + node.presses[0].expect("press stored").expires_at_ms, + Some(u64::from(DEFAULT_PRESS_TTL_MS)) + ); + } + + #[test] + fn hardware_and_synthetic_presses_merge_into_one_logical_button() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 10_000), 0.0).expect("press"); + + frame(&mut node, 0, None); + assert_eq!(down_seq(&node), Some(1)); + + // Hardware presses on top of the synthetic hold: no second down. + frame(&mut node, 10, Some(ButtonEventKind::Pressed)); + assert!(node.state.down.is_empty(), "no extra down edge"); + assert_eq!(held_seq(&node), Some(1)); + + // Hardware releases first: the synthetic hold keeps the button down. + frame(&mut node, 20, Some(ButtonEventKind::Released)); + assert!( + node.state.up.is_empty(), + "synthetic hold survives hw release" + ); + assert_eq!(held_seq(&node), Some(1)); + + // Only when the last source ends does the up edge fire. + node.handle_command(&release(1), 0.0).expect("release"); + frame(&mut node, 30, None); + assert!(node.state.held.is_empty()); + assert_eq!(up_seq(&node), Some(2)); + } + + #[test] + fn synthetic_release_during_a_hardware_hold_keeps_the_button_down() { + let mut node = ButtonNode::new(); + + frame(&mut node, 0, Some(ButtonEventKind::Pressed)); + assert_eq!(down_seq(&node), Some(1)); + + node.handle_command(&press(1, 10_000), 0.0).expect("press"); + frame(&mut node, 10, None); + assert!(node.state.down.is_empty(), "no extra down edge"); + + node.handle_command(&release(1), 0.0).expect("release"); + frame(&mut node, 20, None); + assert!( + node.state.up.is_empty(), + "hardware hold survives synthetic release" + ); + assert_eq!(held_seq(&node), Some(1)); + + frame(&mut node, 30, Some(ButtonEventKind::Released)); + assert_eq!(up_seq(&node), Some(2)); + } + + #[test] + fn click_during_an_active_hold_adds_no_edges() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 10_000), 0.0).expect("press"); + frame(&mut node, 0, None); + assert_eq!(down_seq(&node), Some(1)); + + node.handle_command(&click(), 0.0).expect("click"); + for now_ms in [10, 20, 30] { + frame(&mut node, now_ms, None); + assert!(node.state.down.is_empty(), "no down at {now_ms}"); + assert!(node.state.up.is_empty(), "no up at {now_ms}"); + assert_eq!(held_seq(&node), Some(1), "still held at {now_ms}"); + } + + node.handle_command(&release(1), 0.0).expect("release"); + frame(&mut node, 40, None); + assert_eq!(up_seq(&node), Some(2), "exactly one up edge for the pair"); + } + + #[test] + fn a_fifth_concurrent_press_is_rejected() { + let mut node = ButtonNode::new(); + for press_id in 1..=4 { + node.handle_command(&press(press_id, 1000), 0.0) + .unwrap_or_else(|e| panic!("press {press_id} accepted: {e}")); + } + + let err = node + .handle_command(&press(5, 1000), 0.0) + .expect_err("overflow rejected"); + assert!(err.to_string().contains("4 synthetic presses"), "{err}"); + + // The full set is untouched by the rejected command. + assert_eq!(node.presses.iter().filter(|slot| slot.is_some()).count(), 4); + assert!( + node.presses + .iter() + .flatten() + .all(|press| press.press_id != 5) + ); + } + + #[test] + fn renewing_an_existing_press_does_not_consume_a_slot() { + let mut node = ButtonNode::new(); + for press_id in 1..=4 { + node.handle_command(&press(press_id, 1000), 0.0) + .expect("press accepted"); + } + + node.handle_command(&press(2, 2000), 0.0) + .expect("renewal of a held id is accepted even when full"); + + assert_eq!(node.presses.iter().filter(|slot| slot.is_some()).count(), 4); + } + + #[test] + fn releasing_an_unknown_press_id_is_rejected_and_leaves_state_untouched() { + let mut node = ButtonNode::new(); + node.handle_command(&press(1, 1000), 0.0).expect("press"); + frame(&mut node, 0, None); + + let err = node + .handle_command(&release(9), 0.0) + .expect_err("unknown id rejected"); + assert!(err.to_string().contains("no synthetic press 9"), "{err}"); + + frame(&mut node, 10, None); + assert_eq!(held_seq(&node), Some(1), "the real hold is untouched"); + assert!(node.state.up.is_empty()); + } + + #[test] + fn non_button_commands_are_rejected() { + let mut node = ButtonNode::new(); + + for command in [ + WireNodeCommand::PlaylistActivateEntry { entry: 1 }, + WireNodeCommand::OutputTestPattern { + pattern: lpc_wire::WireOutputTestPattern::Clear, + ttl_ms: 0, + }, + ] { + let err = node + .handle_command(&command, 0.0) + .expect_err("non-button command rejected"); + assert!(err.to_string().contains("does not support"), "{err}"); + } + } + + const TEST_BUTTON_ID: u32 = 7; + + fn frame(node: &mut ButtonNode, now_ms: u64, hw_event: Option) { + node.advance_frame(Revision::new(1), TEST_BUTTON_ID, now_ms, hw_event); + } + + fn click() -> WireNodeCommand { + WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Click, + } + } + + fn press(press_id: u32, ttl_ms: u32) -> WireNodeCommand { + WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Press { press_id, ttl_ms }, + } + } + + fn release(press_id: u32) -> WireNodeCommand { + WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Release { press_id }, + } + } + + fn down_seq(node: &ButtonNode) -> Option { + message_seq(&node.state.down) + } + + fn held_seq(node: &ButtonNode) -> Option { + message_seq(&node.state.held) + } + + fn up_seq(node: &ButtonNode) -> Option { + message_seq(&node.state.up) + } + + fn message_seq(slot: &MapSlot) -> Option { + slot.entries + .get(&TEST_BUTTON_ID) + .map(|message| message.seq()) + } +} diff --git a/lp-core/lpc-engine/src/nodes/output/output_node.rs b/lp-core/lpc-engine/src/nodes/output/output_node.rs index 75587dacd..df520f192 100644 --- a/lp-core/lpc-engine/src/nodes/output/output_node.rs +++ b/lp-core/lpc-engine/src/nodes/output/output_node.rs @@ -16,10 +16,32 @@ use crate::resource::{ RuntimeChannelSampleFormat, }; +/// How long a solid test pattern holds when the command carries no explicit TTL. +/// +/// A command channel is fire-and-forget: if the client that lit the pattern +/// goes away, the pattern must expire on its own rather than strand an +/// installation in test mode. +const DEFAULT_TEST_PATTERN_TTL_MS: u32 = 2000; + +/// A live solid test pattern: the wire color plus the frame time it lapses at. +/// +/// Only the `Solid` wire variant is ever stored — `Clear` is the absence of +/// this state, not a value of it. +#[derive(Clone, Copy, Debug, PartialEq)] +struct TestPattern { + /// The commanded 8-bit color, kept in wire units so a re-send with the + /// same color compares equal without round-trip scaling. + rgb: [u8; 3], + /// Engine frame time (seconds) at which the pattern lapses. Same clock as + /// [`TickContext::time_seconds`] and `handle_command`'s `time_s`. + expires_at_s: f32, +} + /// Output node that owns the materialized control sample buffer. pub struct OutputNode { channel_buffer_id: Option, control_samples: Vec, + test_pattern: Option, } impl OutputNode { @@ -28,12 +50,58 @@ impl OutputNode { Self { channel_buffer_id: None, control_samples: Vec::new(), + test_pattern: None, } } pub fn channel_buffer_id(&self) -> Option { self.channel_buffer_id } + + /// Overwrite every established sample with one color, in place. + /// + /// Keeps the LAST-ESTABLISHED sample count: a test pattern never resizes + /// the channel extent, it only repaints it, so the flush path sees exactly + /// the buffer shape the real render produced. + fn fill_solid(&mut self, rgb: [u8; 3]) { + // 8-bit unorm to 16-bit unorm: 0..=255 maps onto 0..=65535 exactly. + let rgb16 = [ + u16::from(rgb[0]) * 257, + u16::from(rgb[1]) * 257, + u16::from(rgb[2]) * 257, + ]; + for (index, sample) in self.control_samples.iter_mut().enumerate() { + *sample = rgb16[index % 3]; + } + } + + /// Copy `control_samples` into the runtime buffer and mark it dirty for this frame. + /// + /// Shared by the render path and the test-pattern bypass so both publish + /// byte-identical buffer kind, metadata, and revision. + fn publish_channel_buffer(&self, ctx: &mut TickContext<'_>) -> Result<(), NodeError> { + let buffer_id = self + .channel_buffer_id + .ok_or_else(|| NodeError::msg("output channel buffer not initialized"))?; + ctx.with_runtime_buffer_mut(buffer_id, ctx.revision(), |buffer| { + buffer.kind = RuntimeBufferKind::OutputChannels; + buffer.metadata = RuntimeBufferMetadata::OutputChannels { + channels: (self.control_samples.len() / 3) as u32, + sample_format: RuntimeChannelSampleFormat::U16, + }; + buffer + .bytes + .resize(self.control_samples.len().saturating_mul(2), 0); + for (chunk, sample) in buffer + .bytes + .chunks_exact_mut(2) + .zip(self.control_samples.iter()) + { + chunk.copy_from_slice(&sample.to_le_bytes()); + } + Ok(()) + }) + } } pub fn output_input_path() -> SlotPath { @@ -57,7 +125,63 @@ impl NodeRuntime for OutputNode { self.channel_buffer_id } + /// Put this output into (or out of) test-pattern mode. + /// + /// Queue-in-command / apply-in-consume: this only records intent against + /// the command's frame time; the bypass itself happens in + /// [`NodeRuntime::consume`]. + fn handle_command( + &mut self, + command: &lpc_wire::WireNodeCommand, + time_s: f32, + ) -> Result<(), NodeError> { + match command { + lpc_wire::WireNodeCommand::OutputTestPattern { pattern, ttl_ms } => match pattern { + // Idempotent, and deliberately accepted with nothing active: + // a "stop testing" click must never fail just because the + // pattern already lapsed on its own. + lpc_wire::WireOutputTestPattern::Clear => { + self.test_pattern = None; + Ok(()) + } + lpc_wire::WireOutputTestPattern::Solid { r, g, b } => { + if self.control_samples.is_empty() { + return Err(NodeError::msg( + "output has no established channel extent yet", + )); + } + let ttl_ms = if *ttl_ms == 0 { + DEFAULT_TEST_PATTERN_TTL_MS + } else { + *ttl_ms + }; + // Replace AND renew atomically: a re-send is how the + // client holds a pattern alive. + self.test_pattern = Some(TestPattern { + rgb: [*r, *g, *b], + expires_at_s: time_s + (ttl_ms as f32 / 1000.0), + }); + Ok(()) + } + }, + _ => Err(NodeError::msg("output does not support this command")), + } + } + fn consume(&mut self, ctx: &mut TickContext<'_>) -> Result<(), NodeError> { + if let Some(pattern) = self.test_pattern { + if ctx.time_seconds() < pattern.expires_at_s { + // Bypass, not overwrite: the graph resolve is skipped + // entirely, so upstream demand from this root stops while the + // pattern holds. Other outputs are unaffected. + self.fill_solid(pattern.rgb); + return self.publish_channel_buffer(ctx); + } + // Lapsed. Drop it and render the graph THIS frame — falling + // through avoids a black frame at the expiry boundary. + self.test_pattern = None; + } + let prod = ctx .resolve(QueryKey::ConsumedSlot { node: ctx.node_id(), @@ -87,28 +211,7 @@ impl NodeRuntime for OutputNode { ); let _layout = ctx.render_control(control, &request, target)?; - let buffer_id = self - .channel_buffer_id - .ok_or_else(|| NodeError::msg("output channel buffer not initialized"))?; - ctx.with_runtime_buffer_mut(buffer_id, ctx.revision(), |buffer| { - buffer.kind = RuntimeBufferKind::OutputChannels; - buffer.metadata = RuntimeBufferMetadata::OutputChannels { - channels: (self.control_samples.len() / 3) as u32, - sample_format: RuntimeChannelSampleFormat::U16, - }; - buffer - .bytes - .resize(self.control_samples.len().saturating_mul(2), 0); - for (chunk, sample) in buffer - .bytes - .chunks_exact_mut(2) - .zip(self.control_samples.iter()) - { - chunk.copy_from_slice(&sample.to_le_bytes()); - } - Ok(()) - })?; - Ok(()) + self.publish_channel_buffer(ctx) } fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { @@ -123,3 +226,332 @@ impl NodeRuntime for OutputNode { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + use alloc::string::{String, ToString}; + use alloc::vec; + + use lpc_model::{ + ControlExtent, ControlProduct, ControlSampleLayout, LpValue, NodeId, ProductRef, + SlotShapeRegistry, + }; + use lpc_wire::{WireButtonEvent, WireNodeCommand, WireOutputTestPattern}; + + use crate::dataflow::resolver::{Production, ProductionSource, ResolveError, TickResolver}; + use crate::products::visual::{RenderTextureRequest, TextureRenderProduct, VisualProduct}; + use crate::resource::RuntimeBuffer; + + const BUFFER: RuntimeBufferId = RuntimeBufferId::new(1); + /// One RGB lamp: the smallest extent that still exercises the triplet layout. + const EXTENT: ControlExtent = ControlExtent::new(1, 3); + + fn node_id() -> NodeId { + NodeId::new(1) + } + + /// Minimal [`TickResolver`] standing in for the engine's session bridge. + /// + /// It counts `resolve` calls (that is how "the graph was consulted" is + /// asserted) and paints whatever `graph_color` currently is, so a color + /// change that does NOT reach the buffer proves the bypass. + struct FakeResolver { + buffer: RuntimeBuffer, + buffer_frame: Revision, + resolve_calls: u32, + graph_color: [u16; 3], + } + + impl FakeResolver { + fn new() -> Self { + Self { + buffer: RuntimeBuffer::output_channels_u16(0, Vec::new()), + buffer_frame: Revision::default(), + resolve_calls: 0, + graph_color: [1000, 2000, 3000], + } + } + + /// The published buffer decoded back into u16 samples. + fn published_samples(&self) -> Vec { + self.buffer + .bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect() + } + } + + impl TickResolver for FakeResolver { + fn resolve(&mut self, _query: QueryKey) -> Result { + self.resolve_calls += 1; + Ok(Production::leaf( + WithRevision::new( + Revision::new(1), + LpValue::Product(ProductRef::Control(ControlProduct::new( + node_id(), + 0, + EXTENT, + ))), + ), + ProductionSource::Literal, + )) + } + + fn publish_produced_slot( + &mut self, + _node: NodeId, + _slot: SlotPath, + _production: Production, + ) -> Result<(), ResolveError> { + Ok(()) + } + + fn render_texture( + &mut self, + _product: VisualProduct, + _request: &RenderTextureRequest, + ) -> Result { + Err(ResolveError::new(String::from( + "fake resolver renders no textures", + ))) + } + + fn render_control( + &mut self, + _product: ControlProduct, + _request: &ControlRenderRequest, + target: ControlRenderTarget<'_>, + ) -> Result { + for (index, sample) in target.samples.iter_mut().enumerate() { + *sample = self.graph_color[index % 3]; + } + Ok(ControlSampleLayout::empty()) + } + + fn runtime_buffer_mut( + &mut self, + id: RuntimeBufferId, + frame: Revision, + ) -> Result<&mut RuntimeBuffer, ResolveError> { + if id != BUFFER { + return Err(ResolveError::new(String::from("unknown runtime buffer"))); + } + self.buffer_frame = frame; + Ok(&mut self.buffer) + } + } + + /// An output whose buffer id is already assigned (stands in for `init_resources`). + fn output_node() -> OutputNode { + let mut node = OutputNode::new(); + node.channel_buffer_id = Some(BUFFER); + node + } + + /// Run one `consume` at `time_s`, as the engine's tick would. + fn consume_at( + node: &mut OutputNode, + resolver: &mut FakeResolver, + frame: Revision, + time_s: f32, + ) -> Result<(), NodeError> { + let shapes = SlotShapeRegistry::default(); + let mut ctx = TickContext::with_render_services( + node_id(), + frame, + resolver, + &shapes, + None, + None, + time_s, + ); + node.consume(&mut ctx) + } + + fn solid(r: u8, g: u8, b: u8, ttl_ms: u32) -> WireNodeCommand { + WireNodeCommand::OutputTestPattern { + pattern: WireOutputTestPattern::Solid { r, g, b }, + ttl_ms, + } + } + + fn clear() -> WireNodeCommand { + WireNodeCommand::OutputTestPattern { + pattern: WireOutputTestPattern::Clear, + ttl_ms: 0, + } + } + + #[test] + fn solid_is_rejected_before_any_frame_has_been_rendered() { + let mut node = output_node(); + + let err = node + .handle_command(&solid(255, 0, 0, 1000), 0.0) + .expect_err("no established extent yet"); + + assert!(err.to_string().contains("channel extent"), "{err}"); + assert_eq!(node.test_pattern, None); + } + + #[test] + fn solid_pattern_replaces_graph_output_at_the_established_sample_count() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + + consume_at(&mut node, &mut resolver, Revision::new(1), 0.0).expect("graph frame"); + assert_eq!(resolver.published_samples(), vec![1000, 2000, 3000]); + + node.handle_command(&solid(255, 128, 0, 1000), 0.0) + .expect("accepted after a rendered frame"); + + // A graph color change from here on must NOT reach the buffer. + resolver.graph_color = [4000, 5000, 6000]; + let before = resolver.resolve_calls; + consume_at(&mut node, &mut resolver, Revision::new(2), 0.1).expect("pattern frame"); + + assert_eq!( + resolver.published_samples(), + vec![255 * 257, 128 * 257, 0], + "buffer should hold the solid color as u16 unorm triplets", + ); + assert_eq!( + resolver.resolve_calls, before, + "the graph resolve must be skipped entirely while a pattern is active", + ); + assert_eq!( + resolver.buffer.metadata, + RuntimeBufferMetadata::OutputChannels { + channels: 1, + sample_format: RuntimeChannelSampleFormat::U16, + }, + ); + assert_eq!(resolver.buffer.kind, RuntimeBufferKind::OutputChannels); + assert_eq!( + resolver.buffer_frame, + Revision::new(2), + "the bypass must mark the buffer dirty for this frame, like the render path", + ); + } + + #[test] + fn resending_a_different_color_replaces_the_active_pattern() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1), 0.0).expect("graph frame"); + + node.handle_command(&solid(255, 0, 0, 1000), 0.0) + .expect("first accepted"); + node.handle_command(&solid(0, 0, 255, 1000), 0.5) + .expect("second accepted"); + + let pattern = node.test_pattern.expect("pattern active"); + assert_eq!(pattern.rgb, [0, 0, 255]); + assert!((pattern.expires_at_s - 1.5).abs() < 1e-6, "{pattern:?}"); + } + + #[test] + fn resending_the_same_color_renews_the_expiry() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1), 0.0).expect("graph frame"); + + node.handle_command(&solid(255, 0, 0, 1000), 0.0) + .expect("first accepted"); + let first = node.test_pattern.expect("pattern active").expires_at_s; + node.handle_command(&solid(255, 0, 0, 1000), 0.75) + .expect("renewal accepted"); + let renewed = node.test_pattern.expect("pattern active"); + + assert_eq!(renewed.rgb, [255, 0, 0]); + assert!( + renewed.expires_at_s > first, + "renewal should push the expiry out: {first} -> {}", + renewed.expires_at_s, + ); + } + + #[test] + fn a_zero_ttl_falls_back_to_the_default_hold() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1), 0.0).expect("graph frame"); + + node.handle_command(&solid(255, 0, 0, 0), 1.0) + .expect("accepted"); + + let pattern = node.test_pattern.expect("pattern active"); + let expected = 1.0 + (DEFAULT_TEST_PATTERN_TTL_MS as f32 / 1000.0); + assert!( + (pattern.expires_at_s - expected).abs() < 1e-6, + "{pattern:?}" + ); + } + + #[test] + fn an_expired_pattern_renders_the_graph_again_on_the_same_frame() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1), 0.0).expect("graph frame"); + + node.handle_command(&solid(255, 0, 0, 1000), 0.0) + .expect("accepted"); + resolver.graph_color = [4000, 5000, 6000]; + + // t = 2.0 is past the 1.0 s expiry: no black frame, straight back to the graph. + consume_at(&mut node, &mut resolver, Revision::new(2), 2.0).expect("post-expiry frame"); + + assert_eq!(resolver.published_samples(), vec![4000, 5000, 6000]); + assert_eq!(node.test_pattern, None, "the lapsed pattern is dropped"); + } + + #[test] + fn clear_returns_to_graph_output_immediately() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1), 0.0).expect("graph frame"); + + node.handle_command(&solid(255, 0, 0, 10_000), 0.0) + .expect("accepted"); + resolver.graph_color = [4000, 5000, 6000]; + node.handle_command(&clear(), 0.1).expect("clear accepted"); + + // Still well inside the TTL: only the Clear can explain graph output. + consume_at(&mut node, &mut resolver, Revision::new(2), 0.2).expect("post-clear frame"); + + assert_eq!(node.test_pattern, None); + assert_eq!(resolver.published_samples(), vec![4000, 5000, 6000]); + } + + #[test] + fn clear_is_accepted_with_no_pattern_active() { + let mut node = output_node(); + + node.handle_command(&clear(), 0.0) + .expect("clear is idempotent, even before any frame"); + + assert_eq!(node.test_pattern, None); + } + + #[test] + fn unrelated_commands_are_rejected() { + let mut node = output_node(); + + assert!( + node.handle_command(&WireNodeCommand::PlaylistActivateEntry { entry: 1 }, 0.0) + .is_err(), + ); + assert!( + node.handle_command( + &WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Click, + }, + 0.0, + ) + .is_err(), + ); + } +} diff --git a/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs b/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs index d21c4f353..26ca82a74 100644 --- a/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs +++ b/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs @@ -224,6 +224,7 @@ impl NodeRuntime for PlaylistNode { self.pending_activate = Some(*entry); Ok(()) } + _ => Err(NodeError::msg("playlist does not support this command")), } } diff --git a/lp-core/lpc-wire/src/lib.rs b/lp-core/lpc-wire/src/lib.rs index 54b061830..40aa7b47f 100644 --- a/lp-core/lpc-wire/src/lib.rs +++ b/lp-core/lpc-wire/src/lib.rs @@ -46,8 +46,9 @@ pub use project::{ WireRuntimeBufferPayload, WireTextureFormat, }; pub use project_command::{ - WireCreateNodeRequest, WireCreateNodeResponse, WireNodeCommand, WireNodeCommandResponse, - WireProjectCommand, WireProjectCommandResponse, WireRemoveNodeRequest, WireRemoveNodeResponse, + WireButtonEvent, WireCreateNodeRequest, WireCreateNodeResponse, WireNodeCommand, + WireNodeCommandResponse, WireOutputTestPattern, WireProjectCommand, WireProjectCommandResponse, + WireRemoveNodeRequest, WireRemoveNodeResponse, }; pub use project_inventory::{ WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, diff --git a/lp-core/lpc-wire/src/project_command/mod.rs b/lp-core/lpc-wire/src/project_command/mod.rs index c6ac1e8f1..e822c9205 100644 --- a/lp-core/lpc-wire/src/project_command/mod.rs +++ b/lp-core/lpc-wire/src/project_command/mod.rs @@ -6,6 +6,8 @@ mod project_command; mod remove_node; pub use create_node::{WireCreateNodeRequest, WireCreateNodeResponse}; -pub use node_command::{WireNodeCommand, WireNodeCommandResponse}; +pub use node_command::{ + WireButtonEvent, WireNodeCommand, WireNodeCommandResponse, WireOutputTestPattern, +}; pub use project_command::{WireProjectCommand, WireProjectCommandResponse}; pub use remove_node::{WireRemoveNodeRequest, WireRemoveNodeResponse}; diff --git a/lp-core/lpc-wire/src/project_command/node_command.rs b/lp-core/lpc-wire/src/project_command/node_command.rs index 1ac68699a..7cded2952 100644 --- a/lp-core/lpc-wire/src/project_command/node_command.rs +++ b/lp-core/lpc-wire/src/project_command/node_command.rs @@ -26,6 +26,43 @@ pub enum WireNodeCommand { /// same u32 `PlaylistState.active_entry` reports) the active entry, /// resetting the entry clock exactly as a trigger switch does. PlaylistActivateEntry { entry: u32 }, + /// Synthetic button event addressed to a button node runtime. + ButtonEvent { event: WireButtonEvent }, + /// Put an output node into (or out of) test-pattern mode. Sustained + /// patterns auto-expire at `ttl_ms` unless renewed by re-sending the + /// command. + OutputTestPattern { + pattern: WireOutputTestPattern, + ttl_ms: u32, + }, +} + +/// A synthetic button event addressed to a button node runtime. +/// +/// Externally tagged, snake_case, like every project-command payload. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireButtonEvent { + /// Minimal down-then-up pair (down one produce, up the next). + Click, + /// Begin/renew a sustained synthetic hold. Auto-releases at `ttl_ms` + /// unless renewed by re-sending with the same `press_id`. + Press { press_id: u32, ttl_ms: u32 }, + /// End the synthetic hold started with `press_id`. + Release { press_id: u32 }, +} + +/// An output node's test-pattern payload. +/// +/// Externally tagged, snake_case, like every project-command payload. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireOutputTestPattern { + /// End test-pattern mode immediately (ignores `ttl_ms`). + Clear, + /// Fill every pixel with one color. Auto-expires at `ttl_ms` unless + /// renewed by re-sending the command. + Solid { r: u8, g: u8, b: u8 }, } /// Outcome of a node command. @@ -58,6 +95,75 @@ mod tests { assert_eq!(back, command); } + #[test] + fn button_event_click_round_trips() { + let command = WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Click, + }; + let json = serde_json::to_string(&command).unwrap(); + assert!(json.contains("button_event")); + assert!(json.contains("click")); + let back: WireNodeCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(back, command); + } + + #[test] + fn button_event_press_round_trips() { + let command = WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Press { + press_id: 7, + ttl_ms: 500, + }, + }; + let json = serde_json::to_string(&command).unwrap(); + assert!(json.contains("button_event")); + assert!(json.contains("press")); + let back: WireNodeCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(back, command); + } + + #[test] + fn button_event_release_round_trips() { + let command = WireNodeCommand::ButtonEvent { + event: WireButtonEvent::Release { press_id: 7 }, + }; + let json = serde_json::to_string(&command).unwrap(); + assert!(json.contains("button_event")); + assert!(json.contains("release")); + let back: WireNodeCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(back, command); + } + + #[test] + fn output_test_pattern_clear_round_trips() { + let command = WireNodeCommand::OutputTestPattern { + pattern: WireOutputTestPattern::Clear, + ttl_ms: 0, + }; + let json = serde_json::to_string(&command).unwrap(); + assert!(json.contains("output_test_pattern")); + assert!(json.contains("clear")); + let back: WireNodeCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(back, command); + } + + #[test] + fn output_test_pattern_solid_round_trips() { + let command = WireNodeCommand::OutputTestPattern { + pattern: WireOutputTestPattern::Solid { + r: 255, + g: 128, + b: 0, + }, + ttl_ms: 2000, + }; + let json = serde_json::to_string(&command).unwrap(); + assert!(json.contains("output_test_pattern")); + assert!(json.contains("solid")); + let back: WireNodeCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(back, command); + } + #[test] fn node_command_response_round_trips_both_outcomes() { let accepted = WireNodeCommandResponse::Accepted; diff --git a/lp-core/lpc-wire/src/server/hello.rs b/lp-core/lpc-wire/src/server/hello.rs index f6a127da4..c6209170c 100644 --- a/lp-core/lpc-wire/src/server/hello.rs +++ b/lp-core/lpc-wire/src/server/hello.rs @@ -23,6 +23,8 @@ use serde::{Deserialize, Serialize}; /// /// # History /// +/// - 5: `WireNodeCommand::ButtonEvent` + `OutputTestPattern` node action +/// events (`docs/adr/2026-07-31-node-action-events.md`). /// - 4: `OutputDriverOptionsConfig.brightness` removed. Brightness is a /// fixture-level control only; the output node no longer carries one. /// - 3: merge of the two independent "2" bumps below — one wire surface @@ -35,7 +37,7 @@ use serde::{Deserialize, Serialize}; /// runtime command channel (playlist activate-entry; /// `docs/adr/2026-07-27-runtime-node-command-channel.md`). /// - 1: hello handshake introduced. -pub const WIRE_PROTO_VERSION: u32 = 4; +pub const WIRE_PROTO_VERSION: u32 = 5; /// Unsolicited/boot-time server identity and version report. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]