Skip to main content

agent_can/
protocol.rs

1use rmcp::schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8pub const CLI_ABOUT: &str = "Agent-first CAN session frontend";
9pub const ADAPTERS_LIST_SUMMARY: &str = "List supported adapter names for `connect`.";
10pub const CONNECT_SUMMARY: &str = "Start or attach to the one live CAN session. DBCs are validated and fixed for the session lifetime.";
11pub const DISCONNECT_SUMMARY: &str =
12    "Stop periodic sends, finalize trace export, and tear down the current session.";
13pub const STATUS_SUMMARY: &str = "Show the detailed operational status for the live session.";
14pub const SELECTOR_RULES: &str = "Selector rules: `0x...` selects raw arbitration IDs; any other value uses glob matching over `alias.message`.";
15pub const SCHEMA_SUMMARY: &str = "Semantic discovery for the connect-time DBC set. This is what the session can interpret or construct, not what traffic has been observed.";
16pub const MESSAGE_LIST_SUMMARY: &str =
17    "Observed-traffic inventory. Returns compact message entries, not decoded signal values.";
18pub const MESSAGE_READ_SUMMARY: &str = "Detailed inspection for one selector. Raw selectors return raw frames; semantic selectors decode through the selected `alias.message` definition.";
19pub const MESSAGE_SEND_SUMMARY: &str = "Send one message by target shape. Raw `0x...` targets use hex payload strings; named `alias.message` targets use JSON signal maps that are DBC-encoded before transmission.";
20pub const MESSAGE_STOP_SUMMARY: &str =
21    "Stop the periodic schedule for a raw or semantic target identity.";
22pub const TRACE_START_SUMMARY: &str = "Start one raw ASCII trace export for the active session.";
23pub const TRACE_STOP_SUMMARY: &str = "Stop the current raw trace export.";
24
25#[derive(Debug)]
26pub struct PathNormalizationError {
27    message: String,
28}
29
30impl PathNormalizationError {
31    fn new(message: impl Into<String>) -> Self {
32        Self {
33            message: message.into(),
34        }
35    }
36}
37
38impl fmt::Display for PathNormalizationError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(&self.message)
41    }
42}
43
44impl std::error::Error for PathNormalizationError {}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Request {
48    pub id: Uuid,
49    pub action: RequestAction,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
54pub enum RequestAction {
55    AdaptersList,
56    Connect(ConnectRequest),
57    Disconnect,
58    Status,
59    Schema(SchemaRequest),
60    MessageList(MessageListRequest),
61    MessageRead(MessageReadRequest),
62    MessageSend(MessageSendRequest),
63    MessageStop(MessageStopRequest),
64    TraceStart(TraceStartRequest),
65    TraceStop,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
69pub struct ConnectRequest {
70    pub adapter: String,
71    pub bitrate: u32,
72    pub bitrate_data: Option<u32>,
73    pub fd: bool,
74    #[serde(default)]
75    pub dbcs: Vec<DbcSpec>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
79pub struct DbcSpec {
80    pub alias: String,
81    pub path: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
85pub struct SchemaRequest {
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub filter: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
91pub struct MessageListRequest {
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub filter: Option<String>,
94    #[serde(default)]
95    pub allow_raw: bool,
96    #[serde(default)]
97    pub include_tx: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
101pub struct MessageReadRequest {
102    pub select: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub count: Option<usize>,
105    #[serde(default)]
106    pub include_tx: bool,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
110pub struct MessageSendRequest {
111    pub target: String,
112    pub data: MessagePayload,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub periodicity_ms: Option<u64>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
118#[serde(untagged)]
119pub enum MessagePayload {
120    RawHex(String),
121    Signals(BTreeMap<String, f64>),
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
125pub struct MessageStopRequest {
126    pub target: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
130pub struct TraceStartRequest {
131    pub path: String,
132}
133
134pub fn normalize_connect_request_paths(
135    request: ConnectRequest,
136) -> Result<ConnectRequest, PathNormalizationError> {
137    let mut dbcs = request
138        .dbcs
139        .into_iter()
140        .map(|dbc| {
141            let raw_path = dbc.path;
142            let path = normalize_existing_path(&raw_path, "DBC")?;
143            Ok(DbcSpec {
144                alias: dbc.alias,
145                path,
146            })
147        })
148        .collect::<Result<Vec<_>, _>>()?;
149    dbcs.sort();
150    Ok(ConnectRequest { dbcs, ..request })
151}
152
153pub fn normalize_trace_start_request_path(
154    request: TraceStartRequest,
155) -> Result<TraceStartRequest, PathNormalizationError> {
156    Ok(TraceStartRequest {
157        path: normalize_output_path(&request.path, "trace")?,
158    })
159}
160
161fn normalize_existing_path(raw_path: &str, label: &str) -> Result<String, PathNormalizationError> {
162    let candidate = Path::new(raw_path);
163    if !candidate.is_absolute() {
164        return Err(PathNormalizationError::new(format!(
165            "{label} path '{raw_path}' must be absolute"
166        )));
167    }
168    let canonical = std::fs::canonicalize(candidate).map_err(|err| {
169        PathNormalizationError::new(format!(
170            "failed to resolve {label} path '{raw_path}' to an absolute path (candidate '{}'): {err}",
171            candidate.display()
172        ))
173    })?;
174    Ok(canonical.to_string_lossy().into_owned())
175}
176
177fn normalize_output_path(raw_path: &str, label: &str) -> Result<String, PathNormalizationError> {
178    let candidate = Path::new(raw_path);
179    if !candidate.is_absolute() {
180        return Err(PathNormalizationError::new(format!(
181            "{label} path '{raw_path}' must be absolute"
182        )));
183    }
184    let (existing_ancestor, suffix) = split_existing_ancestor(candidate)?;
185    let mut normalized = std::fs::canonicalize(&existing_ancestor).map_err(|err| {
186        PathNormalizationError::new(format!(
187            "failed to resolve {label} path '{raw_path}' via existing ancestor '{}': {err}",
188            existing_ancestor.display()
189        ))
190    })?;
191    for component in suffix {
192        normalized.push(component);
193    }
194    Ok(normalized.to_string_lossy().into_owned())
195}
196
197fn split_existing_ancestor(
198    path: &Path,
199) -> Result<(PathBuf, Vec<std::ffi::OsString>), PathNormalizationError> {
200    let mut current = path.to_path_buf();
201    let mut suffix = Vec::new();
202    while !current.exists() {
203        let Some(name) = current.file_name() else {
204            return Err(PathNormalizationError::new(format!(
205                "failed to resolve path '{}': no existing ancestor found",
206                path.display()
207            )));
208        };
209        suffix.push(name.to_os_string());
210        let Some(parent) = current.parent() else {
211            return Err(PathNormalizationError::new(format!(
212                "failed to resolve path '{}': no existing ancestor found",
213                path.display()
214            )));
215        };
216        current = parent.to_path_buf();
217    }
218    suffix.reverse();
219    Ok((current, suffix))
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Response {
224    pub id: Uuid,
225    pub success: bool,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub data: Option<ResponseData>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub error: Option<String>,
230}
231
232impl Response {
233    pub fn ok(id: Uuid, data: ResponseData) -> Self {
234        Self {
235            id,
236            success: true,
237            data: Some(data),
238            error: None,
239        }
240    }
241
242    pub fn err(id: Uuid, message: impl Into<String>) -> Self {
243        Self {
244            id,
245            success: false,
246            data: None,
247            error: Some(message.into()),
248        }
249    }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
254pub enum ResponseData {
255    AdaptersList { adapters: Vec<String> },
256    Connected(ConnectResult),
257    Disconnected,
258    Status(SessionStatus),
259    Schema { messages: Vec<SchemaMessage> },
260    MessageList { messages: Vec<MessageListEntry> },
261    MessageRead(MessageReadResult),
262    MessageSent(MessageSendResult),
263    MessageStopped { target: String, stopped: bool },
264    TraceStarted { path: String },
265    TraceStopped { path: Option<String> },
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
269pub struct ConnectResult {
270    pub created: bool,
271    pub already_connected: bool,
272    pub status: SessionStatus,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
276pub struct SessionStatus {
277    pub connection_state: String,
278    pub adapter: String,
279    pub bitrate: u32,
280    pub bitrate_data: Option<u32>,
281    pub fd: bool,
282    pub dbcs: Vec<LoadedDbc>,
283    pub trace_path: Option<String>,
284    pub periodic_schedules: Vec<PeriodicSchedule>,
285    pub backend_error: Option<String>,
286    pub retention_window_secs: u64,
287    pub retention_event_cap: usize,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
291pub struct LoadedDbc {
292    pub alias: String,
293    pub path: String,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297pub struct PeriodicSchedule {
298    pub target: String,
299    pub arb_id: u32,
300    pub extended: bool,
301    pub fd: bool,
302    pub len: u8,
303    pub periodicity_ms: u64,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
307pub struct SchemaMessage {
308    pub qualified_name: String,
309    pub alias: String,
310    pub message: String,
311    pub arb_id: u32,
312    pub extended: bool,
313    pub fd: bool,
314    pub len: u8,
315    pub signals: Vec<SchemaSignal>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
319pub struct SchemaSignal {
320    pub name: String,
321    pub value_type: String,
322    pub unit: Option<String>,
323    pub min: Option<f64>,
324    pub max: Option<f64>,
325    pub factor: f64,
326    pub offset: f64,
327    pub start_bit: u64,
328    pub bit_len: u64,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
332pub struct MessageListEntry {
333    pub label: String,
334    pub kind: MessageEntryKind,
335    pub arb_id: u32,
336    pub extended: bool,
337    pub fd: bool,
338    pub len: u8,
339    pub last_seen_unix_ms: u128,
340    pub has_rx: bool,
341    pub has_tx: bool,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
345#[serde(rename_all = "snake_case")]
346pub enum MessageEntryKind {
347    Raw,
348    Semantic,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
352pub struct MessageReadResult {
353    pub selector: String,
354    pub count: usize,
355    pub observations: Vec<MessageObservation>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
359#[serde(tag = "kind", rename_all = "snake_case")]
360pub enum MessageObservation {
361    Raw {
362        seq: u64,
363        direction: EventDirection,
364        unix_ms: u128,
365        arb_id: u32,
366        extended: bool,
367        fd: bool,
368        len: u8,
369        payload_hex: String,
370    },
371    Semantic {
372        seq: u64,
373        direction: EventDirection,
374        unix_ms: u128,
375        qualified_name: String,
376        arb_id: u32,
377        extended: bool,
378        fd: bool,
379        len: u8,
380        payload_hex: String,
381        signals: Vec<DecodedSignalValue>,
382    },
383}
384
385#[cfg(test)]
386mod path_tests {
387    use super::{
388        ConnectRequest, DbcSpec, TraceStartRequest, normalize_connect_request_paths,
389        normalize_trace_start_request_path,
390    };
391    use std::fs;
392
393    #[cfg(unix)]
394    #[test]
395    fn normalize_connect_request_paths_requires_absolute_inputs() {
396        let temp = tempfile::tempdir().expect("tempdir");
397        let dbc_path = temp.path().join("bus.dbc");
398        fs::write(&dbc_path, "VERSION \"\"\n").expect("write dbc");
399
400        let request = ConnectRequest {
401            adapter: "pcan".to_string(),
402            bitrate: 500_000,
403            bitrate_data: None,
404            fd: false,
405            dbcs: vec![DbcSpec {
406                alias: "main".to_string(),
407                path: "bus.dbc".to_string(),
408            }],
409        };
410
411        let err =
412            normalize_connect_request_paths(request).expect_err("relative DBC path must fail");
413        assert!(err.to_string().contains("must be absolute"));
414    }
415
416    #[cfg(unix)]
417    #[test]
418    fn normalize_connect_request_paths_canonicalizes_absolute_dbc_inputs() {
419        let temp = tempfile::tempdir().expect("tempdir");
420        let dbc_dir = temp.path().join("linked");
421        fs::create_dir_all(temp.path().join("real")).expect("real dir");
422        std::os::unix::fs::symlink(temp.path().join("real"), &dbc_dir).expect("symlink dir");
423        let dbc_path = temp.path().join("real").join("bus.dbc");
424        fs::write(&dbc_path, "VERSION \"\"\n").expect("write dbc");
425        let expected_dbc_path = std::fs::canonicalize(&dbc_path)
426            .expect("canonical dbc path")
427            .display()
428            .to_string();
429
430        let request = ConnectRequest {
431            adapter: "pcan".to_string(),
432            bitrate: 500_000,
433            bitrate_data: None,
434            fd: false,
435            dbcs: vec![DbcSpec {
436                alias: "main".to_string(),
437                path: temp
438                    .path()
439                    .join("linked")
440                    .join("..")
441                    .join("linked")
442                    .join("bus.dbc")
443                    .display()
444                    .to_string(),
445            }],
446        };
447
448        let normalized =
449            normalize_connect_request_paths(request).expect("normalize connect request");
450        assert_eq!(normalized.dbcs[0].path, expected_dbc_path);
451    }
452
453    #[cfg(unix)]
454    #[test]
455    fn normalize_trace_start_request_path_canonicalizes_existing_ancestor() {
456        let temp = tempfile::tempdir().expect("tempdir");
457        let real_dir = temp.path().join("real");
458        fs::create_dir_all(&real_dir).expect("real dir");
459        let link_dir = temp.path().join("link");
460        std::os::unix::fs::symlink(&real_dir, &link_dir).expect("symlink dir");
461        let expected_parent = std::fs::canonicalize(&real_dir)
462            .expect("canonical real dir")
463            .join("captures")
464            .join("run.asc")
465            .display()
466            .to_string();
467
468        let request = TraceStartRequest {
469            path: link_dir
470                .join("captures")
471                .join("run.asc")
472                .display()
473                .to_string(),
474        };
475
476        let normalized =
477            normalize_trace_start_request_path(request).expect("normalize trace request");
478        assert_eq!(normalized.path, expected_parent);
479    }
480
481    #[cfg(unix)]
482    #[test]
483    fn normalize_trace_start_request_path_requires_absolute_inputs() {
484        let request = TraceStartRequest {
485            path: "captures/run.asc".to_string(),
486        };
487
488        let err =
489            normalize_trace_start_request_path(request).expect_err("relative trace path must fail");
490        assert!(err.to_string().contains("must be absolute"));
491    }
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
495pub struct DecodedSignalValue {
496    pub name: String,
497    pub value: f64,
498    pub unit: Option<String>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
502#[serde(rename_all = "snake_case")]
503pub enum EventDirection {
504    Rx,
505    Tx,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
509pub struct MessageSendResult {
510    pub target: String,
511    pub arb_id: u32,
512    pub extended: bool,
513    pub fd: bool,
514    pub len: u8,
515    pub periodicity_ms: Option<u64>,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum Selector {
520    ArbId(u32),
521    SemanticPattern(String),
522}
523
524impl Selector {
525    pub fn parse(raw: &str) -> Result<Self, String> {
526        let trimmed = raw.trim();
527        if trimmed.is_empty() {
528            return Err("selector must not be empty".to_string());
529        }
530        if let Some(hex) = trimmed
531            .strip_prefix("0x")
532            .or_else(|| trimmed.strip_prefix("0X"))
533        {
534            let arb_id = u32::from_str_radix(hex, 16)
535                .map_err(|_| format!("invalid raw arbitration selector '{raw}'"))?;
536            return Ok(Self::ArbId(arb_id));
537        }
538        Ok(Self::SemanticPattern(trimmed.to_string()))
539    }
540
541    pub fn matches_qualified_name(&self, qualified_name: &str) -> bool {
542        match self {
543            Self::ArbId(_) => false,
544            Self::SemanticPattern(pattern) => glob_match(pattern, qualified_name),
545        }
546    }
547
548    pub fn matches_arb_id(&self, arb_id: u32) -> bool {
549        matches!(self, Self::ArbId(candidate) if *candidate == arb_id)
550    }
551}
552
553fn glob_match(pattern: &str, value: &str) -> bool {
554    let pattern_chars = pattern.chars().collect::<Vec<_>>();
555    let value_chars = value.chars().collect::<Vec<_>>();
556    let mut dp = vec![vec![false; value_chars.len() + 1]; pattern_chars.len() + 1];
557    dp[0][0] = true;
558    for idx in 0..pattern_chars.len() {
559        if pattern_chars[idx] == '*' {
560            dp[idx + 1][0] = dp[idx][0];
561        }
562    }
563    for p_idx in 0..pattern_chars.len() {
564        for v_idx in 0..value_chars.len() {
565            dp[p_idx + 1][v_idx + 1] = match pattern_chars[p_idx] {
566                '*' => dp[p_idx][v_idx + 1] || dp[p_idx + 1][v_idx],
567                '?' => dp[p_idx][v_idx],
568                literal => dp[p_idx][v_idx] && literal == value_chars[v_idx],
569            };
570        }
571    }
572    dp[pattern_chars.len()][value_chars.len()]
573}
574
575pub fn payload_to_hex(data: &[u8]) -> String {
576    data.iter()
577        .map(|value| format!("{value:02X}"))
578        .collect::<Vec<_>>()
579        .join("")
580}
581
582#[cfg(test)]
583mod tests {
584    use super::{MessagePayload, RequestAction, Selector, glob_match};
585    use std::collections::BTreeMap;
586
587    #[test]
588    fn selector_parses_raw_and_semantic_values() {
589        assert_eq!(
590            Selector::parse("0x123").expect("raw"),
591            Selector::ArbId(0x123)
592        );
593        assert_eq!(
594            Selector::parse("powertrain.*").expect("semantic"),
595            Selector::SemanticPattern("powertrain.*".to_string())
596        );
597    }
598
599    #[test]
600    fn glob_matching_uses_simple_wildcards() {
601        assert!(glob_match("foo.*", "foo.bar"));
602        assert!(glob_match("foo.?ar", "foo.bar"));
603        assert!(!glob_match("foo.?az", "foo.bar"));
604    }
605
606    #[test]
607    fn request_action_round_trip_serializes() {
608        let request = RequestAction::TraceStop;
609        let encoded = serde_json::to_string(&request).expect("serialize");
610        let decoded = serde_json::from_str::<RequestAction>(&encoded).expect("deserialize");
611        assert!(matches!(decoded, RequestAction::TraceStop));
612    }
613
614    #[test]
615    fn message_payload_round_trip_supports_raw_strings_and_signal_maps() {
616        let raw = serde_json::from_str::<MessagePayload>("\"DEADBEEF\"").expect("raw payload");
617        assert_eq!(raw, MessagePayload::RawHex("DEADBEEF".to_string()));
618
619        let semantic = serde_json::from_str::<MessagePayload>(r#"{"enable":1,"torque":12.5}"#)
620            .expect("signal map");
621        assert_eq!(
622            semantic,
623            MessagePayload::Signals(BTreeMap::from([
624                ("enable".to_string(), 1.0),
625                ("torque".to_string(), 12.5),
626            ]))
627        );
628    }
629}