# phoxal
A production-oriented framework for autonomous robots, shipped as one crate.
Phoxal gives a robot a small, strongly-typed core: a contract bus over Zenoh, a single dated API-version contract set, and a derive-based participant authoring model.
The framework owns the awkward parts - argument parsing, bus connection, scheduling, query serving, shutdown, and health - so the code you write is the robot's behavior, not its plumbing.
> **Greenfield rewrite in progress** (`tmp/framework-rewrite`).
> The bus + participant machinery and the macros are new; the official service/simulator/tool crates are being re-ported onto this surface and some are temporarily out of the workspace.
```toml
[dependencies]
phoxal = "0.17" # the engine: runner, derives, SetupContext, prelude
phoxal-api = "0.17" # the contract tree: `use phoxal_api::y2026_1 as api;`
```
## The authoring model
A participant is a `Config` struct, an `Api` struct of typed bus handles, a state struct, and one annotated inherent impl.
The `Api` struct declares the contracts it uses (as handle fields); the impl declares the lifecycle.
```rust,ignore
use phoxal_api::y2026_1 as api;
use phoxal::prelude::*;
#[derive(serde::Deserialize, phoxal::Config)]
struct Config {
cruise_linear_x_mps: f32,
}
#[derive(phoxal::Api)]
struct Api {
state: Latest<api::drive::State>,
target: Publisher<api::drive::Target>,
}
#[phoxal::service(id = "avoid-obstacles")]
struct AvoidObstacles {
cruise_linear_x_mps: f32,
}
#[phoxal::behavior]
impl AvoidObstacles {
#[setup]
async fn setup(ctx: &mut SetupContext<Self>, config: Self::Config) -> Result<(Self, Self::Api)> {
Ok((
Self { cruise_linear_x_mps: config.cruise_linear_x_mps },
Self::Api {
state: ctx.latest(api::topic::new().drive().state()).await?,
target: ctx.publisher(api::topic::new().drive().target()).await?,
},
))
}
#[step(hz = 50)]
async fn step(&mut self, api: &mut Self::Api, step: StepContext) -> Result<()> {
let now = step.time();
// read inputs, publish version-local bodies
api.target.publish_at(now, api::drive::Target {
linear_x_mps: self.cruise_linear_x_mps,
angular_z_radps: 0.0,
curvature_limit_radpm: None,
}).await?;
Ok(())
}
}
fn main() -> phoxal::Result<()> { phoxal::run::<AvoidObstacles>() }
```
Key rules the example shows:
- Import the dated API module(s) you need (`use phoxal_api::y2026_1 as api;`); an `Api` struct's fields may mix bodies from different generations, so there is no per-participant API version to declare.
- Handle fields use version-qualified bodies: `Publisher<api::drive::Target>`, `Latest<api::drive::State>`.
`#[derive(phoxal::Api)]` requires every field to name a `ContractBody`, so a non-contract type is a **compile error**.
- Topics are api-local: `api::topic::new().drive().state()`.
- The wire body is the plain payload; `api_version`, family, and codec ride bus metadata.
Normal participants never open Zenoh - the runner opens the launch-selected bus profile before `#[setup]`.
- `config` is for **user participants only**.
Official participants take no `config` param and read the robot model through `ctx.robot()`.
The runner also owns the rest of the lifecycle: `#[step(hz = ...)]` is the scheduled control loop, `#[server]` / `#[server_snapshot]` serve queries, and `#[shutdown]` runs graceful park/stop/flush before the bus closes.
## Modules
A participant depends on two crates: the `phoxal` engine (the modules below) and the `phoxal-api` contract tree.
| `phoxal_api` (separate crate) | The dated API-version modules (`phoxal_api::y2026_1`, …) generated by `phoxal_api_tree!` from a tree of nested nodes: the marker `enum Api` (`ApiVersion`), version-local wire bodies + their `ContractBody` impls, and the api-local `topic` builders. Imported directly with `use phoxal_api::y2026_1 as api;`. |
| [`phoxal::prelude`] | Everything a participant author imports with `use phoxal::prelude::*;`: the handle types, `SetupContext`/`StepContext`, and `Result`. |
| [`phoxal::bus`] | Re-export of the `phoxal-bus` crate (the ABI floor): the Zenoh-native `bus_abi` boundary: the key scheme `<namespace>/robots/<robot-id>/<topic>`, the MessagePack codec, the `BusMetadata` attachment, and the body-typed handles `Publisher`/`Subscriber`/`Latest`/`Querier`. |
| [`phoxal::participant`] | The static metadata traits the macros target, `SetupContext`/`StepContext`/`ShutdownContext`, the clock (`RealClock`/`TestClock`) + scheduler, `ParticipantLaunch`, and the runner (`run` / `tokio::run`). |
| [`phoxal::model`] | Authored manifest schemas (`robot.yaml`, `structure.urdf`, `component.yaml`, …). |
## Authoring the API tree
The `phoxal_api` modules (in the `phoxal-api` crate) are generated by `phoxal_api_tree!` from a tree of **nested nodes**.
A node is `name { … }` (static) or `name(var) { … }` (dynamic, where `var` is a key segment filled at build time), nestable to any depth.
A node body holds any mix of `struct`/`enum` type declarations, `topic` declarations, and child nodes.
Each topic declares a **role**: `topic <leaf>: command <Body>;` (a control input the owning service subscribes), `topic <leaf>: state <Body>;` (telemetry the owning service publishes), or `topic <leaf>: query <Req> => <Resp>;` (request/response).
`command` and `state` are both pub/sub on the wire; the role selects the side brand of the generated builders (L1), so the public client builder (`api::topic::new()`) and the `internal` owner builder (`api::topic::internal::new(cap)`) return different branded topics (`Publish`/`Subscribe`) and taking the wrong side does not compile.
The owner builder additionally requires the runner-minted `OwnerCap` (L2): pass `ctx.owner_capability()`, so owning a topic is a deliberate, capability-gated opt-in rather than something that can happen by accident.
There are no key-template strings and no topic parameter lists; dynamism comes entirely from the `(var)` nodes on the path.
```rust,ignore
phoxal_api_tree! {
version y2026_1 {
drive { // static node
struct Target { linear_x_mps: f32, angular_z_radps: f32 }
topic target: command Target; // key drive/target, FAMILY drive::Target
struct State { /* … */ }
topic state: state State; // owner-published telemetry
}
component(instance) { // literal "component" + var {instance}
motor(capability) { // literal "motor" + var {capability}
enum Command { Velocity(f32), Torque(f32), Stop }
topic command: command Command;
}
}
}
}
```
Everything a topic exposes is **derived from the node path** `n1 … nk` to its leaf (each `ni` has a `name` and an optional `var`):
- **Topic key** (`ContractBody::TOPIC`, versionless): each node emits `name`, or `name/{var}` for a dynamic node, joined by `/`, then `/<leaf>`.
So `drive` → `target` is `drive/target`, and `component(instance)` → `motor(capability)` → `command` is `component/{instance}/motor/{capability}/command`.
- **Module path / type location**: each node becomes a nested `pub mod name`, and a node's types + their `ContractBody` impls live in that module.
Variables never appear in the module path, so the body above is `phoxal_api::y2026_1::component::motor::Command`.
- **FAMILY** (`ContractBody::FAMILY`): the node names joined by `::`, then `::TypeName` - e.g. `drive::Target`, `component::motor::Command`.
- **Builder**: `api::topic::new().n1(var?)…nk(var?).leaf()`.
A dynamic node's method takes its var as `impl Display` (`*` / `**` stay valid for subscribe); the leaf method yields the typed `Topic`.
So `api::topic::new().component("front_left").motor("drive").command()` builds the key `component/front_left/motor/drive/command`.
Each node is **self-contained**: it declares its own copy of every type it uses, with **no `super::`** and no shared/common module.
Because the node path disambiguates, names are path-local - `component::imu::Sample` and `component::range::Sample` are distinct types that may safely repeat field names.
Duplicating an identical type across sibling nodes is intentional, not a smell.
A later `version yNNNN_M extends yNNNN_K { … }` re-emits the parent tree as **fresh types** under the child version (same FAMILY/TOPIC, a different `Api`), overriding a type by ident or a topic by leaf and appending wholly new nodes - so an inherited node never needs to be retyped.
## Entrypoints
- Default (blocking): `fn main() -> phoxal::Result<()> { phoxal::run::<R>() }`.
- Advanced (async): `phoxal::tokio::run::<R>().await` for custom Tokio mains.
## Official service set
The complete platform participant set ships alongside this crate in the workspace `service/` tree (`drive`, `localize`, `map`, `safety`, …).
They are full official participants authored on exactly this surface, and they double as worked reference reading.
Neutral teaching examples live in `phoxal/examples/` (`runtime_control_loop`, `runtime_query_server`, `runtime_snapshot_server`, `runtime_async_entrypoint`).
## Status
Pre-1.0, building in public.
There is no per-participant API version ceiling; the wire body is the plain payload and the generation identity rides the generation-qualified key.
Compatibility is checked at build/check time (compiled-in artifact metadata + `phoxal-cli check`), never by introspecting a running binary.
## License
AGPL-3.0-only.
A commercial license is available - see the repository.