1const MAX_REQUEST_ID_LEN: usize = 128;
2const MAX_PATH_POSES: usize = 4096;
3
4fn finite(value: f64) -> bool {
5 value.is_finite()
6}
7
8fn finite_f32(value: f32) -> bool {
9 value.is_finite()
10}
11
12fn canonical_yaw(value: f64) -> bool {
13 value.is_finite() && (-std::f64::consts::PI..=std::f64::consts::PI).contains(&value)
14}
15
16fn optional_canonical_yaw(value: Option<f64>) -> bool {
17 value.is_none_or(canonical_yaw)
18}
19
20fn valid_request_id(value: &str) -> bool {
21 let trimmed = value.trim();
22 !trimmed.is_empty()
23 && trimmed.len() <= MAX_REQUEST_ID_LEN
24 && trimmed
25 .bytes()
26 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
27}
28#[derive(Copy, Eq, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum FailureReason {
31 LocalizationUnavailable,
32 MapUnavailable,
33 MapChanged,
34 NoPath,
35 Blocked,
36 Internal,
37}
38
39#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
40pub enum Outcome {
41 Succeeded,
42 Failed(FailureReason),
43 Refused(RefusalReason),
44 Cancelled,
45 TimedOut,
46}
47
48#[derive(Eq, PartialOrd, Ord, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
52#[serde(try_from = "RequestIdWire")]
53pub struct RequestId {
54 value: String,
55}
56
57impl RequestId {
58 pub fn try_new(value: impl Into<String>) -> std::result::Result<Self, RequestIdError> {
59 let value = value.into();
60 valid_request_id(&value)
61 .then_some(Self { value })
62 .ok_or(RequestIdError::Invalid)
63 }
64
65 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.value
68 }
69
70 #[must_use]
71 pub fn is_valid(&self) -> bool {
72 valid_request_id(&self.value)
73 }
74}
75
76#[derive(Clone, Debug, serde::Deserialize)]
77#[serde(deny_unknown_fields)]
78struct RequestIdWire {
79 value: String,
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum RequestIdError {
84 Invalid,
85}
86
87impl std::fmt::Display for RequestIdError {
88 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 formatter.write_str("request id must be a non-empty bounded ASCII token")
90 }
91}
92impl std::error::Error for RequestIdError {}
93
94impl TryFrom<RequestIdWire> for RequestId {
95 type Error = RequestIdError;
96 fn try_from(value: RequestIdWire) -> std::result::Result<Self, Self::Error> {
97 Self::try_new(value.value)
98 }
99}
100
101#[derive(Copy, Eq, Hash, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
104#[serde(try_from = "NavigationOperationIdWire")]
105pub struct NavigationOperationId {
106 producer: ::phoxal_bus::ProducerId,
107 sequence: u64,
108}
109
110impl NavigationOperationId {
111 pub fn new(producer: ::phoxal_bus::ProducerId, sequence: u64) -> Option<Self> {
112 (sequence != 0).then_some(Self { producer, sequence })
113 }
114 #[must_use]
115 pub const fn producer(&self) -> ::phoxal_bus::ProducerId {
116 self.producer
117 }
118 #[must_use]
119 pub const fn sequence(&self) -> u64 {
120 self.sequence
121 }
122}
123
124#[derive(Clone, Debug, serde::Deserialize)]
125#[serde(deny_unknown_fields)]
126struct NavigationOperationIdWire {
127 producer: ::phoxal_bus::ProducerId,
128 sequence: u64,
129}
130
131impl TryFrom<NavigationOperationIdWire> for NavigationOperationId {
132 type Error = &'static str;
133 fn try_from(value: NavigationOperationIdWire) -> std::result::Result<Self, Self::Error> {
134 Self::new(value.producer, value.sequence)
135 .ok_or("navigation operation sequence must be nonzero")
136 }
137}
138
139#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
140#[serde(try_from = "PoseWire")]
141pub struct Pose {
142 pub x_m: f64,
143 pub y_m: f64,
144 pub yaw_rad: Option<f64>,
145}
146
147impl Pose {
148 pub fn try_new(
149 x_m: f64,
150 y_m: f64,
151 yaw_rad: Option<f64>,
152 ) -> std::result::Result<Self, NavigationError> {
153 (finite(x_m) && finite(y_m) && optional_canonical_yaw(yaw_rad))
154 .then_some(Self { x_m, y_m, yaw_rad })
155 .ok_or(NavigationError::InvalidPose)
156 }
157}
158
159#[derive(Clone, Debug, serde::Deserialize)]
160#[serde(deny_unknown_fields)]
161struct PoseWire {
162 x_m: f64,
163 y_m: f64,
164 yaw_rad: Option<f64>,
165}
166impl TryFrom<PoseWire> for Pose {
167 type Error = NavigationError;
168 fn try_from(value: PoseWire) -> std::result::Result<Self, Self::Error> {
169 Self::try_new(value.x_m, value.y_m, value.yaw_rad)
170 }
171}
172
173#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
174#[serde(try_from = "PathWire")]
175pub struct Path {
176 pub poses: Vec<Pose>,
177 pub map_revision: Option<u64>,
178}
179
180impl Path {
181 pub fn try_new(
182 poses: Vec<Pose>,
183 map_revision: Option<u64>,
184 ) -> std::result::Result<Self, NavigationError> {
185 (!poses.is_empty() && poses.len() <= MAX_PATH_POSES)
186 .then_some(Self {
187 poses,
188 map_revision,
189 })
190 .ok_or(NavigationError::PathBoundExceeded)
191 }
192}
193
194#[derive(Clone, Debug, serde::Deserialize)]
195#[serde(deny_unknown_fields)]
196struct PathWire {
197 poses: Vec<Pose>,
198 map_revision: Option<u64>,
199}
200impl TryFrom<PathWire> for Path {
201 type Error = NavigationError;
202 fn try_from(value: PathWire) -> std::result::Result<Self, Self::Error> {
203 Self::try_new(value.poses, value.map_revision)
204 }
205}
206
207#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
208pub enum StartKind {
209 GotoPose(Pose),
210 FollowPath(Path),
211}
212
213#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
214pub struct StartRequest {
215 pub request_id: RequestId,
216 pub kind: StartKind,
217}
218
219#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
220pub enum StartResponse {
221 Accepted { operation_id: NavigationOperationId },
222 Refused(RefusalReason),
223}
224
225#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
226pub struct CancelRequest {
227 pub operation_id: NavigationOperationId,
228}
229
230#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
231pub enum CancelResponse {
232 Accepted,
233 Refused(RefusalReason),
234}
235
236#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum RefusalReason {
239 Busy,
240 InvalidRequest,
241 Unsupported,
242 Unavailable,
243 NotOwner,
244 NotFound,
245}
246
247#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
248pub enum State {
249 Idle,
250 Accepted(NavigationOperationId),
251 Running(NavigationOperationId),
252}
253
254#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
255#[serde(try_from = "ProgressWire")]
256pub struct Progress {
257 pub operation_id: NavigationOperationId,
258 pub request_id: RequestId,
259 pub distance_remaining_m: f64,
260 pub path_index: u32,
261}
262
263#[derive(Clone, Debug, serde::Deserialize)]
264#[serde(deny_unknown_fields)]
265struct ProgressWire {
266 operation_id: NavigationOperationId,
267 request_id: RequestId,
268 distance_remaining_m: f64,
269 path_index: u32,
270}
271impl TryFrom<ProgressWire> for Progress {
272 type Error = NavigationError;
273 fn try_from(value: ProgressWire) -> std::result::Result<Self, Self::Error> {
274 (finite(value.distance_remaining_m)
275 && usize::try_from(value.path_index).is_ok_and(|n| n < MAX_PATH_POSES))
276 .then_some(Self {
277 operation_id: value.operation_id,
278 request_id: value.request_id,
279 distance_remaining_m: value.distance_remaining_m,
280 path_index: value.path_index,
281 })
282 .ok_or(NavigationError::InvalidProgress)
283 }
284}
285
286impl Progress {
287 pub fn try_new(
288 operation_id: NavigationOperationId,
289 request_id: RequestId,
290 distance_remaining_m: f64,
291 path_index: u32,
292 ) -> std::result::Result<Self, NavigationError> {
293 (finite(distance_remaining_m)
294 && usize::try_from(path_index).is_ok_and(|n| n < MAX_PATH_POSES))
295 .then_some(Self {
296 operation_id,
297 request_id,
298 distance_remaining_m,
299 path_index,
300 })
301 .ok_or(NavigationError::InvalidProgress)
302 }
303}
304
305#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
306#[serde(try_from = "ResultWire")]
307pub struct Result {
308 pub operation_id: NavigationOperationId,
309 pub request_id: RequestId,
310 pub outcome: Outcome,
311}
312
313#[derive(Clone, Debug, serde::Deserialize)]
314#[serde(deny_unknown_fields)]
315struct ResultWire {
316 operation_id: NavigationOperationId,
317 request_id: RequestId,
318 outcome: Outcome,
319}
320impl TryFrom<ResultWire> for Result {
321 type Error = NavigationError;
322 fn try_from(value: ResultWire) -> std::result::Result<Self, Self::Error> {
323 Ok(Self {
324 operation_id: value.operation_id,
325 request_id: value.request_id,
326 outcome: value.outcome,
327 })
328 }
329}
330
331#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
332#[serde(try_from = "CandidateWire")]
333pub struct Candidate {
334 pub operation_id: NavigationOperationId,
335 pub linear_x_mps: f32,
336 pub angular_z_radps: f32,
337}
338
339#[derive(Clone, Debug, serde::Deserialize)]
340#[serde(deny_unknown_fields)]
341struct CandidateWire {
342 operation_id: NavigationOperationId,
343 linear_x_mps: f32,
344 angular_z_radps: f32,
345}
346impl TryFrom<CandidateWire> for Candidate {
347 type Error = NavigationError;
348 fn try_from(value: CandidateWire) -> std::result::Result<Self, Self::Error> {
349 (finite_f32(value.linear_x_mps) && finite_f32(value.angular_z_radps))
350 .then_some(Self {
351 operation_id: value.operation_id,
352 linear_x_mps: value.linear_x_mps,
353 angular_z_radps: value.angular_z_radps,
354 })
355 .ok_or(NavigationError::InvalidCandidate)
356 }
357}
358
359impl Candidate {
360 pub fn try_new(
361 operation_id: NavigationOperationId,
362 linear_x_mps: f32,
363 angular_z_radps: f32,
364 ) -> std::result::Result<Self, NavigationError> {
365 (finite_f32(linear_x_mps) && finite_f32(angular_z_radps))
366 .then_some(Self {
367 operation_id,
368 linear_x_mps,
369 angular_z_radps,
370 })
371 .ok_or(NavigationError::InvalidCandidate)
372 }
373}
374
375#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
376pub struct FrontierRequest {
377 pub map_revision: Option<u64>,
378}
379
380#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
381#[serde(try_from = "FrontierWire")]
382pub struct Frontier {
383 pub x_m: f64,
384 pub y_m: f64,
385 pub score: f32,
386 pub size: u32,
387}
388
389#[derive(Clone, Debug, serde::Deserialize)]
390#[serde(deny_unknown_fields)]
391struct FrontierWire {
392 x_m: f64,
393 y_m: f64,
394 score: f32,
395 size: u32,
396}
397impl TryFrom<FrontierWire> for Frontier {
398 type Error = NavigationError;
399 fn try_from(value: FrontierWire) -> std::result::Result<Self, Self::Error> {
400 (finite(value.x_m)
401 && finite(value.y_m)
402 && finite_f32(value.score)
403 && (0.0..=1.0).contains(&value.score)
404 && value.size != 0)
405 .then_some(Self {
406 x_m: value.x_m,
407 y_m: value.y_m,
408 score: value.score,
409 size: value.size,
410 })
411 .ok_or(NavigationError::InvalidFrontier)
412 }
413}
414
415impl Frontier {
416 pub fn try_new(
417 x_m: f64,
418 y_m: f64,
419 score: f32,
420 size: u32,
421 ) -> std::result::Result<Self, NavigationError> {
422 (finite(x_m)
423 && finite(y_m)
424 && finite_f32(score)
425 && (0.0..=1.0).contains(&score)
426 && size != 0)
427 .then_some(Self {
428 x_m,
429 y_m,
430 score,
431 size,
432 })
433 .ok_or(NavigationError::InvalidFrontier)
434 }
435}
436
437#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
438pub struct FrontierResponse {
439 pub frontier: Option<Frontier>,
440 pub map_revision: Option<u64>,
441}
442
443#[derive(Clone, Copy, Debug, PartialEq, Eq)]
444pub enum NavigationError {
445 InvalidPose,
446 PathBoundExceeded,
447 InvalidProgress,
448 InvalidCandidate,
449 InvalidFrontier,
450}
451impl std::fmt::Display for NavigationError {
452 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453 formatter.write_str(match self {
454 Self::InvalidPose => "navigation pose must contain finite values and canonical yaw",
455 Self::PathBoundExceeded => "navigation path must contain between one and 4096 poses",
456 Self::InvalidProgress => "navigation progress must be finite and bounded",
457 Self::InvalidCandidate => "navigation candidate speeds must be finite",
458 Self::InvalidFrontier => "navigation frontier must be finite, bounded, and non-empty",
459 })
460 }
461}
462impl std::error::Error for NavigationError {}
463
464phoxal_macros::phoxal_api_fragment! {
465 path robot / navigation;
466
467 topic state: State<State>;
468 topic progress: State<Progress>;
469 topic result: Event<Result>;
470 topic candidate: State<Candidate>;
471 query start: StartRequest => StartResponse;
472 query cancel: CancelRequest => CancelResponse;
473 query next_frontier: FrontierRequest => FrontierResponse;
474}