use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use phoxal_model::Robot;
use phoxal_model::component::capability::MotorCommand;
use phoxal_model::identity::CapabilityRef;
use phoxal_runtime_contract::identity::{ParticipantArtifactId, ParticipantId};
use phoxal_runtime_contract::metadata::{ParticipantContract, ParticipantRequirement};
use phoxal_runtime_contract::version::{CompatibilityLine, FrameworkVersion};
use serde::{Deserialize, Serialize};
use crate::{
ASSETS_DIR, AssetIndex, BinaryReference, BundleError, BundlePath, DocumentError,
RuntimeParticipant, SelectionError,
};
#[derive(
phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ParticipantClock {
Real,
Simulation,
Clockless,
}
impl fmt::Display for ParticipantClock {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Real => "real",
Self::Simulation => "simulation",
Self::Clockless => "clockless",
})
}
}
#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
#[serde(tag = "schema", deny_unknown_fields)]
pub enum RuntimeDocument {
#[serde(rename = "phoxal/runtime-bundle/v0")]
V0(Runtime),
}
impl<'de> Deserialize<'de> for RuntimeDocument {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(tag = "schema", deny_unknown_fields)]
enum Wire {
#[serde(rename = "phoxal/runtime-bundle/v0")]
V0(Runtime),
}
match Wire::deserialize(deserializer)? {
Wire::V0(runtime) => Ok(Self::new(runtime)),
}
}
}
impl RuntimeDocument {
#[must_use]
pub const fn new(runtime: Runtime) -> Self {
Self::V0(runtime)
}
#[must_use]
pub const fn runtime(&self) -> &Runtime {
match self {
Self::V0(runtime) => runtime,
}
}
#[must_use]
pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
self.runtime().robot.id()
}
#[must_use]
pub fn robot(&self) -> &Robot {
&self.runtime().robot
}
#[must_use]
pub fn framework_line(&self) -> CompatibilityLine {
self.runtime().framework_line()
}
#[must_use]
pub fn participants(&self) -> &[RuntimeParticipant] {
&self.runtime().participants
}
#[must_use]
pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
&self.runtime().artifacts
}
pub fn participant(&self, id: &ParticipantId) -> Result<&RuntimeParticipant, SelectionError> {
self.participants()
.iter()
.find(|participant| participant.id == *id)
.ok_or_else(|| SelectionError::Unknown {
requested: id.clone(),
})
}
}
#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Runtime {
pub(crate) robot: Robot,
pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
pub(crate) participants: Vec<RuntimeParticipant>,
pub(crate) assets: AssetIndex,
pub(crate) router: Option<RuntimeRouterConfig>,
}
impl<'de> Deserialize<'de> for Runtime {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire {
robot: Robot,
artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
participants: Vec<RuntimeParticipant>,
assets: AssetIndex,
router: Option<RuntimeRouterConfig>,
}
let wire = Wire::deserialize(deserializer)?;
Self::new(
wire.robot,
wire.artifacts,
wire.participants,
wire.assets,
wire.router,
)
.map_err(serde::de::Error::custom)
}
}
impl Runtime {
pub fn new(
robot: Robot,
artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
participants: Vec<RuntimeParticipant>,
assets: AssetIndex,
router: Option<RuntimeRouterConfig>,
) -> Result<Self, DocumentError> {
let runtime = Self {
robot,
artifacts,
participants,
assets,
router,
};
runtime.validate()?;
Ok(runtime)
}
#[must_use]
pub const fn robot(&self) -> &Robot {
&self.robot
}
#[must_use]
pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
&self.artifacts
}
#[must_use]
pub fn participants(&self) -> &[RuntimeParticipant] {
&self.participants
}
#[must_use]
pub const fn assets(&self) -> &AssetIndex {
&self.assets
}
#[must_use]
pub const fn router(&self) -> Option<&RuntimeRouterConfig> {
self.router.as_ref()
}
#[must_use]
#[expect(
clippy::expect_used,
reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
)]
pub fn framework_line(&self) -> CompatibilityLine {
self.participants
.first()
.and_then(|participant| self.artifacts.get(&participant.artifact))
.map(|artifact| artifact.contract().framework.compatibility_line())
.expect("validated runtime has a selected participant artifact")
}
fn validate(&self) -> Result<(), DocumentError> {
if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
return Err(DocumentError::TooManyParticipants {
count: self.participants.len(),
});
}
let mut ids = BTreeSet::new();
let mut artifact_paths = BTreeSet::new();
let mut validators = BTreeMap::new();
for (id, artifact) in &self.artifacts {
artifact.validate(id)?;
if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
{
if id.as_str() != "brain" {
return Err(DocumentError::BrainArtifactId { actual: id.clone() });
}
if artifact.path().as_str() != "bin/brain" {
return Err(DocumentError::BrainArtifactPath {
actual: artifact.path().clone(),
});
}
}
if !artifact_paths.insert(artifact.path.clone()) {
return Err(DocumentError::DuplicateBinary {
path: artifact.path.clone(),
});
}
let validator =
jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
DocumentError::InvalidConfigSchema {
artifact: id.clone(),
error: error.to_string(),
}
})?;
validate_requirement(artifact.contract(), id, &self.robot)?;
validators.insert(id, validator);
}
let mut referenced_artifacts = BTreeSet::new();
let mut brain = None;
let mut simulator_count = 0_u8;
let mut framework: Option<FrameworkVersion> = None;
for participant in &self.participants {
let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
DocumentError::UnknownArtifact {
participant: participant.id.clone(),
artifact: participant.artifact.clone(),
}
})?;
let validator = validators.get(&participant.artifact).ok_or_else(|| {
DocumentError::UnknownArtifact {
participant: participant.id.clone(),
artifact: participant.artifact.clone(),
}
})?;
participant.validate(&self.robot, artifact, validator)?;
let artifact_framework = artifact.contract().framework;
let expected = *framework.get_or_insert(artifact_framework);
if !expected.is_compatible_with(artifact_framework) {
return Err(DocumentError::MixedFrameworkLine {
artifact: participant.artifact.clone(),
expected,
actual: artifact_framework,
});
}
if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
{
if participant.id.as_str() != "brain" {
return Err(DocumentError::BrainIdMismatch {
actual: participant.id.clone(),
});
}
if brain.replace(participant.id.clone()).is_some() {
return Err(DocumentError::DuplicateBrain);
}
}
if artifact.contract().kind
== phoxal_runtime_contract::metadata::ParticipantKind::Simulator
{
simulator_count = simulator_count.saturating_add(1);
}
referenced_artifacts.insert(participant.artifact.clone());
if !ids.insert(participant.id.clone()) {
return Err(DocumentError::DuplicateParticipant {
id: participant.id.clone(),
});
}
}
if brain.is_none() {
return Err(DocumentError::MissingBrain);
}
if self.robot.clock() == phoxal_model::Clock::Simulated {
match simulator_count {
0 => return Err(DocumentError::MissingSimulator),
1 => {}
_ => return Err(DocumentError::DuplicateSimulator),
}
}
if let Some(artifact) = self
.artifacts
.keys()
.find(|id| !referenced_artifacts.contains(*id))
{
return Err(DocumentError::UnusedArtifact {
artifact: artifact.clone(),
});
}
self.assets.validate()?;
if let Some(router) = &self.router {
router.validate(&self.assets)?;
}
Ok(())
}
}
pub(crate) fn validate_requirement(
contract: &ParticipantContract,
artifact: &ParticipantArtifactId,
robot: &Robot,
) -> Result<(), DocumentError> {
let Some(requirement) = contract.requirement else {
return Ok(());
};
match requirement {
ParticipantRequirement::DifferentialDriveVelocity => {
let phoxal_model::robot::KinematicConfig::Differential {
left_actuators,
right_actuators,
..
} = robot.motion().kinematic()
else {
return Err(DocumentError::RequirementKinematicsMismatch {
artifact: artifact.clone(),
requirement,
actual: robot.motion().kinematic().kind(),
});
};
validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
validate_drive_side(artifact, "right_actuators", right_actuators, robot)
}
}
}
fn validate_drive_side(
artifact: &ParticipantArtifactId,
side: &'static str,
actuators: &[CapabilityRef],
robot: &Robot,
) -> Result<(), DocumentError> {
if actuators.is_empty() {
return Err(DocumentError::RequirementActuatorListEmpty {
artifact: artifact.clone(),
side,
});
}
for reference in actuators {
let (motor, _) = robot.require_motor(reference).map_err(|error| {
DocumentError::RequirementActuatorInvalid {
artifact: artifact.clone(),
actuator: reference.clone(),
error: error.to_string(),
}
})?;
if motor.command != MotorCommand::Velocity {
return Err(DocumentError::RequirementMotorModeMismatch {
artifact: artifact.clone(),
actuator: reference.clone(),
expected: MotorCommand::Velocity,
actual: motor.command,
});
}
}
Ok(())
}
#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeRouterConfig {
path: BundlePath,
}
impl RuntimeRouterConfig {
#[must_use]
pub const fn new(path: BundlePath) -> Self {
Self { path }
}
#[must_use]
pub const fn path(&self) -> &BundlePath {
&self.path
}
fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
if !self.path.starts_with_directory(ASSETS_DIR) {
return Err(DocumentError::RouterOutsideAssets {
path: self.path.clone(),
});
}
if !assets.entries.iter().any(|entry| entry.path == self.path) {
return Err(DocumentError::RouterMissingAsset {
path: self.path.clone(),
});
}
Ok(())
}
}
pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
serde_json::from_slice(bytes).map_err(BundleError::from)
}