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..e111ae5 --- /dev/null +++ b/crates/muninn-contracts/src/lib.rs @@ -0,0 +1,526 @@ +//! 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}"); + } + } + + /// 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 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, + }; + 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 { + 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 48d02fd..b2d21ca 100644 --- a/crates/muninn-daemon/src/main.rs +++ b/crates/muninn-daemon/src/main.rs @@ -21,20 +21,20 @@ 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, +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}; @@ -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(), @@ -8598,31 +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()), - }; - 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}"); - Ok(()) - } - Err(error) => Err(error), - } + let detail = evaluate_health(options)?; + println!("{detail}"); + Ok(()) } fn evaluate_health(options: &Options) -> Result { @@ -8674,22 +8604,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 +8614,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)); @@ -9710,15 +9526,21 @@ 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. 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; 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::()?; @@ -9727,6 +9549,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, @@ -9837,7 +9660,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 +9670,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 +9906,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 +10093,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 +10115,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 +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 ] [--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." } fn parse_move_state_source(value: &str) -> Result { @@ -10430,6 +10207,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 +10653,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 +10754,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( @@ -12970,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 = @@ -12985,12 +12741,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 +12773,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/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; diff --git a/docs/muninn.md b/docs/muninn.md index 6b7df20..b262640 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,12 @@ 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. + +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, 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.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-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.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-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.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 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)" - } -}