phoxal_api/lib.rs
1//! The single API layer (D60/D61/D1).
2//!
3//! This crate is the dated 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::y2026_1 as api;`, so
8//! `phoxal_api::y2026_1`, `phoxal_api::ApiVersion`, and `phoxal_api::ContractBody`
9//! are the canonical paths.
10//!
11//! # Dated API versions
12//!
13//! An API version is a **dated module** (`phoxal_api::y2026_1`, …) 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 dated module name (`"y2026_1"`);
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//! Each generation is a **standalone, sparse batch** (D1): only the contracts
23//! minted in that batch, never a copy of an earlier generation. There is no
24//! `extends` - an unchanged contract simply keeps its existing name as the one
25//! live identity; a changed contract is minted fresh in the current generation.
26//! A not-yet-promoted generation is authored as `preview version y2026_N { … }`;
27//! it still lives at the final module path (`phoxal_api::y2026_N`) but is
28//! available only when the matching `preview-y2026_N` Cargo feature is enabled.
29//! The generated module docs call out the preview status, and
30//! [`ApiVersion::IS_PREVIEW`] records the lifecycle without changing topics or
31//! wire bytes.
32//!
33//! [`Api`]: y2026_1::Api
34//!
35//! # Per-field generations and per-contract identity
36//!
37//! A participant declares its bus surface with a companion
38//! `#[derive(phoxal::Api)]` handle struct.
39//! Each handle field names its own generation-qualified contract type, so one
40//! participant may freely mix fields from modules such as `y2026_1` and
41//! `y2026_7`.
42//! The derive records each field's resolved [`ContractBody::GENERATION`] and
43//! [`ContractBody::CONTRACT`] in the participant's embedded metadata and does
44//! not declare a participant-wide API generation.
45//! Across the graph, compatibility is **name identity** (D1) - two participants
46//! interoperate on a contract iff they use the exact same version-qualified name
47//! (`y2026_1::drive::Target`), which is real on the wire because the generation
48//! is folded into the key ([`ContractBody::TOPIC`]). There is no `schema_id`: a
49//! released contract type is immutable, so the name alone is the whole identity.
50//!
51//! # Plain serde wire bodies, provenance in metadata
52//!
53//! A wire body is just its serde encoding - there is no `{"v":…}` envelope or any
54//! other version tag inside the payload (D62). Identity lives entirely in the
55//! Zenoh key (the generation-qualified [`ContractBody::TOPIC`]); the bus metadata
56//! alongside the encoded body carries only provenance (source + logical time) and
57//! the codec that produced the bytes - never schema/family/version. Keeping
58//! identity out of both the payload and the metadata means the body bytes for an
59//! unchanged contract are identical across codecs, and a receiver's per-key
60//! subscription is the whole fast-reject.
61//!
62//! # Topic
63//!
64//! [`ContractBody::TOPIC`] is derived from the contract node's path in the tree,
65//! never written by hand: the generation, then the `/`-joined node path plus the
66//! topic leaf, with each dynamic node contributing a `{var}` placeholder, e.g.
67//! `y2026_1/component/{instance}/motor/{capability}/command`. A fully static path
68//! has a literal key (`y2026_1/drive/state`). Folding the generation into the key
69//! (D1) is what makes two differently-versioned contracts physically distinct
70//! Zenoh keys - they cannot collide, so there is no `SCHEMA_ID`/`FAMILY` needed to
71//! disambiguate them.
72//!
73//! # The api-local topic builder
74//!
75//! Each version module exposes a `topic` builder that mirrors the node tree:
76//! `api::topic::new()` returns a root, one method per top-level node walks down the
77//! tree, a dynamic node's method takes its variable as `impl Display`, and a leaf
78//! method binds the topic's side-branded kind to its version-local body. For
79//! example `api::topic::new().drive().state()` yields a
80//! `Topic<Subscribe<drive::State>>` (the CLIENT observes the owner's `state`) over
81//! the generation-qualified key `y2026_1/drive/state`, and
82//! `api::topic::new().component("base").motor("left").command()` fills the dynamic
83//! segments to produce `y2026_1/component/base/motor/left/command`. Because the
84//! builder is generated from the same tree as `TOPIC`, the built key and the
85//! documented key stay in lockstep.
86//!
87//! ## Owner side: `topic::internal`
88//!
89//! The PUBLIC `topic::new()...` chain above is the **client** side. The matching
90//! **owner** side lives at `api::topic::internal::new(cap)...` (L1 + L2, plan #00):
91//! the same node tree and keys, but the leaf brands flip so the owner gets the side
92//! it must take - `api::topic::internal::new(cap).drive().state()` is
93//! `Topic<Publish<drive::State>>` (the owner publishes its telemetry), and
94//! `api::topic::internal::new(cap).drive().target()` is `Topic<Subscribe<drive::Target>>`
95//! (the owner reads its command input). A query owner reaches its `ServeQuery`
96//! brand the same way. The `internal` chain is the deliberate, greppable owner
97//! opt-in; a participant acquires the topics of its OWN node through it and everything
98//! it consumes through the public chain.
99//!
100//! The `internal::new` entry requires the runner-minted owner capability
101//! ([`OwnerCap`](phoxal_bus::OwnerCap), Layer 2): a participant obtains it from
102//! `phoxal::SetupContext::owner_capability()` and passes it in. On the documented
103//! surface, owning a topic therefore cannot happen by accident - only with a
104//! capability the runner mints.
105
106use phoxal_macros::phoxal_api_tree;
107
108/// The contract primitive traits, re-exported from the `phoxal-bus` crate (the
109/// ABI floor) so they stay addressable at `phoxal_api::ApiVersion` /
110/// `phoxal_api::ContractBody`.
111///
112/// - [`ApiVersion`] is the marker trait identifying one dated API version (D60),
113/// implemented only by the zero-variant `enum Api {}` that [`phoxal_api_tree!`]
114/// generates inside each version module; its `ID` is the dated module name
115/// (`"y2026_1"`). Its `IS_PREVIEW` const is lifecycle metadata only.
116/// - [`ContractBody`] is a version-local wire body (D61): a plain serde type
117/// bound to exactly one [`ApiVersion`] and one contract topic. Every body
118/// declared inside a [`phoxal_api_tree!`] node gets a generated impl; handles,
119/// `SetupContext` builders, and the `Service`/`Driver` derive assertions key
120/// off its `Api`/`TOPIC`. `TOPIC` is generation-qualified (D1) and *is* the
121/// compatibility key - there is no `SCHEMA_ID`/`FAMILY`; its serde encoding
122/// *is* the wire payload, with no version envelope (D62).
123pub use phoxal_bus::{ApiVersion, ContractBody};
124
125phoxal_api_tree! {
126 version y2026_1 {
127 drive {
128 /// Why actuation authority is in its current state.
129 enum StopReason {
130 NoTarget,
131 EmergencyStop,
132 Fault,
133 }
134
135 /// Whether the drive is actively commanding the actuators.
136 enum ActuatorAuthority {
137 Active,
138 Stopped,
139 }
140
141 /// A requested or limited planar velocity.
142 struct Target {
143 linear_x_mps: f32,
144 angular_z_radps: f32,
145 curvature_limit_radpm: Option<f32>,
146 }
147
148 /// The drive participant's published control state.
149 struct State {
150 target: Target,
151 limited_target: Target,
152 actuator_authority: ActuatorAuthority,
153 stop_reason: Option<StopReason>,
154 }
155
156 topic target: command Target;
157 topic state: state State;
158 }
159
160 safety {
161 /// Safety participant decision for a candidate motion command.
162 #[derive(Copy, Eq)]
163 enum SafetyDecision {
164 Allow,
165 Slow,
166 Stop,
167 EmergencyStop,
168 UnknownConservative,
169 }
170
171 /// An inclusive `[min, max]` bound on a scalar control axis.
172 struct Constraint {
173 min: f64,
174 max: f64,
175 }
176
177 /// Per-axis velocity bounds the safety participant will allow.
178 struct MotionConstraint {
179 linear_x_mps: Constraint,
180 angular_z_radps: Constraint,
181 }
182
183 #[derive(Copy, Eq)]
184 #[serde(rename_all = "snake_case")]
185 enum SafetyReasonCode {
186 ObstacleDetected,
187 BatteryLow,
188 BatteryCritical,
189 DriveFault,
190 LocalizationLost,
191 SourceStale,
192 EmergencyStopEngaged,
193 Unknown,
194 }
195
196 /// A coded reason behind a safety decision, with optional detail text.
197 struct SafetyReason {
198 code: SafetyReasonCode,
199 detail: Option<String>,
200 }
201
202 /// Revisions of the inputs a safety decision was computed against.
203 struct SafetySourceRevision {
204 localization: Option<u64>,
205 map: Option<u64>,
206 }
207
208 /// The safety participant's authorization for downstream motion: the
209 /// decision, the motion it approves, why, and when it expires.
210 struct SafetyAuthorization {
211 decision: SafetyDecision,
212 approved_motion: MotionConstraint,
213 reasons: Vec<SafetyReason>,
214 source_revision: SafetySourceRevision,
215 expires_at_ns: Option<u64>,
216 }
217
218 /// The safety participant's published status: current decision + reasons.
219 struct Status {
220 decision: SafetyDecision,
221 active_reasons: Vec<SafetyReason>,
222 }
223
224 /// A request to engage or release the emergency stop.
225 #[derive(Eq)]
226 struct EmergencyStopRequest {
227 engaged: bool,
228 }
229
230 topic authorization: state SafetyAuthorization;
231 topic state: state Status;
232 topic estop: command EmergencyStopRequest;
233 }
234
235 mission {
236 /// A target pose in the map frame (yaw optional).
237 struct Goal {
238 x_m: f64,
239 y_m: f64,
240 yaw_rad: Option<f64>,
241 }
242
243 /// A command driving the mission lifecycle.
244 enum Command {
245 Start(Goal),
246 Pause,
247 Resume,
248 Cancel,
249 }
250
251 /// Where the mission is in its lifecycle.
252 #[derive(Copy, Eq)]
253 enum Phase {
254 Idle,
255 Active,
256 Paused,
257 Succeeded,
258 Failed,
259 }
260
261 /// The mission participant's published state.
262 struct State {
263 phase: Phase,
264 goal: Option<Goal>,
265 detail: Option<String>,
266 }
267
268 topic command: command Command;
269 topic goal: state Goal;
270 topic state: state State;
271 }
272
273 joint(joint) {
274 /// Per-joint position/velocity (and optional effort) on a dynamic
275 /// per-joint key.
276 struct JointState {
277 position_rad: f64,
278 velocity_radps: f64,
279 effort_nm: Option<f64>,
280 }
281
282 topic state: state JointState;
283 }
284
285 frame {
286 /// A parent → child rigid transform (translation + xyzw quaternion).
287 struct FrameTransform {
288 parent_frame_id: String,
289 child_frame_id: String,
290 translation_m: [f64; 3],
291 rotation_quat_xyzw: [f64; 4],
292 stamp_ns: Option<u64>,
293 }
294
295 /// Transforms that do not change over time.
296 struct StaticTransforms {
297 transforms: Vec<FrameTransform>,
298 }
299
300 /// The current transform tree.
301 struct Tree {
302 transforms: Vec<FrameTransform>,
303 }
304
305 /// Ask for the transform between two frames, optionally at a time.
306 struct LookupRequest {
307 target_frame_id: String,
308 source_frame_id: String,
309 at_ns: Option<u64>,
310 }
311
312 /// The resolved transform, or `None` if it is not available.
313 struct LookupResponse {
314 transform: Option<FrameTransform>,
315 }
316
317 topic tree: state Tree;
318 topic static_transforms: state StaticTransforms;
319 topic lookup: query LookupRequest => LookupResponse;
320 }
321
322 power {
323 /// A platform power command.
324 #[derive(Copy, Eq)]
325 enum Command {
326 Reboot,
327 Shutdown,
328 }
329
330 /// Where the power participant is in handling a command.
331 #[derive(Copy, Eq)]
332 enum Status {
333 Idle,
334 Rebooting,
335 ShuttingDown,
336 Failed,
337 }
338
339 /// Why a power command was rejected outright.
340 #[derive(Copy, Eq)]
341 #[serde(rename_all = "snake_case")]
342 enum RejectedReason {
343 SupervisorUnavailable,
344 SupervisorReturnedHttp,
345 }
346
347 /// Why an accepted power command later failed.
348 #[derive(Copy, Eq)]
349 #[serde(rename_all = "snake_case")]
350 enum FailedReason {
351 SupervisorTransport,
352 }
353
354 /// The power participant's published state.
355 struct State {
356 status: Status,
357 detail: Option<String>,
358 }
359
360 topic command: command Command;
361 topic state: state State;
362 }
363
364 motion {
365 /// A planar velocity the motion arbiter may select.
366 struct Target {
367 linear_x_mps: f32,
368 angular_z_radps: f32,
369 curvature_limit_radpm: Option<f32>,
370 }
371
372 /// The motion arbiter's local view of the active safety decision.
373 #[derive(Copy, Eq)]
374 #[serde(rename_all = "snake_case")]
375 enum SafetyDecision {
376 Allow,
377 Slow,
378 Stop,
379 EmergencyStop,
380 UnknownConservative,
381 }
382
383 /// Which input the motion arbiter is currently following.
384 #[derive(Copy, Eq)]
385 #[serde(rename_all = "snake_case")]
386 enum MotionSource {
387 Manual,
388 Follow,
389 MissionStop,
390 Recovery,
391 EmergencyStop,
392 }
393
394 /// Why the motion arbiter chose its current source/target.
395 #[derive(Copy, Eq)]
396 #[serde(rename_all = "snake_case")]
397 enum MotionReason {
398 SafetyEmergencyStop,
399 ManualEscapeUnderStop,
400 SafetyConstrained(SafetyDecision),
401 NoFollowTarget,
402 FollowTargetStale,
403 SafetyAuthorizationUnavailable,
404 }
405
406 /// A direct teleop velocity command.
407 struct ManualCommand {
408 linear_x_mps: f64,
409 angular_z_radps: f64,
410 }
411
412 /// The motion arbiter's published state.
413 struct State {
414 active_source: Option<MotionSource>,
415 selected: Option<Target>,
416 reason: Option<MotionReason>,
417 }
418
419 topic manual: command ManualCommand;
420 topic state: state State;
421 }
422
423 logs(participant_id) {
424 /// Wall-clock timestamp carried by a structured bus log event.
425 struct Timestamp {
426 unix_seconds: i64,
427 nanos: u32,
428 }
429
430 /// The severity level of a structured bus log event.
431 #[derive(Copy, Eq)]
432 #[serde(rename_all = "snake_case")]
433 enum Level {
434 Error,
435 Warn,
436 Info,
437 Debug,
438 Trace,
439 }
440
441 /// A scalar tracing field value captured from a log event.
442 #[serde(untagged)]
443 enum LogValue {
444 Bool(bool),
445 I64(i64),
446 U64(u64),
447 F64(f64),
448 String(String),
449 }
450
451 /// One structured runner log event published out-of-band.
452 struct Event {
453 seq: u64,
454 time: Timestamp,
455 level: Level,
456 target: String,
457 message: String,
458 fields: ::std::collections::BTreeMap<String, LogValue>,
459 dropped: u32,
460 }
461
462 topic self: state Event;
463 }
464
465 bus {
466 uplink {
467 /// Observable state of the router's optional upstream connection.
468 #[derive(Copy, Eq)]
469 #[serde(rename_all = "snake_case")]
470 enum UplinkPhase {
471 Disabled,
472 Connecting,
473 Connected,
474 Retrying,
475 }
476
477 /// Router-owned out-of-band state for the optional site uplink.
478 struct State {
479 phase: UplinkPhase,
480 connect: Option<String>,
481 retry_attempt: u32,
482 detail: Option<String>,
483 }
484
485 topic state: state State;
486 }
487 }
488
489 plan {
490 /// One pose along a planned path.
491 struct PathPose {
492 x_m: f64,
493 y_m: f64,
494 yaw_rad: Option<f64>,
495 }
496
497 /// A planned path, tagged with the map revision it was built on.
498 struct Path {
499 poses: Vec<PathPose>,
500 map_revision: Option<u64>,
501 }
502
503 /// Why the planner declined to produce a path.
504 #[derive(Copy, Eq)]
505 #[serde(rename_all = "snake_case")]
506 enum Refusal {
507 MissionInactive,
508 NoGoal,
509 NoMap,
510 NoLocalization,
511 Unreachable,
512 NonPlanarGoalUnsupported,
513 LocalizationInitializing,
514 LocalizationLost,
515 LocalizationRelocalizing,
516 UnsupportedLocalizationMode,
517 NoLocalizationPose,
518 NoLocalizationRevision,
519 GoalMapRevisionMismatch,
520 MapLocalizeRevisionMismatch,
521 }
522
523 /// The planner's published state.
524 struct State {
525 has_path: bool,
526 refusal: Option<Refusal>,
527 }
528
529 topic path: state Path;
530 topic state: state State;
531 }
532
533 follow {
534 /// The velocity the path follower wants next, with provenance.
535 struct Target {
536 map_revision: Option<u64>,
537 built_from_localize_revision: Option<u64>,
538 frame_id: String,
539 linear_x_mps: f64,
540 angular_z_radps: f64,
541 }
542
543 /// The path follower's published state.
544 struct State {
545 active: bool,
546 target_index: Option<u32>,
547 finished: bool,
548 }
549
550 topic target: state Target;
551 topic state: state State;
552 }
553
554 explore {
555 /// A candidate frontier to explore, with a size and a ranking score.
556 struct Frontier {
557 x_m: f64,
558 y_m: f64,
559 size: u32,
560 score: f32,
561 }
562
563 /// The current frontier set, tagged with its map revision.
564 struct Frontiers {
565 frontiers: Vec<Frontier>,
566 map_revision: Option<u64>,
567 }
568
569 /// The exploration participant's published state.
570 struct State {
571 exploring: bool,
572 selected: Option<Frontier>,
573 }
574
575 topic frontiers: state Frontiers;
576 topic state: state State;
577 }
578
579 perception {
580 /// A single detected object: class, confidence, and pose in a frame.
581 struct Detection {
582 class_id: String,
583 confidence: f32,
584 position_m: [f64; 3],
585 frame_id: String,
586 track_id: Option<u64>,
587 }
588
589 /// A batch of detections from one perception cycle.
590 struct Detections {
591 detections: Vec<Detection>,
592 stamp_ns: Option<u64>,
593 }
594
595 /// The perception participant's published health.
596 struct State {
597 healthy: bool,
598 detector: String,
599 }
600
601 topic detections: state Detections;
602 topic state: state State;
603 }
604
605 video {
606 /// Ask to open a video stream for a capability at an optional size.
607 struct OpenRequest {
608 capability: String,
609 width_px: Option<u32>,
610 height_px: Option<u32>,
611 }
612
613 /// The id of the stream that was opened.
614 struct OpenResponse {
615 stream_id: String,
616 }
617
618 topic open: query OpenRequest => OpenResponse;
619
620 stream(stream) {
621 /// Where one open video stream is in its lifecycle.
622 #[derive(Copy, Eq)]
623 #[serde(rename_all = "snake_case")]
624 enum StreamPhase {
625 Starting,
626 Active,
627 Stopped,
628 }
629
630 /// The published state of one video stream: its lifecycle phase
631 /// and the number of source frames seen so far. The video participant
632 /// publishes it per stream; clients subscribe, hence `state`.
633 struct StreamState {
634 phase: StreamPhase,
635 frames_seen: u64,
636 }
637
638 topic state: state StreamState;
639 }
640 }
641
642 simulation {
643 /// The simulator clock: current time and whether it is advancing.
644 struct Clock {
645 now_ns: u64,
646 running: bool,
647 }
648
649 /// A command to the simulator's run loop.
650 #[derive(Copy, Eq)]
651 enum Control {
652 Pause,
653 Resume,
654 Reset,
655 }
656
657 /// The simulated robot's ground-truth planar pose.
658 struct RobotPose {
659 x_m: f64,
660 y_m: f64,
661 yaw_rad: f64,
662 }
663
664 /// Whether the simulated robot is in contact, with optional detail.
665 struct Contact {
666 in_contact: bool,
667 detail: Option<String>,
668 }
669
670 topic clock: state Clock;
671 topic control: command Control;
672 topic robot_pose: state RobotPose;
673 topic contact: state Contact;
674 }
675
676 // Per-instance component capabilities (D17/D38: framework participant / driver
677 // territory). `component(instance)` selects a manifest-declared component;
678 // each child `kind(capability)` is a self-contained node whose key is
679 // `component/{instance}/<kind>/{capability}/<leaf>`. Nodes duplicate any
680 // types they share by design - the node path disambiguates, so the names
681 // are path-local.
682 component(instance) {
683 motor(capability) {
684 /// A per-actuator command.
685 enum Command {
686 Velocity(f32),
687 Torque(f32),
688 Stop,
689 }
690
691 topic command: command Command;
692 }
693
694 encoder(capability) {
695 /// Per-encoder sample on a dynamic per-instance key.
696 struct Sample {
697 position_rad: f64,
698 velocity_radps: f32,
699 }
700
701 topic sample: state Sample;
702 }
703
704 accelerometer(capability) {
705 /// Raw accelerometer sample in the sensor-local frame in m/s^2.
706 struct Sample {
707 linear_acceleration: [f32; 3],
708 }
709
710 topic sample: state Sample;
711 }
712
713 gyroscope(capability) {
714 /// Raw angular velocity sample in the sensor-local frame in rad/s.
715 struct Sample {
716 angular_velocity: [f32; 3],
717 }
718
719 topic sample: state Sample;
720 }
721
722 magnetometer(capability) {
723 /// Raw magnetic-field sample in the sensor-local frame.
724 struct Sample {
725 magnetic_field: [f32; 3],
726 }
727
728 topic sample: state Sample;
729 }
730
731 imu(capability) {
732 #[derive(Copy, Eq)]
733 #[serde(rename_all = "snake_case")]
734 enum SensorHealth {
735 Nominal,
736 Degraded,
737 Fault,
738 }
739
740 #[derive(Copy)]
741 struct Bias {
742 angular_velocity_radps: [f32; 3],
743 linear_acceleration_mps2: [f32; 3],
744 }
745
746 struct Sample {
747 orientation: Option<[f32; 4]>,
748 angular_velocity_radps: [f32; 3],
749 linear_acceleration_mps2: [f32; 3],
750 covariance: Option<[f32; 9]>,
751 noise_density: Option<[f32; 3]>,
752 sensor_frame_id: Option<String>,
753 measured_at_ns: Option<u64>,
754 health: SensorHealth,
755 bias: Option<Bias>,
756 }
757
758 topic sample: state Sample;
759 }
760
761 range(capability) {
762 #[derive(Copy, Eq)]
763 #[serde(rename_all = "snake_case")]
764 enum SensorHealth {
765 Nominal,
766 Degraded,
767 Fault,
768 }
769
770 #[derive(Copy)]
771 struct Limits {
772 min_m: f32,
773 max_m: f32,
774 }
775
776 #[derive(Copy)]
777 struct SampleQuality {
778 valid: bool,
779 confidence: Option<f32>,
780 }
781
782 struct Sample {
783 distance_m: f32,
784 limits: Option<Limits>,
785 measured_at_ns: Option<u64>,
786 quality: Option<SampleQuality>,
787 health: SensorHealth,
788 }
789
790 topic sample: state Sample;
791 }
792
793 gnss(capability) {
794 /// A GNSS fix: geodetic position plus a 3x3 position covariance.
795 struct Sample {
796 latitude: f64,
797 longitude: f64,
798 altitude: f64,
799 position_covariance: [f64; 9],
800 }
801
802 topic sample: state Sample;
803 }
804
805 camera(capability) {
806 #[derive(Copy, Eq)]
807 #[serde(rename_all = "snake_case")]
808 enum Encoding {
809 Jpeg,
810 Png,
811 L8,
812 Rgb8,
813 Rgba8,
814 }
815
816 #[derive(Copy)]
817 struct Intrinsics {
818 fx: f32,
819 fy: f32,
820 cx: f32,
821 cy: f32,
822 }
823
824 struct Distortion {
825 model: String,
826 coefficients: Vec<f32>,
827 }
828
829 #[derive(Copy)]
830 struct ExposureTiming {
831 exposure_start_ns: Option<u64>,
832 exposure_duration_ns: Option<u64>,
833 }
834
835 struct CalibrationIdentity {
836 id: String,
837 version: String,
838 }
839
840 /// One camera frame: encoded pixel bytes plus optional calibration
841 /// and timing metadata.
842 struct Frame {
843 width: u32,
844 height: u32,
845 encoding: Encoding,
846 intrinsics: Option<Intrinsics>,
847 distortion: Option<Distortion>,
848 exposure: Option<ExposureTiming>,
849 measured_at_ns: Option<u64>,
850 calibration: Option<CalibrationIdentity>,
851 #[serde(with = "serde_bytes")]
852 data: Vec<u8>,
853 }
854
855 topic frame: state Frame;
856 }
857
858 depth(capability) {
859 #[derive(Copy, Eq)]
860 #[serde(rename_all = "snake_case")]
861 enum Encoding {
862 U16Millimeters,
863 }
864
865 #[derive(Copy, Eq)]
866 #[serde(rename_all = "snake_case")]
867 enum InvalidSamplePolicy {
868 ZeroIsInvalid,
869 NonFiniteIsInvalid,
870 }
871
872 #[derive(Copy)]
873 struct Intrinsics {
874 fx: f32,
875 fy: f32,
876 cx: f32,
877 cy: f32,
878 }
879
880 struct Distortion {
881 model: String,
882 coefficients: Vec<f32>,
883 }
884
885 #[derive(Copy)]
886 struct ExposureTiming {
887 exposure_start_ns: Option<u64>,
888 exposure_duration_ns: Option<u64>,
889 }
890
891 struct CalibrationIdentity {
892 id: String,
893 version: String,
894 }
895
896 /// One depth frame: per-pixel millimetre samples plus optional
897 /// calibration and timing metadata.
898 struct Frame {
899 samples_mm: Vec<u16>,
900 encoding: Encoding,
901 invalid_sample_policy: InvalidSamplePolicy,
902 width: Option<u32>,
903 height: Option<u32>,
904 intrinsics: Option<Intrinsics>,
905 distortion: Option<Distortion>,
906 exposure: Option<ExposureTiming>,
907 measured_at_ns: Option<u64>,
908 calibration: Option<CalibrationIdentity>,
909 }
910
911 topic frame: state Frame;
912 }
913
914 lidar(capability) {
915 #[derive(Copy, Eq)]
916 #[serde(rename_all = "snake_case")]
917 enum SensorHealth {
918 Nominal,
919 Degraded,
920 Fault,
921 }
922
923 #[derive(Copy)]
924 struct ScanGeometry {
925 angle_min_rad: f32,
926 angle_increment_rad: f32,
927 }
928
929 #[derive(Copy)]
930 struct RangeLimits {
931 min_m: f32,
932 max_m: f32,
933 }
934
935 #[derive(Copy)]
936 struct ScanQuality {
937 valid_points: u32,
938 }
939
940 struct Ranges {
941 ranges: Vec<f32>,
942 geometry: Option<ScanGeometry>,
943 limits: Option<RangeLimits>,
944 measured_at_ns: Option<u64>,
945 quality: Option<ScanQuality>,
946 health: SensorHealth,
947 }
948
949 struct Points {
950 points: Vec<[f32; 3]>,
951 limits: Option<RangeLimits>,
952 measured_at_ns: Option<u64>,
953 quality: Option<ScanQuality>,
954 health: SensorHealth,
955 }
956
957 /// One lidar scan, either as polar ranges or as cartesian points.
958 #[serde(tag = "kind", rename_all = "snake_case")]
959 enum Scan {
960 Ranges(Ranges),
961 Points(Points),
962 }
963
964 topic scan: state Scan;
965 }
966
967 mmwave(capability) {
968 /// One mmWave radar detection: position, velocity, and SNR.
969 #[derive(Copy)]
970 struct Detection {
971 position: [f32; 3],
972 velocity: [f32; 3],
973 snr: f32,
974 }
975
976 /// One mmWave radar scan as a set of detections.
977 struct Scan {
978 detections: Vec<Detection>,
979 }
980
981 topic scan: state Scan;
982 }
983
984 microphone(capability) {
985 /// One audio frame as raw encoded bytes.
986 struct Frame {
987 data: Vec<u8>,
988 }
989
990 topic frame: state Frame;
991 }
992
993 led(capability) {
994 /// A per-LED on/off command.
995 #[derive(Copy, Eq)]
996 enum Command {
997 On,
998 Off,
999 }
1000
1001 topic command: command Command;
1002 }
1003
1004 emergency_stop(capability) {
1005 /// Per-instance emergency-stop state.
1006 #[derive(Eq)]
1007 struct State {
1008 engaged: bool,
1009 }
1010
1011 topic state: state State;
1012 }
1013 }
1014
1015 odometry {
1016 /// A planar pose + twist estimate in the odometry frame.
1017 struct State {
1018 x_m: f64,
1019 y_m: f64,
1020 yaw_rad: f64,
1021 linear_x_mps: f32,
1022 angular_z_radps: f32,
1023 }
1024
1025 topic state: state State;
1026 }
1027
1028 localize {
1029 /// A planar localization estimate in the map frame.
1030 struct LocalizationState {
1031 x_m: f64,
1032 y_m: f64,
1033 yaw_rad: f64,
1034 confidence: f32,
1035 }
1036
1037 topic state: state LocalizationState;
1038 }
1039
1040 presence {
1041 /// Per-participant liveness + readiness beacon.
1042 enum Readiness {
1043 NotStarted,
1044 Initializing,
1045 Ready,
1046 Degraded,
1047 Failed,
1048 }
1049
1050 /// A single participant's liveness beacon. Participants publish their
1051 /// own heartbeat; the presence participant subscribes them as its control
1052 /// input, hence the `command` role.
1053 struct Heartbeat {
1054 participant: String,
1055 readiness: Readiness,
1056 }
1057
1058 /// The presence participant's aggregate readiness across all participants.
1059 /// Presence publishes it; clients subscribe, hence the `state` role.
1060 struct State {
1061 readiness: Readiness,
1062 }
1063
1064 topic heartbeat: command Heartbeat;
1065 topic state: state State;
1066 }
1067
1068 map {
1069 /// A published map revision marker.
1070 struct Revision {
1071 revision: u64,
1072 resolution_m: f32,
1073 }
1074
1075 /// Request a rectangular submap window (map-frame metres).
1076 struct SubmapRequest {
1077 min_x_m: f64,
1078 min_y_m: f64,
1079 max_x_m: f64,
1080 max_y_m: f64,
1081 }
1082
1083 /// An occupancy-grid window: row-major cells, 0..=100 + 255 = unknown.
1084 struct SubmapResponse {
1085 width: u32,
1086 height: u32,
1087 resolution_m: f32,
1088 cells: Vec<u8>,
1089 }
1090
1091 topic revision: state Revision;
1092 topic submap: query SubmapRequest => SubmapResponse;
1093 }
1094
1095 asset {
1096 /// Fetch a stored asset by path.
1097 struct GetRequest {
1098 path: String,
1099 }
1100
1101 /// The asset bytes, a not-found marker, or a rejected path.
1102 enum GetResponse {
1103 Found { bytes: Vec<u8> },
1104 Missing,
1105 InvalidPath,
1106 }
1107
1108 topic get: query GetRequest => GetResponse;
1109 }
1110 }
1111
1112 // The second minted generation (the per-contract-versioning ground-breaker):
1113 // a standalone, sparse batch (D1) that mints only `battery::State`, moved
1114 // here from y2026_1. There is no `extends` - this is not "y2026_1 plus a
1115 // change," it is its own generation, and a participant may mix it with
1116 // y2026_1 fields freely (`phoxal::participant::api`'s module docs).
1117 version y2026_7 {
1118 battery {
1119 /// Battery state - re-minted in y2026_7 (moved from y2026_1, the
1120 /// first contract to prove per-contract generation mixing works
1121 /// end-to-end across the graph).
1122 struct State {
1123 voltage_v: f32,
1124 current_a: f32,
1125 charge_ratio: f32,
1126 }
1127
1128 topic state: state State;
1129 }
1130 }
1131
1132 // The next standalone sparse generation. y2026_1 and y2026_7 have both
1133 // shipped and are frozen, so new simulation bootstrap contracts start here.
1134 version y2026_8 {
1135 simulation {
1136 /// One robot node that the simulator spawn authority should import.
1137 struct RobotSpawn {
1138 robot_id: String,
1139 node_string: String,
1140 }
1141
1142 /// Requests the current complete robot spawn set.
1143 ///
1144 /// `known_revision` lets a future responder distinguish initial
1145 /// bootstrap from refreshes after a runtime join. Responders may
1146 /// still return the current set when the revision is unchanged.
1147 struct SpawnRequest {
1148 known_revision: Option<u64>,
1149 }
1150
1151 /// The complete robot spawn set for one simulation world.
1152 struct SpawnSet {
1153 revision: u64,
1154 robots: Vec<RobotSpawn>,
1155 }
1156
1157 topic spawn: query SpawnRequest => SpawnSet;
1158 }
1159 }
1160}
1161
1162#[cfg(test)]
1163mod tests;