1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{accelerator::AcceleratorStatus, capability::CapabilityDescriptor, module::ModuleInfo};
7
8#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
9#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
10#[serde(rename_all = "lowercase")]
11pub enum SpeedMode {
12 Low,
13 #[default]
14 Medium,
15 High,
16}
17
18impl SpeedMode {
19 pub fn cap(self) -> f64 {
20 match self {
21 Self::Low => 0.22,
22 Self::Medium => 0.35,
23 Self::High => 1.0,
24 }
25 }
26}
27
28pub const VISUALIZATION_FRAME_VERSION: &str = "leash-visualization-v1";
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
32pub struct Health {
33 pub ok: bool,
34 pub mode: String,
35 pub replay: bool,
36 pub role: String,
37 pub profile: String,
38 pub uptime_ms: u128,
39 pub estop: bool,
40 pub deadman_ok: bool,
41 pub physical_actuation_enabled: bool,
42 pub accelerator: AcceleratorStatus,
43 pub modules: Vec<ModuleInfo>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
48pub struct Capabilities {
49 pub ok: bool,
50 pub mode: String,
51 pub replay: bool,
52 pub role: String,
53 pub profile: String,
54 pub physical: bool,
55 pub stream_transport: String,
56 pub endpoints: Vec<String>,
57 pub mcp_tools: Vec<String>,
58 pub speed_modes: Vec<SpeedMode>,
59 pub accelerator: AcceleratorStatus,
60 pub modules: Vec<ModuleInfo>,
61 pub capabilities: Vec<CapabilityDescriptor>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
66pub struct TelemetryFrame {
67 pub ts_ms: u128,
68 pub robot: String,
69 pub profile: String,
70 pub battery_v: Option<f64>,
71 pub left_cmd: f64,
72 pub right_cmd: f64,
73 pub odometry_left: Option<f64>,
74 pub odometry_right: Option<f64>,
75 pub session_id: Option<String>,
76 pub deadman_ok: bool,
77 pub estop: bool,
78 pub stopped_by_deadman: bool,
79 pub soft_odometry_limited: bool,
80 pub soft_odometry_limit_m: f64,
81 pub speed_mode: SpeedMode,
82 pub max_speed: f64,
83 pub sensors: SensorSnapshot,
84 pub resource: Option<ResourceSample>,
85 pub source: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
90pub struct ResourceSample {
91 pub sampled_at_ms: u128,
92 pub process_id: u32,
93 pub cpu_time_ticks: Option<u64>,
94 pub memory_rss_bytes: Option<u64>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
99pub struct RunLogEntry {
100 pub timestamp: u128,
101 pub run_id: String,
102 pub module: String,
103 pub event: String,
104 pub level: String,
105 pub fields: BTreeMap<String, Value>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
110pub struct AgentMessage {
111 pub id: u64,
112 pub ts_ms: u128,
113 pub source: String,
114 pub text: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
119pub struct AgentMessageAck {
120 pub ok: bool,
121 pub message: AgentMessage,
122 pub response: Option<AgentModelResponse>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
127pub struct AgentMessageList {
128 pub ok: bool,
129 pub messages: Vec<AgentMessage>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
134pub struct AgentModelResponse {
135 pub ok: bool,
136 pub provider: String,
137 pub model: String,
138 pub prompt: String,
139 pub text: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
144pub struct TelemetryStreamFrame {
145 pub kind: String,
146 pub ts_ms: u128,
147 pub telemetry: TelemetryFrame,
148 pub health: Health,
149 pub command: CommandStreamState,
150 pub safety: SafetyStreamState,
151 #[serde(default)]
152 pub visualization: VisualizationFrame,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
157pub struct CommandStreamState {
158 pub left_cmd: f64,
159 pub right_cmd: f64,
160 pub session_id: Option<String>,
161 pub speed_mode: SpeedMode,
162 pub max_speed: f64,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
167pub struct SafetyStreamState {
168 pub estop: bool,
169 pub deadman_ok: bool,
170 pub stopped_by_deadman: bool,
171 pub soft_odometry_limited: bool,
172 pub soft_odometry_limit_m: f64,
173 pub physical_actuation_enabled: bool,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
178pub struct VisualizationFrame {
179 pub version: String,
180 pub ts_ms: u128,
181 pub robot: String,
182 pub profile: String,
183 pub pose: Pose2d,
184 pub path: VisualizationPath,
185 pub occupancy_grid: OccupancyGridFrame,
186 pub point_cloud: PointCloudMetadata,
187 pub detections: Vec<DetectionFrame>,
188 pub command: CommandOverlay,
189}
190
191impl Default for VisualizationFrame {
192 fn default() -> Self {
193 Self {
194 version: VISUALIZATION_FRAME_VERSION.to_string(),
195 ts_ms: 0,
196 robot: String::new(),
197 profile: String::new(),
198 pose: Pose2d::default(),
199 path: VisualizationPath::default(),
200 occupancy_grid: OccupancyGridFrame::default(),
201 point_cloud: PointCloudMetadata::default(),
202 detections: Vec::new(),
203 command: CommandOverlay::default(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
209#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
210pub struct Pose2d {
211 pub frame_id: String,
212 pub x_m: f64,
213 pub y_m: f64,
214 pub yaw_rad: f64,
215}
216
217#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
218#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
219pub struct VisualizationPath {
220 pub frame_id: String,
221 pub poses: Vec<Pose2d>,
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
225#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
226pub struct OccupancyGridFrame {
227 pub frame_id: String,
228 pub width: u32,
229 pub height: u32,
230 pub resolution_m: f64,
231 pub origin: Pose2d,
232 pub cells: Vec<i8>,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
236#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
237pub struct PointCloudMetadata {
238 pub frame_id: String,
239 pub point_count: u32,
240 pub fields: Vec<String>,
241 pub source: String,
242}
243
244#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
245#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
246pub struct DetectionFrame {
247 pub frame_id: String,
248 pub id: String,
249 pub label: String,
250 pub confidence: f64,
251 pub x_m: f64,
252 pub y_m: f64,
253 pub width_m: f64,
254 pub height_m: f64,
255}
256
257#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
258#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
259pub struct CommandOverlay {
260 pub left_cmd: f64,
261 pub right_cmd: f64,
262 pub speed_mode: SpeedMode,
263 pub max_speed: f64,
264 pub estop: bool,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
269pub struct SensorSnapshot {
270 pub battery: BatteryStatus,
271 pub odometry: OdometryStatus,
272 pub camera: CameraStatus,
273 pub raw_frame: RawFrameStatus,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
278pub struct BatteryStatus {
279 pub status: String,
280 pub voltage_v: Option<f64>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
285pub struct OdometryStatus {
286 pub status: String,
287 pub left_m: Option<f64>,
288 pub right_m: Option<f64>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
293pub struct CameraStatus {
294 pub status: String,
295 pub health: String,
296 pub stream_url: Option<String>,
297 pub snapshot_url: Option<String>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
302pub struct RawFrameStatus {
303 pub status: String,
304 pub source: String,
305 pub last_ms: Option<u128>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
310pub struct CaptureResult {
311 pub ok: bool,
312 pub source: String,
313 pub content_type: String,
314 pub byte_len: usize,
315 pub captured_at_ms: u128,
316 pub sha256: String,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
321pub struct DriveOutcome {
322 pub ok: bool,
323 pub left: f64,
324 pub right: f64,
325 pub speed_mode: SpeedMode,
326 pub max_speed: f64,
327 pub stopped_by_deadman: bool,
328 pub soft_odometry_limited: bool,
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn visualization_frame_round_trips_as_versioned_json() {
337 let frame = VisualizationFrame {
338 version: VISUALIZATION_FRAME_VERSION.to_string(),
339 ts_ms: 42,
340 robot: "robot".to_string(),
341 profile: "sim".to_string(),
342 pose: Pose2d {
343 frame_id: "map".to_string(),
344 x_m: 1.0,
345 y_m: 2.0,
346 yaw_rad: 0.5,
347 },
348 path: VisualizationPath {
349 frame_id: "map".to_string(),
350 poses: vec![Pose2d {
351 frame_id: "map".to_string(),
352 x_m: 1.0,
353 y_m: 2.0,
354 yaw_rad: 0.5,
355 }],
356 },
357 occupancy_grid: OccupancyGridFrame {
358 frame_id: "map".to_string(),
359 width: 2,
360 height: 2,
361 resolution_m: 0.25,
362 origin: Pose2d::default(),
363 cells: vec![0, 0, 50, 100],
364 },
365 point_cloud: PointCloudMetadata {
366 frame_id: "base_link".to_string(),
367 point_count: 0,
368 fields: vec!["x".to_string(), "y".to_string(), "z".to_string()],
369 source: "sim".to_string(),
370 },
371 detections: vec![DetectionFrame {
372 frame_id: "camera".to_string(),
373 id: "det-1".to_string(),
374 label: "fixture".to_string(),
375 confidence: 0.9,
376 x_m: 0.1,
377 y_m: 0.2,
378 width_m: 0.3,
379 height_m: 0.4,
380 }],
381 command: CommandOverlay {
382 left_cmd: 0.1,
383 right_cmd: 0.1,
384 speed_mode: SpeedMode::Low,
385 max_speed: SpeedMode::Low.cap(),
386 estop: false,
387 },
388 };
389
390 let value = serde_json::to_value(&frame).unwrap();
391 assert_eq!(value["version"], VISUALIZATION_FRAME_VERSION);
392 assert_eq!(value["pose"]["frame_id"], "map");
393 assert_eq!(
394 value["occupancy_grid"]["cells"].as_array().unwrap().len(),
395 4
396 );
397
398 let parsed: VisualizationFrame = serde_json::from_value(value).unwrap();
399 assert_eq!(parsed.version, VISUALIZATION_FRAME_VERSION);
400 assert_eq!(parsed.path.poses.len(), 1);
401 assert_eq!(parsed.detections[0].label, "fixture");
402 }
403}