From e9580009ea30623772b56925f0921247a5641edc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:17:13 +0200 Subject: [PATCH 1/6] Retire --idunn-rudp-health and the daemon-health publisher The flag family (--idunn-rudp-health, --idunn-daemon, --idunn-health-contract), IdunnRudpHealthOptions, the health worker thread, idunn_health_payload, the RUDP health connection and the idunn.daemon_health document had no consumer since the previous Idunn generation. The raven-muninn recipe never passed them; liveness is the runtime-presence publisher. Removes the IdunnDaemonHealthRecord odin-core symbol (three remain). The daemon id is now derived from the host alone. The transport profile and command-boundary records no longer carry health_publication, and health_contract is null. The starfire, nightwing and raven health and restart scripts stop passing and checking the flags; muninn.md follows. Tests: the retired flags are unknown arguments; the boundary record carries the host-derived daemon id and no health_publication. Co-Authored-By: Claude Opus 5.5 --- crates/muninn-daemon/src/main.rs | 354 +++------------------------ docs/muninn.md | 22 +- docs/teardown-map.md | 9 +- scripts/health-muninn.ps1 | 10 +- scripts/health-nightwing-muninn.ps1 | 17 +- scripts/health-starfire-muninn.ps1 | 16 +- scripts/restart-nightwing-muninn.ps1 | 17 +- scripts/restart-starfire-muninn.ps1 | 23 +- 8 files changed, 42 insertions(+), 426 deletions(-) diff --git a/crates/muninn-daemon/src/main.rs b/crates/muninn-daemon/src/main.rs index 48d02fd..b28e042 100644 --- a/crates/muninn-daemon/src/main.rs +++ b/crates/muninn-daemon/src/main.rs @@ -21,12 +21,12 @@ use cultnet_rs::{ GAMECULT_MEDIA_STREAM_REQUEST_SCHEMA, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultMediaStreamAdvertisementRecord, GameCultMediaStreamRequestRecord, GameCultRuntimePresenceHealthRecord, decode_cultnet_message_from_slice, - encode_cultnet_message_to_vec, media_stream_request_key, validate_media_stream_request, + media_stream_request_key, validate_media_stream_request, }; #[cfg(feature = "psmoveapi-tracker")] use odin_core::MuninnMoveTrackerHealthRecord; use odin_core::{ - EveProviderAdvertisementRecord, EveSurfaceStateRecord, IdunnDaemonHealthRecord, + EveProviderAdvertisementRecord, EveSurfaceStateRecord, MUNINN_MOVE_HUE_PROGRAM_SCHEMA, MUNINN_OBS_STREAM_CATALOG_SCHEMA, MuninnCaptureStreamCommandRecord, MuninnCaptureStreamRecord, MuninnCommandBoundaryCompatRecord, MuninnHidControllerStateRecord, MuninnMoveControllerStateRecord, @@ -70,7 +70,6 @@ use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::UI::Input::XboxController::{XINPUT_GAMEPAD, XINPUT_STATE, XInputGetState}; const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; -const IDUNN_HEALTH_RUDP_CONNECTION_ID: u32 = 0x1d0d_0001; const MUNINN_MEDIA_RUDP_CONNECTION_ID: u32 = 0x6d75_0001; const MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID: u32 = 0x6d75_0005; const MUNINN_COMMAND_RUDP_CONNECTION_ID: u32 = 0x61e0_0001; @@ -214,7 +213,6 @@ struct Options { quest_input_stream_id: Option, quest_pose_stream_id: Option, quest_video_input_stream_id: Option, - idunn_rudp_health: Option, odin_cultmesh_uri: Option, hid_controller_rudp_target: Option, hid_controller_rudp_bind: Option, @@ -275,13 +273,6 @@ struct MoveStateSource { hidraw_path: String, } -#[derive(Clone, Debug, PartialEq, Eq)] -struct IdunnRudpHealthOptions { - endpoint: SocketAddr, - daemon_id: String, - health_contract: String, -} - #[derive(Clone, Debug, PartialEq, Eq)] struct MuxPlan { command_line: String, @@ -406,7 +397,6 @@ fn serve(options: Options) -> Result<()> { let move_runtime_enabled = serve_should_manage_move_runtime(&options); let mut active_move_states = active_move_state_sources(serve_move_state_sources(&options, move_runtime_enabled)); - start_daemon_health_worker(&options); start_idunn_presence_worker(&options); let mut active_move_marker_cameras = active_move_marker_camera_sources(&options, Arc::clone(&move_hue_program)); @@ -613,21 +603,6 @@ fn start_idunn_presence_worker(options: &Options) { }); } -fn start_daemon_health_worker(options: &Options) { - if options.idunn_rudp_health.is_none() { - return; - } - let options = options.clone(); - thread::spawn(move || { - loop { - if let Err(error) = run_daemon_health_publisher(&options) { - eprintln!("Muninn Idunn health publish failed: {error:#}"); - } - thread::sleep(Duration::from_secs(5)); - } - }); -} - fn move_hue_program_key(host_id: &str) -> String { format!("muninn:{host_id}:move-hue-program") } @@ -2846,16 +2821,8 @@ fn publish_runtime_boundary_records( .as_ref() .map(|command| json!([command])) .unwrap_or_else(|| json!([])); - let current_transport = if options.idunn_rudp_health.is_some() { - "daemon-published-rudp-health + daemon-owned-cultcache-telemetry-store" - } else { - "daemon-owned-cultcache-telemetry-store + missing-rudp-health-publication" - }; - let transport_state = if options.idunn_rudp_health.is_some() { - "rudp-health-and-provider-store-live" - } else { - "cultcache-provider-store-only" - }; + let current_transport = "daemon-owned-cultcache-telemetry-store"; + let transport_state = "cultcache-provider-store-only"; let hid_controller_endpoint = options.hid_controller_rudp_bind.map(|bind| { options .hid_controller_rudp_advertise @@ -2994,18 +2961,6 @@ fn publish_runtime_boundary_records( "state_store": store_path, "log_root": log_root, "lifecycle_authority": "idunn-supervisor-command", - "health_publication": options.idunn_rudp_health.as_ref().map(|idunn| json!({ - "contract": idunn.health_contract, - "transport": CULTNET_RUDP_PROTOCOL_ID, - "publication_source": "daemon-published", - "endpoint": idunn.endpoint.to_string(), - "state_owner": "Muninn serve process" - })).unwrap_or_else(|| json!({ - "contract": serde_json::Value::Null, - "transport": "unconfigured", - "publication_source": "missing-daemon-publication", - "state_owner": "Muninn local store" - })), "commands": [ { "command": "muninn.capture_stream_command", @@ -3053,7 +3008,7 @@ fn publish_runtime_boundary_records( "target_transport": CULTNET_RUDP_PROTOCOL_ID, "current_transport": current_transport, "state": transport_state, - "health_contract": options.idunn_rudp_health.as_ref().map(|idunn| idunn.health_contract.clone()), + "health_contract": serde_json::Value::Null, "provider_advertisement_schema": "gamecult.eve.provider_advertisement.v1", "command_boundary_schema": "muninn.command_boundary.v1", "telemetry_surface_schema": "muninn.telemetry_surface.v1", @@ -3191,9 +3146,6 @@ fn muninn_provider_id(options: &Options) -> String { } fn muninn_daemon_id(options: &Options) -> String { - if let Some(idunn) = options.idunn_rudp_health.as_ref() { - return idunn.daemon_id.clone(); - } match options.host_id.as_str() { "raven" => "muninn".to_string(), "starfire" => "starfire-muninn".to_string(), @@ -8604,18 +8556,6 @@ fn health_check(options: &Options) -> Result<()> { Ok(detail) => ("active", detail.clone()), Err(error) => ("failed", error.to_string()), }; - if let Some(idunn) = options.idunn_rudp_health.as_ref() { - let publish_result = publish_idunn_rudp_health(idunn, state, &detail, &observed_at); - if let Err(publish_error) = publish_result { - return match result { - Ok(_) => Err(publish_error).context("publishing Muninn health to Idunn RUDP"), - Err(health_error) => Err(anyhow!( - "{health_error}; also failed to publish Muninn health to Idunn RUDP: {publish_error}" - )), - }; - } - } - match result { Ok(detail) => { println!("{detail}"); @@ -8674,22 +8614,6 @@ fn evaluate_health_from_node( )) } -fn publish_idunn_rudp_health( - options: &IdunnRudpHealthOptions, - state: &str, - detail: &str, - observed_at: &str, -) -> Result<()> { - let mut transport = connect_idunn_rudp_health(options)?; - transport - .send( - "schema", - idunn_health_payload(options, state, detail, observed_at)?, - ) - .with_context(|| format!("sending Idunn health to {}", options.endpoint))?; - Ok(()) -} - fn smootherstep(value: f64) -> f64 { let t = value.clamp(0.0, 1.0); t * t * t * (t * (t * 6.0 - 15.0) + 10.0) @@ -8700,104 +8624,6 @@ fn wrapped_hue_lerp(source: f64, target: f64, amount: f64) -> f64 { (source + delta * amount).rem_euclid(1.0) } -fn run_daemon_health_publisher(options: &Options) -> Result<()> { - let idunn = options - .idunn_rudp_health - .as_ref() - .context("Muninn daemon health publisher requires Idunn RUDP options")?; - let mut transport = connect_idunn_rudp_health(idunn)?; - let cadence = Duration::from_secs(options.interval_seconds.unwrap_or(15).max(1)); - let mut next_publish_at = Instant::now(); - loop { - if Instant::now() >= next_publish_at { - let observed_at = idunn_timestamp()?; - let (state, detail) = match evaluate_health(options) { - Ok(detail) => ("active", detail), - Err(error) => ("failed", error.to_string()), - }; - transport - .send( - "schema", - idunn_health_payload(idunn, state, &detail, &observed_at)?, - ) - .with_context(|| format!("sending Idunn health to {}", idunn.endpoint))?; - next_publish_at = Instant::now() + cadence; - } - let _ = transport.receive_once()?; - transport.poll_resends()?; - } -} - -fn idunn_health_payload( - options: &IdunnRudpHealthOptions, - state: &str, - detail: &str, - observed_at: &str, -) -> Result> { - let health = IdunnDaemonHealthRecord { - daemon_id: options.daemon_id.clone(), - state: state.to_string(), - detail: detail.to_string(), - observed_at: observed_at.to_string(), - health_contract: options.health_contract.clone(), - publication_source: "daemon-published".to_string(), - transport: CULTNET_RUDP_PROTOCOL_ID.to_string(), - }; - let message = CultNetMessage::DocumentPutRaw { - message_id: format!( - "muninn-health:{}:{}", - options.daemon_id, - observed_at.replace(':', "-") - ), - document: CultNetRawDocumentRecord { - schema_id: "idunn.daemon_health".to_string(), - record_key: options.daemon_id.clone(), - stored_at: observed_at.to_string(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: rmp_serde::to_vec(&health).context("encoding Idunn daemon health")?, - source_runtime_id: Some("muninn-daemon".to_string()), - source_agent_id: None, - source_role: Some("daemon-health-publisher".to_string()), - tags: Some(vec![CULTNET_RUDP_PROTOCOL_ID.to_string()]), - }, - }; - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) - .context("encoding Idunn health CultNet message") -} - -fn connect_idunn_rudp_health( - options: &IdunnRudpHealthOptions, -) -> Result { - let bind_address = if options.endpoint.is_ipv4() { - "0.0.0.0:0" - } else { - "[::]:0" - }; - let socket = UdpSocket::bind(bind_address) - .with_context(|| format!("binding Muninn RUDP sender at {bind_address}"))?; - socket.set_read_timeout(Some(Duration::from_secs(1)))?; - let mut transport = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( - "muninn-daemon", - socket, - options.endpoint, - IDUNN_HEALTH_RUDP_CONNECTION_ID, - ))?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_secs(2); - while !transport.connected() { - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - return Err(anyhow!( - "timed out connecting Muninn RUDP sender to {}", - options.endpoint - )); - } - } - Ok(transport) -} - fn verify_move_sources_fresh(options: &Options, node: &cultmesh_rs::CultMeshNode) -> Result<()> { let move_state_sources = serve_move_state_sources(options, serve_should_manage_move_runtime(options)); @@ -9837,7 +9663,6 @@ impl Options { quest_input_stream_id: None, quest_pose_stream_id: None, quest_video_input_stream_id: None, - idunn_rudp_health: None, odin_cultmesh_uri: Some("cultmesh://odin/rendezvous/provider-catalog".to_string()), hid_controller_rudp_target: None, hid_controller_rudp_bind: None, @@ -9848,9 +9673,6 @@ impl Options { }; let mut args = args.peekable(); - let mut idunn_rudp_health_endpoint = None; - let mut idunn_daemon_id = None; - let mut idunn_health_contract = None; while let Some(arg) = args.next() { match arg.as_str() { "serve" => options.mode = Mode::Serve, @@ -10087,19 +9909,6 @@ impl Options { options.quest_video_input_stream_id = Some(take_value(&mut args, "--quest-video-input-stream")?) } - "--idunn-rudp-health" => { - idunn_rudp_health_endpoint = Some( - take_value(&mut args, "--idunn-rudp-health")? - .parse() - .context("--idunn-rudp-health must be a socket address")?, - ) - } - "--idunn-daemon" => { - idunn_daemon_id = Some(take_value(&mut args, "--idunn-daemon")?) - } - "--idunn-health-contract" => { - idunn_health_contract = Some(take_value(&mut args, "--idunn-health-contract")?) - } "--odin-cultmesh-rudp" => { let _ = take_value(&mut args, "--odin-cultmesh-rudp")?; return Err(anyhow!( @@ -10287,32 +10096,11 @@ impl Options { options.quest_video_input_stream_id.as_ref(), ), ("--move-host", options.move_host_address.as_ref()), - ("--idunn-daemon", idunn_daemon_id.as_ref()), - ("--idunn-health-contract", idunn_health_contract.as_ref()), ] { if value.is_some_and(|value| value.trim().is_empty()) { return Err(anyhow!("{name} must be non-empty")); } } - options.idunn_rudp_health = match ( - idunn_rudp_health_endpoint, - idunn_daemon_id, - idunn_health_contract, - ) { - (None, None, None) => None, - (Some(endpoint), Some(daemon_id), Some(health_contract)) => { - Some(IdunnRudpHealthOptions { - endpoint, - daemon_id, - health_contract, - }) - } - _ => { - return Err(anyhow!( - "--idunn-rudp-health, --idunn-daemon, and --idunn-health-contract must be provided together" - )); - } - }; Ok(options) } } @@ -10330,14 +10118,6 @@ fn timestamp() -> Result { Ok(format!("unix-{seconds}")) } -fn idunn_timestamp() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before Unix epoch")? - .as_secs(); - Ok(format!("unix:{seconds}")) -} - fn timestamp_ns() -> Result { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -10406,7 +10186,7 @@ fn parse_move_marker_camera_source(value: &str) -> Result &'static str { - "Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store ] [--activate-store ] [--stream-action ] [--target-host ] [--media-transport ] [--media-rudp-bind ] [--media-rudp-advertise ] [--media-packet-bytes ] [--rudp-video-bitrate-kbps ] [--rudp-latency-budget-ms ] [--video-source ] [--audio-source ] [--audio-source-id ] [--no-video] [--no-audio] [--loopback-script ] [--ffmpeg ] [--odin-cultmesh-uri ] [--move-state =] [--move-marker-camera =] [--move-psmoveapi-tracker] [--move-tracker-exposure-milli <0..1000>] [--move-marker-width ] [--move-marker-height ] [--move-marker-fps ] [--move-host ] [--move-evidence-stream ] [--move-evidence-verse ] [--move-evidence-ring-slots ] [--move-evidence-slot-bytes ] [--move-evidence-snapshot ] [--quest-adb] [--quest-serial ] [--quest-input-stream ] [--quest-pose-stream ] [--quest-video-input-stream ] [--idunn-rudp-health ] [--idunn-daemon ] [--idunn-health-contract ] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-marker-camera, --move-host, or --move-evidence-stream it may publish source-local Move controller state, source-local optical marker candidates, typed Move identity records, a CultMesh Move evidence stream, optionally write a latest one-copy Move proof evidence snapshot for Mimir field capture/replay, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; --move-psmoveapi-tracker delegates camera exposure and optical extraction to the reference PSMoveAPI backend while Muninn retains stable-ID light actuation; serve consumes typed capture stream commands from its provider-owned activation store and owns the local ffmpeg/loopback activation child lifecycle, listens for media receivers on --media-rudp-bind and advertises that endpoint (--media-rudp-advertise when the bind address is not reachable as-is), and publishes its discovery advertisement through --odin-cultmesh-uri; activate starts an explicitly requested local CultNet RUDP stream as a daemon child that serves every receiver dialling --media-rudp-bind; request-stream discovers the provider through Odin and sends its typed command to that provider; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state. In --health mode, the Idunn RUDP flags publish the same typed daemon health document to Idunn for explicit diagnostics." + "Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store ] [--activate-store ] [--stream-action ] [--target-host ] [--media-transport ] [--media-rudp-bind ] [--media-rudp-advertise ] [--media-packet-bytes ] [--rudp-video-bitrate-kbps ] [--rudp-latency-budget-ms ] [--video-source ] [--audio-source ] [--audio-source-id ] [--no-video] [--no-audio] [--loopback-script ] [--ffmpeg ] [--odin-cultmesh-uri ] [--move-state =] [--move-marker-camera =] [--move-psmoveapi-tracker] [--move-tracker-exposure-milli <0..1000>] [--move-marker-width ] [--move-marker-height ] [--move-marker-fps ] [--move-host ] [--move-evidence-stream ] [--move-evidence-verse ] [--move-evidence-ring-slots ] [--move-evidence-slot-bytes ] [--move-evidence-snapshot ] [--quest-adb] [--quest-serial ] [--quest-input-stream ] [--quest-pose-stream ] [--quest-video-input-stream ] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-marker-camera, --move-host, or --move-evidence-stream it may publish source-local Move controller state, source-local optical marker candidates, typed Move identity records, a CultMesh Move evidence stream, optionally write a latest one-copy Move proof evidence snapshot for Mimir field capture/replay, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; --move-psmoveapi-tracker delegates camera exposure and optical extraction to the reference PSMoveAPI backend while Muninn retains stable-ID light actuation; serve consumes typed capture stream commands from its provider-owned activation store and owns the local ffmpeg/loopback activation child lifecycle, listens for media receivers on --media-rudp-bind and advertises that endpoint (--media-rudp-advertise when the bind address is not reachable as-is), and publishes its discovery advertisement through --odin-cultmesh-uri; activate starts an explicitly requested local CultNet RUDP stream as a daemon child that serves every receiver dialling --media-rudp-bind; request-stream discovers the provider through Odin and sends its typed command to that provider; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state. In --health mode, the Idunn RUDP flags publish the same typed daemon health document to Idunn for explicit diagnostics." } fn parse_move_state_source(value: &str) -> Result { @@ -10430,6 +10210,7 @@ fn parse_move_state_source(value: &str) -> Result { mod tests { use super::*; use crate::media_send::{MediaKind, QueuedMediaGroup}; + use cultnet_rs::encode_cultnet_message_to_vec; fn media_stream_request() -> GameCultMediaStreamRequestRecord { GameCultMediaStreamRequestRecord { @@ -10875,39 +10656,20 @@ mod tests { } #[test] - fn health_idunn_rudp_options_are_explicit_bundle() { - let options = Options::parse( - [ - "--health", - "--idunn-rudp-health", - "127.0.0.1:17870", - "--idunn-daemon", - "starfire-muninn", - "--idunn-health-contract", - "muninn.cultnet-rudp-local-telemetry-and-quest-access", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let idunn = options.idunn_rudp_health.unwrap(); - assert_eq!(options.mode, Mode::Health); - assert_eq!(idunn.endpoint, "127.0.0.1:17870".parse().unwrap()); - assert_eq!(idunn.daemon_id, "starfire-muninn"); - assert_eq!( - idunn.health_contract, - "muninn.cultnet-rudp-local-telemetry-and-quest-access" - ); - - let error = Options::parse( - ["--health", "--idunn-rudp-health", "127.0.0.1:17870"] - .into_iter() - .map(String::from), - ) - .unwrap_err() - .to_string(); - assert!(error.contains("must be provided together")); + fn retired_idunn_rudp_health_flags_are_unknown_arguments() { + for flag in [ + "--idunn-rudp-health", + "--idunn-daemon", + "--idunn-health-contract", + ] { + let error = Options::parse(["--health", flag, "127.0.0.1:17870"].into_iter().map(String::from)) + .unwrap_err() + .to_string(); + assert!( + error.contains(&format!("unknown Muninn argument: {flag}")), + "{flag} must be rejected, got: {error}" + ); + } } #[test] @@ -10995,67 +10757,6 @@ mod tests { assert!(error.contains("request-stream requires --odin-cultmesh-uri")); } - #[test] - fn idunn_rudp_health_publisher_sends_raw_daemon_health_document() { - let receiver = UdpSocket::bind("127.0.0.1:0").unwrap(); - receiver - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); - let receiver_addr = receiver.local_addr().unwrap(); - let handle = thread::spawn(move || { - let mut transport = CultNetRudpSocketTransportConnection::new( - CultNetRudpSocketTransportOptions::server( - "idunn-test", - receiver, - IDUNN_HEALTH_RUDP_CONNECTION_ID, - ), - ) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); - loop { - if let Some(frame) = transport.receive_once().unwrap() { - return cultnet_rs::decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap(); - } - transport.poll_resends().unwrap(); - if Instant::now() >= deadline { - panic!("timed out waiting for Muninn RUDP health frame"); - } - } - }); - let options = IdunnRudpHealthOptions { - endpoint: receiver_addr, - daemon_id: "starfire-muninn".to_string(), - health_contract: "muninn.cultnet-rudp-local-telemetry-and-quest-access".to_string(), - }; - - publish_idunn_rudp_health(&options, "active", "Muninn healthy", "unix:100").unwrap(); - - let message = handle.join().unwrap(); - let CultNetMessage::DocumentPutRaw { document, .. } = message else { - panic!("expected raw document put"); - }; - assert_eq!(document.schema_id, "idunn.daemon_health"); - assert_eq!(document.record_key, "starfire-muninn"); - assert_eq!( - document.payload_encoding, - CultNetRawPayloadEncoding::Messagepack - ); - let health: IdunnDaemonHealthRecord = rmp_serde::from_slice(&document.payload).unwrap(); - assert_eq!(health.daemon_id, "starfire-muninn"); - assert_eq!(health.state, "active"); - assert_eq!( - health.health_contract, - "muninn.cultnet-rudp-local-telemetry-and-quest-access" - ); - assert_eq!(health.publication_source, "daemon-published"); - assert_eq!(health.transport, CULTNET_RUDP_PROTOCOL_ID); - assert_eq!(health.observed_at, "unix:100"); - } - #[test] fn repeated_capture_stream_start_is_equivalent_when_media_shape_matches() { let options = Options::parse( @@ -12985,12 +12686,6 @@ Device 00:07:04:A8:00:D0 (public) "C:/Meta/Odin/state/muninn.activate.cc", "--target-host", "cultmesh://odin/media/muninn-raven-av", - "--idunn-rudp-health", - "198.51.100.10:17870", - "--idunn-daemon", - "muninn", - "--idunn-health-contract", - "muninn.cultnet-rudp-remote-telemetry-health", ] .into_iter() .map(String::from), @@ -13023,6 +12718,13 @@ Device 00:07:04:A8:00:D0 (public) assert!(invocation.contains("request-stream")); assert!(invocation.contains("--target-host cultmesh://odin/media/muninn-raven-av")); + assert_eq!( + boundary.value.get("daemon_id").and_then(|value| value.as_str()), + Some("muninn") + ); + assert!(boundary.value.get("health_publication").is_none()); + assert!(transport.value.get("health_contract").is_some_and(|value| value.is_null())); + let command_lowerings = transport .value .get("command_lowerings") diff --git a/docs/muninn.md b/docs/muninn.md index 6b7df20..bd9f5b5 100644 --- a/docs/muninn.md +++ b/docs/muninn.md @@ -22,7 +22,7 @@ microphones, cameras, and future sensors. ```powershell cargo build -p muninn-daemon -muninn serve --store C:\Meta\Odin\state\muninn.telemetry.cc --interval-seconds 15 --idunn-rudp-health $env:IDUNN_RUDP_HEALTH --idunn-daemon starfire-muninn --idunn-health-contract muninn.cultnet-rudp-local-telemetry-and-quest-access +muninn serve --store C:\Meta\Odin\state\muninn.telemetry.cc --interval-seconds 15 muninn --health --store C:\Meta\Odin\state\muninn.telemetry.cc ``` @@ -97,10 +97,7 @@ E:\Projects\Odin\scripts\restart-starfire-muninn.cmd E:\Projects\Odin\scripts\health-starfire-muninn.cmd ``` -The restart script launches Muninn hidden with `--host starfire --quest-adb`, -`--idunn-rudp-health` from explicit `-IdunnRudpHealth` or -`IDUNN_RUDP_HEALTH`, `--idunn-daemon starfire-muninn`, and -`--idunn-health-contract muninn.cultnet-rudp-local-telemetry-and-quest-access`. +The restart script launches Muninn hidden with `--host starfire --quest-adb`. If the CultCache store at `C:\Meta\Odin\state\starfire.muninn.telemetry.cc` fails MessagePack decode on boot, the restart path archives the corrupt file, clears the stale `.lock`, and relaunches the daemon instead of leaving the lane @@ -192,11 +189,7 @@ muninn move-light-status \ Idunn keeps the Muninn daemon alive. Idunn does not learn a Move-specific watcher, and Mimir does not write HID directly except through temporary smoke scripts used to prove hardware behavior before a Muninn daemon is available. -When `serve` is launched with `--idunn-rudp-health`, `--idunn-daemon`, and -`--idunn-health-contract`, the long-running Muninn body publishes -`idunn.daemon_health` directly to Idunn over `cultnet.transport.rudp.v0` on its -normal cadence. `--health` keeps the same publication path for manual proof and -compatibility probes, but the live owner is the daemon's `serve` process. +`--health` reads the telemetry store and reports; it publishes nothing. Quest ADB probing is a telemetry input, not daemon liveness. If `adb` is missing or the Quest is unavailable, Muninn publishes `muninn.quest_access` as `unavailable` and keeps serving the local telemetry surface. @@ -235,10 +228,6 @@ actuators only, not lifecycle owners. The binary is installed at `/home/metacrat/.local/state/gamecult/muninn/muninn.telemetry.cc`, and the restart actuator launches `serve --host nightwing --interval-seconds 15` with `--move-state move-usb=/dev/input/by-id/usb-Sony_Computer_Entertainment_Motion_Controller-joystick`, -`--idunn-rudp-health` supplied by explicit `-IdunnRudpHealth` or -`IDUNN_RUDP_HEALTH`, -`--idunn-daemon nightwing-muninn`, -`--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-and-move-hid`, PID, and logs under `/home/metacrat/.local/state/gamecult/muninn`. With that Move state source attached, `serve` also publishes the @@ -248,10 +237,7 @@ Nightwing source discovery must emit one Move source per controller id. The USB pairing collection and Bluetooth joystick can expose the same controller as separate `/dev/input/js*` paths; `nightwing-move-state-sources.sh` prefers the Bluetooth `HID_ID=0005:0000054C:000003D5` path so Idunn health does not demand -fresh records from two faces of the same controller. The Nightwing health -actuator requires the same configured Idunn RUDP endpoint, but that command is -fallback proof only; the live keepalive contract is now published by the -long-running Nightwing `serve` process. +fresh records from two faces of the same controller. Each private PSMoveAPI Eye worker owns optical observation admission before the parent aggregates evidence. Positions older than 50 ms, blobs below 2 px, diff --git a/docs/teardown-map.md b/docs/teardown-map.md index 69356c3..d89ec71 100644 --- a/docs/teardown-map.md +++ b/docs/teardown-map.md @@ -701,8 +701,7 @@ by continuity in under 10 s. The catalog on Odin named the new endpoint the first time the restarted `serve` advertised. Still Raven-specific and unmanaged: the two activation scripts and -`health-muninn.ps1`, which still passes the retired `--idunn-rudp-health` -flags. That flag family (`IdunnRudpHealthOptions`, `run_daemon_health_publisher`, -`idunn.daemon_health` documents) has no consumer since the previous Idunn -generation was retired and is the next cut; nightwing and starfire's restart -scripts still require it, so it goes with them. +`health-muninn.ps1`. The retired `--idunn-rudp-health` flag family +(`IdunnRudpHealthOptions`, `run_daemon_health_publisher`, `idunn.daemon_health` +documents) had no consumer since the previous Idunn generation was retired; it +was deleted from `muninn`, its five host scripts and `muninn.md` in named cut 3. diff --git a/scripts/health-muninn.ps1 b/scripts/health-muninn.ps1 index f783148..407b89e 100644 --- a/scripts/health-muninn.ps1 +++ b/scripts/health-muninn.ps1 @@ -5,7 +5,6 @@ param( [string] $ActivateStorePath = "C:\Meta\Odin\state\muninn.activate.cc", [string] $LogRoot = "C:\Meta\Odin\logs\muninn", [int] $MaxStoreAgeSeconds = 180, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, [int] $ConnectTimeoutSeconds = 10, [string] $SshUser = "madman's lullaby", [string] $IdentityFile = "" @@ -13,10 +12,6 @@ param( $ErrorActionPreference = "Stop" -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} - function Set-AsciiFile { param( [Parameter(Mandatory = $true)] [string] $Path, @@ -177,10 +172,7 @@ if (`$null -eq `$process) { } foreach (`$pattern in @( "--host raven", - "--activate-store $ActivateStorePath", - "--idunn-rudp-health $IdunnRudpHealth", - "--idunn-daemon muninn", - "--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-health" + "--activate-store $ActivateStorePath" )) { if (`$process.CommandLine -notlike "*`$pattern*") { throw "Muninn serve process is missing expected command-line segment `${pattern}: `$(`$process.CommandLine)" diff --git a/scripts/health-nightwing-muninn.ps1 b/scripts/health-nightwing-muninn.ps1 index ce2ce79..d6319cd 100644 --- a/scripts/health-nightwing-muninn.ps1 +++ b/scripts/health-nightwing-muninn.ps1 @@ -5,16 +5,11 @@ param( [string] $PidPath = "/home/metacrat/.local/state/gamecult/muninn/muninn.pid", [string[]] $MoveState = @(), [int] $IntervalSeconds = 15, - [int] $MaxStoreAgeSeconds = 180, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH + [int] $MaxStoreAgeSeconds = 180 ) $ErrorActionPreference = "Stop" -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no WireGuard endpoint default is allowed." -} - function Set-AsciiFile { param( [Parameter(Mandatory = $true)] [string] $Path, @@ -76,16 +71,6 @@ if [ -z "`$process_line" ]; then echo 'Nightwing Muninn serve command line is unavailable' >&2 exit 1 fi -for pattern in \ - '--idunn-rudp-health $IdunnRudpHealth' \ - '--idunn-daemon nightwing-muninn' \ - '--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-and-move-hid' -do - if ! printf '%s\n' "`$process_line" | grep -F -- "`$pattern" >/dev/null 2>&1; then - echo "Nightwing Muninn serve command line is missing `$pattern" >&2 - exit 1 - fi -done if [ ! -f '$StorePath' ]; then echo 'Muninn telemetry store is missing on Nightwing' >&2 exit 1 diff --git a/scripts/health-starfire-muninn.ps1 b/scripts/health-starfire-muninn.ps1 index c331145..d5be3ec 100644 --- a/scripts/health-starfire-muninn.ps1 +++ b/scripts/health-starfire-muninn.ps1 @@ -3,16 +3,11 @@ param( [string] $StorePath = "C:\Meta\Odin\state\starfire.muninn.telemetry.cc", [string] $LogRoot = "C:\Meta\Odin\logs\starfire-muninn", [string[]] $MoveState = @(), - [int] $MaxStoreAgeSeconds = 180, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH + [int] $MaxStoreAgeSeconds = 180 ) $ErrorActionPreference = "Stop" -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} - if (-not (Test-Path -LiteralPath $MuninnExe)) { throw "Muninn executable not found at $MuninnExe" } @@ -28,15 +23,6 @@ $process = Get-CimInstance Win32_Process | if ($null -eq $process) { throw "Starfire Muninn serve process is not running with Quest access enabled" } -foreach ($pattern in @( - "--idunn-rudp-health $IdunnRudpHealth", - "--idunn-daemon starfire-muninn", - "--idunn-health-contract muninn.cultnet-rudp-local-telemetry-and-quest-access" -)) { - if ($process.CommandLine -notlike "*$pattern*") { - throw "Starfire Muninn serve process is missing expected command-line segment ${pattern}: $($process.CommandLine)" - } -} if (-not (Test-Path -LiteralPath $StorePath)) { throw "Starfire Muninn telemetry store is missing at $StorePath" } diff --git a/scripts/restart-nightwing-muninn.ps1 b/scripts/restart-nightwing-muninn.ps1 index 9f92fa4..5c7392c 100644 --- a/scripts/restart-nightwing-muninn.ps1 +++ b/scripts/restart-nightwing-muninn.ps1 @@ -17,9 +17,6 @@ param( "nightwing-eye-1" = 100 }, [int] $IntervalSeconds = 15, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, - [string] $IdunnDaemon = "nightwing-muninn", - [string] $IdunnHealthContract = "muninn.cultnet-rudp-remote-telemetry-and-move-hid", [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), [string] $OdinCultMeshRudpEndpoint = $(if ($env:CULTMESH_URI_ODIN_RUDP) { $env:CULTMESH_URI_ODIN_RUDP } else { "10.77.0.2:17871" }), [string] $HidControllerRudpBind = "0.0.0.0:17888", @@ -34,12 +31,6 @@ if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daem throw "restart-nightwing-muninn.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." } -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} -if ($IdunnRudpHealth -match '^(127\.0\.0\.1|localhost):(\d+)$') { - $IdunnRudpHealth = "10.77.0.2:$($Matches[2])" -} if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind) -and [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { throw "HID controller RUDP advertise endpoint must be supplied by -HidControllerRudpAdvertise or MUNINN_HID_CONTROLLER_RUDP_ADVERTISE when HID RUDP bind is enabled; no Nightwing LAN default is allowed." } @@ -162,10 +153,7 @@ set -- serve \ --host nightwing $moveRuntimeSetBlock set -- "`$@" \ - --interval-seconds '$IntervalSeconds' \ - --idunn-rudp-health '$IdunnRudpHealth' \ - --idunn-daemon '$IdunnDaemon' \ - --idunn-health-contract '$IdunnHealthContract' + --interval-seconds '$IntervalSeconds' $odinCultMeshUriSetBlock $hidControllerRudpSetBlock $commandRudpSetBlock @@ -179,10 +167,9 @@ muninn_pid="`$(cat '$LogRoot/muninn.pid')" kill -0 "`$muninn_pid" 2>/dev/null muninn_cmdline="`$(tr '\0' ' ' < "/proc/`$muninn_pid/cmdline")" if ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--host nightwing' >/dev/null 2>&1 || - ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--idunn-rudp-health $IdunnRudpHealth' >/dev/null 2>&1 || ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--move-marker-camera nightwing-eye-0=/dev/video2' >/dev/null 2>&1 || ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--move-marker-camera nightwing-eye-1=/dev/video3' >/dev/null 2>&1; then - echo 'Nightwing Muninn serve command line is missing Idunn health or dual-camera arguments' >&2 + echo 'Nightwing Muninn serve command line is missing dual-camera arguments' >&2 exit 1 fi "@ diff --git a/scripts/restart-starfire-muninn.ps1 b/scripts/restart-starfire-muninn.ps1 index 70b79f6..69af854 100644 --- a/scripts/restart-starfire-muninn.ps1 +++ b/scripts/restart-starfire-muninn.ps1 @@ -6,9 +6,6 @@ param( [string] $MoveBluetoothHost = "", [string[]] $MoveState = @(), [switch] $EnableUsbMoveState, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, - [string] $IdunnDaemon = "starfire-muninn", - [string] $IdunnHealthContract = "muninn.cultnet-rudp-local-telemetry-and-quest-access", [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), [string] $OdinCultMeshRudpEndpoint = $(if ($env:CULTMESH_URI_ODIN_RUDP) { $env:CULTMESH_URI_ODIN_RUDP } else { "127.0.0.1:17871" }), [string] $HidControllerRudpBind = "", @@ -29,9 +26,6 @@ if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daem if (-not (Test-Path -LiteralPath $MuninnExe)) { throw "Muninn executable not found at $MuninnExe" } -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind) -and [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { throw "HID controller RUDP advertise endpoint must be supplied by -HidControllerRudpAdvertise or MUNINN_HID_CONTROLLER_RUDP_ADVERTISE when HID RUDP bind is enabled; no Starfire LAN default is allowed." } @@ -63,10 +57,7 @@ $arguments = @( "--host", "starfire", "--interval-seconds", "15", "--quest-adb", - "--quest-serial", $QuestSerial, - "--idunn-rudp-health", $IdunnRudpHealth, - "--idunn-daemon", $IdunnDaemon, - "--idunn-health-contract", $IdunnHealthContract + "--quest-serial", $QuestSerial ) if (-not [string]::IsNullOrWhiteSpace($OdinCultMeshUri)) { $arguments += @("--odin-cultmesh-uri", $OdinCultMeshUri) @@ -160,15 +151,3 @@ if ($null -eq $processCheck) { if ($null -eq $processCheck) { throw "Starfire Muninn serve process is not running after restart" } -foreach ($pattern in @( - "--idunn-rudp-health", - $IdunnRudpHealth, - "--idunn-daemon", - $IdunnDaemon, - "--idunn-health-contract", - $IdunnHealthContract -)) { - if ($processCheck.CommandLine -notlike "*$pattern*") { - throw "Starfire Muninn serve command line is missing ${pattern}: $($processCheck.CommandLine)" - } -} From 7b8b37a2fd58b819ad7cd7d34390b482f116690c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:18:57 +0200 Subject: [PATCH 2/6] health_check no longer reads a timestamp it does not publish The previous commit left health_check computing an observed_at and a state/detail pair whose only consumer was the deleted publisher; the timestamp helper itself was deleted, so the workspace did not build. Co-Authored-By: Claude Opus 5.5 --- crates/muninn-daemon/src/main.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/muninn-daemon/src/main.rs b/crates/muninn-daemon/src/main.rs index b28e042..c3b310c 100644 --- a/crates/muninn-daemon/src/main.rs +++ b/crates/muninn-daemon/src/main.rs @@ -8550,19 +8550,9 @@ fn quest_access_status(options: Options) -> Result<()> { } fn health_check(options: &Options) -> Result<()> { - let observed_at = idunn_timestamp()?; - let result = evaluate_health(options); - let (state, detail) = match &result { - Ok(detail) => ("active", detail.clone()), - Err(error) => ("failed", error.to_string()), - }; - match result { - Ok(detail) => { - println!("{detail}"); - Ok(()) - } - Err(error) => Err(error), - } + let detail = evaluate_health(options)?; + println!("{detail}"); + Ok(()) } fn evaluate_health(options: &Options) -> Result { From e27de13efd0f94194ff0bbf17bd41c0e6a46bc75 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:31:46 +0200 Subject: [PATCH 3/6] muninn-contracts: Muninn's 15 state records leave odin-core New library crate crates/muninn-contracts holds the records Muninn owns (telemetry surface, capture stream and command, OBS catalog, Move marker candidate, controller state, identity, light command, hue program, tracker health and evidence transport health, HID controller state, Quest access, command boundary, transport profile), their schema constants, and the MuninnRecords document set. The definitions are the odin-core ones moved verbatim; the wire is unchanged. muninn and sleipnir import the records from here, so both link one definition. muninn registers MuninnRecords after OdinDocuments, which stays because it still carries Odin's own records and stale copies of the same schema ids. Muninn's odin-core imports are now EveProviderAdvertisementRecord, EveSurfaceStateRecord and OdinDocuments. Tests pin every type name and schema id, the document-set bindings, and the positional wire layout of two records. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 15 + Cargo.toml | 1 + crates/muninn-contracts/Cargo.toml | 21 + crates/muninn-contracts/src/lib.rs | 176 +++++++++ crates/muninn-contracts/src/records.rs | 508 +++++++++++++++++++++++++ crates/muninn-daemon/Cargo.toml | 1 + crates/muninn-daemon/src/main.rs | 22 +- crates/sleipnir-daemon/Cargo.toml | 1 + crates/sleipnir-daemon/src/main.rs | 5 +- 9 files changed, 739 insertions(+), 11 deletions(-) create mode 100644 crates/muninn-contracts/Cargo.toml create mode 100644 crates/muninn-contracts/src/lib.rs create mode 100644 crates/muninn-contracts/src/records.rs diff --git a/Cargo.lock b/Cargo.lock index 2419f4b..70a0f01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,6 +479,19 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "muninn-contracts" +version = "0.1.0" +dependencies = [ + "anyhow", + "cultcache-rs", + "cultmesh-rs", + "cultnet-rs", + "rmp-serde", + "serde", + "serde_json", +] + [[package]] name = "muninn-daemon" version = "0.1.0" @@ -488,6 +501,7 @@ dependencies = [ "cultmesh-rs", "cultnet-rs", "libc", + "muninn-contracts", "muninn-move-tracker", "muninn-psmoveapi-tracker", "odin-core", @@ -799,6 +813,7 @@ dependencies = [ "cultcache-rs", "cultmesh-rs", "cultnet-rs", + "muninn-contracts", "odin-core", "rmp-serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 12add68..b9d770a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/muninn-contracts", "crates/muninn-daemon", "crates/muninn-move-tracker", "crates/muninn-psmoveapi-tracker", diff --git a/crates/muninn-contracts/Cargo.toml b/crates/muninn-contracts/Cargo.toml new file mode 100644 index 0000000..8b674b5 --- /dev/null +++ b/crates/muninn-contracts/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "muninn-contracts" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "muninn_contracts" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +cultcache-rs = { workspace = true } +cultmesh-rs = { workspace = true } +cultnet-rs = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +rmp-serde = "1.3" diff --git a/crates/muninn-contracts/src/lib.rs b/crates/muninn-contracts/src/lib.rs new file mode 100644 index 0000000..0ec0605 --- /dev/null +++ b/crates/muninn-contracts/src/lib.rs @@ -0,0 +1,176 @@ +//! Muninn's typed state records: the wire and store contract that `muninn` and +//! `sleipnir` share. Library only: no sockets, threads, devices or Odin. + +mod records; + +pub use records::*; + +cultmesh_rs::cultmesh_documents!(MuninnRecords { + MuninnTelemetrySurfaceRecord => MUNINN_TELEMETRY_SURFACE_SCHEMA, + MuninnCaptureStreamRecord => MUNINN_CAPTURE_STREAM_SCHEMA, + MuninnCaptureStreamCommandRecord => MUNINN_CAPTURE_STREAM_COMMAND_SCHEMA, + MuninnObsStreamCatalogRecord => MUNINN_OBS_STREAM_CATALOG_SCHEMA, + MuninnMoveMarkerCandidateRecord => MUNINN_MOVE_MARKER_CANDIDATE_SCHEMA, + MuninnMoveControllerStateRecord => MUNINN_MOVE_CONTROLLER_STATE_SCHEMA, + MuninnHidControllerStateRecord => MUNINN_HID_CONTROLLER_STATE_SCHEMA, + MuninnMoveIdentityRecord => MUNINN_MOVE_IDENTITY_SCHEMA, + MuninnMoveLightCommandRecord => MUNINN_MOVE_LIGHT_COMMAND_SCHEMA, + MuninnMoveHueProgramRecord => MUNINN_MOVE_HUE_PROGRAM_SCHEMA, + MuninnMoveTrackerHealthRecord => MUNINN_MOVE_TRACKER_HEALTH_SCHEMA, + MuninnMoveEvidenceTransportHealthRecord => MUNINN_MOVE_EVIDENCE_TRANSPORT_HEALTH_SCHEMA, + MuninnQuestAccessRecord => MUNINN_QUEST_ACCESS_SCHEMA, + MuninnCommandBoundaryCompatRecord => MUNINN_COMMAND_BOUNDARY_SCHEMA, + MuninnTransportProfileCompatRecord => MUNINN_TRANSPORT_PROFILE_SCHEMA, +}); + +#[cfg(test)] +mod tests { + use super::*; + use cultcache_rs::{CultCache, DatabaseEntry}; + use cultmesh_rs::CultMeshDocumentSet; + use cultnet_rs::CultNetDocumentRegistry; + + /// Every Muninn record: its type name, its schema id, the schema constant + /// the document set binds it under, and its Rust name. + fn contract() -> Vec<(&'static str, &'static str, &'static str, &'static str)> { + macro_rules! row { + ($record:ty, $constant:expr) => { + ( + <$record as DatabaseEntry>::TYPE, + <$record as DatabaseEntry>::SCHEMA_NAME, + $constant, + stringify!($record), + ) + }; + } + vec![ + row!(MuninnTelemetrySurfaceRecord, MUNINN_TELEMETRY_SURFACE_SCHEMA), + row!(MuninnCaptureStreamRecord, MUNINN_CAPTURE_STREAM_SCHEMA), + row!(MuninnCaptureStreamCommandRecord, MUNINN_CAPTURE_STREAM_COMMAND_SCHEMA), + row!(MuninnObsStreamCatalogRecord, MUNINN_OBS_STREAM_CATALOG_SCHEMA), + row!(MuninnMoveMarkerCandidateRecord, MUNINN_MOVE_MARKER_CANDIDATE_SCHEMA), + row!(MuninnMoveControllerStateRecord, MUNINN_MOVE_CONTROLLER_STATE_SCHEMA), + row!(MuninnHidControllerStateRecord, MUNINN_HID_CONTROLLER_STATE_SCHEMA), + row!(MuninnMoveIdentityRecord, MUNINN_MOVE_IDENTITY_SCHEMA), + row!(MuninnMoveLightCommandRecord, MUNINN_MOVE_LIGHT_COMMAND_SCHEMA), + row!(MuninnMoveHueProgramRecord, MUNINN_MOVE_HUE_PROGRAM_SCHEMA), + row!(MuninnMoveTrackerHealthRecord, MUNINN_MOVE_TRACKER_HEALTH_SCHEMA), + row!( + MuninnMoveEvidenceTransportHealthRecord, + MUNINN_MOVE_EVIDENCE_TRANSPORT_HEALTH_SCHEMA + ), + row!(MuninnQuestAccessRecord, MUNINN_QUEST_ACCESS_SCHEMA), + row!(MuninnCommandBoundaryCompatRecord, MUNINN_COMMAND_BOUNDARY_SCHEMA), + row!(MuninnTransportProfileCompatRecord, MUNINN_TRANSPORT_PROFILE_SCHEMA), + ] + } + + #[test] + fn record_names_are_the_published_schema_ids() { + let expected = [ + ("muninn.telemetry_surface", "muninn.telemetry_surface.v1"), + ("muninn.capture_stream", "muninn.capture_stream.v1"), + ("muninn.capture_stream_command", "muninn.capture_stream_command.v1"), + ("muninn.obs_stream_catalog", "muninn.obs_stream_catalog.v1"), + ("muninn.move_marker_candidate", "muninn.move_marker_candidate.v1"), + ("muninn.move_controller_state", "muninn.move_controller_state.v1"), + ("muninn.hid_controller_state", "muninn.hid_controller_state.v1"), + ("muninn.move_identity", "muninn.move_identity.v1"), + ("muninn.move_light_command", "muninn.move_light_command.v1"), + ("muninn.move_hue_program", "muninn.move_hue_program.v1"), + ("muninn.move_tracker_health", "muninn.move_tracker_health.v1"), + ( + "muninn.move_evidence_transport_health", + "muninn.move_evidence_transport_health.v1", + ), + ("muninn.quest_access", "muninn.quest_access.v1"), + ("muninn.command_boundary", "muninn.command_boundary.v1"), + ("muninn.transport_profile", "muninn.transport_profile.v1"), + ]; + let actual: Vec<_> = contract() + .into_iter() + .map(|(record_type, schema, constant, _)| { + assert_eq!(schema, constant, "schema constant and derive disagree"); + (record_type, schema) + }) + .collect(); + assert_eq!(actual, expected); + } + + #[test] + fn document_set_binds_every_record_under_its_schema() { + let mut cache = CultCache::new(); + MuninnRecords.register_cache(&mut cache).unwrap(); + let mut registry = CultNetDocumentRegistry::new(); + MuninnRecords.register_documents(&mut registry).unwrap(); + + let rows = contract(); + let mut registered = cache.registered_entry_types(); + registered.sort(); + let mut expected: Vec = rows.iter().map(|row| row.0.to_string()).collect(); + expected.sort(); + assert_eq!(registered, expected); + for (record_type, schema, _, record) in rows { + let binding = registry + .binding_by_schema_id(schema) + .unwrap_or_else(|| panic!("{record} is not bound under {schema}")); + assert_eq!(binding.document_type, record_type, "{record}"); + } + } + + #[test] + fn hid_controller_state_wire_layout_is_positional_by_key() { + let record = MuninnHidControllerStateRecord { + stream_id: "muninn.raven.hid".into(), + host_id: "raven".into(), + device_id: "xbox-0".into(), + device_kind: "xinput".into(), + sequence: 7, + source_timestamp_ns: -3, + axes: vec![0.5, -1.0], + buttons: vec!["a".into(), "b".into()], + battery01: 1.0, + observed_at: "unix:100".into(), + source_path: "xinput://0".into(), + }; + let bytes = rmp_serde::to_vec(&record).unwrap(); + assert_eq!( + hex(&bytes), + "9bb06d756e696e6e2e726176656e2e686964a5726176656ea678626f782d30a678696e70757407fd92ca3f000000cabf80000092a161a162ca3f800000a8756e69783a313030aa78696e7075743a2f2f30" + ); + assert_eq!( + rmp_serde::from_slice::(&bytes).unwrap(), + record + ); + } + + #[test] + fn hue_program_wire_layout_carries_its_defaulted_tail() { + let record = MuninnMoveHueProgramRecord { + program_id: "prog".into(), + host_id: "raven".into(), + mode: "cycle".into(), + cycle_ms: 20, + epoch_ns: -1, + hold_at_ns: 0, + requested_by: "mimir".into(), + updated_at: "unix:1".into(), + order_mode: "ring".into(), + transition_percent: 50, + transition_percent_explicit: true, + }; + let bytes = rmp_serde::to_vec(&record).unwrap(); + assert_eq!( + hex(&bytes), + "9ba470726f67a5726176656ea56379636c6514ff00a56d696d6972a6756e69783a31a472696e6732c3" + ); + assert_eq!( + rmp_serde::from_slice::(&bytes).unwrap(), + record + ); + } + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() + } +} diff --git a/crates/muninn-contracts/src/records.rs b/crates/muninn-contracts/src/records.rs new file mode 100644 index 0000000..7742843 --- /dev/null +++ b/crates/muninn-contracts/src/records.rs @@ -0,0 +1,508 @@ +use cultcache_rs::DatabaseEntry; +use serde_json::Value; + +pub const MUNINN_TELEMETRY_SURFACE_SCHEMA: &str = "muninn.telemetry_surface.v1"; +pub const MUNINN_CAPTURE_STREAM_SCHEMA: &str = "muninn.capture_stream.v1"; +pub const MUNINN_CAPTURE_STREAM_COMMAND_SCHEMA: &str = "muninn.capture_stream_command.v1"; +pub const MUNINN_OBS_STREAM_CATALOG_SCHEMA: &str = "muninn.obs_stream_catalog.v1"; +pub const MUNINN_MOVE_MARKER_CANDIDATE_SCHEMA: &str = "muninn.move_marker_candidate.v1"; +pub const MUNINN_MOVE_CONTROLLER_STATE_SCHEMA: &str = "muninn.move_controller_state.v1"; +pub const MUNINN_HID_CONTROLLER_STATE_SCHEMA: &str = "muninn.hid_controller_state.v1"; +pub const MUNINN_MOVE_IDENTITY_SCHEMA: &str = "muninn.move_identity.v1"; +pub const MUNINN_MOVE_LIGHT_COMMAND_SCHEMA: &str = "muninn.move_light_command.v1"; +pub const MUNINN_MOVE_HUE_PROGRAM_SCHEMA: &str = "muninn.move_hue_program.v1"; +pub const MUNINN_MOVE_TRACKER_HEALTH_SCHEMA: &str = "muninn.move_tracker_health.v1"; +pub const MUNINN_MOVE_EVIDENCE_TRANSPORT_HEALTH_SCHEMA: &str = + "muninn.move_evidence_transport_health.v1"; +pub const MUNINN_QUEST_ACCESS_SCHEMA: &str = "muninn.quest_access.v1"; +pub const MUNINN_COMMAND_BOUNDARY_SCHEMA: &str = "muninn.command_boundary.v1"; +pub const MUNINN_TRANSPORT_PROFILE_SCHEMA: &str = "muninn.transport_profile.v1"; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "muninn.capture_stream", schema = "muninn.capture_stream.v1")] +pub struct MuninnCaptureStreamRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub state: String, + #[cultcache(key = 3)] + pub video_source: String, + #[cultcache(key = 4)] + pub audio_source: String, + #[cultcache(key = 5)] + pub transport: String, + #[cultcache(key = 6)] + pub targets: Vec, + #[cultcache(key = 7)] + pub command_witness: String, + #[cultcache(key = 8)] + pub supervisor_pid: Option, + #[cultcache(key = 9)] + pub mux_pid: Option, + #[cultcache(key = 10)] + pub restart_count: u32, + #[cultcache(key = 11)] + pub detail: String, + #[cultcache(key = 12)] + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.capture_stream_command", + schema = "muninn.capture_stream_command.v1" +)] +pub struct MuninnCaptureStreamCommandRecord { + #[cultcache(key = 0)] + pub command_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub stream_id: String, + #[cultcache(key = 3)] + pub state: String, + #[cultcache(key = 4)] + pub action: String, + #[cultcache(key = 5)] + pub target_host: String, + #[cultcache(key = 6)] + pub port: u16, + #[cultcache(key = 7)] + pub obs_target_host: Option, + #[cultcache(key = 8)] + pub obs_port: u16, + #[cultcache(key = 9)] + pub media_transport: String, + #[cultcache(key = 10)] + pub media_packet_bytes: u32, + #[cultcache(key = 11)] + pub requested_by: String, + #[cultcache(key = 12)] + pub detail: String, + #[cultcache(key = 13)] + pub updated_at: String, + #[cultcache(key = 14, default)] + pub rudp_video_bitrate_kbps: u32, + #[cultcache(key = 15, default)] + pub rudp_latency_budget_ms: u32, + #[cultcache(key = 16, default)] + pub video_source_id: String, + #[cultcache(key = 17, default)] + pub audio_source_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.telemetry_surface", + schema = "muninn.telemetry_surface.v1" +)] +pub struct MuninnTelemetrySurfaceRecord { + #[cultcache(key = 0)] + pub surface_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub state: String, + #[cultcache(key = 3)] + pub available_sources: Vec, + #[cultcache(key = 4)] + pub stream_affordances: Vec, + #[cultcache(key = 5)] + pub active_streams: Vec, + #[cultcache(key = 6)] + pub activation_authority: String, + #[cultcache(key = 7)] + pub detail: String, + #[cultcache(key = 8)] + pub updated_at: String, + #[cultcache(key = 9, default)] + pub primary_stream_id: String, + #[cultcache(key = 10, default)] + pub primary_stream_label: String, + #[cultcache(key = 11, default)] + pub command_rudp_target: String, + #[cultcache(key = 12, default)] + pub media_target_host: String, + #[cultcache(key = 13, default)] + pub media_port: u16, + #[cultcache(key = 14, default)] + pub media_packet_bytes: u32, + #[cultcache(key = 15, default)] + pub rudp_video_bitrate_kbps: u32, + #[cultcache(key = 16, default)] + pub rudp_latency_budget_ms: u32, + #[cultcache(key = 17, default)] + pub video_source_ids: Vec, + #[cultcache(key = 18, default)] + pub video_source_labels: Vec, + #[cultcache(key = 19, default)] + pub audio_source_ids: Vec, + #[cultcache(key = 20, default)] + pub audio_source_labels: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.obs_stream_catalog", + schema = "muninn.obs_stream_catalog.v1" +)] +pub struct MuninnObsStreamCatalogRecord { + #[cultcache(key = 0)] + pub catalog_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub stream_ids: Vec, + #[cultcache(key = 3)] + pub labels: Vec, + #[cultcache(key = 4)] + pub urls: Vec, + #[cultcache(key = 5)] + pub states: Vec, + #[cultcache(key = 6)] + pub updated_at: String, + #[cultcache(key = 7, default)] + pub command_rudp_target: String, + #[cultcache(key = 8, default)] + pub media_target_host: String, + #[cultcache(key = 9, default)] + pub media_port: u16, + #[cultcache(key = 10, default)] + pub media_packet_bytes: u32, + #[cultcache(key = 11, default)] + pub rudp_video_bitrate_kbps: u32, + #[cultcache(key = 12, default)] + pub rudp_latency_budget_ms: u32, + #[cultcache(key = 13, default)] + pub video_source_ids: Vec, + #[cultcache(key = 14, default)] + pub video_source_labels: Vec, + #[cultcache(key = 15, default)] + pub audio_source_ids: Vec, + #[cultcache(key = 16, default)] + pub audio_source_labels: Vec, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "muninn.move_marker_candidate", + schema = "muninn.move_marker_candidate.v1" +)] +pub struct MuninnMoveMarkerCandidateRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub camera_id: String, + #[cultcache(key = 3)] + pub frame_sequence: u64, + #[cultcache(key = 4)] + pub source_id_hash: u64, + #[cultcache(key = 5)] + pub tile_x: u32, + #[cultcache(key = 6)] + pub tile_y: u32, + #[cultcache(key = 7)] + pub center_x_px: f32, + #[cultcache(key = 8)] + pub center_y_px: f32, + #[cultcache(key = 9)] + pub radius_px: f32, + #[cultcache(key = 10)] + pub area_px: u32, + #[cultcache(key = 11)] + pub mean_luma: f32, + #[cultcache(key = 12)] + pub peak_luma: u32, + #[cultcache(key = 13)] + pub score: f32, + #[cultcache(key = 14)] + pub observed_at: String, + #[cultcache(key = 15, default)] + pub move_id: String, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "muninn.move_controller_state", + schema = "muninn.move_controller_state.v1" +)] +pub struct MuninnMoveControllerStateRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub move_id: String, + #[cultcache(key = 3)] + pub sequence: u64, + #[cultcache(key = 4)] + pub source_timestamp_ns: i64, + #[cultcache(key = 5)] + pub accelerometer_xyz: Vec, + #[cultcache(key = 6)] + pub gyroscope_xyz: Vec, + #[cultcache(key = 7)] + pub magnetometer_xyz: Vec, + #[cultcache(key = 8)] + pub trigger_value: f32, + #[cultcache(key = 9)] + pub buttons: Vec, + #[cultcache(key = 10)] + pub battery01: f32, + #[cultcache(key = 11)] + pub observed_at: String, + #[cultcache(key = 12, default)] + pub source_path: String, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "muninn.hid_controller_state", + schema = "muninn.hid_controller_state.v1" +)] +pub struct MuninnHidControllerStateRecord { + #[cultcache(key = 0)] + pub stream_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub device_id: String, + #[cultcache(key = 3)] + pub device_kind: String, + #[cultcache(key = 4)] + pub sequence: u64, + #[cultcache(key = 5)] + pub source_timestamp_ns: i64, + #[cultcache(key = 6)] + pub axes: Vec, + #[cultcache(key = 7)] + pub buttons: Vec, + #[cultcache(key = 8)] + pub battery01: f32, + #[cultcache(key = 9)] + pub observed_at: String, + #[cultcache(key = 10)] + pub source_path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "muninn.move_identity", schema = "muninn.move_identity.v1")] +pub struct MuninnMoveIdentityRecord { + #[cultcache(key = 0)] + pub identity_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub move_id: String, + #[cultcache(key = 3)] + pub source_path: String, + #[cultcache(key = 4)] + pub bluetooth_host_address: String, + #[cultcache(key = 5)] + pub state: String, + #[cultcache(key = 6)] + pub detail: String, + #[cultcache(key = 7)] + pub observed_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.move_light_command", + schema = "muninn.move_light_command.v1" +)] +pub struct MuninnMoveLightCommandRecord { + #[cultcache(key = 0)] + pub command_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub move_id: String, + #[cultcache(key = 3)] + pub hidraw_path: String, + #[cultcache(key = 4)] + pub colors: Vec, + #[cultcache(key = 5)] + pub durations_ms: Vec, + #[cultcache(key = 6)] + pub repeat_count: u32, + #[cultcache(key = 7)] + pub authority: String, + #[cultcache(key = 8)] + pub state: String, + #[cultcache(key = 9)] + pub detail: String, + #[cultcache(key = 10)] + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.move_hue_program", + schema = "muninn.move_hue_program.v1" +)] +pub struct MuninnMoveHueProgramRecord { + #[cultcache(key = 0)] + pub program_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub mode: String, + #[cultcache(key = 3)] + pub cycle_ms: u64, + #[cultcache(key = 4)] + pub epoch_ns: i64, + #[cultcache(key = 5)] + pub hold_at_ns: i64, + #[cultcache(key = 6)] + pub requested_by: String, + #[cultcache(key = 7)] + pub updated_at: String, + #[cultcache(key = 8, default)] + pub order_mode: String, + #[cultcache(key = 9, default)] + pub transition_percent: u8, + #[cultcache(key = 10, default)] + pub transition_percent_explicit: bool, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "muninn.move_tracker_health", + schema = "muninn.move_tracker_health.v1" +)] +pub struct MuninnMoveTrackerHealthRecord { + #[cultcache(key = 0)] + pub health_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub camera_id: String, + #[cultcache(key = 3)] + pub camera_index: i32, + #[cultcache(key = 4)] + pub state: String, + #[cultcache(key = 5)] + pub camera_name: String, + #[cultcache(key = 6)] + pub camera_api: String, + #[cultcache(key = 7)] + pub width: u32, + #[cultcache(key = 8)] + pub height: u32, + #[cultcache(key = 9)] + pub exposure: f32, + #[cultcache(key = 10)] + pub calibrated_controller_count: u32, + #[cultcache(key = 11)] + pub update_count: u64, + #[cultcache(key = 12)] + pub observation_count: u64, + #[cultcache(key = 13)] + pub latest_observation_count: u32, + #[cultcache(key = 14)] + pub last_observation_at: String, + #[cultcache(key = 15)] + pub detail: String, + #[cultcache(key = 16)] + pub updated_at: String, + #[cultcache(key = 17, default)] + pub image_mean_rgb: Vec, + #[cultcache(key = 18, default)] + pub image_peak_rgb: Vec, + #[cultcache(key = 19, default)] + pub color_evidence_move_ids: Vec, + #[cultcache(key = 20, default)] + pub color_evidence_pixel_counts: Vec, + #[cultcache(key = 21, default)] + pub rejected_stale_count: u64, + #[cultcache(key = 22, default)] + pub rejected_radius_count: u64, + #[cultcache(key = 23, default)] + pub rejected_bounds_count: u64, + #[cultcache(key = 24, default)] + pub rejected_continuity_count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "muninn.move_evidence_transport_health", + schema = "muninn.move_evidence_transport_health.v1" +)] +pub struct MuninnMoveEvidenceTransportHealthRecord { + #[cultcache(key = 0)] + pub health_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub stream_id: String, + #[cultcache(key = 3)] + pub produced_frames: u64, + #[cultcache(key = 4)] + pub local_ring_admissions: u64, + #[cultcache(key = 5)] + pub remote_handoffs: u64, + #[cultcache(key = 6)] + pub remote_sends: u64, + #[cultcache(key = 7)] + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "muninn.quest_access", schema = "muninn.quest_access.v1")] +pub struct MuninnQuestAccessRecord { + #[cultcache(key = 0)] + pub access_id: String, + #[cultcache(key = 1)] + pub host_id: String, + #[cultcache(key = 2)] + pub serial: String, + #[cultcache(key = 3)] + pub connection_state: String, + #[cultcache(key = 4)] + pub product: String, + #[cultcache(key = 5)] + pub model: String, + #[cultcache(key = 6)] + pub device: String, + #[cultcache(key = 7)] + pub transport_id: String, + #[cultcache(key = 8)] + pub input_stream_id: String, + #[cultcache(key = 9)] + pub pose_stream_id: String, + #[cultcache(key = 10)] + pub video_input_stream_id: String, + #[cultcache(key = 11)] + pub video_input_transport: String, + #[cultcache(key = 12)] + pub state: String, + #[cultcache(key = 13)] + pub detail: String, + #[cultcache(key = 14)] + pub observed_at: String, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "muninn.command_boundary", + schema = "muninn.command_boundary.v1" +)] +pub struct MuninnCommandBoundaryCompatRecord { + #[cultcache(key = 0)] + pub value: Value, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "muninn.transport_profile", + schema = "muninn.transport_profile.v1" +)] +pub struct MuninnTransportProfileCompatRecord { + #[cultcache(key = 0)] + pub value: Value, +} diff --git a/crates/muninn-daemon/Cargo.toml b/crates/muninn-daemon/Cargo.toml index 5b495ee..c77d6c5 100644 --- a/crates/muninn-daemon/Cargo.toml +++ b/crates/muninn-daemon/Cargo.toml @@ -19,6 +19,7 @@ cultcache-rs = { workspace = true } cultmesh-rs = { workspace = true } cultnet-rs = { workspace = true } libc = "0.2" +muninn-contracts = { path = "../muninn-contracts" } muninn-move-tracker = { path = "../muninn-move-tracker" } muninn-psmoveapi-tracker = { path = "../muninn-psmoveapi-tracker", optional = true } odin-core = { workspace = true } diff --git a/crates/muninn-daemon/src/main.rs b/crates/muninn-daemon/src/main.rs index c3b310c..cf8e119 100644 --- a/crates/muninn-daemon/src/main.rs +++ b/crates/muninn-daemon/src/main.rs @@ -23,18 +23,18 @@ use cultnet_rs::{ GameCultRuntimePresenceHealthRecord, decode_cultnet_message_from_slice, media_stream_request_key, validate_media_stream_request, }; -#[cfg(feature = "psmoveapi-tracker")] -use odin_core::MuninnMoveTrackerHealthRecord; -use odin_core::{ - EveProviderAdvertisementRecord, EveSurfaceStateRecord, +use muninn_contracts::{ MUNINN_MOVE_HUE_PROGRAM_SCHEMA, MUNINN_OBS_STREAM_CATALOG_SCHEMA, MuninnCaptureStreamCommandRecord, MuninnCaptureStreamRecord, MuninnCommandBoundaryCompatRecord, MuninnHidControllerStateRecord, MuninnMoveControllerStateRecord, MuninnMoveEvidenceTransportHealthRecord, MuninnMoveHueProgramRecord, MuninnMoveIdentityRecord, MuninnMoveLightCommandRecord, MuninnMoveMarkerCandidateRecord, MuninnObsStreamCatalogRecord, - MuninnQuestAccessRecord, MuninnTelemetrySurfaceRecord, MuninnTransportProfileCompatRecord, - OdinDocuments, + MuninnQuestAccessRecord, MuninnRecords, MuninnTelemetrySurfaceRecord, + MuninnTransportProfileCompatRecord, }; +#[cfg(feature = "psmoveapi-tracker")] +use muninn_contracts::MuninnMoveTrackerHealthRecord; +use odin_core::{EveProviderAdvertisementRecord, EveSurfaceStateRecord, OdinDocuments}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{HashMap, HashSet}; @@ -9526,15 +9526,18 @@ fn ensure_state_dirs(options: &Options) -> Result<()> { Ok(()) } -/// Odin's document set plus the producer-agnostic media stream contract. -/// Muninn advertises through the latter and takes requests through it; the -/// former is what the rest of its state still speaks. +/// Odin's document set, Muninn's own records, and the producer-agnostic media +/// stream contract. Muninn's records are registered after Odin's, so +/// `muninn-contracts` decides every `muninn.*` binding; Odin's document set +/// still carries stale copies of the same schema ids (and the store may hold +/// legacy Odin records), which is why it stays registered. #[derive(Clone, Copy, Debug, Default)] struct MuninnDocuments; impl cultmesh_rs::CultMeshDocumentSet for MuninnDocuments { fn register_cache(&self, cache: &mut cultcache_rs::CultCache) -> Result<()> { OdinDocuments.register_cache(cache)?; + MuninnRecords.register_cache(cache)?; cache.register_entry_type::()?; cache.register_entry_type::()?; cache.register_entry_type::()?; @@ -9543,6 +9546,7 @@ impl cultmesh_rs::CultMeshDocumentSet for MuninnDocuments { fn register_documents(&self, registry: &mut cultnet_rs::CultNetDocumentRegistry) -> Result<()> { OdinDocuments.register_documents(registry)?; + MuninnRecords.register_documents(registry)?; registry.register( cultnet_rs::CultNetDocumentBinding::for_entry_with_schema_id::< GameCultMediaStreamAdvertisementRecord, diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml index b10f048..09e4533 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -14,6 +14,7 @@ anyhow = "1" cultmesh-rs = { workspace = true } cultnet-rs = { workspace = true } cultcache-rs = { workspace = true } +muninn-contracts = { path = "../muninn-contracts" } odin-core = { workspace = true } rmp-serde = "1.3" serde_json = "1" diff --git a/crates/sleipnir-daemon/src/main.rs b/crates/sleipnir-daemon/src/main.rs index 1be0193..cccedfd 100644 --- a/crates/sleipnir-daemon/src/main.rs +++ b/crates/sleipnir-daemon/src/main.rs @@ -7,10 +7,11 @@ use cultnet_rs::{ CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, encode_cultnet_message_to_vec, }; +use muninn_contracts::{MUNINN_HID_CONTROLLER_STATE_SCHEMA, MuninnHidControllerStateRecord}; use odin_core::{ EVE_PROVIDER_ADVERTISEMENT_SCHEMA, EveProviderAdvertisementRecord, EveSurfaceStateRecord, - IdunnDaemonHealthRecord, MUNINN_HID_CONTROLLER_STATE_SCHEMA, MuninnHidControllerStateRecord, - OdinDocuments, OdinEndpointQuery, SleipnirInputMappingRecord, discover_provider_endpoints, + IdunnDaemonHealthRecord, OdinDocuments, OdinEndpointQuery, SleipnirInputMappingRecord, + discover_provider_endpoints, }; use std::collections::HashMap; use std::env; From b2014284d1731c27abe8f8185b3e17f6127cb26a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:34:35 +0200 Subject: [PATCH 4/6] muninn-contracts: pin that a defaulted tail decodes from an older payload The wire-layout goldens set every field, so dropping `default` from a record's tail survived them. Adds a test that decodes an eight-element hue program payload and expects the defaulted tail. Co-Authored-By: Claude Opus 5.5 --- crates/muninn-contracts/src/lib.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/muninn-contracts/src/lib.rs b/crates/muninn-contracts/src/lib.rs index 0ec0605..fb312ef 100644 --- a/crates/muninn-contracts/src/lib.rs +++ b/crates/muninn-contracts/src/lib.rs @@ -170,6 +170,25 @@ mod tests { ); } + #[test] + fn hue_program_from_an_older_payload_defaults_its_tail() { + let older = decode_hex( + "98a470726f67a5726176656ea56379636c6514ff00a56d696d6972a6756e69783a31", + ); + let record: MuninnMoveHueProgramRecord = rmp_serde::from_slice(&older).unwrap(); + assert_eq!(record.updated_at, "unix:1"); + assert_eq!(record.order_mode, ""); + assert_eq!(record.transition_percent, 0); + assert!(!record.transition_percent_explicit); + } + + fn decode_hex(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&text[index..index + 2], 16).unwrap()) + .collect() + } + fn hex(bytes: &[u8]) -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() } From 139ca7397fffe5502aea2e1300e7c8a5ccf76328 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:17:50 +0200 Subject: [PATCH 5/6] muninn-contracts: pin every record's wire bytes and every defaulted key One table, 15 records: each encodes to its pinned positional MessagePack array (dropped, swapped, retyped keys change the bytes), and every record with a defaulted tail decodes from a payload that ends before each defaulted key, one truncation per key, reading the rest as zero values. Replaces the two hand-written hid and hue tests and the single hue truncation. Co-Authored-By: Claude Sonnet 5.5 --- crates/muninn-contracts/src/lib.rs | 465 ++++++++++++++++++++++++----- 1 file changed, 398 insertions(+), 67 deletions(-) diff --git a/crates/muninn-contracts/src/lib.rs b/crates/muninn-contracts/src/lib.rs index fb312ef..e111ae5 100644 --- a/crates/muninn-contracts/src/lib.rs +++ b/crates/muninn-contracts/src/lib.rs @@ -118,75 +118,406 @@ mod tests { } } + /// One wire pin per record. `bytes` is the record's positional MessagePack + /// array, encoded from a sample whose every field holds a distinct, + /// non-default value (Options: one `None`, one `Some`). `first_default` is + /// the key of the first defaulted tail field; records without one carry + /// `None`. + struct Pin { + record: &'static str, + bytes: Vec, + first_default: Option, + golden: &'static str, + /// Decode a payload as this record and encode it again. + reencode: fn(&[u8]) -> Result, String>, + } + + fn pin( + record: &'static str, + sample: T, + first_default: Option, + golden: &'static str, + ) -> Pin + where + T: serde::Serialize + serde::de::DeserializeOwned, + { + Pin { + record, + bytes: rmp_serde::to_vec(&sample).unwrap(), + first_default, + golden, + reencode: |bytes| { + let decoded: T = rmp_serde::from_slice(bytes).map_err(|error| error.to_string())?; + rmp_serde::to_vec(&decoded).map_err(|error| error.to_string()) + }, + } + } + + fn text(value: &str) -> String { + value.to_string() + } + + fn texts(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + fn pins() -> Vec { + vec![ + pin( + "MuninnTelemetrySurfaceRecord", + MuninnTelemetrySurfaceRecord { + surface_id: text("surface"), + host_id: text("host"), + state: text("live"), + available_sources: texts(&["src"]), + stream_affordances: texts(&["aff"]), + active_streams: texts(&["act"]), + activation_authority: text("auth"), + detail: text("detail"), + updated_at: text("t"), + primary_stream_id: text("psid"), + primary_stream_label: text("plabel"), + command_rudp_target: text("cmd"), + media_target_host: text("mth"), + media_port: 4001, + media_packet_bytes: 1200, + rudp_video_bitrate_kbps: 8000, + rudp_latency_budget_ms: 40, + video_source_ids: texts(&["vid"]), + video_source_labels: texts(&["vlab"]), + audio_source_ids: texts(&["aid"]), + audio_source_labels: texts(&["alab"]), + }, + Some(9), + "dc0015a773757266616365a4686f7374a46c69766591a373726391a361666691a3616374a461757468a664657461696ca174a470736964a6706c6162656ca3636d64a36d7468cd0fa1cd04b0cd1f402891a376696491a4766c616291a361696491a4616c6162", + ), + pin( + "MuninnCaptureStreamRecord", + MuninnCaptureStreamRecord { + stream_id: text("stream"), + host_id: text("host"), + state: text("live"), + video_source: text("vsrc"), + audio_source: text("asrc"), + transport: text("rudp"), + targets: texts(&["tgt"]), + command_witness: text("wit"), + supervisor_pid: None, + mux_pid: Some(77), + restart_count: 3, + detail: text("detail"), + updated_at: text("t"), + }, + None, + "9da673747265616da4686f7374a46c697665a476737263a461737263a47275647091a3746774a3776974c04d03a664657461696ca174", + ), + pin( + "MuninnCaptureStreamCommandRecord", + MuninnCaptureStreamCommandRecord { + command_id: text("cmd"), + host_id: text("host"), + stream_id: text("stream"), + state: text("live"), + action: text("start"), + target_host: text("tgt"), + port: 4001, + obs_target_host: Some(text("obs")), + obs_port: 4002, + media_transport: text("rudp"), + media_packet_bytes: 1200, + requested_by: text("who"), + detail: text("detail"), + updated_at: text("t"), + rudp_video_bitrate_kbps: 8000, + rudp_latency_budget_ms: 40, + video_source_id: text("vid"), + audio_source_id: text("aid"), + }, + Some(14), + "dc0012a3636d64a4686f7374a673747265616da46c697665a57374617274a3746774cd0fa1a36f6273cd0fa2a472756470cd04b0a377686fa664657461696ca174cd1f4028a3766964a3616964", + ), + pin( + "MuninnObsStreamCatalogRecord", + MuninnObsStreamCatalogRecord { + catalog_id: text("catalog"), + host_id: text("host"), + stream_ids: texts(&["sid"]), + labels: texts(&["lab"]), + urls: texts(&["url"]), + states: texts(&["st"]), + updated_at: text("t"), + command_rudp_target: text("cmd"), + media_target_host: text("mth"), + media_port: 4001, + media_packet_bytes: 1200, + rudp_video_bitrate_kbps: 8000, + rudp_latency_budget_ms: 40, + video_source_ids: texts(&["vid"]), + video_source_labels: texts(&["vlab"]), + audio_source_ids: texts(&["aid"]), + audio_source_labels: texts(&["alab"]), + }, + Some(7), + "dc0011a7636174616c6f67a4686f737491a373696491a36c616291a375726c91a27374a174a3636d64a36d7468cd0fa1cd04b0cd1f402891a376696491a4766c616291a361696491a4616c6162", + ), + pin( + "MuninnMoveMarkerCandidateRecord", + MuninnMoveMarkerCandidateRecord { + stream_id: text("stream"), + host_id: text("host"), + camera_id: text("cam"), + frame_sequence: 11, + source_id_hash: 12, + tile_x: 13, + tile_y: 14, + center_x_px: 1.5, + center_y_px: 2.5, + radius_px: 3.5, + area_px: 15, + mean_luma: 4.5, + peak_luma: 16, + score: 0.5, + observed_at: text("t"), + move_id: text("move"), + }, + Some(15), + "dc0010a673747265616da4686f7374a363616d0b0c0d0eca3fc00000ca40200000ca406000000fca4090000010ca3f000000a174a46d6f7665", + ), + pin( + "MuninnMoveControllerStateRecord", + MuninnMoveControllerStateRecord { + stream_id: text("stream"), + host_id: text("host"), + move_id: text("move"), + sequence: 21, + source_timestamp_ns: -22, + accelerometer_xyz: vec![1.0, 2.0, 3.0], + gyroscope_xyz: vec![4.0, 5.0, 6.0], + magnetometer_xyz: vec![7.0, 8.0, 9.0], + trigger_value: 0.25, + buttons: texts(&["cross"]), + battery01: 0.75, + observed_at: text("t"), + source_path: text("/dev/js0"), + }, + Some(12), + "9da673747265616da4686f7374a46d6f766515ea93ca3f800000ca40000000ca4040000093ca40800000ca40a00000ca40c0000093ca40e00000ca41000000ca41100000ca3e80000091a563726f7373ca3f400000a174a82f6465762f6a7330", + ), + pin( + "MuninnHidControllerStateRecord", + MuninnHidControllerStateRecord { + stream_id: text("muninn.raven.hid"), + host_id: text("raven"), + device_id: text("xbox-0"), + device_kind: text("xinput"), + sequence: 7, + source_timestamp_ns: -3, + axes: vec![0.5, -1.0], + buttons: texts(&["a", "b"]), + battery01: 1.0, + observed_at: text("unix:100"), + source_path: text("xinput://0"), + }, + None, + "9bb06d756e696e6e2e726176656e2e686964a5726176656ea678626f782d30a678696e70757407fd92ca3f000000cabf80000092a161a162ca3f800000a8756e69783a313030aa78696e7075743a2f2f30", + ), + pin( + "MuninnMoveIdentityRecord", + MuninnMoveIdentityRecord { + identity_id: text("identity"), + host_id: text("host"), + move_id: text("move"), + source_path: text("path"), + bluetooth_host_address: text("bt"), + state: text("live"), + detail: text("detail"), + observed_at: text("t"), + }, + None, + "98a86964656e74697479a4686f7374a46d6f7665a470617468a26274a46c697665a664657461696ca174", + ), + pin( + "MuninnMoveLightCommandRecord", + MuninnMoveLightCommandRecord { + command_id: text("cmd"), + host_id: text("host"), + move_id: text("move"), + hidraw_path: text("hidraw"), + colors: texts(&["red"]), + durations_ms: vec![100, 200], + repeat_count: 5, + authority: text("auth"), + state: text("live"), + detail: text("detail"), + updated_at: text("t"), + }, + None, + "9ba3636d64a4686f7374a46d6f7665a668696472617791a37265649264ccc805a461757468a46c697665a664657461696ca174", + ), + pin( + "MuninnMoveHueProgramRecord", + MuninnMoveHueProgramRecord { + program_id: text("prog"), + host_id: text("raven"), + mode: text("cycle"), + cycle_ms: 20, + epoch_ns: -1, + hold_at_ns: 0, + requested_by: text("mimir"), + updated_at: text("unix:1"), + order_mode: text("ring"), + transition_percent: 50, + transition_percent_explicit: true, + }, + Some(8), + "9ba470726f67a5726176656ea56379636c6514ff00a56d696d6972a6756e69783a31a472696e6732c3", + ), + pin( + "MuninnMoveTrackerHealthRecord", + MuninnMoveTrackerHealthRecord { + health_id: text("health"), + host_id: text("host"), + camera_id: text("cam"), + camera_index: -2, + state: text("live"), + camera_name: text("name"), + camera_api: text("api"), + width: 640, + height: 480, + exposure: 0.5, + calibrated_controller_count: 2, + update_count: 30, + observation_count: 31, + latest_observation_count: 32, + last_observation_at: text("last"), + detail: text("detail"), + updated_at: text("t"), + image_mean_rgb: vec![1, 2, 3], + image_peak_rgb: vec![4, 5, 6], + color_evidence_move_ids: texts(&["move"]), + color_evidence_pixel_counts: vec![40], + rejected_stale_count: 41, + rejected_radius_count: 42, + rejected_bounds_count: 43, + rejected_continuity_count: 44, + }, + Some(17), + "dc0019a66865616c7468a4686f7374a363616dfea46c697665a46e616d65a3617069cd0280cd01e0ca3f000000021e1f20a46c617374a664657461696ca174930102039304050691a46d6f76659128292a2b2c", + ), + pin( + "MuninnMoveEvidenceTransportHealthRecord", + MuninnMoveEvidenceTransportHealthRecord { + health_id: text("health"), + host_id: text("host"), + stream_id: text("stream"), + produced_frames: 50, + local_ring_admissions: 51, + remote_handoffs: 52, + remote_sends: 53, + updated_at: text("t"), + }, + None, + "98a66865616c7468a4686f7374a673747265616d32333435a174", + ), + pin( + "MuninnQuestAccessRecord", + MuninnQuestAccessRecord { + access_id: text("access"), + host_id: text("host"), + serial: text("serial"), + connection_state: text("conn"), + product: text("product"), + model: text("model"), + device: text("device"), + transport_id: text("tid"), + input_stream_id: text("input"), + pose_stream_id: text("pose"), + video_input_stream_id: text("video"), + video_input_transport: text("vtransport"), + state: text("live"), + detail: text("detail"), + observed_at: text("t"), + }, + None, + "9fa6616363657373a4686f7374a673657269616ca4636f6e6ea770726f64756374a56d6f64656ca6646576696365a3746964a5696e707574a4706f7365a5766964656faa767472616e73706f7274a46c697665a664657461696ca174", + ), + pin( + "MuninnCommandBoundaryCompatRecord", + MuninnCommandBoundaryCompatRecord { + value: serde_json::json!({ "daemon_id": "muninn" }), + }, + None, + "9181a96461656d6f6e5f6964a66d756e696e6e", + ), + pin( + "MuninnTransportProfileCompatRecord", + MuninnTransportProfileCompatRecord { + value: serde_json::json!({ "state": "live" }), + }, + None, + "9181a57374617465a46c697665", + ), + ] + } + + /// Every record's bytes are exactly the pinned positional array: a swapped + /// key, a changed type or a moved field changes them. #[test] - fn hid_controller_state_wire_layout_is_positional_by_key() { - let record = MuninnHidControllerStateRecord { - stream_id: "muninn.raven.hid".into(), - host_id: "raven".into(), - device_id: "xbox-0".into(), - device_kind: "xinput".into(), - sequence: 7, - source_timestamp_ns: -3, - axes: vec![0.5, -1.0], - buttons: vec!["a".into(), "b".into()], - battery01: 1.0, - observed_at: "unix:100".into(), - source_path: "xinput://0".into(), + fn every_record_encodes_to_its_pinned_positional_bytes() { + let mut wrong = Vec::new(); + for pin in pins() { + let actual = hex(&pin.bytes); + if actual != pin.golden { + wrong.push(format!("{}: {actual}", pin.record)); + } + let reencoded = (pin.reencode)(&pin.bytes) + .unwrap_or_else(|error| panic!("{} does not decode its own bytes: {error}", pin.record)); + assert_eq!(reencoded, pin.bytes, "{} round trip", pin.record); + } + assert!( + wrong.is_empty(), + "unpinned or moved wire layouts:\n{}", + wrong.join("\n") + ); + } + + /// A payload written before a defaulted key existed still decodes, once + /// per defaulted key, and every key from there on reads as its zero value. + #[test] + fn every_defaulted_tail_key_decodes_from_a_payload_that_ends_before_it() { + use serde_json::Value; + let is_zero = |value: &Value| match value { + Value::Null => true, + Value::Bool(flag) => !flag, + Value::Number(number) => number.as_f64() == Some(0.0), + Value::String(text) => text.is_empty(), + Value::Array(items) => items.is_empty(), + Value::Object(_) => false, }; - let bytes = rmp_serde::to_vec(&record).unwrap(); - assert_eq!( - hex(&bytes), - "9bb06d756e696e6e2e726176656e2e686964a5726176656ea678626f782d30a678696e70757407fd92ca3f000000cabf80000092a161a162ca3f800000a8756e69783a313030aa78696e7075743a2f2f30" - ); - assert_eq!( - rmp_serde::from_slice::(&bytes).unwrap(), - record - ); - } - - #[test] - fn hue_program_wire_layout_carries_its_defaulted_tail() { - let record = MuninnMoveHueProgramRecord { - program_id: "prog".into(), - host_id: "raven".into(), - mode: "cycle".into(), - cycle_ms: 20, - epoch_ns: -1, - hold_at_ns: 0, - requested_by: "mimir".into(), - updated_at: "unix:1".into(), - order_mode: "ring".into(), - transition_percent: 50, - transition_percent_explicit: true, - }; - let bytes = rmp_serde::to_vec(&record).unwrap(); - assert_eq!( - hex(&bytes), - "9ba470726f67a5726176656ea56379636c6514ff00a56d696d6972a6756e69783a31a472696e6732c3" - ); - assert_eq!( - rmp_serde::from_slice::(&bytes).unwrap(), - record - ); - } - - #[test] - fn hue_program_from_an_older_payload_defaults_its_tail() { - let older = decode_hex( - "98a470726f67a5726176656ea56379636c6514ff00a56d696d6972a6756e69783a31", - ); - let record: MuninnMoveHueProgramRecord = rmp_serde::from_slice(&older).unwrap(); - assert_eq!(record.updated_at, "unix:1"); - assert_eq!(record.order_mode, ""); - assert_eq!(record.transition_percent, 0); - assert!(!record.transition_percent_explicit); - } - - fn decode_hex(text: &str) -> Vec { - (0..text.len()) - .step_by(2) - .map(|index| u8::from_str_radix(&text[index..index + 2], 16).unwrap()) - .collect() + for pin in pins() { + let Some(first_default) = pin.first_default else { + continue; + }; + let full: Vec = rmp_serde::from_slice(&pin.bytes).unwrap(); + for end in first_default..full.len() { + let truncated = rmp_serde::to_vec(&full[..end]).unwrap(); + let decoded = (pin.reencode)(&truncated).unwrap_or_else(|error| { + panic!( + "{} does not decode a payload ending before key {end}: {error}", + pin.record + ) + }); + let decoded: Vec = rmp_serde::from_slice(&decoded).unwrap(); + assert_eq!(decoded.len(), full.len(), "{} ending before key {end}", pin.record); + assert_eq!(&decoded[..end], &full[..end], "{} ending before key {end}", pin.record); + assert!( + decoded[end..].iter().all(is_zero), + "{} ending before key {end} defaulted to {:?}", + pin.record, + &decoded[end..] + ); + } + } } fn hex(bytes: &[u8]) -> String { From 2742b4ddcf880f67856e5857bf690dc179464a5e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:17:51 +0200 Subject: [PATCH 6/6] Correct stale health text, pin per-host boundary identity, note deploy order The MuninnRecords comment claimed muninn-contracts decides every muninn.* binding; today odin-core registers identical strings first and the registration is inert until Odin deletes its copies. The help text no longer describes the retired --idunn flags, the health-*.cmd stubs no longer claim daemon-published health, and docs/muninn.md says to install the binary and the scripts together. A test now pins daemon_id, state and current_transport for raven, starfire and nightwing. Co-Authored-By: Claude Sonnet 5.5 --- crates/muninn-daemon/src/main.rs | 71 +++++++++++++++++++++++++++-- docs/muninn.md | 5 ++ scripts/health-muninn.cmd | 2 +- scripts/health-nightwing-muninn.cmd | 2 +- scripts/health-starfire-muninn.cmd | 2 +- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/crates/muninn-daemon/src/main.rs b/crates/muninn-daemon/src/main.rs index cf8e119..b2d21ca 100644 --- a/crates/muninn-daemon/src/main.rs +++ b/crates/muninn-daemon/src/main.rs @@ -9527,10 +9527,13 @@ fn ensure_state_dirs(options: &Options) -> Result<()> { } /// Odin's document set, Muninn's own records, and the producer-agnostic media -/// stream contract. Muninn's records are registered after Odin's, so -/// `muninn-contracts` decides every `muninn.*` binding; Odin's document set -/// still carries stale copies of the same schema ids (and the store may hold -/// legacy Odin records), which is why it stays registered. +/// stream contract. Today Odin's document set (odin-core) is registered first +/// and carries identical `muninn.*` type names and schema ids, so +/// `MuninnRecords` registers the same strings again and binds nothing new; +/// cultcache errors at startup if the two ever diverge. Odin's copies are to be +/// deleted, at which point `muninn-contracts` becomes the sole owner of every +/// `muninn.*` binding. The registration stays so that deletion changes nothing +/// here. #[derive(Clone, Copy, Debug, Default)] struct MuninnDocuments; @@ -10180,7 +10183,7 @@ fn parse_move_marker_camera_source(value: &str) -> Result &'static str { - "Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store ] [--activate-store ] [--stream-action ] [--target-host ] [--media-transport ] [--media-rudp-bind ] [--media-rudp-advertise ] [--media-packet-bytes ] [--rudp-video-bitrate-kbps ] [--rudp-latency-budget-ms ] [--video-source ] [--audio-source ] [--audio-source-id ] [--no-video] [--no-audio] [--loopback-script ] [--ffmpeg ] [--odin-cultmesh-uri ] [--move-state =] [--move-marker-camera =] [--move-psmoveapi-tracker] [--move-tracker-exposure-milli <0..1000>] [--move-marker-width ] [--move-marker-height ] [--move-marker-fps ] [--move-host ] [--move-evidence-stream ] [--move-evidence-verse ] [--move-evidence-ring-slots ] [--move-evidence-slot-bytes ] [--move-evidence-snapshot ] [--quest-adb] [--quest-serial ] [--quest-input-stream ] [--quest-pose-stream ] [--quest-video-input-stream ] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-marker-camera, --move-host, or --move-evidence-stream it may publish source-local Move controller state, source-local optical marker candidates, typed Move identity records, a CultMesh Move evidence stream, optionally write a latest one-copy Move proof evidence snapshot for Mimir field capture/replay, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; --move-psmoveapi-tracker delegates camera exposure and optical extraction to the reference PSMoveAPI backend while Muninn retains stable-ID light actuation; serve consumes typed capture stream commands from its provider-owned activation store and owns the local ffmpeg/loopback activation child lifecycle, listens for media receivers on --media-rudp-bind and advertises that endpoint (--media-rudp-advertise when the bind address is not reachable as-is), and publishes its discovery advertisement through --odin-cultmesh-uri; activate starts an explicitly requested local CultNet RUDP stream as a daemon child that serves every receiver dialling --media-rudp-bind; request-stream discovers the provider through Odin and sends its typed command to that provider; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state. In --health mode, the Idunn RUDP flags publish the same typed daemon health document to Idunn for explicit diagnostics." + "Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store ] [--activate-store ] [--stream-action ] [--target-host ] [--media-transport ] [--media-rudp-bind ] [--media-rudp-advertise ] [--media-packet-bytes ] [--rudp-video-bitrate-kbps ] [--rudp-latency-budget-ms ] [--video-source ] [--audio-source ] [--audio-source-id ] [--no-video] [--no-audio] [--loopback-script ] [--ffmpeg ] [--odin-cultmesh-uri ] [--move-state =] [--move-marker-camera =] [--move-psmoveapi-tracker] [--move-tracker-exposure-milli <0..1000>] [--move-marker-width ] [--move-marker-height ] [--move-marker-fps ] [--move-host ] [--move-evidence-stream ] [--move-evidence-verse ] [--move-evidence-ring-slots ] [--move-evidence-slot-bytes ] [--move-evidence-snapshot ] [--quest-adb] [--quest-serial ] [--quest-input-stream ] [--quest-pose-stream ] [--quest-video-input-stream ] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-marker-camera, --move-host, or --move-evidence-stream it may publish source-local Move controller state, source-local optical marker candidates, typed Move identity records, a CultMesh Move evidence stream, optionally write a latest one-copy Move proof evidence snapshot for Mimir field capture/replay, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; --move-psmoveapi-tracker delegates camera exposure and optical extraction to the reference PSMoveAPI backend while Muninn retains stable-ID light actuation; serve consumes typed capture stream commands from its provider-owned activation store and owns the local ffmpeg/loopback activation child lifecycle, listens for media receivers on --media-rudp-bind and advertises that endpoint (--media-rudp-advertise when the bind address is not reachable as-is), and publishes its discovery advertisement through --odin-cultmesh-uri; activate starts an explicitly requested local CultNet RUDP stream as a daemon child that serves every receiver dialling --media-rudp-bind; request-stream discovers the provider through Odin and sends its typed command to that provider; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state." } fn parse_move_state_source(value: &str) -> Result { @@ -12665,6 +12668,64 @@ Device 00:07:04:A8:00:D0 (public) let _ = fs::remove_file(store_path); } + #[test] + fn help_names_no_retired_health_flag() { + assert!(!help_text().contains("--idunn")); + assert!(!help_text().contains("daemon health")); + } + + #[test] + fn runtime_boundary_names_each_host_daemon_and_its_transport() { + for (host, daemon_id) in [ + ("raven", "muninn"), + ("starfire", "starfire-muninn"), + ("nightwing", "nightwing-muninn"), + ] { + let store_path = std::env::temp_dir().join(format!( + "muninn-boundary-{host}-{}.cc", + timestamp_ns().unwrap() + )); + let options = Options::parse( + ["serve", "--host", host, "--store", store_path.to_str().unwrap()] + .into_iter() + .map(String::from), + ) + .unwrap(); + let mut node = open_node(&options, "muninn-boundary-host-test").unwrap(); + + publish_runtime_boundary_records(&mut node, &options, "idle", &[], &[]).unwrap(); + + let boundary = node + .get_required::(&format!( + "command-boundary:{daemon_id}" + )) + .unwrap(); + let transport = node + .get_required::(&format!( + "transport-profile:{daemon_id}" + )) + .unwrap(); + for record in [&boundary.value, &transport.value] { + assert_eq!( + record.get("daemon_id").and_then(|value| value.as_str()), + Some(daemon_id), + "{host}" + ); + } + assert_eq!( + transport.value.get("current_transport").and_then(|value| value.as_str()), + Some("daemon-owned-cultcache-telemetry-store"), + "{host}" + ); + assert_eq!( + transport.value.get("state").and_then(|value| value.as_str()), + Some("cultcache-provider-store-only"), + "{host}" + ); + let _ = fs::remove_file(store_path); + } + } + #[test] fn runtime_boundary_records_use_explicit_activation_store_path() { let store_path = diff --git a/docs/muninn.md b/docs/muninn.md index bd9f5b5..b262640 100644 --- a/docs/muninn.md +++ b/docs/muninn.md @@ -239,6 +239,11 @@ separate `/dev/input/js*` paths; `nightwing-move-state-sources.sh` prefers the Bluetooth `HID_ID=0005:0000054C:000003D5` path so Idunn health does not demand fresh records from two faces of the same controller. +Starfire and Nightwing binaries and their `scripts/*-muninn.ps1` actuators +must be installed together: a binary that rejects the retired +`--idunn-rudp-health`, `--idunn-daemon` and `--idunn-health-contract` flags +fails to start under an old restart script, and a new script never passes them. + Each private PSMoveAPI Eye worker owns optical observation admission before the parent aggregates evidence. Positions older than 50 ms, blobs below 2 px, out-of-frame coordinates, implausible radius jumps, and frame-to-frame diff --git a/scripts/health-muninn.cmd b/scripts/health-muninn.cmd index 7811b7d..e2ddbe7 100644 --- a/scripts/health-muninn.cmd +++ b/scripts/health-muninn.cmd @@ -1,3 +1,3 @@ @echo off -echo health-muninn.cmd archived: Raven Muninn health is daemon-published over CultNet/RUDP as muninn.cultnet-rudp-remote-telemetry-health. Use health-muninn.ps1 only as an explicit witness inspection, not Idunn health truth. +echo health-muninn.cmd archived: Raven Muninn has no daemon-published health. Run health-muninn.ps1 as an explicit witness inspection (scheduled-task actions, serve process, store freshness); it is not Idunn health truth. exit /b 2 diff --git a/scripts/health-nightwing-muninn.cmd b/scripts/health-nightwing-muninn.cmd index 6b6554b..eede45d 100644 --- a/scripts/health-nightwing-muninn.cmd +++ b/scripts/health-nightwing-muninn.cmd @@ -1,3 +1,3 @@ @echo off -echo health-nightwing-muninn.cmd archived: Nightwing Muninn health is daemon-published over CultNet/RUDP as muninn.cultnet-rudp-remote-telemetry-and-move-hid. Use health-nightwing-muninn.ps1 only as an explicit witness inspection, not Idunn health truth. +echo health-nightwing-muninn.cmd archived: Nightwing Muninn has no daemon-published health. Run health-nightwing-muninn.ps1 as an explicit witness inspection (serve process, store freshness, PID file); it is not Idunn health truth. exit /b 2 diff --git a/scripts/health-starfire-muninn.cmd b/scripts/health-starfire-muninn.cmd index 36d421c..db26de6 100644 --- a/scripts/health-starfire-muninn.cmd +++ b/scripts/health-starfire-muninn.cmd @@ -1,3 +1,3 @@ @echo off -echo health-starfire-muninn.cmd archived: Starfire Muninn health is daemon-published over CultNet/RUDP as muninn.cultnet-rudp-local-telemetry-and-quest-access. Use health-starfire-muninn.ps1 only as an explicit witness inspection, not Idunn health truth. +echo health-starfire-muninn.cmd archived: Starfire Muninn has no daemon-published health. Run health-starfire-muninn.ps1 as an explicit witness inspection (serve process, store freshness, PID file); it is not Idunn health truth. exit /b 2