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