1use 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
19pub 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
42pub 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
67pub 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
105pub 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
163pub 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
174static 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
185const APDU_HEADER_LEN: usize = 4;
187
188const MAX_SHORT_LC: usize = 255;
191
192fn 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
212fn 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
232pub 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 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 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 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 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 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 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 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 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 [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 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 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 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}