1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use phoxal_model::Robot;
7use phoxal_model::component::capability::MotorCommand;
8use phoxal_model::identity::CapabilityRef;
9use phoxal_runtime_contract::identity::{ParticipantArtifactId, ParticipantId};
10use phoxal_runtime_contract::metadata::{ParticipantContract, ParticipantRequirement};
11use phoxal_runtime_contract::version::RobotApiVersion;
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 ASSETS_DIR, AssetIndex, BinaryReference, BundleError, BundlePath, DocumentError,
16 RuntimeParticipant, SelectionError,
17};
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ParticipantClock {
26 Real,
28 Simulation,
30 Clockless,
32}
33
34impl fmt::Display for ParticipantClock {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 formatter.write_str(match self {
37 Self::Real => "real",
38 Self::Simulation => "simulation",
39 Self::Clockless => "clockless",
40 })
41 }
42}
43
44#[derive(Clone, Debug, Serialize)]
46#[serde(tag = "schema", deny_unknown_fields)]
47pub enum RuntimeDocument {
48 #[serde(rename = "phoxal/runtime-bundle/v0")]
51 V0(Runtime),
52}
53
54impl<'de> Deserialize<'de> for RuntimeDocument {
55 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
56 #[derive(Deserialize)]
57 #[serde(tag = "schema", deny_unknown_fields)]
58 enum Wire {
59 #[serde(rename = "phoxal/runtime-bundle/v0")]
60 V0(Runtime),
61 }
62
63 match Wire::deserialize(deserializer)? {
64 Wire::V0(runtime) => Ok(Self::new(runtime)),
65 }
66 }
67}
68
69impl RuntimeDocument {
70 #[must_use]
72 pub const fn new(runtime: Runtime) -> Self {
73 Self::V0(runtime)
74 }
75
76 #[must_use]
78 pub const fn runtime(&self) -> &Runtime {
79 match self {
80 Self::V0(runtime) => runtime,
81 }
82 }
83
84 #[must_use]
86 pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
87 self.runtime().robot.id()
88 }
89
90 #[must_use]
92 pub fn robot(&self) -> &Robot {
93 &self.runtime().robot
94 }
95
96 #[must_use]
98 pub fn robot_api(&self) -> RobotApiVersion {
99 self.runtime().robot_api()
100 }
101
102 #[must_use]
104 pub fn participants(&self) -> &[RuntimeParticipant] {
105 &self.runtime().participants
106 }
107
108 #[must_use]
110 pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
111 &self.runtime().artifacts
112 }
113
114 pub fn participant(&self, id: &ParticipantId) -> Result<&RuntimeParticipant, SelectionError> {
116 self.participants()
117 .iter()
118 .find(|participant| participant.id == *id)
119 .ok_or_else(|| SelectionError::Unknown {
120 requested: id.clone(),
121 })
122 }
123}
124
125#[derive(Clone, Debug, Serialize)]
127#[serde(deny_unknown_fields)]
128pub struct Runtime {
129 pub(crate) robot: Robot,
132 pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
135 pub(crate) participants: Vec<RuntimeParticipant>,
138 pub(crate) assets: AssetIndex,
140 pub(crate) router: Option<RuntimeRouterConfig>,
142}
143
144impl<'de> Deserialize<'de> for Runtime {
145 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
146 #[derive(Deserialize)]
147 #[serde(deny_unknown_fields)]
148 struct Wire {
149 robot: Robot,
150 artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
151 participants: Vec<RuntimeParticipant>,
152 assets: AssetIndex,
153 router: Option<RuntimeRouterConfig>,
154 }
155
156 let wire = Wire::deserialize(deserializer)?;
157 Self::new(
158 wire.robot,
159 wire.artifacts,
160 wire.participants,
161 wire.assets,
162 wire.router,
163 )
164 .map_err(serde::de::Error::custom)
165 }
166}
167
168impl Runtime {
169 pub fn new(
172 robot: Robot,
173 artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
174 participants: Vec<RuntimeParticipant>,
175 assets: AssetIndex,
176 router: Option<RuntimeRouterConfig>,
177 ) -> Result<Self, DocumentError> {
178 let runtime = Self {
179 robot,
180 artifacts,
181 participants,
182 assets,
183 router,
184 };
185 runtime.validate()?;
186 Ok(runtime)
187 }
188
189 #[must_use]
191 pub const fn robot(&self) -> &Robot {
192 &self.robot
193 }
194
195 #[must_use]
197 pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
198 &self.artifacts
199 }
200
201 #[must_use]
203 pub fn participants(&self) -> &[RuntimeParticipant] {
204 &self.participants
205 }
206
207 #[must_use]
209 pub const fn assets(&self) -> &AssetIndex {
210 &self.assets
211 }
212
213 #[must_use]
215 pub const fn router(&self) -> Option<&RuntimeRouterConfig> {
216 self.router.as_ref()
217 }
218
219 #[must_use]
224 #[expect(
225 clippy::expect_used,
226 reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
227 )]
228 pub fn robot_api(&self) -> RobotApiVersion {
229 self.participants
230 .first()
231 .and_then(|participant| self.artifacts.get(&participant.artifact))
232 .map(|artifact| artifact.contract().api)
233 .expect("validated runtime has a selected participant artifact")
234 }
235
236 fn validate(&self) -> Result<(), DocumentError> {
237 let mut ids = BTreeSet::new();
238 let mut artifact_paths = BTreeSet::new();
239 let mut validators = BTreeMap::new();
240 for (id, artifact) in &self.artifacts {
241 artifact.validate(id)?;
242 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
243 {
244 if id.as_str() != "brain" {
245 return Err(DocumentError::BrainArtifactId { actual: id.clone() });
246 }
247 if artifact.path().as_str() != "bin/brain" {
248 return Err(DocumentError::BrainArtifactPath {
249 actual: artifact.path().clone(),
250 });
251 }
252 }
253 if !artifact_paths.insert(artifact.path.clone()) {
254 return Err(DocumentError::DuplicateBinary {
255 path: artifact.path.clone(),
256 });
257 }
258 let validator =
259 jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
260 DocumentError::InvalidConfigSchema {
261 artifact: id.clone(),
262 error: error.to_string(),
263 }
264 })?;
265 validate_requirement(artifact.contract(), id, &self.robot)?;
266 validators.insert(id, validator);
267 }
268 let mut referenced_artifacts = BTreeSet::new();
269 let mut brain = None;
270 let mut simulator_count = 0_u8;
271 let mut robot_api = None;
272 for participant in &self.participants {
273 let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
274 DocumentError::UnknownArtifact {
275 participant: participant.id.clone(),
276 artifact: participant.artifact.clone(),
277 }
278 })?;
279 let validator = validators.get(&participant.artifact).ok_or_else(|| {
280 DocumentError::UnknownArtifact {
281 participant: participant.id.clone(),
282 artifact: participant.artifact.clone(),
283 }
284 })?;
285 participant.validate(&self.robot, artifact, validator)?;
286 let api = artifact.contract().api;
287 if let Some(expected) = robot_api
288 && expected != api
289 {
290 return Err(DocumentError::MixedRobotApi {
291 artifact: participant.artifact.clone(),
292 expected,
293 actual: api,
294 });
295 }
296 robot_api = Some(api);
297 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
298 {
299 if participant.id.as_str() != "brain" {
300 return Err(DocumentError::BrainIdMismatch {
301 actual: participant.id.clone(),
302 });
303 }
304 if brain.replace(participant.id.clone()).is_some() {
305 return Err(DocumentError::DuplicateBrain);
306 }
307 }
308 if artifact.contract().kind
309 == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
310 {
311 simulator_count = simulator_count.saturating_add(1);
312 }
313 referenced_artifacts.insert(participant.artifact.clone());
314 if !ids.insert(participant.id.clone()) {
315 return Err(DocumentError::DuplicateParticipant {
316 id: participant.id.clone(),
317 });
318 }
319 }
320 if brain.is_none() {
321 return Err(DocumentError::MissingBrain);
322 }
323 if self.robot.clock() == phoxal_model::Clock::Simulated {
324 match simulator_count {
325 0 => return Err(DocumentError::MissingSimulator),
326 1 => {}
327 _ => return Err(DocumentError::DuplicateSimulator),
328 }
329 }
330 if let Some(artifact) = self
331 .artifacts
332 .keys()
333 .find(|id| !referenced_artifacts.contains(*id))
334 {
335 return Err(DocumentError::UnusedArtifact {
336 artifact: artifact.clone(),
337 });
338 }
339 self.assets.validate()?;
340 if let Some(router) = &self.router {
341 router.validate(&self.assets)?;
342 }
343 Ok(())
344 }
345}
346
347pub(crate) fn validate_requirement(
350 contract: &ParticipantContract,
351 artifact: &ParticipantArtifactId,
352 robot: &Robot,
353) -> Result<(), DocumentError> {
354 let Some(requirement) = contract.requirement else {
355 return Ok(());
356 };
357 match requirement {
358 ParticipantRequirement::DifferentialDriveVelocity => {
359 let phoxal_model::robot::KinematicConfig::Differential {
360 left_actuators,
361 right_actuators,
362 ..
363 } = robot.motion().kinematic()
364 else {
365 return Err(DocumentError::RequirementKinematicsMismatch {
366 artifact: artifact.clone(),
367 requirement,
368 actual: robot.motion().kinematic().kind(),
369 });
370 };
371 validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
372 validate_drive_side(artifact, "right_actuators", right_actuators, robot)
373 }
374 }
375}
376
377fn validate_drive_side(
378 artifact: &ParticipantArtifactId,
379 side: &'static str,
380 actuators: &[CapabilityRef],
381 robot: &Robot,
382) -> Result<(), DocumentError> {
383 if actuators.is_empty() {
384 return Err(DocumentError::RequirementActuatorListEmpty {
385 artifact: artifact.clone(),
386 side,
387 });
388 }
389 for reference in actuators {
390 let (motor, _) = robot.require_motor(reference).map_err(|error| {
391 DocumentError::RequirementActuatorInvalid {
392 artifact: artifact.clone(),
393 actuator: reference.clone(),
394 error: error.to_string(),
395 }
396 })?;
397 if motor.command != MotorCommand::Velocity {
398 return Err(DocumentError::RequirementMotorModeMismatch {
399 artifact: artifact.clone(),
400 actuator: reference.clone(),
401 expected: MotorCommand::Velocity,
402 actual: motor.command,
403 });
404 }
405 }
406 Ok(())
407}
408
409#[derive(Clone, Debug, Deserialize, Serialize)]
411#[serde(deny_unknown_fields)]
412pub struct RuntimeRouterConfig {
413 path: BundlePath,
415}
416
417impl RuntimeRouterConfig {
418 #[must_use]
420 pub const fn new(path: BundlePath) -> Self {
421 Self { path }
422 }
423
424 #[must_use]
426 pub const fn path(&self) -> &BundlePath {
427 &self.path
428 }
429
430 fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
431 if !self.path.starts_with_directory(ASSETS_DIR) {
432 return Err(DocumentError::RouterOutsideAssets {
433 path: self.path.clone(),
434 });
435 }
436 if !assets.entries.iter().any(|entry| entry.path == self.path) {
437 return Err(DocumentError::RouterMissingAsset {
438 path: self.path.clone(),
439 });
440 }
441 Ok(())
442 }
443}
444
445pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
447 serde_json::from_slice(bytes).map_err(BundleError::from)
448}