Skip to main content

netget/client/nfc/
actions.rs

1//! NFC (Near Field Communication) client protocol actions implementation
2//! Uses PC/SC API for cross-platform smart card/NFC reader support
3
4use crate::llm::actions::{
5    client_trait::{Client, ClientActionResult},
6    protocol_trait::Protocol,
7    ActionDefinition, Parameter, ParameterDefinition,
8};
9use crate::protocol::ConnectContext;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::{anyhow, Result};
13use serde_json::json;
14use std::future::Future;
15use std::net::SocketAddr;
16use std::pin::Pin;
17use std::sync::LazyLock;
18
19/// NFC reader list event - triggered after listing available PC/SC readers
20pub static NFC_READERS_LISTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
21    EventType::new(
22        "nfc_readers_listed",
23        "Available NFC/smart card readers enumerated via PC/SC",
24        json!({
25            "type": "send_apdu",
26            "cla": "00",
27            "ins": "A4",
28            "p1": "04",
29            "p2": "00",
30            "data": "D2760000850101",
31            "le": "00"
32        }),
33    )
34    .with_parameters(vec![Parameter {
35        name: "readers".to_string(),
36        type_hint: "array".to_string(),
37        description: "List of reader names (strings)".to_string(),
38        required: true,
39    }])
40});
41
42/// NFC card detected event - triggered when a card/tag is detected in reader
43pub static NFC_CARD_DETECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
44    EventType::new(
45        "nfc_card_detected",
46        "NFC card/tag detected in reader",
47        json!({
48            "type": "read_ndef"
49        }),
50    )
51    .with_parameters(vec![
52        Parameter {
53            name: "atr".to_string(),
54            type_hint: "string".to_string(),
55            description: "Answer to Reset (ATR) hex string".to_string(),
56            required: true,
57        },
58        Parameter {
59            name: "protocol".to_string(),
60            type_hint: "string".to_string(),
61            description: "Active protocol (T0, T1, etc.)".to_string(),
62            required: false,
63        },
64    ])
65});
66
67/// NFC APDU response event - triggered after sending APDU command
68pub static NFC_APDU_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
69    EventType::new(
70        "nfc_apdu_response",
71        "APDU response received from card/tag",
72        json!({
73            "type": "send_apdu_raw",
74            "apdu_hex": "00B0000010"
75        }),
76    )
77    .with_parameters(vec![
78        Parameter {
79            name: "response_hex".to_string(),
80            type_hint: "string".to_string(),
81            description: "Response APDU as hex string (includes SW1 SW2 status bytes)".to_string(),
82            required: true,
83        },
84        Parameter {
85            name: "sw1".to_string(),
86            type_hint: "string".to_string(),
87            description: "Status byte 1 (hex)".to_string(),
88            required: true,
89        },
90        Parameter {
91            name: "sw2".to_string(),
92            type_hint: "string".to_string(),
93            description: "Status byte 2 (hex)".to_string(),
94            required: true,
95        },
96        Parameter {
97            name: "data_hex".to_string(),
98            type_hint: "string".to_string(),
99            description: "Response data (without status bytes) as hex string".to_string(),
100            required: false,
101        },
102    ])
103});
104
105/// NFC NDEF data read event - triggered after successfully reading NDEF message
106///
107/// `records` is the decoded form and used to be declared here while the emit site sent only
108/// `length` / `message_hex` / `message_text` — the model was promised typed records and given
109/// none. All four fields are declared now and all four are emitted.
110pub static NFC_NDEF_READ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
111    EventType::new(
112        "nfc_ndef_read",
113        "NDEF message read from NFC tag",
114        json!({
115            "type": "write_ndef",
116            "records": [
117                {
118                    "type": "text",
119                    "language": "en",
120                    "text": "Response message"
121                }
122            ]
123        }),
124    )
125    .with_parameters(vec![
126        Parameter {
127            name: "records".to_string(),
128            type_hint: "array".to_string(),
129            description:
130                "Decoded NDEF records: 'text' (with language), 'uri', 'mime', 'external', or \
131                 'chunked'/'undecodable'/'other' where the tag's bytes could not be read as a \
132                 typed record. A record flagged unsafe_characters_removed carried control or \
133                 bidirectional-override characters, which were replaced with U+FFFD - treat \
134                 that tag as hostile."
135                    .to_string(),
136            required: true,
137        },
138        Parameter {
139            name: "length".to_string(),
140            type_hint: "number".to_string(),
141            description: "Length of the raw NDEF message in bytes".to_string(),
142            required: true,
143        },
144        Parameter {
145            name: "message_hex".to_string(),
146            type_hint: "string".to_string(),
147            description: "The raw NDEF message as uppercase hex. Authoritative: 'records' is a \
148                          best-effort decode of these bytes."
149                .to_string(),
150            required: true,
151        },
152        Parameter {
153            name: "message_text".to_string(),
154            type_hint: "string".to_string(),
155            description: "Lossy text view of the raw bytes, present only when every byte is \
156                          printable ASCII. Prefer 'records'."
157                .to_string(),
158            required: false,
159        },
160    ])
161});
162
163/// NFC card disconnected event
164pub static NFC_CARD_DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
165    EventType::new(
166        "nfc_card_disconnected",
167        "NFC card/tag disconnected from reader",
168        json!({
169            "type": "wait_for_more"
170        }),
171    )
172});
173
174/// The `file_id` both NDEF verbs accept. Shared so the two descriptions cannot drift.
175static NDEF_FILE_ID_PARAMETER: LazyLock<Parameter> = LazyLock::new(|| Parameter {
176    name: "file_id".to_string(),
177    type_hint: "string".to_string(),
178    description: "NDEF file identifier as four hex digits, default 'E104'. This is the file \
179                  nearly every Type 4 tag's Capability Container points at; NetGet does not \
180                  read the CC, so a tag that puts its NDEF file elsewhere needs this."
181        .to_string(),
182    required: false,
183});
184
185/// CLA INS P1 P2 — the shortest thing that can be a command APDU (ISO 7816-4 case 1).
186const APDU_HEADER_LEN: usize = 4;
187
188/// Largest `Lc` a short-form APDU can express. The extended form reaches 65535 but needs a
189/// three-byte `Lc` and a two-byte `Le`, which `send_apdu` does not build.
190const MAX_SHORT_LC: usize = 255;
191
192/// Read one two-hex-digit field, refusing anything that is not exactly one byte.
193///
194/// The check that matters is the *length*: "4" and "004" both decode or fail in ways that
195/// used to slide silently into the APDU and displace every byte after them.
196fn one_hex_byte(action: &serde_json::Value, field: &str) -> Result<u8> {
197    let raw = action[field]
198        .as_str()
199        .ok_or_else(|| anyhow!("Missing '{field}' field"))?
200        .trim();
201    let bytes =
202        hex::decode(raw).map_err(|e| anyhow!("'{field}' must be two hex digits ('{raw}'): {e}"))?;
203    match bytes.as_slice() {
204        [byte] => Ok(*byte),
205        other => Err(anyhow!(
206            "'{field}' must be exactly one byte (two hex digits), got {} byte(s) from '{raw}'",
207            other.len()
208        )),
209    }
210}
211
212/// Validate the optional `file_id` of `read_ndef` / `write_ndef`, defaulting to `E104`.
213///
214/// E104 is the NDEF file identifier nearly every Type 4 tag uses and the one its Capability
215/// Container normally points at. NetGet does not read the CC, so a tag that put its NDEF file
216/// somewhere else needs this parameter — which is why it is declared rather than assumed.
217fn ndef_file_id_param(action: &serde_json::Value) -> Result<String> {
218    let Some(raw) = action["file_id"].as_str().map(str::trim) else {
219        return Ok("E104".to_string());
220    };
221    let bytes = hex::decode(raw)
222        .map_err(|e| anyhow!("'file_id' must be four hex digits ('{raw}'): {e}"))?;
223    if bytes.len() != 2 {
224        return Err(anyhow!(
225            "'file_id' must be exactly two bytes (four hex digits), got {} byte(s) from '{raw}'",
226            bytes.len()
227        ));
228    }
229    Ok(hex::encode_upper(&bytes))
230}
231
232/// NFC client protocol implementation
233pub struct NfcClientProtocol;
234
235impl Protocol for NfcClientProtocol {
236    fn protocol_name(&self) -> &'static str {
237        "nfc"
238    }
239
240    fn stack_name(&self) -> &'static str {
241        "application"
242    }
243
244    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
245        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
246
247        // Was `Incomplete`, which means `is_available_to_llm()` returns false — the model
248        // could not see this client at all, while the root CLAUDE.md said no Incomplete
249        // protocol remained. That was a leftover from when the NDEF verbs did nothing;
250        // hiding a protocol is not how an unfinished one is reported (the
251        // `bluetooth_ble_beacon` precedent), and the client is reachable, drivable from the
252        // dashboard, and has a working vocabulary. `Experimental` with the untested part
253        // named is the honest rating.
254        ProtocolMetadataV2::builder()
255            .state(DevelopmentState::Experimental)
256            .implementation(
257                "PC/SC (pcsc 2.9) for reader access: SCardListReaders picks a reader, and every \
258                 command is SCardConnect + SCardTransmit on a blocking thread, with the card \
259                 handle opened and dropped per command so a re-presented card still works. \
260                 read_ndef/write_ndef are the NFC Forum Type 4 APDU sequence over that same \
261                 path; NDEF records are encoded and decoded in src/client/nfc/ndef.rs.",
262            )
263            .llm_control(
264                "Raw and structured APDUs, NDEF read and write as typed records, and \
265                 disconnect. Every card answer raises nfc_apdu_response and every NDEF read \
266                 raises nfc_ndef_read, so the model drives the whole conversation, bounded by \
267                 MAX_FOLLOWUP_DEPTH.",
268            )
269            .e2e_testing(
270                "The NDEF codec and the APDU builder are unit-tested against literal spec bytes \
271                 and need no hardware (tests/client/nfc/). Everything that touches a card is \
272                 NOT tested: this machine has no PC/SC reader, a contactless card cannot be \
273                 emulated through PC/SC (SCardConnect needs a card in the field), and those \
274                 tests are #[ignore]d - which is not evidence. Treat the wire behaviour as \
275                 unverified.",
276            )
277            .notes(
278                "Cross-platform via PC/SC (Windows/macOS native, Linux via pcscd). The Type 4 \
279                 read/write sequence does NOT consult the Capability Container: it selects the \
280                 NDEF file by the file_id parameter, default E104, and ignores the CC's MLe/MLc \
281                 read and write limits. Chunked NDEF records are reported, not reassembled, and \
282                 nested messages (smart poster, handover) are returned as hex rather than \
283                 decoded - deliberately, since a recursive NDEF decoder is the stack-overflow \
284                 class the root CLAUDE.md describes.",
285            )
286            .build()
287    }
288
289    fn get_event_types(&self) -> Vec<EventType> {
290        vec![
291            (*NFC_READERS_LISTED_EVENT).clone(),
292            (*NFC_CARD_DETECTED_EVENT).clone(),
293            (*NFC_APDU_RESPONSE_EVENT).clone(),
294            (*NFC_NDEF_READ_EVENT).clone(),
295            (*NFC_CARD_DISCONNECTED_EVENT).clone(),
296        ]
297    }
298
299    fn keywords(&self) -> Vec<&'static str> {
300        vec!["nfc", "smart card", "pcsc", "reader", "apdu", "ndef"]
301    }
302
303    fn description(&self) -> &'static str {
304        "NFC/Smart card reader client using PC/SC API"
305    }
306
307    fn example_prompt(&self) -> &'static str {
308        "Connect to NFC reader and read tag UID"
309    }
310
311    fn group_name(&self) -> &'static str {
312        "NFC & Smart Cards"
313    }
314
315    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
316        vec![
317            ParameterDefinition {
318                name: "reader_index".to_string(),
319                type_hint: "number".to_string(),
320                description: "Index of PC/SC reader to use (0-based, default: 0)".to_string(),
321                required: false,
322                example: json!(0),
323                default: None,
324            },
325            ParameterDefinition {
326                name: "reader_name".to_string(),
327                type_hint: "string".to_string(),
328                description: "Name of specific reader to use (optional, overrides reader_index)"
329                    .to_string(),
330                required: false,
331                example: json!("reader_name"),
332                default: None,
333            },
334        ]
335    }
336
337    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
338        // `list_readers` and `connect_card` used to be advertised here and neither could run:
339        // `execute_action` below has no arm for either name, so both came back
340        // "Unknown action type" and cost the model a retry. Reader enumeration and explicit
341        // card connection are real capabilities worth adding — `connect()` currently picks a
342        // reader and waits for a card on its own — but they need PC/SC work in `mod.rs`, not
343        // an action declaration on its own.
344        vec![ActionDefinition {
345            name: "disconnect_card".to_string(),
346            description: "Disconnect from current card/tag".to_string(),
347            parameters: vec![],
348            example: json!({
349                "type": "disconnect_card"
350            }),
351            log_template: None,
352        }]
353    }
354
355    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
356        vec![
357            ActionDefinition {
358                name: "send_apdu".to_string(),
359                description: "Send APDU command to card/tag (structured format)".to_string(),
360                parameters: vec![
361                    Parameter {
362                        name: "cla".to_string(),
363                        type_hint: "string".to_string(),
364                        description: "Class byte (hex, e.g., '00')".to_string(),
365                        required: true,
366                    },
367                    Parameter {
368                        name: "ins".to_string(),
369                        type_hint: "string".to_string(),
370                        description: "Instruction byte (hex, e.g., 'A4' for SELECT)".to_string(),
371                        required: true,
372                    },
373                    Parameter {
374                        name: "p1".to_string(),
375                        type_hint: "string".to_string(),
376                        description: "Parameter 1 byte (hex)".to_string(),
377                        required: true,
378                    },
379                    Parameter {
380                        name: "p2".to_string(),
381                        type_hint: "string".to_string(),
382                        description: "Parameter 2 byte (hex)".to_string(),
383                        required: true,
384                    },
385                    Parameter {
386                        name: "data".to_string(),
387                        type_hint: "string".to_string(),
388                        description: "Command data (hex string, optional)".to_string(),
389                        required: false,
390                    },
391                    Parameter {
392                        name: "le".to_string(),
393                        type_hint: "string".to_string(),
394                        description: "Expected response length (hex, optional)".to_string(),
395                        required: false,
396                    },
397                ],
398                example: json!({
399                    "type": "send_apdu",
400                    "cla": "00",
401                    "ins": "A4",
402                    "p1": "04",
403                    "p2": "00",
404                    "data": "D2760000850101",
405                    "le": "00"
406                }),
407                log_template: None,
408            },
409            ActionDefinition {
410                name: "send_apdu_raw".to_string(),
411                description: "Send raw APDU command (hex string)".to_string(),
412                parameters: vec![Parameter {
413                    name: "apdu_hex".to_string(),
414                    type_hint: "string".to_string(),
415                    description: "Raw APDU command as hex string".to_string(),
416                    required: true,
417                }],
418                example: json!({
419                    "type": "send_apdu_raw",
420                    "apdu_hex": "00A4040007D276000085010100"
421                }),
422                log_template: None,
423            },
424            ActionDefinition {
425                name: "read_ndef".to_string(),
426                description: "Read the NDEF message from an NFC Forum Type 4 tag: SELECT the \
427                              NDEF application, SELECT the NDEF file, then READ BINARY. The \
428                              decoded records arrive as an nfc_ndef_read event."
429                    .to_string(),
430                parameters: vec![NDEF_FILE_ID_PARAMETER.clone()],
431                example: json!({
432                    "type": "read_ndef"
433                }),
434                log_template: None,
435            },
436            ActionDefinition {
437                name: "write_ndef".to_string(),
438                description: "Write an NDEF message to an NFC Forum Type 4 tag. Records are \
439                              typed, never raw bytes: 'text' (with 'language'), 'uri', 'mime' \
440                              (with 'mime_type') and 'external' (with 'domain_type'). A URI \
441                              must be printable US-ASCII as RFC 3986 requires, and text \
442                              carrying control or bidirectional-override characters is \
443                              refused - an NDEF URI record is what a phone offers to open. \
444                              Nested records (smart poster, handover) are not supported."
445                    .to_string(),
446                parameters: vec![
447                    Parameter {
448                        name: "records".to_string(),
449                        type_hint: "array".to_string(),
450                        description:
451                            "Array of NDEF records: {\"type\": \"text\", \"language\": \"en\", \
452                             \"text\": \"...\"}, {\"type\": \"uri\", \"uri\": \"https://...\"}, \
453                             {\"type\": \"mime\", \"mime_type\": \"text/plain\", \
454                             \"payload_text\": \"...\"} or {\"type\": \"external\", \
455                             \"domain_type\": \"example.com:mytype\", \"payload_text\": \"...\"}"
456                                .to_string(),
457                        required: true,
458                    },
459                    NDEF_FILE_ID_PARAMETER.clone(),
460                ],
461                example: json!({
462                    "type": "write_ndef",
463                    "records": [
464                        {
465                            "type": "text",
466                            "language": "en",
467                            "text": "Hello NFC!"
468                        },
469                        {
470                            "type": "uri",
471                            "uri": "https://example.com"
472                        }
473                    ]
474                }),
475                log_template: None,
476            },
477            ActionDefinition {
478                name: "wait_for_more".to_string(),
479                description: "Wait for more events without taking action".to_string(),
480                parameters: vec![],
481                example: json!({
482                    "type": "wait_for_more"
483                }),
484                log_template: None,
485            },
486        ]
487    }
488    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
489        use crate::llm::actions::StartupExamples;
490
491        StartupExamples::new(
492            // LLM mode: LLM handles NFC reader operations
493            json!({
494                "type": "open_client",
495                "remote_addr": "nfc:0",
496                "base_stack": "nfc",
497                "instruction": "Connect to NFC reader and read NDEF data from tag",
498                "startup_params": {
499                    "reader_index": 0
500                }
501            }),
502            // Script mode: Code-based NFC handling
503            json!({
504                "type": "open_client",
505                "remote_addr": "nfc:0",
506                "base_stack": "nfc",
507                "startup_params": {
508                    "reader_index": 0
509                },
510                "event_handlers": [{
511                    "event_pattern": "nfc_card_detected",
512                    "handler": {
513                        "type": "script",
514                        "language": "python",
515                        "code": "<nfc_client_handler>"
516                    }
517                }]
518            }),
519            // Static mode: Fixed NFC action
520            json!({
521                "type": "open_client",
522                "remote_addr": "nfc:0",
523                "base_stack": "nfc",
524                "startup_params": {
525                    "reader_index": 0
526                },
527                "event_handlers": [{
528                    "event_pattern": "nfc_card_detected",
529                    "handler": {
530                        "type": "static",
531                        "actions": [{
532                            "type": "read_ndef"
533                        }]
534                    }
535                }]
536            }),
537        )
538    }
539}
540
541impl Client for NfcClientProtocol {
542    fn connect(
543        &self,
544        ctx: ConnectContext,
545    ) -> Pin<Box<dyn Future<Output = Result<SocketAddr>> + Send>> {
546        Box::pin(async move {
547            // NFC client uses PC/SC, not socket addresses
548            // Remote addr is ignored, we use reader_index from startup params
549
550            // Build startup params JSON manually since StartupParams doesn't expose to_json
551            let startup_params_json = if let Some(ref params) = ctx.startup_params {
552                serde_json::json!({
553                    "reader_index": params.get_optional_u64("reader_index")?,
554                    "reader_name": params.get_optional_string("reader_name")?,
555                })
556            } else {
557                serde_json::json!({})
558            };
559
560            crate::client::nfc::NfcClient::connect_with_llm_actions(
561                ctx.llm_client,
562                ctx.state,
563                ctx.status_tx,
564                ctx.client_id,
565                startup_params_json,
566            )
567            .await
568        })
569    }
570
571    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
572        let action_type = action["type"]
573            .as_str()
574            .ok_or_else(|| anyhow!("Missing 'type' field in action"))?;
575
576        match action_type {
577            "send_apdu" => {
578                // Every field is range-checked *before* it becomes a byte. The previous
579                // version interpolated the model's strings straight into a hex string and
580                // derived Lc as `data.len() / 2`, so an odd-length `data` floored the
581                // length, a `p1` of "4" instead of "04" shifted every byte after it, and
582                // more than 255 data bytes made `{:02X}` print three digits - each of
583                // which puts a *different, valid-looking* command on the card. A shifted
584                // APDU is not a malformed one: it is a command the model did not choose.
585                let cla = one_hex_byte(&action, "cla")?;
586                let ins = one_hex_byte(&action, "ins")?;
587                let p1 = one_hex_byte(&action, "p1")?;
588                let p2 = one_hex_byte(&action, "p2")?;
589
590                let mut apdu = vec![cla, ins, p1, p2];
591
592                let data = match action["data"].as_str().map(str::trim).unwrap_or("") {
593                    "" => Vec::new(),
594                    raw => hex::decode(raw).map_err(|e| {
595                        anyhow!("'data' must be an even number of hex digits ('{raw}'): {e}")
596                    })?,
597                };
598                if !data.is_empty() {
599                    if data.len() > MAX_SHORT_LC {
600                        return Err(anyhow!(
601                            "'data' is {} bytes; a short-form APDU carries at most {MAX_SHORT_LC}. \
602                             Use send_apdu_raw to build an extended-length APDU, which needs a \
603                             three-byte Lc and a two-byte Le",
604                            data.len()
605                        ));
606                    }
607                    // Bounds-checked immediately above, so the cast cannot lose a digit.
608                    apdu.push(data.len() as u8);
609                    apdu.extend_from_slice(&data);
610                }
611
612                match action["le"].as_str().map(str::trim).unwrap_or("") {
613                    "" => {}
614                    raw => {
615                        let le = hex::decode(raw)
616                            .map_err(|e| anyhow!("'le' must be two hex digits ('{raw}'): {e}"))?;
617                        match le.as_slice() {
618                            // '00' is the short-form "as much as you have" (256 bytes),
619                            // which is why it is not the same as omitting Le entirely.
620                            [byte] => apdu.push(*byte),
621                            other => {
622                                return Err(anyhow!(
623                                    "'le' must be exactly one byte (two hex digits) in a \
624                                     short-form APDU, got {} byte(s); use send_apdu_raw for the \
625                                     extended form",
626                                    other.len()
627                                ))
628                            }
629                        }
630                    }
631                }
632
633                Ok(ClientActionResult::Custom {
634                    name: "send_apdu".to_string(),
635                    data: json!({ "apdu_hex": hex::encode_upper(&apdu) }),
636                })
637            }
638            "send_apdu_raw" => {
639                let raw = action["apdu_hex"]
640                    .as_str()
641                    .ok_or_else(|| anyhow!("Missing 'apdu_hex' field"))?
642                    .trim();
643                // Decoded here rather than only in `mod.rs` so a static handler carrying a
644                // bad APDU fails where it is written, not one card exchange later.
645                let apdu = hex::decode(raw)
646                    .map_err(|e| anyhow!("'apdu_hex' is not valid hexadecimal ('{raw}'): {e}"))?;
647                if apdu.len() < APDU_HEADER_LEN {
648                    return Err(anyhow!(
649                        "'apdu_hex' is {} byte(s); an ISO 7816-4 command APDU is at least the \
650                         {APDU_HEADER_LEN}-byte CLA INS P1 P2 header",
651                        apdu.len()
652                    ));
653                }
654
655                Ok(ClientActionResult::Custom {
656                    name: "send_apdu".to_string(),
657                    data: json!({ "apdu_hex": hex::encode_upper(&apdu) }),
658                })
659            }
660            "read_ndef" => Ok(ClientActionResult::Custom {
661                name: "read_ndef".to_string(),
662                // `file_id` used to be dropped here while `mod.rs` looked for it, so the
663                // parameter could not do anything and every read went to E104.
664                data: json!({ "file_id": ndef_file_id_param(&action)? }),
665            }),
666            "write_ndef" => {
667                let records = action["records"]
668                    .as_array()
669                    .ok_or_else(|| anyhow!("Missing 'records' array"))?;
670                // Encode here, so a record the model cannot express is refused at the point
671                // it is written rather than becoming a half-written tag. `mod.rs` receives
672                // bytes it can put on the wire unchanged.
673                let message = crate::client::nfc::ndef::encode_message(records)?;
674
675                Ok(ClientActionResult::Custom {
676                    name: "write_ndef".to_string(),
677                    data: json!({
678                        "file_id": ndef_file_id_param(&action)?,
679                        "records": records,
680                        "message_hex": hex::encode_upper(&message),
681                    }),
682                })
683            }
684            "disconnect_card" => Ok(ClientActionResult::Disconnect),
685            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
686            _ => Err(anyhow!("Unknown action type: {}", action_type)),
687        }
688    }
689}