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