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, version-qualified contracts, 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.
[]
= "=0.36.0"
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.
use ;
Key rules the example shows:
- Import
phoxal::{api, prelude::*}. The locked framework train selects the complete concrete API behind the facade; official participants do not mix revisions field by field. - Handle fields use version-qualified bodies:
Publisher<api::drive::Target>,Latest<api::drive::State>.#[derive(phoxal::Api)]requires every field to name aContractBody, so a non-contract type is a compile error. - Topics are api-local:
api::topic::client().drive().state(). - The wire body is the plain payload, and contract identity lives only in the version-qualified topic key.
Bus metadata carries the codec and provenance - the producing process, its sample sequence, and the robot instant the content was produced at when the sample expresses robot time at all - but no API version or contract family.
Normal participants never open Zenoh - the runner opens the launch-selected bus profile before
#[setup]. configis for user participants only. Official participants take noconfigparam and read the robot model throughctx.robot().
The runner also owns the rest of the lifecycle: #[step(hz = ...)] is the
scheduled control loop, optional #[reset] clears prior simulation-execution
state, #[server] / #[server_snapshot] serve queries, and #[shutdown] runs
graceful park/stop/flush before the bus closes. #[reset] is serialized with
steps and exclusive servers and receives ResetContext. Clockless operator
input needs no marker to survive it: a command carries no production instant, so
it belongs to no timeline, and what bounds it is its Lease rather than the
world boundary.
Every process carries a ProducerId - minted by the supervisor and passed
through the launch contract, or minted by the process itself for an ad hoc run.
It rides bus metadata and forms the participant's exact Liveliness key,
<robot-root>/liveliness/participants/<participant-id>/<producer-id>.
Observers use that exact key for spawn readiness and aggregate all live
producers of a participant id when they need stable present/not-present state.
Because a fresh process is always a fresh producer whose sequence restarts at
zero, a restart is structurally distinguishable from a replay - there is no
sequence-reset rule to get wrong.
It measures handler duration/lateness/missed ticks plus the exact
version-qualified typed-bus buffers declared by the participant and emits one
bounded portable runtime-performance rollup per host-monotonic grid interval.
The setup-declared rows persist for the process lifetime; interval counters are
best-effort boundary samples rather than a transactional queue snapshot. A
participant writes no telemetry code, unscheduled participants report step
timing as not applicable, and no per-process CPU/RSS sampler is involved.
#[phoxal::tool] is intentionally outside the robot clock. Tools use managed
host/event loops and LocalInstant - the host's suspend-aware boot clock - for
cadence and freshness. They express no robot time: a tool's publications are
commands and diagnostics, which carry no production instant.
The macro rejects #[step], the setup context exposes no clock, and
the tool process launch has no clock option. The normal embedding API likewise
accepts no clock argument; deterministic clock injection is restricted to typed
graph participants. Official tool sources are additionally checked against
importing or subscribing to simulation-clock surfaces; user-authored tools must
preserve the same boundary when using the privileged raw bus. A service that
consumes asynchronous tool input keeps a bounded latest value with
consumer-local monotonic arrival time and samples it at its own logical step.
Modules
A participant depends on the phoxal facade.
| Module | What it is |
|---|---|
[phoxal::api] |
Alias of the train-selected phoxal_api::latest concrete revision. phoxal-api retains concrete modules such as v0_1 for compatibility adapters. |
[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 key scheme <namespace>/robots/<robot-id>/<version-qualified-topic>, the named-field MessagePack codec, provenance-only BusMetadata, 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 explicit client (api::topic::client()) and owner (api::topic::owner()) builders return different branded topics (Publish/Subscribe) and taking the wrong side does not compile.
There are no key-template strings and no topic parameter lists; dynamism comes entirely from the (var) nodes on the path.
phoxal_api_tree!
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, version-qualified): thev<major>_<minor>module is the first segment; each node then emitsname, orname/{var}for a dynamic node, joined by/, followed by/<leaf>. Sodrive→targetisv0.1/drive/target, andcomponent(instance)→motor(capability)→commandisv0.1/component/{instance}/motor/{capability}/command. - Module path / type location: each node becomes a nested
pub mod name, and a node's types + theirContractBodyimpls live in that module. Variables never appear in the module path, so the body above isphoxal_api::v0_1::component::motor::Command. - Contract identity (
ContractBody::NAME): the version-qualified Rust path, e.g.v0.1::drive::Targetorv0.1::component::motor::Command. - Builder:
api::topic::client().n1(var?)…nk(var?).leaf(). A dynamic node's method takes its var asimpl Display(*/**stay valid for subscribe); the leaf method yields the typedTopic. Soapi::topic::client().component("front_left").motor("drive").command()builds the keyv0.1/component/front_left/motor/drive/command.
Each topic node is self-contained: it declares its own request, response,
and state bodies, with no super:: and no shared/common module. This is
required because one body has one ContractBody identity and therefore one
topic. The narrow exception is a plain, non-topic protocol value declared by a
parent node and referenced from children through an absolute crate path. For
example, v0.1::tool::Cursor carries the same retention position in the separate
tool::log and tool::bus protocols, while each child still owns its distinct
SnapshotRequest, Snapshot, and Follow topic bodies.
Because the node path disambiguates, topic-body names are path-local -
component::imu::Sample and component::range::Sample are distinct types that
may safely repeat field names. Duplicating an identical topic body across
sibling nodes is intentional, not a smell.
Published concrete revisions are immutable. A new revision uses one-parent
extends; unchanged definitions are materialized under the child's concrete
identity, while replace and remove make breaking deltas explicit. Exactly
one final latest <revision>; selects the facade for the train.
Entrypoints
- Default (blocking):
fn main() -> phoxal::Result<()> { phoxal::run::<R>() }. - Advanced (async):
phoxal::tokio::run::<R>().awaitfor custom Tokio mains.
Official service set
The platform participant set ships alongside this crate in the workspace service/ tree (drive, motion, navigation, localize, map, …).
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).
The matching GitHub train also ships the official simulator controller binaries.
Release packaging requires those controllers to link their native simulator
runtime; metadata-only test builds are never published as runnable artifacts.
Status
Pre-1.0, building in public.
There is no per-participant API version ceiling; the wire body is the plain payload and the version identity rides the version-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.