Skip to main content

netget/server/can/
actions.rs

1//! CAN bus actions and events — what the model sees, and what it can do about it.
2//!
3//! # Structured fields, and one honest hex field
4//!
5//! Everything about a frame that has meaning as a *value* is a named field: `id`, `extended`,
6//! `rtr`, `fd`, `brs`, `dlc`. None of it is a bit position the model has to compute.
7//!
8//! The payload is different, and it is the one place in this protocol where a hex string is the
9//! right answer rather than a shortcut. **CAN data is genuinely opaque.** Eight octets mean
10//! whatever a DBC file says they mean — engine speed as a 16-bit big-endian value scaled by 0.25
11//! starting at bit 24, say — and NetGet does not have the DBC file. There is no structure to
12//! expose because the structure is not in the protocol; it is in a database owned by whoever
13//! built the vehicle. So `data` is hex, `encoding` says so, and `"hex"` is the **default**, which
14//! is unusual in this codebase and deliberate here: there is no text interpretation to fall back
15//! to. `"text"` is offered because ASCII payloads do occur — a VIN in a UDS response, some
16//! telematics gateways — and writing `"484921"` for `"HI!"` helps nobody.
17//!
18//! [`crate::server::can::frame::CanFrame::from_action`] **really decodes what `encoding` says**
19//! and never sniffs. `"48656c6c6f"` is simultaneously valid text and valid hex and only the
20//! sender knows which it meant; the root `CLAUDE.md` records `send_tcp_data` documenting hex in
21//! three places while its executor called `as_bytes()`, which put literal ASCII on the wire.
22//!
23//! # There is no action that emits an error frame, and that is a safety property
24//!
25//! A CAN error frame is not a message. It is six dominant bits transmitted *on top of* a frame
26//! in flight, which destroys that frame and forces every node to discard it; the transmitter
27//! then increments its error counter, and enough of them drive it error-passive and then bus-off.
28//! Emitting one to signal "NetGet's backend is down" would corrupt somebody else's traffic and
29//! could take a node off the bus. So the vocabulary here cannot express one, and an LLM failure
30//! transmits nothing at all. See this protocol's `CLAUDE.md`.
31
32use crate::llm::actions::{
33    protocol_trait::{ActionResult, Protocol, Server},
34    ActionDefinition, Parameter, ParameterDefinition,
35};
36use crate::protocol::log_template::LogTemplate;
37use crate::protocol::EventType;
38use crate::server::can::frame::CanFrame;
39use anyhow::{anyhow, Result};
40use serde_json::json;
41use std::sync::LazyLock;
42
43/// The action that says "say nothing", as a real decision rather than an absence.
44pub const NO_RESPONSE_ACTION: &str = "no_response";
45/// The action that puts a frame on the bus.
46pub const SEND_CAN_FRAME_ACTION: &str = "send_can_frame";
47
48/// Default CAN interface. `can0` is the conventional first hardware interface; `vcan0` is the
49/// conventional virtual one, and is what a test on Linux would use.
50pub const DEFAULT_INTERFACE: &str = "can0";
51
52// =================================================================================================
53// Events
54// =================================================================================================
55
56/// A data or remote frame arrived. The ordinary case, and the one an ECU simulator answers.
57pub static CAN_FRAME_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58    EventType::new(
59        "can_frame_received",
60        "A CAN frame arrived on the bus. There is no addressing: this frame reached every node, \
61         and every node decides for itself whether the identifier is one it answers. Reply with \
62         send_can_frame if this identifier is one the ECU you are simulating owns, or with \
63         no_response if it is not - saying nothing is the normal case on a CAN bus, not a \
64         failure.",
65        json!({
66            "type": "send_can_frame",
67            "id": "0x7E8",
68            "extended": false,
69            "data": "0341050f",
70            "encoding": "hex"
71        }),
72    )
73    .with_actions(can_actions())
74    .with_alternative_example(json!({"type": "no_response"}))
75    .with_parameters(vec![
76        Parameter {
77            name: "id".to_string(),
78            type_hint: "string".to_string(),
79            description: "Identifier in hex, e.g. '0x7DF' (11-bit) or '0x18DAF110' (29-bit)"
80                .to_string(),
81            required: true,
82        },
83        Parameter {
84            name: "extended".to_string(),
85            type_hint: "boolean".to_string(),
86            description: "True for a 29-bit identifier, false for 11-bit. Never inferred from \
87                          the identifier's magnitude - 0x123 is legal in both formats and they \
88                          are different frames."
89                .to_string(),
90            required: true,
91        },
92        Parameter {
93            name: "rtr".to_string(),
94            type_hint: "boolean".to_string(),
95            description: "Remote transmission request: another node is asking for this \
96                          identifier's data and sent none of its own. The dlc says how many \
97                          bytes it wants."
98                .to_string(),
99            required: true,
100        },
101        Parameter {
102            name: "dlc".to_string(),
103            type_hint: "number".to_string(),
104            description: "Data length code. Equal to the byte count for classic CAN; for CAN FD \
105                          it is a length CODE, where 9..15 mean 12, 16, 20, 24, 32, 48 and 64 \
106                          bytes."
107                .to_string(),
108            required: true,
109        },
110        Parameter {
111            name: "data".to_string(),
112            type_hint: "string".to_string(),
113            description: "The payload, hex-encoded (data_encoding is always 'hex' on receive). \
114                          CAN payloads are opaque: their meaning lives in a DBC file."
115                .to_string(),
116            required: true,
117        },
118        Parameter {
119            name: "fd".to_string(),
120            type_hint: "boolean".to_string(),
121            description: "True for a CAN FD frame (up to 64 bytes), false for classic CAN 2.0"
122                .to_string(),
123            required: true,
124        },
125        Parameter {
126            name: "brs".to_string(),
127            type_hint: "boolean".to_string(),
128            description: "CAN FD bit-rate switch: the data phase ran at the faster bit rate"
129                .to_string(),
130            required: true,
131        },
132        Parameter {
133            name: "interface".to_string(),
134            type_hint: "string".to_string(),
135            description: "CAN interface the frame arrived on, e.g. 'can0' or 'vcan0'".to_string(),
136            required: true,
137        },
138    ])
139    .with_log_template(
140        LogTemplate::new()
141            .with_info("CAN {interface} rx {id} dlc={dlc}")
142            .with_debug("CAN rx: id={id} ext={extended} rtr={rtr} fd={fd} dlc={dlc} data={data}")
143            .with_trace("CAN rx: {json_pretty(.)}"),
144    )
145});
146
147/// The controller reported an error condition on the bus.
148pub static CAN_ERROR_FRAME_EVENT: LazyLock<EventType> = LazyLock::new(|| {
149    EventType::new(
150        "can_error_frame",
151        "The CAN controller delivered an error frame. This is a report from the local controller \
152         about the state of the bus - a lost arbitration, a missing acknowledgement, a protocol \
153         violation - and NOT a message any node sent you. You cannot reply to it with an error \
154         frame: NetGet has no action that emits one, because an error frame corrupts whatever is \
155         in flight and repeated ones drive nodes off the bus. Answer with no_response unless the \
156         condition means the ECU you simulate should transmit something.",
157        json!({"type": "no_response"}),
158    )
159    .with_actions(can_actions())
160    .with_alternative_example(json!({
161        "type": "send_can_frame",
162        "id": "0x7E8",
163        "data": "03410c1aF8",
164        "encoding": "hex"
165    }))
166    .with_parameters(vec![
167        Parameter {
168            name: "error_classes".to_string(),
169            type_hint: "array".to_string(),
170            description: "Decoded error classes, e.g. ['no_acknowledgement'], \
171                          ['arbitration_lost'], ['controller_problem'], ['bus_off']"
172                .to_string(),
173            required: true,
174        },
175        Parameter {
176            name: "id_decimal".to_string(),
177            type_hint: "number".to_string(),
178            description: "The raw error class bitmask, for anyone who wants the bits".to_string(),
179            required: true,
180        },
181        Parameter {
182            name: "data".to_string(),
183            type_hint: "string".to_string(),
184            description: "The eight controller detail octets, hex-encoded".to_string(),
185            required: true,
186        },
187        Parameter {
188            name: "interface".to_string(),
189            type_hint: "string".to_string(),
190            description: "CAN interface reporting the error".to_string(),
191            required: true,
192        },
193    ])
194    .with_log_template(
195        LogTemplate::new()
196            .with_info("CAN {interface} error frame {error_classes}")
197            .with_debug("CAN error frame: classes={error_classes} detail={data}")
198            .with_trace("CAN error frame: {json_pretty(.)}"),
199    )
200});
201
202/// The controller crossed an error-confinement boundary.
203pub static CAN_BUS_STATE_CHANGED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
204    EventType::new(
205        "can_bus_state_changed",
206        "The local CAN controller changed error-confinement state. The ladder is error_active -> \
207         error_warning -> error_passive -> bus_off, driven by the controller's own error \
208         counters. At bus_off the controller has disconnected itself: nothing you transmit will \
209         reach the bus until it is restarted. Usually the right answer is no_response; \
210         transmitting into a degrading bus makes it worse.",
211        json!({"type": "no_response"}),
212    )
213    .with_actions(can_actions())
214    .with_parameters(vec![
215        Parameter {
216            name: "bus_state".to_string(),
217            type_hint: "string".to_string(),
218            description: "One of 'error_active', 'error_warning', 'error_passive', 'bus_off'"
219                .to_string(),
220            required: true,
221        },
222        Parameter {
223            name: "previous_state".to_string(),
224            type_hint: "string".to_string(),
225            description: "The state being left".to_string(),
226            required: true,
227        },
228        Parameter {
229            name: "error_classes".to_string(),
230            type_hint: "array".to_string(),
231            description: "Error classes of the frame that reported the change".to_string(),
232            required: true,
233        },
234        Parameter {
235            name: "interface".to_string(),
236            type_hint: "string".to_string(),
237            description: "CAN interface whose state changed".to_string(),
238            required: true,
239        },
240    ])
241    .with_log_template(
242        LogTemplate::new()
243            .with_info("CAN {interface} bus state {previous_state} -> {bus_state}")
244            .with_debug("CAN bus state change: {previous_state} -> {bus_state} ({error_classes})")
245            .with_trace("CAN bus state: {json_pretty(.)}"),
246    )
247});
248
249// =================================================================================================
250// Actions
251// =================================================================================================
252
253/// Everything the model may do, in the order it should consider them.
254///
255/// The same set is returned by `get_sync_actions`, `get_async_actions` and every event's
256/// `.with_actions(...)`. A CAN bus has no request/response pairing at the protocol level, so
257/// there is no narrowing to express: anything that can be sent unprompted can be sent in reply.
258pub fn can_actions() -> Vec<ActionDefinition> {
259    vec![send_can_frame_action(), no_response_action()]
260}
261
262fn send_can_frame_action() -> ActionDefinition {
263    ActionDefinition {
264        name: SEND_CAN_FRAME_ACTION.to_string(),
265        description:
266            "Transmit one CAN frame. It reaches every node on the bus - there is no addressing \
267             and no authentication, so the identifier alone decides who treats it as theirs. \
268             Diagnostic convention: a tester requests on 0x7DF (broadcast) or 0x7E0+n, and ECU n \
269             answers on 0x7E8+n."
270                .to_string(),
271        parameters: vec![
272            Parameter {
273                name: "id".to_string(),
274                type_hint: "string".to_string(),
275                description:
276                    "Identifier, hex with or without the 0x prefix ('0x7E8', '7E8') or a decimal \
277                     number. 11 bits (max 0x7FF) unless 'extended' is true, then 29 (max \
278                     0x1FFFFFFF)."
279                        .to_string(),
280                required: true,
281            },
282            Parameter {
283                name: "extended".to_string(),
284                type_hint: "boolean".to_string(),
285                description:
286                    "True for a 29-bit identifier. Default false. State it explicitly: it is NOT \
287                     inferred from the identifier's size, because 0x123 is a legal identifier in \
288                     both formats and they are different frames that different nodes answer."
289                        .to_string(),
290                required: false,
291            },
292            Parameter {
293                name: "data".to_string(),
294                type_hint: "string".to_string(),
295                description:
296                    "The payload (see 'encoding'). 0-8 bytes for classic CAN; 0-8, 12, 16, 20, \
297                     24, 32, 48 or 64 for CAN FD - no other FD length exists, and one that is not \
298                     encodable is refused rather than padded. Omit for a zero-length frame."
299                        .to_string(),
300                required: false,
301            },
302            Parameter {
303                name: "encoding".to_string(),
304                type_hint: "string".to_string(),
305                description:
306                    "How 'data' is written: 'hex' (the default - CAN payloads are opaque bytes \
307                     whose meaning lives in a DBC file) or 'text' for ASCII. This is read, not \
308                     guessed: '48656c6c6f' is valid hex AND valid text and only you know which \
309                     you meant."
310                        .to_string(),
311                required: false,
312            },
313            Parameter {
314                name: "rtr".to_string(),
315                type_hint: "boolean".to_string(),
316                description:
317                    "Send a remote transmission request instead of data: ask whichever node owns \
318                     this identifier to transmit it. Carries no payload; 'dlc' says how many \
319                     bytes are being requested. Not available on CAN FD."
320                        .to_string(),
321                required: false,
322            },
323            Parameter {
324                name: "dlc".to_string(),
325                type_hint: "number".to_string(),
326                description: "Bytes requested by a remote frame, 0-8. Ignored on a data frame, \
327                              whose length comes from 'data'."
328                    .to_string(),
329                required: false,
330            },
331            Parameter {
332                name: "fd".to_string(),
333                type_hint: "boolean".to_string(),
334                description:
335                    "Send a CAN FD frame (payloads up to 64 bytes). Default false. The bus and \
336                     every listening node must support FD; a classic-only node sees an error."
337                        .to_string(),
338                required: false,
339            },
340            Parameter {
341                name: "brs".to_string(),
342                type_hint: "boolean".to_string(),
343                description: "CAN FD bit-rate switch: run the data phase at the faster bit rate. \
344                              Requires 'fd'."
345                    .to_string(),
346                required: false,
347            },
348        ],
349        example: json!({
350            "type": "send_can_frame",
351            "id": "0x7E8",
352            "extended": false,
353            "data": "0341050f",
354            "encoding": "hex"
355        }),
356        log_template: Some(
357            LogTemplate::new()
358                .with_info("-> CAN {id} {data}")
359                .with_debug("CAN tx: id={id} ext={extended} rtr={rtr} fd={fd} data={data}"),
360        ),
361    }
362}
363
364fn no_response_action() -> ActionDefinition {
365    ActionDefinition {
366        name: NO_RESPONSE_ACTION.to_string(),
367        description:
368            "Transmit nothing, deliberately. On a CAN bus this is the normal outcome: every frame \
369             reaches every node and almost every node ignores almost every frame. Use it whenever \
370             the identifier is not one the ECU you are simulating owns. It is a real decision and \
371             is logged as one (decision=model_reject), distinct from failing to answer."
372                .to_string(),
373        parameters: vec![],
374        example: json!({"type": "no_response"}),
375        log_template: Some(LogTemplate::new().with_info("-> CAN silent (no_response)")),
376    }
377}
378
379// =================================================================================================
380// The protocol
381// =================================================================================================
382
383/// CAN bus (SocketCAN) server
384pub struct CanProtocol;
385
386impl CanProtocol {
387    pub fn new() -> Self {
388        Self
389    }
390}
391
392impl Default for CanProtocol {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398impl Protocol for CanProtocol {
399    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
400        vec![
401            ParameterDefinition {
402                name: "interface".to_string(),
403                type_hint: "string".to_string(),
404                description: format!(
405                    "CAN interface to bind, e.g. 'can0' or 'vcan0' (default: {DEFAULT_INTERFACE}). \
406                     It must already exist and be up. A virtual bus needs no hardware: `sudo \
407                     modprobe vcan && sudo ip link add dev vcan0 type vcan && sudo ip link set up \
408                     vcan0`."
409                ),
410                required: false,
411                example: json!("vcan0"),
412                default: None,
413            },
414            ParameterDefinition {
415                name: "transport".to_string(),
416                type_hint: "string".to_string(),
417                description:
418                    "'socketcan' (default) for a real AF_CAN socket, which exists only on Linux; \
419                     'udp' for the unprivileged TEST transport, which carries SocketCAN frame \
420                     structs as UDP datagram payloads and which no real CAN node speaks."
421                        .to_string(),
422                required: false,
423                example: json!("socketcan"),
424                default: None,
425            },
426            ParameterDefinition {
427                name: "udp_peer".to_string(),
428                type_hint: "string".to_string(),
429                description:
430                    "HOST:PORT to transmit to on the 'udp' test transport when nothing has been \
431                     received yet. Rejected with transport 'socketcan', where it would do nothing."
432                        .to_string(),
433                required: false,
434                example: json!("127.0.0.1:34567"),
435                default: None,
436            },
437        ]
438    }
439
440    /// A frame can be transmitted at any time, not only in reply to one.
441    ///
442    /// A CAN bus is a broadcast medium with no sessions: an ECU simulator sending a periodic
443    /// status frame is doing exactly what a real one does.
444    fn get_async_actions(
445        &self,
446        _state: &crate::state::app_state::AppState,
447    ) -> Vec<ActionDefinition> {
448        can_actions()
449    }
450
451    /// The same set. There is nothing a reply may do that an unprompted transmission may not.
452    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
453        can_actions()
454    }
455
456    fn get_event_types(&self) -> Vec<EventType> {
457        vec![
458            CAN_FRAME_RECEIVED_EVENT.clone(),
459            CAN_ERROR_FRAME_EVENT.clone(),
460            CAN_BUS_STATE_CHANGED_EVENT.clone(),
461        ]
462    }
463
464    fn protocol_name(&self) -> &'static str {
465        "CAN"
466    }
467
468    fn stack_name(&self) -> &'static str {
469        "CAN"
470    }
471
472    fn keywords(&self) -> Vec<&'static str> {
473        vec![
474            "can",
475            "canbus",
476            "socketcan",
477            "automotive",
478            "ecu",
479            "obd",
480            "obd2",
481            "j1939",
482            "vcan",
483            "canfd",
484        ]
485    }
486
487    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
488        use crate::protocol::metadata::{
489            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
490        };
491
492        ProtocolMetadataV2::builder()
493            .state(DevelopmentState::Experimental)
494            // Binding an AF_CAN socket needs no privilege. Bringing the *interface* up does, and
495            // so does creating a vcan device, but those are done before NetGet runs and spawn()
496            // fails with the `ip link` command to run if they were not. Declaring Root here would
497            // refuse to start for every user who can in fact open the socket - the "don't claim
498            // more than you need" rule that ospf got wrong.
499            .privilege_requirement(PrivilegeRequirement::None)
500            // A CAN bus is a broadcast medium with no connections at all. The per-identifier
501            // bookkeeping entries exist only so the dashboard can show traffic, and the
502            // 10-second idle sweep is exactly what should reap them.
503            .connectionless()
504            // Deliberately silent: CAN has no error reply. An error frame destroys the frame in
505            // flight for every node and drives controllers toward bus-off, and a data frame asserts
506            // a value or command to the whole bus. Nodes ignoring frames is the normal state.
507            .deliberately_silent()
508            .implementation(
509                "Frame representation, validation and the SocketCAN struct can_frame / \
510                 canfd_frame wire encoding are a pure module (src/server/can/frame.rs) with no \
511                 socket, no cfg and no state. Two transports sit on it: a real AF_CAN socket via \
512                 the socketcan crate on Linux, and a UDP test transport carrying the same frame \
513                 structs in datagrams. On macOS and Windows the socketcan transport returns an \
514                 error naming the reason instead of starting.",
515            )
516            .llm_control(
517                "The model is the ECU. It sees can_frame_received (id, extended, rtr, error, \
518                 dlc, hex data, fd, brs, interface), can_error_frame (decoded error classes) and \
519                 can_bus_state_changed (the error-confinement ladder), and answers with \
520                 send_can_frame or no_response. There is no action that emits a CAN error frame, \
521                 deliberately: an error frame corrupts traffic in flight and repeated ones drive \
522                 nodes bus-off.",
523            )
524            .e2e_testing(
525                "The codec is asserted against literal values - both DLC tables in both \
526                 directions, all sixteen CAN FD length codes, standard vs extended identifiers, \
527                 RTR, error-class decoding, the 16- and 72-octet struct layouts, and an \
528                 over-length payload being refused rather than truncated. The whole event -> \
529                 handler/LLM -> action -> frame path runs in-process over the UDP test transport \
530                 (tests/server/can/e2e_test.rs), including the assertion that an LLM failure puts \
531                 nothing on the bus. The AF_CAN transport is covered by none of it.",
532            )
533            .notes(
534                "VERIFIED: frame construction, validation and the SocketCAN wire layout, against \
535                 literal values; that the CAN FD DLC is treated as a length CODE and not a byte \
536                 count; that an unencodable FD length is refused; that macOS/Windows refuse to \
537                 start with an explicit reason rather than reporting Running; and the full \
538                 event/action path over the UDP test transport. NOT VERIFIED: the SocketCAN \
539                 transport has never been compiled or run. AF_CAN exists only in the Linux \
540                 kernel, the machine this was written on is macOS, and no frame this code \
541                 produced has reached a real CAN bus or a real CAN peer. Treat first use on Linux \
542                 as bring-up. Experimental, not Beta, for exactly that reason - the path to Beta \
543                 is cheap and is written down in src/server/can/CLAUDE.md: Linux vcan needs no \
544                 hardware and can-utils' cansend/candump are real third-party peers.",
545            )
546            .build()
547    }
548
549    fn description(&self) -> &'static str {
550        "CAN bus (SocketCAN) ECU simulator - Linux only, or a UDP test transport"
551    }
552
553    fn example_prompt(&self) -> &'static str {
554        "Act as an engine ECU on the CAN bus: answer OBD-II requests on 0x7DF from 0x7E8, \
555         reporting 2400 rpm and 88 degrees coolant temperature"
556    }
557
558    fn group_name(&self) -> &'static str {
559        "Core"
560    }
561
562    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
563        use crate::llm::actions::StartupExamples;
564
565        StartupExamples::new(
566            // LLM mode: the model is the ECU and reasons about each request.
567            json!({
568                "type": "open_server",
569                "port": 0,
570                "base_stack": "can",
571                "instruction": "You are an engine control unit on a vehicle CAN bus. Answer \
572                                OBD-II mode 01 requests addressed to 0x7DF or 0x7E0 by \
573                                transmitting from 0x7E8. Ignore every other identifier.",
574                "startup_params": {"interface": "vcan0"}
575            }),
576            // Script mode: a deterministic ECU, computed in-process with no model call.
577            json!({
578                "type": "open_server",
579                "port": 0,
580                "base_stack": "can",
581                "startup_params": {"interface": "vcan0"},
582                "event_handlers": [
583                    {
584                        "event_pattern": "can_frame_received",
585                        "handler": {
586                            "type": "script",
587                            "language": "python",
588                            "code": "actions = [{'type': 'send_can_frame', 'id': '0x7E8', 'data': '0341051e', 'encoding': 'hex'}] if event['data'].startswith('024105') else [{'type': 'no_response'}]"
589                        }
590                    }
591                ]
592            }),
593            // Static mode: one fixed frame in reply to anything, with no model call.
594            json!({
595                "type": "open_server",
596                "port": 0,
597                "base_stack": "can",
598                "startup_params": {"interface": "vcan0"},
599                "event_handlers": [
600                    {
601                        "event_pattern": "can_*",
602                        "handler": {
603                            "type": "static",
604                            "actions": [
605                                {
606                                    "type": "send_can_frame",
607                                    "id": "0x7E8",
608                                    "data": "0341050f",
609                                    "encoding": "hex"
610                                }
611                            ]
612                        }
613                    }
614                ]
615            }),
616        )
617    }
618}
619
620impl Server for CanProtocol {
621    fn spawn(
622        &self,
623        ctx: crate::protocol::SpawnContext,
624    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
625    {
626        Box::pin(async move {
627            let listen_addr = ctx
628                .socket_addr()
629                .unwrap_or_else(|| ctx.legacy_listen_addr());
630            crate::server::can::CanServer::spawn_with_llm_actions(
631                listen_addr,
632                ctx.interface.clone(),
633                ctx.llm_client,
634                ctx.state,
635                ctx.status_tx,
636                ctx.server_id,
637                ctx.startup_params,
638            )
639            .await
640        })
641    }
642
643    /// Turn one action into bytes the transport can put on the bus.
644    ///
645    /// `send_can_frame` returns the SocketCAN wire struct — 16 octets for a classic frame, 72 for
646    /// an FD one — which is what both transports write. Building it here rather than in the
647    /// server loop means the encoder runs at the action, so a bad identifier, an impossible CAN
648    /// FD length or an `rtr` + `fd` combination is refused with the model watching instead of
649    /// producing a frame the bus silently drops.
650    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
651        let action_type = action
652            .get("type")
653            .and_then(|v| v.as_str())
654            .ok_or_else(|| anyhow!("action must have a 'type' field"))?;
655
656        match action_type {
657            SEND_CAN_FRAME_ACTION => {
658                let frame = CanFrame::from_action(&action)?;
659                Ok(ActionResult::Output(frame.to_wire_bytes()?))
660            }
661            NO_RESPONSE_ACTION => Ok(ActionResult::NoAction),
662            other => Err(anyhow!(
663                "unknown CAN action {other:?}; expected one of {SEND_CAN_FRAME_ACTION}, \
664                 {NO_RESPONSE_ACTION}"
665            )),
666        }
667    }
668}