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