phoxal_api/lib.rs
1//! The single API layer (D60/D61/D1).
2//!
3//! This crate is the versioned API contract tree. It depends only on the
4//! [`phoxal-bus`](phoxal_bus) ABI floor (the contract primitive traits and the
5//! typed-topic builders) and the [`phoxal-macros`](phoxal_macros) proc-macros; it
6//! does **not** depend on the `phoxal` engine. A participant depends on both crates
7//! and imports the API tree directly with `use phoxal_api::v1 as api;`, so
8//! `phoxal_api::v1`, `phoxal_api::ApiVersion`, and `phoxal_api::ContractBody`
9//! are the canonical paths.
10//!
11//! # Stable and preview API versions
12//!
13//! An API version is a conventional `vN` module generated by
14//! [`phoxal_api_tree!`]. Each version module carries:
15//!
16//! - a zero-variant marker `enum Api {}` implementing [`ApiVersion`], whose
17//! [`ApiVersion::ID`] is the module name (`"v1"` or `"v2"`);
18//! - the version-local wire bodies, one `pub mod` per contract node holding plain
19//! serde structs/enums and their [`ContractBody`] impls;
20//! - an api-local `topic` builder rooted at `topic::new()`.
21//!
22//! `v1` is the current production surface and may change in place while the
23//! pre-stability topology is simplified. `v2` is the single preview surface for
24//! contracts that have not entered production. Preview work does not mint a new
25//! public version for each change.
26//! `v2` is authored as `preview version v2 { … }` and is available when the
27//! `preview-v2` Cargo feature is enabled. [`ApiVersion::IS_PREVIEW`] records the
28//! lifecycle without changing topics or wire bytes.
29//!
30//! [`Api`]: v1::Api
31//!
32//! # Per-field versions and per-contract identity
33//!
34//! A participant declares its bus surface with a companion
35//! `#[derive(phoxal::Api)]` handle struct.
36//! Each handle field names its own version-qualified contract type, so one
37//! participant may freely mix fields from modules such as `v1` and
38//! `v2`.
39//! The derive records each field's resolved [`ContractBody::VERSION`] and
40//! [`ContractBody::CONTRACT`] in the participant's embedded metadata and does
41//! not declare a participant-wide API version.
42//! Across the graph, compatibility is **name identity** (D1) - two participants
43//! interoperate on a contract iff they use the exact same version-qualified name
44//! (`v1::drive::Target`), which is real on the wire because the version
45//! is folded into the key ([`ContractBody::TOPIC`]). There is no `schema_id`: a
46//! stable contract type is immutable, so the name alone is the whole identity.
47//!
48//! # Plain serde wire bodies, provenance in metadata
49//!
50//! A wire body is just its serde encoding - there is no `{"v":…}` envelope or any
51//! other version tag inside the payload (D62). Identity lives entirely in the
52//! Zenoh key (the version-qualified [`ContractBody::TOPIC`]); the bus metadata
53//! alongside the encoded body carries only provenance (source + logical time) and
54//! the codec that produced the bytes - never schema/family/version. Keeping
55//! identity out of both the payload and the metadata means the body bytes for an
56//! unchanged contract are identical across codecs, and a receiver's per-key
57//! subscription is the whole fast-reject.
58//!
59//! # Topic
60//!
61//! [`ContractBody::TOPIC`] is derived from the contract node's path in the tree,
62//! never written by hand: the version, then the `/`-joined node path plus the
63//! topic leaf, with each dynamic node contributing a `{var}` placeholder, e.g.
64//! `v1/component/{instance}/motor/{capability}/command`. A fully static path
65//! has a literal key (`v1/drive/state`). Folding the version into the key
66//! (D1) is what makes two differently-versioned contracts physically distinct
67//! Zenoh keys - they cannot collide, so there is no `SCHEMA_ID`/`FAMILY` needed to
68//! disambiguate them.
69//!
70//! # The api-local topic builder
71//!
72//! Each version module exposes a `topic` builder that mirrors the node tree:
73//! `api::topic::new()` returns a root, one method per top-level node walks down the
74//! tree, a dynamic node's method takes its variable as `impl Display`, and a leaf
75//! method binds the topic's side-branded kind to its version-local body. For
76//! example `api::topic::new().drive().state()` yields a
77//! `Topic<Subscribe<drive::State>>` (the CLIENT observes the owner's `state`) over
78//! the version-qualified key `v1/drive/state`, and
79//! `api::topic::new().component("base").motor("left").command()` fills the dynamic
80//! segments to produce `v1/component/base/motor/left/command`. Because the
81//! builder is generated from the same tree as `TOPIC`, the built key and the
82//! documented key stay in lockstep.
83//!
84//! ## Owner side: `topic::internal`
85//!
86//! The PUBLIC `topic::new()...` chain above is the **client** side. The matching
87//! **owner** side lives at `api::topic::internal::new(cap)...` (L1 + L2, plan #00):
88//! the same node tree and keys, but the leaf brands flip so the owner gets the side
89//! it must take - `api::topic::internal::new(cap).drive().state()` is
90//! `Topic<Publish<drive::State>>` (the owner publishes its telemetry), and
91//! `api::topic::internal::new(cap).drive().target()` is `Topic<Subscribe<drive::Target>>`
92//! (the owner reads its command input). A query owner reaches its `ServeQuery`
93//! brand the same way. The `internal` chain is the deliberate, greppable owner
94//! opt-in; a participant acquires the topics of its OWN node through it and everything
95//! it consumes through the public chain.
96//!
97//! The `internal::new` entry requires the runner-minted owner capability
98//! ([`OwnerCap`](phoxal_bus::OwnerCap), Layer 2): a participant obtains it from
99//! `phoxal::SetupContext::owner_capability()` and passes it in. On the documented
100//! surface, owning a topic therefore cannot happen by accident - only with a
101//! capability the runner mints.
102
103use phoxal_macros::phoxal_api_tree;
104
105/// The contract primitive traits, re-exported from the `phoxal-bus` crate (the
106/// ABI floor) so they stay addressable at `phoxal_api::ApiVersion` /
107/// `phoxal_api::ContractBody`.
108///
109/// - [`ApiVersion`] is the marker trait identifying one API version (D60),
110/// implemented only by the zero-variant `enum Api {}` that [`phoxal_api_tree!`]
111/// generates inside each version module; its `ID` is the module name (`"v1"`).
112/// Its `IS_PREVIEW` const is lifecycle metadata only.
113/// - [`ContractBody`] is a version-local wire body (D61): a plain serde type
114/// bound to exactly one [`ApiVersion`] and one contract topic. Every body
115/// declared inside a [`phoxal_api_tree!`] node gets a generated impl; handles,
116/// `SetupContext` builders, and the `Service`/`Driver` derive assertions key
117/// off its `Api`/`TOPIC`. `TOPIC` is version-qualified (D1) and *is* the
118/// compatibility key - there is no `SCHEMA_ID`/`FAMILY`; its serde encoding
119/// *is* the wire payload, with no version envelope (D62).
120pub use phoxal_bus::{ApiVersion, ContractBody};
121
122phoxal_api_tree! {
123 version v1 {
124 drive {
125 /// Why actuation authority is in its current state.
126 enum StopReason {
127 NoTarget,
128 TargetStale,
129 TargetFromFuture,
130 TargetNotFinite,
131 ActuatorCommandNotFinite,
132 Inactive,
133 EmergencyStop,
134 Fault,
135 }
136
137 /// Whether the drive is actively commanding the actuators.
138 enum ActuatorAuthority {
139 Active,
140 Stopped,
141 }
142
143 /// A requested or limited planar velocity.
144 struct Target {
145 linear_x_mps: f32,
146 angular_z_radps: f32,
147 curvature_limit_radpm: Option<f32>,
148 }
149
150 /// The drive participant's published control state.
151 struct State {
152 target: Target,
153 limited_target: Target,
154 actuator_authority: ActuatorAuthority,
155 stop_reason: Option<StopReason>,
156 target_age_ns: Option<u64>,
157 }
158
159 topic target: command Target;
160 topic state: state State;
161 }
162
163 joint(joint) {
164 /// Per-joint position/velocity (and optional effort) on a dynamic
165 /// per-joint key.
166 struct JointState {
167 position_rad: f64,
168 velocity_radps: f64,
169 effort_nm: Option<f64>,
170 }
171
172 topic state: state JointState;
173 }
174
175 frame {
176 /// A parent → child rigid transform (translation + xyzw quaternion).
177 struct FrameTransform {
178 parent_frame_id: String,
179 child_frame_id: String,
180 translation_m: [f64; 3],
181 rotation_quat_xyzw: [f64; 4],
182 stamp_ns: Option<u64>,
183 }
184
185 /// Transforms that do not change over time.
186 struct StaticTransforms {
187 transforms: Vec<FrameTransform>,
188 }
189
190 /// The current transform tree.
191 struct Tree {
192 transforms: Vec<FrameTransform>,
193 }
194
195 /// Ask for the transform between two frames, optionally at a time.
196 struct LookupRequest {
197 target_frame_id: String,
198 source_frame_id: String,
199 at_ns: Option<u64>,
200 }
201
202 /// The resolved transform, or `None` if it is not available.
203 struct LookupResponse {
204 transform: Option<FrameTransform>,
205 }
206
207 topic tree: state Tree;
208 topic static_transforms: state StaticTransforms;
209 topic lookup: query LookupRequest => LookupResponse;
210 }
211
212 power {
213 /// A platform power command.
214 #[derive(Copy, Eq)]
215 enum Command {
216 Reboot,
217 Shutdown,
218 }
219
220 /// Where the power participant is in handling a command.
221 #[derive(Copy, Eq)]
222 enum Status {
223 Idle,
224 Rebooting,
225 ShuttingDown,
226 Failed,
227 }
228
229 /// Why a power command was rejected outright.
230 #[derive(Copy, Eq)]
231 #[serde(rename_all = "snake_case")]
232 enum RejectedReason {
233 HostIntegrationUnavailable,
234 CommandRejected,
235 }
236
237 /// Why an accepted power command later failed.
238 #[derive(Copy, Eq)]
239 #[serde(rename_all = "snake_case")]
240 enum FailedReason {
241 HostCommandFailed,
242 }
243
244 /// The power participant's published state.
245 struct State {
246 status: Status,
247 detail: Option<String>,
248 }
249
250 topic command: command Command;
251 topic state: state State;
252 }
253
254 motion {
255 struct Target {
256 linear_x_mps: f32,
257 angular_z_radps: f32,
258 curvature_limit_radpm: Option<f32>,
259 }
260
261 #[derive(Copy, Eq)]
262 #[serde(rename_all = "snake_case")]
263 enum Source {
264 Manual,
265 Navigation,
266 EmergencyStop,
267 }
268
269 #[derive(Copy, Eq)]
270 #[serde(rename_all = "snake_case")]
271 enum ZeroReason {
272 NoCandidate,
273 ManualCandidateStale,
274 NavigationCandidateStale,
275 ManualCandidateFromFuture,
276 NavigationCandidateFromFuture,
277 ManualCandidateNotFinite,
278 NavigationCandidateNotFinite,
279 EmergencyStopEngaged,
280 SafetyConstraintsUnavailable,
281 SafetyProtectiveStop,
282 }
283
284 #[derive(Copy, Eq)]
285 #[serde(rename_all = "snake_case")]
286 enum SafetyRuntime {
287 Absent,
288 Present,
289 }
290
291 struct ManualCommand {
292 linear_x_mps: f64,
293 angular_z_radps: f64,
294 }
295
296 #[derive(Eq)]
297 struct EmergencyStopRequest {
298 engaged: bool,
299 }
300
301 struct State {
302 manual_candidate_age_ns: Option<u64>,
303 autonomous_candidate_age_ns: Option<u64>,
304 safety_constraints_age_ns: Option<u64>,
305 selected_source: Option<Source>,
306 final_target: Target,
307 zero_reason: Option<ZeroReason>,
308 safety_runtime: SafetyRuntime,
309 software_estop_engaged: bool,
310 component_estop_blocked: bool,
311 active_safety_constraints: Vec<super::safety::Constraint>,
312 }
313
314 topic manual: command ManualCommand;
315 topic estop: command EmergencyStopRequest;
316 topic state: state State;
317 }
318
319 safety {
320 /// Why safety is stopping or limiting body motion.
321 #[derive(Copy, Eq)]
322 #[serde(rename_all = "snake_case")]
323 enum ConstraintReason {
324 WorldUnavailable,
325 MapUnavailable,
326 DrivableSpaceUnavailable,
327 LocalizationUnavailable,
328 LocalizationUncertain,
329 ObstacleProximity,
330 RangeSensorFault,
331 DriveFault,
332 BatteryLow,
333 BatteryCritical,
334 SpeedZone,
335 OperatorPolicy,
336 }
337
338 /// Typed origin of one constraint, suitable for operator diagnosis.
339 #[derive(Copy, Eq)]
340 #[serde(rename_all = "snake_case")]
341 enum ConstraintSourceKind {
342 WorldModel,
343 Map,
344 Localization,
345 Range,
346 Drive,
347 Battery,
348 Operator,
349 }
350
351 struct ConstraintSource {
352 kind: ConstraintSourceKind,
353 participant_id: String,
354 component_id: Option<String>,
355 capability_id: Option<String>,
356 }
357
358 struct Constraint {
359 reason: ConstraintReason,
360 source: ConstraintSource,
361 stop: bool,
362 max_linear_speed_mps: Option<f32>,
363 max_angular_speed_radps: Option<f32>,
364 observed_value: Option<f32>,
365 valid_from_ns: u64,
366 expires_at_ns: u64,
367 }
368
369 /// The sole safety-to-motion control product. Motion accepts it only
370 /// in the same epoch and before `expires_at_ns`.
371 struct MotionConstraints {
372 sequence: u64,
373 stop: bool,
374 max_linear_speed_mps: Option<f32>,
375 max_angular_speed_radps: Option<f32>,
376 constraints: Vec<Constraint>,
377 expires_at_ns: u64,
378 }
379
380 /// Operator-facing state mirrors the exact product consumed by motion.
381 struct State {
382 clear: bool,
383 motion: MotionConstraints,
384 }
385
386 topic constraints: state MotionConstraints;
387 topic state: state State;
388 }
389
390 navigation {
391 #[derive(Eq)]
392 struct RequestId {
393 value: String,
394 }
395
396 struct Pose {
397 x_m: f64,
398 y_m: f64,
399 yaw_rad: Option<f64>,
400 }
401
402 struct Path {
403 poses: Vec<Pose>,
404 map_revision: Option<u64>,
405 }
406
407 enum RequestKind {
408 GotoPose(Pose),
409 FollowPath(Path),
410 Cancel(RequestId),
411 }
412
413 struct Request {
414 request_id: RequestId,
415 kind: RequestKind,
416 }
417
418 enum State {
419 Idle,
420 Accepted(RequestId),
421 Running(RequestId),
422 }
423
424 #[derive(Copy, Eq)]
425 #[serde(rename_all = "snake_case")]
426 enum FailureReason {
427 LocalizationUnavailable,
428 MapUnavailable,
429 MapChanged,
430 NoPath,
431 Blocked,
432 Internal,
433 }
434
435 #[derive(Copy, Eq)]
436 #[serde(rename_all = "snake_case")]
437 enum RefusalReason {
438 Busy,
439 InvalidRequest,
440 Unsupported,
441 }
442
443 enum Outcome {
444 Succeeded,
445 Failed(FailureReason),
446 Refused(RefusalReason),
447 Cancelled,
448 TimedOut,
449 }
450
451 struct Progress {
452 request_id: RequestId,
453 distance_remaining_m: f64,
454 path_index: u32,
455 }
456
457 struct Result {
458 request_id: RequestId,
459 outcome: Outcome,
460 }
461
462 struct Candidate {
463 request_id: RequestId,
464 linear_x_mps: f32,
465 angular_z_radps: f32,
466 }
467
468 struct FrontierRequest {
469 map_revision: Option<u64>,
470 }
471
472 struct Frontier {
473 x_m: f64,
474 y_m: f64,
475 score: f32,
476 size: u32,
477 }
478
479 struct FrontierResponse {
480 frontier: Option<Frontier>,
481 map_revision: Option<u64>,
482 }
483
484 topic request: command Request;
485 topic state: state State;
486 topic progress: state Progress;
487 topic result: state Result;
488 topic candidate: state Candidate;
489 topic next_frontier: query FrontierRequest => FrontierResponse;
490 }
491
492 behavior {
493 #[derive(Eq)]
494 struct RequestId {
495 value: String,
496 }
497
498 #[derive(Copy, Eq)]
499 #[serde(rename_all = "snake_case")]
500 enum ConflictPolicy {
501 Reject,
502 Queue,
503 Interrupt,
504 }
505
506 enum Value {
507 Bool(bool),
508 Integer(i64),
509 Number(f64),
510 String(String),
511 Pose(super::navigation::Pose),
512 }
513
514 struct Request {
515 request_id: RequestId,
516 behavior_id: String,
517 args: ::std::collections::BTreeMap<String, Value>,
518 priority: u8,
519 conflict_policy: ConflictPolicy,
520 }
521
522 enum Command {
523 Pause,
524 Resume,
525 Cancel,
526 }
527
528 #[derive(Copy, Eq)]
529 #[serde(rename_all = "snake_case")]
530 enum ExecutionStatus {
531 Idle,
532 Running,
533 Paused,
534 Succeeded,
535 Failed,
536 Cancelled,
537 Abandoned,
538 }
539
540 #[derive(Copy, Eq)]
541 #[serde(rename_all = "snake_case")]
542 enum NodeStatus {
543 Idle,
544 Running,
545 Succeeded,
546 Failed,
547 Skipped,
548 Waiting,
549 Cancelling,
550 }
551
552 #[derive(Copy, Eq)]
553 #[serde(rename_all = "snake_case")]
554 enum FailureReason {
555 MissingCapability,
556 ActionRefused,
557 ActionFailed,
558 ActionTimedOut,
559 ActionCancelled,
560 ConditionFailed,
561 SafetyStopped,
562 EmergencyStopped,
563 ResourceConflict,
564 InvalidArgument,
565 InvalidBlackboardValue,
566 SubtreeFailed,
567 ExecutionAbandoned,
568 InternalError,
569 }
570
571 struct Failure {
572 reason: FailureReason,
573 detail: Option<String>,
574 node_path: Option<String>,
575 action_id: Option<String>,
576 }
577
578 struct DefinitionRef {
579 id: String,
580 version: String,
581 content_hash: String,
582 }
583
584 struct State {
585 execution_id: Option<String>,
586 root_behavior_id: Option<String>,
587 active_request_id: Option<RequestId>,
588 active_behavior_id: Option<String>,
589 status: ExecutionStatus,
590 active_node_path: Option<String>,
591 failure: Option<Failure>,
592 }
593
594 struct Snapshot {
595 execution_id: Option<String>,
596 root: Option<DefinitionRef>,
597 definition_stack: Vec<DefinitionRef>,
598 active_request_id: Option<RequestId>,
599 active_behavior_id: Option<String>,
600 status: ExecutionStatus,
601 node_statuses: ::std::collections::BTreeMap<String, NodeStatus>,
602 active_node_path: Option<String>,
603 blackboard: ::std::collections::BTreeMap<String, Value>,
604 args: ::std::collections::BTreeMap<String, Value>,
605 started_at_ns: Option<u64>,
606 updated_at_ns: u64,
607 failure: Option<Failure>,
608 }
609
610 enum EventKind {
611 ExecutionStarted,
612 ExecutionPaused,
613 ExecutionResumed,
614 ExecutionCompleted,
615 ExecutionCancelled,
616 ExecutionAbandoned,
617 NodeTransition(NodeStatus),
618 RequestAccepted,
619 RequestCompleted(ExecutionStatus),
620 RequestRejected(FailureReason),
621 }
622
623 struct Event {
624 sequence: u64,
625 execution_id: Option<String>,
626 request_id: Option<RequestId>,
627 behavior_id: Option<String>,
628 content_hash: Option<String>,
629 node_path: Option<String>,
630 kind: EventKind,
631 failure: Option<Failure>,
632 participant_id: String,
633 logical_time_ns: u64,
634 }
635
636 topic command: command Command;
637 topic request: command Request;
638 topic state: state State;
639 topic snapshot: state Snapshot;
640 topic event: state Event;
641 }
642
643 logs(participant_id) {
644 /// Wall-clock timestamp carried by a structured bus log event.
645 struct Timestamp {
646 unix_seconds: i64,
647 nanos: u32,
648 }
649
650 /// The severity level of a structured bus log event.
651 #[derive(Copy, Eq)]
652 #[serde(rename_all = "snake_case")]
653 enum Level {
654 Error,
655 Warn,
656 Info,
657 Debug,
658 Trace,
659 }
660
661 /// A scalar tracing field value captured from a log event.
662 #[serde(untagged)]
663 enum LogValue {
664 Bool(bool),
665 I64(i64),
666 U64(u64),
667 F64(f64),
668 String(String),
669 }
670
671 /// One structured runner log event published out-of-band.
672 struct Event {
673 seq: u64,
674 time: Timestamp,
675 level: Level,
676 target: String,
677 message: String,
678 fields: ::std::collections::BTreeMap<String, LogValue>,
679 /// Complete records lost before publication because a bounded
680 /// queue or publish attempt was saturated.
681 dropped: u32,
682 /// Values or fields truncated inside this published record to
683 /// keep its wire representation bounded.
684 #[serde(default)]
685 truncated: u32,
686 }
687
688 topic self: state Event;
689 }
690
691 tool {
692 /// Opaque identity for one retention-tool process together with a
693 /// position in its completed follow stream. A snapshot cursor
694 /// covers the retained completed items; a bus snapshot's optional
695 /// `current` window deliberately has the next sequence. Consumers
696 /// compare `generation` for equality only and must never parse or
697 /// order it.
698 #[derive(Eq)]
699 struct Cursor {
700 generation: String,
701 sequence: u64,
702 }
703
704 /// Which side of one participant-local bus buffer a runtime row
705 /// measures. The version-qualified `topic` field remains the wire
706 /// identity; direction is never inferred from its spelling.
707 #[derive(Copy, Eq, Ord, PartialOrd)]
708 #[serde(rename_all = "snake_case")]
709 enum RuntimeDirection {
710 Publish,
711 Subscribe,
712 /// Used only by the bounded overflow row, which may combine
713 /// omitted rows from both directions.
714 Mixed,
715 }
716
717 /// The concrete bounded buffer whose pressure a runtime row
718 /// measures.
719 #[derive(Copy, Eq, Ord, PartialOrd)]
720 #[serde(rename_all = "snake_case")]
721 enum RuntimeBufferKind {
722 /// Per-topic view of the one shared process outbound queue.
723 /// Its sample capacity is repeated on each row and must not be
724 /// summed; the queue's separate byte pressure is not in v1.
725 Outbound,
726 /// Keep-last slot. Depth `1` means occupied, never backlog.
727 Latest,
728 Subscriber,
729 /// Used only by the bounded overflow row.
730 Mixed,
731 }
732
733 /// Host-monotonic scheduled-step work completed during one rollup
734 /// window. An unscheduled participant reports `None` instead.
735 struct RuntimeStep {
736 target_period_ns: u64,
737 completed: u64,
738 errors: u64,
739 mean_duration_ns: u64,
740 max_duration_ns: u64,
741 mean_lateness_ns: u64,
742 max_lateness_ns: u64,
743 missed_ticks: u64,
744 overruns: u64,
745 }
746
747 /// One exact version-qualified topic/direction/buffer row. These
748 /// are process-lifetime setup declarations: dropping an authoring
749 /// handle does not dynamically unregister a row. Empty `topic`
750 /// plus `Mixed` direction/kind identifies the explicit overflow
751 /// row; `overflowed_rows` is zero on normal rows.
752 struct RuntimeTopic {
753 topic: String,
754 direction: RuntimeDirection,
755 buffer_kind: RuntimeBufferKind,
756 count: u64,
757 /// Finite, non-negative message rate. Retention tools clamp
758 /// malformed non-finite inputs before they reach snapshots.
759 rate_hz: f32,
760 drops: u64,
761 latest_overwrites: u64,
762 bounded_evictions: u64,
763 /// Sample capacity. Outbound rows repeat the shared process
764 /// queue capacity and are non-additive; byte pressure is not
765 /// represented. Latest capacity/depth describe slot occupancy.
766 capacity: u64,
767 current_depth: u64,
768 high_water_depth: u64,
769 decode_errors: u64,
770 overflowed_rows: u32,
771 }
772
773 log {
774 /// Requests tool-log's complete current bounded snapshot. The
775 /// first protocol version intentionally has no pagination or
776 /// filtering surface.
777 struct SnapshotRequest {}
778
779 /// Wall-clock timestamp copied from one participant-originated
780 /// structured `v1::logs` event.
781 struct Timestamp {
782 unix_seconds: i64,
783 nanos: u32,
784 }
785
786 #[derive(Copy, Eq)]
787 #[serde(rename_all = "snake_case")]
788 enum Level {
789 Error,
790 Warn,
791 Info,
792 Debug,
793 Trace,
794 }
795
796 #[serde(untagged)]
797 enum LogValue {
798 Bool(bool),
799 I64(i64),
800 U64(u64),
801 F64(f64),
802 String(String),
803 }
804
805 /// One retained participant log. `sequence` is assigned by
806 /// tool-log at ingest and is independent of the producer's
807 /// `source_sequence`.
808 struct Record {
809 sequence: u64,
810 participant_id: String,
811 source_sequence: u64,
812 time: Timestamp,
813 level: Level,
814 target: String,
815 message: String,
816 fields: ::std::collections::BTreeMap<String, LogValue>,
817 dropped: u32,
818 truncated: u32,
819 }
820
821 /// The complete bounded tool-log state at `cursor`.
822 struct Snapshot {
823 cursor: crate::v1::tool::Cursor,
824 /// Cumulative structured log samples evicted from
825 /// tool-log's bounded ingest subscriber in this process.
826 /// An increase is observable, unrecoverable source loss;
827 /// it is distinct from producer-side `Record::dropped`.
828 ingest_dropped: u64,
829 records: Vec<Record>,
830 }
831
832 /// One live record following the snapshot query. A consumer
833 /// must re-query when the generation changes or the sequence is
834 /// not exactly one after its installed cursor.
835 struct Follow {
836 cursor: crate::v1::tool::Cursor,
837 /// Current cumulative tool-log ingest loss counter.
838 ingest_dropped: u64,
839 record: Record,
840 }
841
842 topic snapshot: query SnapshotRequest => Snapshot;
843 topic follow: state Follow;
844 }
845
846 bus {
847 /// Requests tool-bus's complete current bounded snapshot. The
848 /// first protocol version intentionally has no pagination or
849 /// filtering surface.
850 struct SnapshotRequest {}
851
852 /// One topic-producer pair measured during a one-second window.
853 /// Empty topic and producer identify the bounded overflow row.
854 struct TopicMetric {
855 topic: String,
856 from_participant: String,
857 ingress_rate_hz: f32,
858 count: u64,
859 }
860
861 /// One retained bus-rate window. `window_ns` is a duration, not
862 /// a production timestamp.
863 struct Window {
864 sequence: u64,
865 topics: Vec<TopicMetric>,
866 throughput_msg_s: f32,
867 window_ns: u64,
868 }
869
870 /// The current partial counters plus the bounded recent
871 /// completed-window history. The partial `current` window has
872 /// the next sequence that its eventual follow item will use;
873 /// the snapshot cursor covers only `windows`.
874 struct Snapshot {
875 cursor: crate::v1::tool::Cursor,
876 current: Option<Window>,
877 windows: Vec<Window>,
878 }
879
880 /// One newly completed bus-rate window.
881 struct Follow {
882 cursor: crate::v1::tool::Cursor,
883 window: Window,
884 }
885
886 topic snapshot: query SnapshotRequest => Snapshot;
887 topic follow: state Follow;
888 }
889
890 device {
891 /// One mounted filesystem capacity observation. The contract
892 /// intentionally excludes OS-specific device names and I/O
893 /// counters; an unavailable storage inventory is represented
894 /// by `Sample::disks == None`, never fabricated zero rows.
895 struct Disk {
896 mount_point: String,
897 file_system: String,
898 used_bytes: u64,
899 total_bytes: u64,
900 }
901
902 /// Portable whole-device observations produced by one
903 /// runner-owned tool-device. Every optional field is `None`
904 /// when the current platform cannot provide a truthful value.
905 /// These totals must never be attributed to a runtime.
906 struct Sample {
907 /// Logical device identity within the robot root. The
908 /// current execution environment is always `main`.
909 device_id: String,
910 cpu_pct: Option<f32>,
911 ram_used_bytes: Option<u64>,
912 ram_total_bytes: Option<u64>,
913 swap_used_bytes: Option<u64>,
914 swap_total_bytes: Option<u64>,
915 load_1m: Option<f32>,
916 load_5m: Option<f32>,
917 load_15m: Option<f32>,
918 uptime_s: Option<u64>,
919 disks: Option<Vec<Disk>>,
920 /// Host-monotonic duration between sampler refreshes.
921 window_ns: u64,
922 }
923
924 /// Requests tool-telemetry's bounded device history.
925 struct SnapshotRequest {
926 /// Optional exact logical device filter.
927 device_id: Option<String>,
928 /// Maximum newest records to return. Zero selects the
929 /// tool's bounded default.
930 limit: u32,
931 /// Exclusive global ingest-sequence upper bound.
932 before_sequence: Option<u64>,
933 }
934
935 struct Record {
936 sequence: u64,
937 sample: Sample,
938 /// Text fields or rows truncated by tool-telemetry.
939 truncated: u32,
940 }
941
942 struct Snapshot {
943 cursor: crate::v1::tool::Cursor,
944 records: Vec<Record>,
945 /// Records evicted by the absolute capacity bound before
946 /// their five-minute age horizon elapsed.
947 capacity_evictions: u64,
948 next_before_sequence: Option<u64>,
949 }
950
951 struct Follow {
952 cursor: crate::v1::tool::Cursor,
953 record: Record,
954 }
955
956 topic sample: state Sample;
957 topic snapshot: query SnapshotRequest => Snapshot;
958 topic follow: state Follow;
959 }
960
961 runtime {
962 /// Runner-originated portable performance rollup. The runner
963 /// publishes at most one per host-monotonic grid interval;
964 /// envelope provenance identifies the participant. Interval
965 /// counters are best-effort sequential atomic samples, not a
966 /// transactional stop-the-world boundary, so concurrent queue
967 /// activity may land on either neighboring rollup.
968 struct Rollup {
969 window_ns: u64,
970 step: Option<crate::v1::tool::RuntimeStep>,
971 topics: Vec<crate::v1::tool::RuntimeTopic>,
972 overflow: Option<crate::v1::tool::RuntimeTopic>,
973 }
974
975 /// Requests tool-telemetry's current bounded five-minute
976 /// runtime history.
977 struct SnapshotRequest {
978 /// Optional exact participant filter. `None` selects the
979 /// complete per-robot history.
980 participant_id: Option<String>,
981 /// Maximum newest records to return. Zero selects the
982 /// tool's bounded default.
983 limit: u32,
984 /// Exclusive global ingest-sequence upper bound for
985 /// backward pagination. `None` starts at the newest record.
986 before_sequence: Option<u64>,
987 }
988
989 /// One retained rollup. `sequence` is assigned by
990 /// tool-telemetry at ingest, independent of producer metadata.
991 /// Duplicate normal topic keys are deterministically
992 /// re-aggregated before row bounds are applied.
993 struct Record {
994 sequence: u64,
995 participant_id: String,
996 /// Text values truncated by tool-telemetry's ingest bound.
997 /// Oversized/excess topic identities are not truncated;
998 /// they are aggregated into the explicit overflow row.
999 truncated: u32,
1000 window_ns: u64,
1001 step: Option<crate::v1::tool::RuntimeStep>,
1002 topics: Vec<crate::v1::tool::RuntimeTopic>,
1003 overflow: Option<crate::v1::tool::RuntimeTopic>,
1004 }
1005
1006 struct Snapshot {
1007 cursor: crate::v1::tool::Cursor,
1008 records: Vec<Record>,
1009 /// Records evicted by the absolute memory cap before their
1010 /// five-minute age horizon elapsed.
1011 capacity_evictions: u64,
1012 /// Pass this as the next request's `before_sequence` to
1013 /// continue backward. `None` means the retained matching
1014 /// history is complete.
1015 next_before_sequence: Option<u64>,
1016 }
1017
1018 struct Follow {
1019 cursor: crate::v1::tool::Cursor,
1020 record: Record,
1021 }
1022
1023 topic rollup: state Rollup;
1024 topic snapshot: query SnapshotRequest => Snapshot;
1025 topic follow: state Follow;
1026 }
1027 }
1028
1029 bus {
1030 uplink {
1031 /// Reserved observable state for a future authenticated router
1032 /// uplink. Phase 1 has no publisher for this contract.
1033 #[derive(Copy, Eq)]
1034 #[serde(rename_all = "snake_case")]
1035 enum UplinkPhase {
1036 Disabled,
1037 Connecting,
1038 Connected,
1039 /// Reserved for future retry telemetry.
1040 Retrying,
1041 }
1042
1043 /// Reserved router-owned state for a future optional site uplink.
1044 struct State {
1045 phase: UplinkPhase,
1046 connect: Option<String>,
1047 /// Reserved for future retry telemetry.
1048 retry_attempt: u32,
1049 /// Optional human-readable diagnostic while not connected,
1050 /// such as DNS failure or waiting for the configured remote
1051 /// link. Invalid endpoints fail configuration before startup.
1052 detail: Option<String>,
1053 }
1054
1055 topic state: state State;
1056 }
1057 }
1058
1059
1060
1061
1062 perception {
1063 /// A single detected object: class, confidence, and pose in a frame.
1064 struct Detection {
1065 class_id: String,
1066 confidence: f32,
1067 position_m: [f64; 3],
1068 frame_id: String,
1069 track_id: Option<u64>,
1070 }
1071
1072 /// A batch of detections from one perception cycle.
1073 struct Detections {
1074 detections: Vec<Detection>,
1075 stamp_ns: Option<u64>,
1076 }
1077
1078 /// The perception participant's published health.
1079 struct State {
1080 healthy: bool,
1081 detector: String,
1082 }
1083
1084 topic detections: state Detections;
1085 topic state: state State;
1086 }
1087
1088 video {
1089 /// Ask to open a video stream for a capability at an optional size.
1090 struct OpenRequest {
1091 capability: String,
1092 width_px: Option<u32>,
1093 height_px: Option<u32>,
1094 }
1095
1096 /// The id of the stream that was opened.
1097 struct OpenResponse {
1098 stream_id: String,
1099 }
1100
1101 topic open: query OpenRequest => OpenResponse;
1102
1103 stream(stream) {
1104 /// Where one open video stream is in its lifecycle.
1105 #[derive(Copy, Eq)]
1106 #[serde(rename_all = "snake_case")]
1107 enum StreamPhase {
1108 Starting,
1109 Active,
1110 Stopped,
1111 }
1112
1113 /// The published state of one video stream: its lifecycle phase
1114 /// and the number of source frames seen so far. The video participant
1115 /// publishes it per stream; clients subscribe, hence `state`.
1116 struct StreamState {
1117 phase: StreamPhase,
1118 frames_seen: u64,
1119 }
1120
1121 topic state: state StreamState;
1122 }
1123 }
1124
1125 simulation {
1126 /// The simulator clock: current time and whether it is advancing.
1127 struct Clock {
1128 now_ns: u64,
1129 running: bool,
1130 }
1131
1132 /// A command to the simulator's run loop.
1133 #[derive(Copy, Eq)]
1134 enum Control {
1135 Pause,
1136 Resume,
1137 Reset,
1138 }
1139
1140 /// The simulated robot's ground-truth planar pose.
1141 struct RobotPose {
1142 x_m: f64,
1143 y_m: f64,
1144 yaw_rad: f64,
1145 }
1146
1147 /// Whether the simulated robot is in contact, with optional detail.
1148 struct Contact {
1149 in_contact: bool,
1150 detail: Option<String>,
1151 }
1152
1153 topic clock: state Clock;
1154 topic control: command Control;
1155 topic robot_pose: state RobotPose;
1156 topic contact: state Contact;
1157 }
1158
1159 // Per-instance component capabilities (D17/D38: framework participant / driver
1160 // territory). `component(instance)` selects a manifest-declared component;
1161 // each child `kind(capability)` is a self-contained node whose key is
1162 // `component/{instance}/<kind>/{capability}/<leaf>`. Nodes duplicate any
1163 // types they share by design - the node path disambiguates, so the names
1164 // are path-local.
1165 component(instance) {
1166 motor(capability) {
1167 /// A per-actuator command.
1168 enum Command {
1169 Velocity(f32),
1170 Torque(f32),
1171 Stop,
1172 }
1173
1174 topic command: command Command;
1175 }
1176
1177 encoder(capability) {
1178 /// Per-encoder sample on a dynamic per-instance key.
1179 struct Sample {
1180 position_rad: f64,
1181 velocity_radps: f32,
1182 }
1183
1184 topic sample: state Sample;
1185 }
1186
1187 accelerometer(capability) {
1188 /// Raw accelerometer sample in the sensor-local frame in m/s^2.
1189 struct Sample {
1190 linear_acceleration: [f32; 3],
1191 }
1192
1193 topic sample: state Sample;
1194 }
1195
1196 gyroscope(capability) {
1197 /// Raw angular velocity sample in the sensor-local frame in rad/s.
1198 struct Sample {
1199 angular_velocity: [f32; 3],
1200 }
1201
1202 topic sample: state Sample;
1203 }
1204
1205 magnetometer(capability) {
1206 /// Raw magnetic-field sample in the sensor-local frame.
1207 struct Sample {
1208 magnetic_field: [f32; 3],
1209 }
1210
1211 topic sample: state Sample;
1212 }
1213
1214 imu(capability) {
1215 #[derive(Copy, Eq)]
1216 #[serde(rename_all = "snake_case")]
1217 enum SensorHealth {
1218 Nominal,
1219 Degraded,
1220 Fault,
1221 }
1222
1223 #[derive(Copy)]
1224 struct Bias {
1225 angular_velocity_radps: [f32; 3],
1226 linear_acceleration_mps2: [f32; 3],
1227 }
1228
1229 struct Sample {
1230 orientation: Option<[f32; 4]>,
1231 angular_velocity_radps: [f32; 3],
1232 linear_acceleration_mps2: [f32; 3],
1233 covariance: Option<[f32; 9]>,
1234 noise_density: Option<[f32; 3]>,
1235 sensor_frame_id: Option<String>,
1236 measured_at_ns: Option<u64>,
1237 health: SensorHealth,
1238 bias: Option<Bias>,
1239 }
1240
1241 topic sample: state Sample;
1242 }
1243
1244 range(capability) {
1245 #[derive(Copy, Eq)]
1246 #[serde(rename_all = "snake_case")]
1247 enum SensorHealth {
1248 Nominal,
1249 Degraded,
1250 Fault,
1251 }
1252
1253 #[derive(Copy)]
1254 struct Limits {
1255 min_m: f32,
1256 max_m: f32,
1257 }
1258
1259 #[derive(Copy)]
1260 struct SampleQuality {
1261 valid: bool,
1262 confidence: Option<f32>,
1263 }
1264
1265 struct Sample {
1266 distance_m: f32,
1267 limits: Option<Limits>,
1268 measured_at_ns: Option<u64>,
1269 quality: Option<SampleQuality>,
1270 health: SensorHealth,
1271 }
1272
1273 topic sample: state Sample;
1274 }
1275
1276 gnss(capability) {
1277 /// A GNSS fix: geodetic position plus a 3x3 position covariance.
1278 struct Sample {
1279 latitude: f64,
1280 longitude: f64,
1281 altitude: f64,
1282 position_covariance: [f64; 9],
1283 }
1284
1285 topic sample: state Sample;
1286 }
1287
1288 camera(capability) {
1289 #[derive(Copy, Eq)]
1290 #[serde(rename_all = "snake_case")]
1291 enum Encoding {
1292 Jpeg,
1293 Png,
1294 L8,
1295 Rgb8,
1296 Rgba8,
1297 }
1298
1299 #[derive(Copy)]
1300 struct Intrinsics {
1301 fx: f32,
1302 fy: f32,
1303 cx: f32,
1304 cy: f32,
1305 }
1306
1307 struct Distortion {
1308 model: String,
1309 coefficients: Vec<f32>,
1310 }
1311
1312 #[derive(Copy)]
1313 struct ExposureTiming {
1314 exposure_start_ns: Option<u64>,
1315 exposure_duration_ns: Option<u64>,
1316 }
1317
1318 struct CalibrationIdentity {
1319 id: String,
1320 version: String,
1321 }
1322
1323 /// One camera frame: encoded pixel bytes plus optional calibration
1324 /// and timing metadata.
1325 struct Frame {
1326 width: u32,
1327 height: u32,
1328 encoding: Encoding,
1329 intrinsics: Option<Intrinsics>,
1330 distortion: Option<Distortion>,
1331 exposure: Option<ExposureTiming>,
1332 measured_at_ns: Option<u64>,
1333 calibration: Option<CalibrationIdentity>,
1334 #[serde(with = "serde_bytes")]
1335 data: Vec<u8>,
1336 }
1337
1338 topic frame: state Frame;
1339 }
1340
1341 depth(capability) {
1342 #[derive(Copy, Eq)]
1343 #[serde(rename_all = "snake_case")]
1344 enum Encoding {
1345 U16Millimeters,
1346 }
1347
1348 #[derive(Copy, Eq)]
1349 #[serde(rename_all = "snake_case")]
1350 enum InvalidSamplePolicy {
1351 ZeroIsInvalid,
1352 NonFiniteIsInvalid,
1353 }
1354
1355 #[derive(Copy)]
1356 struct Intrinsics {
1357 fx: f32,
1358 fy: f32,
1359 cx: f32,
1360 cy: f32,
1361 }
1362
1363 struct Distortion {
1364 model: String,
1365 coefficients: Vec<f32>,
1366 }
1367
1368 #[derive(Copy)]
1369 struct ExposureTiming {
1370 exposure_start_ns: Option<u64>,
1371 exposure_duration_ns: Option<u64>,
1372 }
1373
1374 struct CalibrationIdentity {
1375 id: String,
1376 version: String,
1377 }
1378
1379 /// One depth frame: per-pixel millimetre samples plus optional
1380 /// calibration and timing metadata.
1381 struct Frame {
1382 samples_mm: Vec<u16>,
1383 encoding: Encoding,
1384 invalid_sample_policy: InvalidSamplePolicy,
1385 width: Option<u32>,
1386 height: Option<u32>,
1387 intrinsics: Option<Intrinsics>,
1388 distortion: Option<Distortion>,
1389 exposure: Option<ExposureTiming>,
1390 measured_at_ns: Option<u64>,
1391 calibration: Option<CalibrationIdentity>,
1392 }
1393
1394 topic frame: state Frame;
1395 }
1396
1397 lidar(capability) {
1398 #[derive(Copy, Eq)]
1399 #[serde(rename_all = "snake_case")]
1400 enum SensorHealth {
1401 Nominal,
1402 Degraded,
1403 Fault,
1404 }
1405
1406 #[derive(Copy)]
1407 struct ScanGeometry {
1408 angle_min_rad: f32,
1409 angle_increment_rad: f32,
1410 }
1411
1412 #[derive(Copy)]
1413 struct RangeLimits {
1414 min_m: f32,
1415 max_m: f32,
1416 }
1417
1418 #[derive(Copy)]
1419 struct ScanQuality {
1420 valid_points: u32,
1421 }
1422
1423 struct Ranges {
1424 ranges: Vec<f32>,
1425 geometry: Option<ScanGeometry>,
1426 limits: Option<RangeLimits>,
1427 measured_at_ns: Option<u64>,
1428 quality: Option<ScanQuality>,
1429 health: SensorHealth,
1430 }
1431
1432 struct Points {
1433 points: Vec<[f32; 3]>,
1434 limits: Option<RangeLimits>,
1435 measured_at_ns: Option<u64>,
1436 quality: Option<ScanQuality>,
1437 health: SensorHealth,
1438 }
1439
1440 /// One lidar scan, either as polar ranges or as cartesian points.
1441 #[serde(tag = "kind", rename_all = "snake_case")]
1442 enum Scan {
1443 Ranges(Ranges),
1444 Points(Points),
1445 }
1446
1447 topic scan: state Scan;
1448 }
1449
1450 mmwave(capability) {
1451 /// One mmWave radar detection: position, velocity, and SNR.
1452 #[derive(Copy)]
1453 struct Detection {
1454 position: [f32; 3],
1455 velocity: [f32; 3],
1456 snr: f32,
1457 }
1458
1459 /// One mmWave radar scan as a set of detections.
1460 struct Scan {
1461 detections: Vec<Detection>,
1462 }
1463
1464 topic scan: state Scan;
1465 }
1466
1467 microphone(capability) {
1468 /// One audio frame as raw encoded bytes.
1469 struct Frame {
1470 data: Vec<u8>,
1471 }
1472
1473 topic frame: state Frame;
1474 }
1475
1476 led(capability) {
1477 /// A per-LED on/off command.
1478 #[derive(Copy, Eq)]
1479 enum Command {
1480 On,
1481 Off,
1482 }
1483
1484 topic command: command Command;
1485 }
1486
1487 emergency_stop(capability) {
1488 /// Per-instance emergency-stop state.
1489 #[derive(Eq)]
1490 struct State {
1491 engaged: bool,
1492 }
1493
1494 topic state: state State;
1495 }
1496 }
1497
1498 odometry {
1499 /// A planar pose + twist estimate in the odometry frame.
1500 struct State {
1501 x_m: f64,
1502 y_m: f64,
1503 yaw_rad: f64,
1504 linear_x_mps: f32,
1505 angular_z_radps: f32,
1506 }
1507
1508 topic state: state State;
1509 }
1510
1511 localize {
1512 /// A planar localization estimate in the map frame.
1513 struct LocalizationState {
1514 x_m: f64,
1515 y_m: f64,
1516 yaw_rad: f64,
1517 confidence: f32,
1518 }
1519
1520 topic state: state LocalizationState;
1521 }
1522
1523 map {
1524 /// A published map revision marker.
1525 struct Revision {
1526 revision: u64,
1527 resolution_m: f32,
1528 }
1529
1530 /// Request a rectangular submap window (map-frame metres).
1531 struct SubmapRequest {
1532 min_x_m: f64,
1533 min_y_m: f64,
1534 max_x_m: f64,
1535 max_y_m: f64,
1536 }
1537
1538 /// An occupancy-grid window: row-major cells, 0..=100 + 255 = unknown.
1539 struct SubmapResponse {
1540 width: u32,
1541 height: u32,
1542 resolution_m: f32,
1543 cells: Vec<u8>,
1544 }
1545
1546 topic revision: state Revision;
1547 topic submap: query SubmapRequest => SubmapResponse;
1548 }
1549
1550 asset {
1551 /// Fetch a stored asset by path.
1552 struct GetRequest {
1553 path: String,
1554 }
1555
1556 /// The asset bytes, a not-found marker, or a rejected path.
1557 enum GetResponse {
1558 Found { bytes: Vec<u8> },
1559 Missing,
1560 InvalidPath,
1561 }
1562
1563 topic get: query GetRequest => GetResponse;
1564 }
1565 }
1566
1567 // v2 is the one evolving preview surface. New and changed contracts are
1568 // edited here until the complete version is ready to freeze; preview work
1569 // does not mint another public API namespace for each implementation batch.
1570 preview version v2 {
1571 battery {
1572 /// Battery state remains preview while hardware ownership evolves.
1573 struct State {
1574 voltage_v: f32,
1575 current_a: f32,
1576 charge_ratio: f32,
1577 }
1578
1579 topic state: state State;
1580 }
1581
1582 simulation {
1583 /// One robot node that the simulator spawn authority should import.
1584 struct RobotSpawn {
1585 robot_id: String,
1586 node_string: String,
1587 }
1588
1589 /// Requests the current complete robot spawn set.
1590 ///
1591 /// `known_revision` lets a future responder distinguish initial
1592 /// bootstrap from refreshes after a runtime join. Responders may
1593 /// still return the current set when the revision is unchanged.
1594 struct SpawnRequest {
1595 known_revision: Option<u64>,
1596 }
1597
1598 /// The complete robot spawn set for one simulation world.
1599 struct SpawnSet {
1600 revision: u64,
1601 robots: Vec<RobotSpawn>,
1602 }
1603
1604 /// The authoritative advancing simulation clock. Publication means
1605 /// the world advanced; silence means it did not.
1606 struct Clock {
1607 now_ns: u64,
1608 step: u64,
1609 }
1610
1611 topic spawn: query SpawnRequest => SpawnSet;
1612 topic clock: state Clock;
1613 }
1614
1615 joypad {
1616 /// Whether an observed controller is ready for the fixed manual
1617 /// input preset, disconnected, or connected without a compatible
1618 /// control mapping.
1619 enum DeviceStatus {
1620 Ready,
1621 Disconnected,
1622 Unsupported,
1623 }
1624
1625 /// One gamepad the tool can see. `id` is a STABLE wire id the tool
1626 /// assigns (name/guid-derived) - NOT a process-local gilrs id.
1627 struct Device {
1628 id: String,
1629 name: String,
1630 status: DeviceStatus,
1631 }
1632
1633 /// The joypad tool's published device state.
1634 struct Devices {
1635 available: Vec<Device>,
1636 selected: Option<String>,
1637 enabled: bool,
1638 /// Structural reason manual input cannot be enabled in this
1639 /// session (for example robot-model or backend limitations),
1640 /// independent of transient device/request errors.
1641 unavailable_reason: Option<String>,
1642 /// One-shot acknowledgement of a failed select/enable/rescan
1643 /// request. Event-driven consumers may show it once; periodic
1644 /// state heartbeats omit it. The tool also writes the failure
1645 /// to its log stream for durable diagnostics.
1646 last_error: Option<String>,
1647 }
1648
1649 /// Client asks the tool to select a device by its stable id.
1650 struct Select {
1651 id: String,
1652 }
1653
1654 /// Client asks the tool to enable or disable manual input.
1655 struct SetEnabled {
1656 enabled: bool,
1657 }
1658
1659 /// Client asks the tool to re-enumerate devices.
1660 struct Rescan {}
1661
1662 topic devices: state Devices;
1663 topic select: command Select;
1664 topic set_enabled: command SetEnabled;
1665 topic rescan: command Rescan;
1666 }
1667 }
1668}
1669
1670#[cfg(test)]
1671mod tests;