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 if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
238 return Err(DocumentError::TooManyParticipants {
239 count: self.participants.len(),
240 });
241 }
242 let mut ids = BTreeSet::new();
243 let mut artifact_paths = BTreeSet::new();
244 let mut validators = BTreeMap::new();
245 for (id, artifact) in &self.artifacts {
246 artifact.validate(id)?;
247 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
248 {
249 if id.as_str() != "brain" {
250 return Err(DocumentError::BrainArtifactId { actual: id.clone() });
251 }
252 if artifact.path().as_str() != "bin/brain" {
253 return Err(DocumentError::BrainArtifactPath {
254 actual: artifact.path().clone(),
255 });
256 }
257 }
258 if !artifact_paths.insert(artifact.path.clone()) {
259 return Err(DocumentError::DuplicateBinary {
260 path: artifact.path.clone(),
261 });
262 }
263 let validator =
264 jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
265 DocumentError::InvalidConfigSchema {
266 artifact: id.clone(),
267 error: error.to_string(),
268 }
269 })?;
270 validate_requirement(artifact.contract(), id, &self.robot)?;
271 validators.insert(id, validator);
272 }
273 let mut referenced_artifacts = BTreeSet::new();
274 let mut brain = None;
275 let mut simulator_count = 0_u8;
276 let mut robot_api = None;
277 for participant in &self.participants {
278 let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
279 DocumentError::UnknownArtifact {
280 participant: participant.id.clone(),
281 artifact: participant.artifact.clone(),
282 }
283 })?;
284 let validator = validators.get(&participant.artifact).ok_or_else(|| {
285 DocumentError::UnknownArtifact {
286 participant: participant.id.clone(),
287 artifact: participant.artifact.clone(),
288 }
289 })?;
290 participant.validate(&self.robot, artifact, validator)?;
291 let api = artifact.contract().api;
292 if let Some(expected) = robot_api
293 && expected != api
294 {
295 return Err(DocumentError::MixedRobotApi {
296 artifact: participant.artifact.clone(),
297 expected,
298 actual: api,
299 });
300 }
301 robot_api = Some(api);
302 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
303 {
304 if participant.id.as_str() != "brain" {
305 return Err(DocumentError::BrainIdMismatch {
306 actual: participant.id.clone(),
307 });
308 }
309 if brain.replace(participant.id.clone()).is_some() {
310 return Err(DocumentError::DuplicateBrain);
311 }
312 }
313 if artifact.contract().kind
314 == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
315 {
316 simulator_count = simulator_count.saturating_add(1);
317 }
318 referenced_artifacts.insert(participant.artifact.clone());
319 if !ids.insert(participant.id.clone()) {
320 return Err(DocumentError::DuplicateParticipant {
321 id: participant.id.clone(),
322 });
323 }
324 }
325 if brain.is_none() {
326 return Err(DocumentError::MissingBrain);
327 }
328 if self.robot.clock() == phoxal_model::Clock::Simulated {
329 match simulator_count {
330 0 => return Err(DocumentError::MissingSimulator),
331 1 => {}
332 _ => return Err(DocumentError::DuplicateSimulator),
333 }
334 }
335 if let Some(artifact) = self
336 .artifacts
337 .keys()
338 .find(|id| !referenced_artifacts.contains(*id))
339 {
340 return Err(DocumentError::UnusedArtifact {
341 artifact: artifact.clone(),
342 });
343 }
344 self.assets.validate()?;
345 if let Some(router) = &self.router {
346 router.validate(&self.assets)?;
347 }
348 Ok(())
349 }
350}
351
352pub(crate) fn validate_requirement(
355 contract: &ParticipantContract,
356 artifact: &ParticipantArtifactId,
357 robot: &Robot,
358) -> Result<(), DocumentError> {
359 let Some(requirement) = contract.requirement else {
360 return Ok(());
361 };
362 match requirement {
363 ParticipantRequirement::DifferentialDriveVelocity => {
364 let phoxal_model::robot::KinematicConfig::Differential {
365 left_actuators,
366 right_actuators,
367 ..
368 } = robot.motion().kinematic()
369 else {
370 return Err(DocumentError::RequirementKinematicsMismatch {
371 artifact: artifact.clone(),
372 requirement,
373 actual: robot.motion().kinematic().kind(),
374 });
375 };
376 validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
377 validate_drive_side(artifact, "right_actuators", right_actuators, robot)
378 }
379 }
380}
381
382fn validate_drive_side(
383 artifact: &ParticipantArtifactId,
384 side: &'static str,
385 actuators: &[CapabilityRef],
386 robot: &Robot,
387) -> Result<(), DocumentError> {
388 if actuators.is_empty() {
389 return Err(DocumentError::RequirementActuatorListEmpty {
390 artifact: artifact.clone(),
391 side,
392 });
393 }
394 for reference in actuators {
395 let (motor, _) = robot.require_motor(reference).map_err(|error| {
396 DocumentError::RequirementActuatorInvalid {
397 artifact: artifact.clone(),
398 actuator: reference.clone(),
399 error: error.to_string(),
400 }
401 })?;
402 if motor.command != MotorCommand::Velocity {
403 return Err(DocumentError::RequirementMotorModeMismatch {
404 artifact: artifact.clone(),
405 actuator: reference.clone(),
406 expected: MotorCommand::Velocity,
407 actual: motor.command,
408 });
409 }
410 }
411 Ok(())
412}
413
414#[derive(Clone, Debug, Deserialize, Serialize)]
416#[serde(deny_unknown_fields)]
417pub struct RuntimeRouterConfig {
418 path: BundlePath,
420}
421
422impl RuntimeRouterConfig {
423 #[must_use]
425 pub const fn new(path: BundlePath) -> Self {
426 Self { path }
427 }
428
429 #[must_use]
431 pub const fn path(&self) -> &BundlePath {
432 &self.path
433 }
434
435 fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
436 if !self.path.starts_with_directory(ASSETS_DIR) {
437 return Err(DocumentError::RouterOutsideAssets {
438 path: self.path.clone(),
439 });
440 }
441 if !assets.entries.iter().any(|entry| entry.path == self.path) {
442 return Err(DocumentError::RouterMissingAsset {
443 path: self.path.clone(),
444 });
445 }
446 Ok(())
447 }
448}
449
450pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
452 serde_json::from_slice(bytes).map_err(BundleError::from)
453}