Skip to main content

netget/client/nfc/
mod.rs

1//! NFC (Near Field Communication) client implementation
2//!
3//! Uses PC/SC (Personal Computer/Smart Card) API for cross-platform NFC reader support:
4//! - Windows: Native WinSCard.dll
5//! - macOS: Native PCSC framework
6//! - Linux: PCSC lite library (pcscd daemon)
7//!
8//! Supports ISO14443 A/B cards, MIFARE, NFC tags via APDU commands and NDEF messages.
9
10pub mod actions;
11pub mod ndef;
12
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::nfc::actions::*;
15use crate::llm::ollama_client::OllamaClient;
16use crate::protocol::Event;
17use crate::state::app_state::AppState;
18use crate::state::ClientId;
19use anyhow::{anyhow, Context, Result};
20use serde_json::{json, Value};
21use std::net::SocketAddr;
22use std::sync::Arc;
23use tokio::sync::mpsc;
24use tracing::{error, info, trace, warn};
25
26// Re-export protocol
27pub use actions::NfcClientProtocol;
28
29/// What applying one action to the PC/SC reader actually did.
30///
31/// `Sent` is reported only when an APDU was really handed to a card that
32/// answered — those bytes crossed the contactless interface. Everything else
33/// says specifically why nothing did.
34/// How many LLM turns one card exchange may take before the chain is cut.
35///
36/// Executing an event's answer makes the cycle real: an APDU produces
37/// `nfc_apdu_response`, whose answer may be another APDU — which is exactly the
38/// select-then-read conversation a smartcard requires, so it must be possible, and
39/// bounded rather than forbidden.
40const MAX_FOLLOWUP_DEPTH: u8 = 6;
41
42/// Largest `Le` a short-form READ BINARY can ask for. `Le` of `00` means 256, but asking for
43/// 255 keeps the request unambiguous and inside every tag's `MLe`.
44const MAX_SHORT_READ: usize = 255;
45
46/// Largest data field one short-form UPDATE BINARY carries. `Lc` reaches 255, and the margin
47/// keeps the whole command inside the 261-byte short-APDU envelope some readers enforce.
48const MAX_SHORT_WRITE: usize = 249;
49
50pub enum NfcApplied {
51    /// This many APDU bytes reached the card; the response APDU is attached so
52    /// the caller can log it.
53    Sent {
54        bytes_sent: usize,
55        response_hex: String,
56    },
57    /// The action ran but nothing reached a card; the string says why.
58    Executed(String),
59    /// The card session was ended.
60    Disconnected,
61}
62
63/// NFC client implementation
64pub struct NfcClient;
65
66impl NfcClient {
67    /// Connect to NFC reader and start LLM integration loop
68    pub async fn connect_with_llm_actions(
69        llm_client: OllamaClient,
70        app_state: Arc<AppState>,
71        status_tx: mpsc::UnboundedSender<String>,
72        client_id: ClientId,
73        startup_params: Value,
74    ) -> Result<SocketAddr> {
75        info!("Starting NFC client via PC/SC...");
76
77        // Extract reader selection from startup params
78        let reader_index = startup_params["reader_index"].as_u64().unwrap_or(0) as usize;
79        let reader_name = startup_params["reader_name"]
80            .as_str()
81            .map(|s| s.to_string());
82
83        // Create PC/SC context
84        let ctx = pcsc::Context::establish(pcsc::Scope::User)
85            .context("Failed to establish PC/SC context. Is pcscd running (Linux)?")?;
86
87        // List available readers
88        let readers_buf = ctx
89            .list_readers_owned()
90            .context("Failed to list PC/SC readers")?;
91
92        if readers_buf.is_empty() {
93            return Err(anyhow!(
94                "No PC/SC readers found. Please connect an NFC reader (e.g., ACR122U)"
95            ));
96        }
97
98        let readers: Vec<String> = readers_buf
99            .iter()
100            .map(|r| r.to_string_lossy().to_string())
101            .collect();
102
103        info!("Found {} PC/SC reader(s): {:?}", readers.len(), readers);
104
105        // Select reader. Keep the `CString` form: that is what `Context::connect`
106        // wants, and round-tripping through `String` can lose a non-UTF-8 name.
107        let selected_reader: std::ffi::CString = if let Some(name) = reader_name {
108            readers_buf
109                .iter()
110                .find(|r| r.to_string_lossy().contains(&name))
111                .ok_or_else(|| anyhow!("Reader '{}' not found", name))?
112                .clone()
113        } else {
114            readers_buf
115                .get(reader_index)
116                .ok_or_else(|| anyhow!("Reader index {} out of range", reader_index))?
117                .clone()
118        };
119
120        let selected_reader_name = selected_reader.to_string_lossy().to_string();
121        info!("Using PC/SC reader: {}", selected_reader_name);
122        let _ = status_tx.send(format!("Using NFC reader: {}", selected_reader_name));
123
124        // Command channel for injected actions (the dashboard's [ send ]).
125        // Registered BEFORE the readers-listed LLM call below: a dashboard-created
126        // client defaults to a `*` -> manual rule, so that call can park for minutes
127        // waiting for a human, and the operator must be able to reach the reader while
128        // it waits.
129        let command_rx =
130            crate::client::command_support::register_command_channel(&app_state, client_id).await;
131        let cmd_task = tokio::spawn({
132            let ctx = ctx.clone();
133            let reader = selected_reader.clone();
134            let app_state = app_state.clone();
135            let llm_client = llm_client.clone();
136            let status_tx = status_tx.clone();
137            async move {
138                Self::command_loop(
139                    command_rx, ctx, reader, client_id, app_state, llm_client, status_tx,
140                )
141                .await;
142            }
143        });
144        app_state.register_client_task(client_id, cmd_task).await;
145
146        // Watch the reader for cards arriving and leaving.
147        //
148        // nfc_card_detected and nfc_card_disconnected are both advertised in
149        // get_event_types(), so the model can be told a card will appear and write a
150        // handler for it -- and nothing raised either, so that handler waited forever. A
151        // card being presented is the entire trigger an NFC client exists to react to; its
152        // example answer is `read_ndef`, which is now implemented and reachable.
153        let watch_ctx = ctx.clone();
154        let watch_reader = selected_reader.clone();
155        let watch_state = app_state.clone();
156        let watch_llm = llm_client.clone();
157        let watch_tx = status_tx.clone();
158        let watch_task = tokio::spawn(async move {
159            let mut present = false;
160            loop {
161                if watch_state.get_client(client_id).await.is_none() {
162                    return;
163                }
164                // PC/SC is a blocking C API, so the probe runs on the blocking pool.
165                //
166                // `status2_owned` rather than `get_attribute(AtrString)`: it returns the ATR
167                // *and* the negotiated transmission protocol in one call, and `protocol` is
168                // declared on nfc_card_detected. It used to be declared and never emitted.
169                let probe_ctx = watch_ctx.clone();
170                let probe_reader = watch_reader.clone();
171                let probe = tokio::task::spawn_blocking(move || -> Option<(String, String)> {
172                    let card = probe_ctx
173                        .connect(&probe_reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY)
174                        .ok()?;
175                    let status = card.status2_owned().ok()?;
176                    let protocol = match status.protocol2() {
177                        Some(pcsc::Protocol::T0) => "T0",
178                        Some(pcsc::Protocol::T1) => "T1",
179                        Some(pcsc::Protocol::RAW) => "RAW",
180                        None => "undefined",
181                    };
182                    Some((hex::encode_upper(status.atr()), protocol.to_string()))
183                })
184                .await
185                .unwrap_or(None);
186
187                match (present, probe) {
188                    (false, Some((atr, protocol))) => {
189                        present = true;
190                        info!(
191                            "NFC client {} card detected (ATR {}, protocol {})",
192                            client_id, atr, protocol
193                        );
194                        Self::raise_card_event(
195                            &NFC_CARD_DETECTED_EVENT,
196                            serde_json::json!({ "atr": atr, "protocol": protocol }),
197                            &watch_ctx,
198                            &watch_reader,
199                            0,
200                            client_id,
201                            &watch_state,
202                            &watch_llm,
203                            &watch_tx,
204                        )
205                        .await;
206                    }
207                    (true, None) => {
208                        present = false;
209                        info!("NFC client {} card removed", client_id);
210                        Self::raise_card_event(
211                            &NFC_CARD_DISCONNECTED_EVENT,
212                            serde_json::json!({}),
213                            &watch_ctx,
214                            &watch_reader,
215                            0,
216                            client_id,
217                            &watch_state,
218                            &watch_llm,
219                            &watch_tx,
220                        )
221                        .await;
222                    }
223                    _ => {}
224                }
225                tokio::time::sleep(std::time::Duration::from_millis(500)).await;
226            }
227        });
228        app_state.register_client_task(client_id, watch_task).await;
229
230        // Send initial event to LLM: readers listed
231        {
232            let event = Event::new(
233                &NFC_READERS_LISTED_EVENT,
234                json!({ "readers": readers.clone() }),
235            );
236
237            // Get default instruction from startup params or use default
238            let instruction = startup_params["instruction"]
239                .as_str()
240                .unwrap_or("Monitor NFC reader and respond to card events");
241
242            // Create protocol instance for action definitions
243            let protocol = Arc::new(NfcClientProtocol);
244
245            // A failing readers-listed call must not kill the client: the command
246            // channel is already registered and an operator can drive the reader by
247            // hand, which is exactly the case a `*` -> manual rule sets up.
248            match call_llm_for_client(
249                &llm_client,
250                &app_state,
251                client_id.to_string(),
252                instruction,
253                "", // No memory yet
254                Some(&event),
255                protocol.as_ref(),
256                &status_tx,
257            )
258            .await
259            {
260                Ok(result) => {
261                    for action in result.actions {
262                        let applied = Self::apply_nfc_action(
263                            &ctx,
264                            &selected_reader,
265                            action.clone(),
266                            0,
267                            client_id,
268                            &app_state,
269                            &llm_client,
270                            &status_tx,
271                        )
272                        .await;
273                        if matches!(applied, Ok(NfcApplied::Disconnected)) {
274                            break;
275                        }
276                        if let Err(e) = applied {
277                            error!("NFC client {} action error: {}", client_id, e);
278                        }
279                    }
280                }
281                Err(e) => {
282                    error!("LLM error for NFC client {}: {}", client_id, e);
283                }
284            }
285        }
286
287        // Return dummy socket address (NFC doesn't use network sockets)
288        Ok(SocketAddr::from(([127, 0, 0, 1], 0)))
289    }
290
291    /// Drain injected commands until the channel closes (the client was removed,
292    /// which drops the handle) or an injected `disconnect_card` ends the session.
293    ///
294    /// The generic `command_support::handle_stream_client_command` cannot serve this
295    /// client: it owns no socket, and every NFC verb yields a
296    /// `ClientActionResult::Custom` that only PC/SC can carry out. So the action goes
297    /// through [`Self::apply_nfc_action`] — the same function the readers-listed LLM
298    /// path uses — and the outcome is recorded and replied the way the generic arm
299    /// does it.
300    async fn command_loop(
301        mut command_rx: tokio::sync::mpsc::Receiver<crate::state::client_handles::ClientCommand>,
302        ctx: pcsc::Context,
303        reader: std::ffi::CString,
304        client_id: ClientId,
305        app_state: Arc<AppState>,
306        llm_client: OllamaClient,
307        status_tx: mpsc::UnboundedSender<String>,
308    ) {
309        use crate::llm::actions::protocol_trait::Protocol;
310        use crate::state::client_handles::ClientSendOutcome;
311        use crate::state::AccessLogOwner;
312
313        let protocol = NfcClientProtocol;
314
315        while let Some(command) = command_rx.recv().await {
316            let action = command.action.clone();
317            let mut extra_log: Vec<serde_json::Value> = Vec::new();
318
319            let outcome: anyhow::Result<ClientSendOutcome> = match Self::apply_nfc_action(
320                &ctx,
321                &reader,
322                action.clone(),
323                0,
324                client_id,
325                &app_state,
326                &llm_client,
327                &status_tx,
328            )
329            .await
330            {
331                Ok(NfcApplied::Sent {
332                    bytes_sent,
333                    response_hex,
334                }) => {
335                    extra_log.push(json!({ "response_hex": response_hex }));
336                    Ok(ClientSendOutcome::Sent { bytes_sent })
337                }
338                Ok(NfcApplied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
339                Ok(NfcApplied::Disconnected) => Ok(ClientSendOutcome::Disconnected),
340                Err(e) => Ok(ClientSendOutcome::Rejected {
341                    error: e.to_string(),
342                }),
343            };
344
345            let mut responses = vec![match &outcome {
346                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
347                Err(e) => json!({ "error": e.to_string() }),
348            }];
349            responses.append(&mut extra_log);
350            app_state
351                .record_access_log(
352                    AccessLogOwner::Client(client_id.as_u32()),
353                    protocol.protocol_name(),
354                    None,
355                    "injected_action",
356                    action,
357                    responses,
358                )
359                .await;
360
361            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
362            let _ = status_tx.send("__UPDATE_UI__".to_string());
363            crate::client::command_support::reply(command, outcome);
364
365            if disconnect {
366                break;
367            }
368        }
369
370        // Every exit path lands here: drop the command handle so the dashboard stops
371        // offering [ send ] on a client whose loop is gone.
372        app_state.remove_client_handle(client_id).await;
373        let _ = status_tx.send("__UPDATE_UI__".to_string());
374    }
375
376    /// Carry one action out against the reader. Shared by the readers-listed LLM path
377    /// and injected commands so the PC/SC calls exist exactly once.
378    ///
379    /// `Err` means the protocol rejected the action (unknown verb / bad params); a
380    /// PC/SC failure is `Ok(Executed(..))` with the reason, because the action did run.
381    /// Raise one card-presence event at the model.
382    ///
383    /// Executes nothing it answers with: the command loop owns the card path, and a
384    /// presence-driven action chain would re-fire every time a card is tapped.
385    #[allow(clippy::too_many_arguments)]
386    async fn raise_card_event(
387        event_type: &'static std::sync::LazyLock<crate::protocol::EventType>,
388        data: serde_json::Value,
389        ctx: &pcsc::Context,
390        reader: &std::ffi::CStr,
391        depth: u8,
392        client_id: ClientId,
393        app_state: &Arc<AppState>,
394        llm_client: &OllamaClient,
395        status_tx: &mpsc::UnboundedSender<String>,
396    ) {
397        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
398            return;
399        };
400        let memory = app_state
401            .get_memory_for_client(client_id)
402            .await
403            .unwrap_or_default();
404        let event = Event::new(event_type, data);
405        match call_llm_for_client(
406            llm_client,
407            app_state,
408            client_id.to_string(),
409            &instruction,
410            &memory,
411            Some(&event),
412            &NfcClientProtocol,
413            status_tx,
414        )
415        .await
416        {
417            Ok(result) => {
418                if let Some(mem) = result.memory_updates {
419                    app_state.set_memory_for_client(client_id, mem).await;
420                }
421                Self::run_followups(
422                    result.actions,
423                    ctx,
424                    reader,
425                    depth,
426                    client_id,
427                    app_state,
428                    llm_client,
429                    status_tx,
430                )
431                .await;
432            }
433            Err(e) => {
434                error!("NFC client {} LLM error on card event: {}", client_id, e);
435            }
436        }
437    }
438
439    /// NDEF file identifier, defaulting to E104 -- the value nearly every Type 4 tag uses
440    /// and the one the Capability Container normally points at.
441    fn ndef_file_id(data: &serde_json::Value) -> [u8; 2] {
442        data.get("file_id")
443            .and_then(|v| v.as_str())
444            .and_then(|s| hex::decode(s.trim()).ok())
445            .filter(|b| b.len() == 2)
446            .map(|b| [b[0], b[1]])
447            .unwrap_or([0xE1, 0x04])
448    }
449
450    /// Transmit one APDU on a freshly-connected card. Blocking PC/SC, off the runtime.
451    async fn transmit_apdu(
452        ctx: &pcsc::Context,
453        reader: &std::ffi::CStr,
454        apdu: Vec<u8>,
455    ) -> Result<Vec<u8>> {
456        let ctx = ctx.clone();
457        let reader = reader.to_owned();
458        tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
459            let card = ctx
460                .connect(&reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY)
461                .context("PC/SC connect failed (is a card on the reader?)")?;
462            let mut rx = vec![0u8; pcsc::MAX_BUFFER_SIZE];
463            let response = card
464                .transmit(&apdu, &mut rx)
465                .context("PC/SC transmit failed")?;
466            Ok(response.to_vec())
467        })
468        .await
469        .context("PC/SC blocking task panicked")?
470    }
471
472    /// True when an APDU response ends in the success status word 0x9000.
473    fn apdu_ok(response: &[u8]) -> bool {
474        response.len() >= 2 && response[response.len() - 2..] == [0x90, 0x00]
475    }
476
477    /// Read the NDEF message from an NFC Forum Type 4 tag.
478    ///
479    /// The sequence is fixed by NFC Forum Type 4 Tag Operation: SELECT the NDEF
480    /// application by AID D2760000850101, SELECT the NDEF file by `file_id`, then READ
481    /// BINARY -- first the two-byte NLEN, then that many bytes. It is expressed here as
482    /// ordinary APDUs so it rides the same PC/SC transmit path `send_apdu` uses.
483    ///
484    /// **It does not read the Capability Container**, though this comment used to say it
485    /// took the file ID from there. `file_id` is a parameter defaulting to E104, which is
486    /// what nearly every tag's CC points at; the CC's `MLe`/`MLc` read and write limits are
487    /// ignored too, so a tag that answers fewer bytes than asked is the case below.
488    ///
489    /// Untested against real hardware: this machine has no PC/SC reader. The byte
490    /// sequences are taken from the specification rather than observed, which is what
491    /// `Experimental` is for -- see `src/client/nfc/CLAUDE.md`.
492    async fn read_ndef(
493        ctx: &pcsc::Context,
494        reader: &std::ffi::CStr,
495        file_id: [u8; 2],
496    ) -> Result<Vec<u8>> {
497        // SELECT the NDEF application (by name).
498        let select_app = vec![
499            0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00,
500        ];
501        let r = Self::transmit_apdu(ctx, reader, select_app).await?;
502        if !Self::apdu_ok(&r) {
503            anyhow::bail!(
504                "SELECT NDEF application refused (SW {})",
505                hex::encode_upper(&r)
506            );
507        }
508
509        // SELECT the NDEF file (by identifier).
510        let select_file = vec![0x00, 0xA4, 0x00, 0x0C, 0x02, file_id[0], file_id[1]];
511        let r = Self::transmit_apdu(ctx, reader, select_file).await?;
512        if !Self::apdu_ok(&r) {
513            anyhow::bail!("SELECT NDEF file refused (SW {})", hex::encode_upper(&r));
514        }
515
516        // READ BINARY the 2-byte NLEN header, then the message itself.
517        let r = Self::transmit_apdu(ctx, reader, vec![0x00, 0xB0, 0x00, 0x00, 0x02]).await?;
518        if !Self::apdu_ok(&r) || r.len() < 4 {
519            anyhow::bail!("READ BINARY of NLEN failed (SW {})", hex::encode_upper(&r));
520        }
521        let nlen = u16::from_be_bytes([r[0], r[1]]) as usize;
522        if nlen == 0 {
523            return Ok(Vec::new());
524        }
525
526        let mut message = Vec::with_capacity(nlen);
527        let mut offset = 2usize;
528        while message.len() < nlen {
529            // `Le` is a maximum, not a promise: a tag may answer fewer bytes than asked,
530            // and its CC's MLe may be well under 255. So the offset advances by what came
531            // *back*, not by what was requested - advancing by the request skipped every
532            // byte the tag withheld, and a tag answering zero data bytes made this loop
533            // run forever, hammering the card.
534            let want = std::cmp::min(MAX_SHORT_READ, nlen - message.len());
535            let offset_bytes = Self::read_binary_offset(offset)?;
536            let apdu = vec![
537                0x00,
538                0xB0,
539                offset_bytes[0],
540                offset_bytes[1],
541                // Bounded by MAX_SHORT_READ (255) immediately above.
542                want as u8,
543            ];
544            let r = Self::transmit_apdu(ctx, reader, apdu).await?;
545            if !Self::apdu_ok(&r) {
546                anyhow::bail!("READ BINARY failed (SW {})", hex::encode_upper(&r));
547            }
548            let got = &r[..r.len() - 2];
549            if got.is_empty() {
550                anyhow::bail!(
551                    "READ BINARY returned 9000 with no data at offset {offset} while {} of {} \
552                     byte(s) are still outstanding; the tag is not making progress",
553                    nlen - message.len(),
554                    nlen
555                );
556            }
557            message.extend_from_slice(got);
558            offset += got.len();
559        }
560        message.truncate(nlen);
561        Ok(message)
562    }
563
564    /// P1 P2 of a READ/UPDATE BINARY, refusing an offset the two bytes cannot hold.
565    ///
566    /// `(offset >> 8) as u8` wraps silently past 65535, and a wrapped offset is not a failed
567    /// command: it is a *successful* read or write of the wrong part of the file.
568    fn read_binary_offset(offset: usize) -> Result<[u8; 2]> {
569        let offset = u16::try_from(offset).map_err(|_| {
570            anyhow!(
571                "offset {offset} does not fit the two P1 P2 bytes of a short-form \
572                 READ/UPDATE BINARY (max {})",
573                u16::MAX
574            )
575        })?;
576        Ok(offset.to_be_bytes())
577    }
578
579    /// Write an NDEF message to an NFC Forum Type 4 tag.
580    ///
581    /// NLEN is zeroed first and written last, which the specification requires: a reader
582    /// that interrupts the write mid-way then sees an empty message rather than a
583    /// truncated one it would parse as valid.
584    ///
585    /// Untested against real hardware, as for [`Self::read_ndef`].
586    async fn write_ndef(
587        ctx: &pcsc::Context,
588        reader: &std::ffi::CStr,
589        file_id: [u8; 2],
590        message: &[u8],
591    ) -> Result<()> {
592        // Checked before anything is written, and before the `as u16` that publishes NLEN
593        // below. A 70 000-byte message would otherwise write NLEN = 4464 over wrapped
594        // offsets: a tag left holding a body that describes itself wrongly, which no
595        // reader can recover from and nothing on this side would have reported.
596        if message.len() > ndef::MAX_MESSAGE_LEN {
597            anyhow::bail!(
598                "NDEF message is {} bytes; a Type 4 tag's two-byte NLEN describes at most {}",
599                message.len(),
600                ndef::MAX_MESSAGE_LEN
601            );
602        }
603
604        let select_app = vec![
605            0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00,
606        ];
607        let r = Self::transmit_apdu(ctx, reader, select_app).await?;
608        if !Self::apdu_ok(&r) {
609            anyhow::bail!(
610                "SELECT NDEF application refused (SW {})",
611                hex::encode_upper(&r)
612            );
613        }
614        let select_file = vec![0x00, 0xA4, 0x00, 0x0C, 0x02, file_id[0], file_id[1]];
615        let r = Self::transmit_apdu(ctx, reader, select_file).await?;
616        if !Self::apdu_ok(&r) {
617            anyhow::bail!("SELECT NDEF file refused (SW {})", hex::encode_upper(&r));
618        }
619
620        // NLEN = 0 while the body is in flight.
621        let r = Self::transmit_apdu(ctx, reader, vec![0x00, 0xD6, 0x00, 0x00, 0x02, 0x00, 0x00])
622            .await?;
623        if !Self::apdu_ok(&r) {
624            anyhow::bail!(
625                "UPDATE BINARY of NLEN failed (SW {})",
626                hex::encode_upper(&r)
627            );
628        }
629
630        let mut written = 0usize;
631        while written < message.len() {
632            let chunk = std::cmp::min(MAX_SHORT_WRITE, message.len() - written);
633            let offset = 2 + written;
634            let offset_bytes = Self::read_binary_offset(offset)?;
635            let mut apdu = vec![
636                0x00,
637                0xD6,
638                offset_bytes[0],
639                offset_bytes[1],
640                // Bounded by MAX_SHORT_WRITE immediately above.
641                chunk as u8,
642            ];
643            apdu.extend_from_slice(&message[written..written + chunk]);
644            let r = Self::transmit_apdu(ctx, reader, apdu).await?;
645            if !Self::apdu_ok(&r) {
646                anyhow::bail!("UPDATE BINARY failed (SW {})", hex::encode_upper(&r));
647            }
648            written += chunk;
649        }
650
651        // Publish the real length last. Range-checked at the top of this function, so the
652        // cast is the safe one it looks like.
653        let nlen = (message.len() as u16).to_be_bytes();
654        let r = Self::transmit_apdu(
655            ctx,
656            reader,
657            vec![0x00, 0xD6, 0x00, 0x00, 0x02, nlen[0], nlen[1]],
658        )
659        .await?;
660        if !Self::apdu_ok(&r) {
661            anyhow::bail!(
662                "UPDATE BINARY of final NLEN failed (SW {})",
663                hex::encode_upper(&r)
664            );
665        }
666        Ok(())
667    }
668
669    /// Carry out what the model answered an NFC event with, bounded by [`MAX_FOLLOWUP_DEPTH`].
670    ///
671    /// The three notify paths used to be `if let Err(..)` with no success arm, so every answer
672    /// was dropped. `nfc_apdu_response` is the one that mattered most: a smartcard exchange is
673    /// inherently a conversation (SELECT, then READ BINARY against what SELECT returned), and
674    /// the model was told what the card said and then never asked again.
675    #[allow(clippy::too_many_arguments)]
676    async fn run_followups(
677        actions: Vec<serde_json::Value>,
678        ctx: &pcsc::Context,
679        reader: &std::ffi::CStr,
680        depth: u8,
681        client_id: ClientId,
682        app_state: &Arc<AppState>,
683        llm_client: &OllamaClient,
684        status_tx: &mpsc::UnboundedSender<String>,
685    ) {
686        if actions.is_empty() {
687            return;
688        }
689        if depth >= MAX_FOLLOWUP_DEPTH {
690            warn!(
691                "NFC client {} stopped a follow-up chain at depth {}: {} action(s) not executed",
692                client_id,
693                depth,
694                actions.len()
695            );
696            let _ = status_tx.send(format!(
697                "[CLIENT] ⚠ nfc client {} hit the follow-up depth limit ({}); {} action(s) were \
698                 not executed",
699                client_id,
700                MAX_FOLLOWUP_DEPTH,
701                actions.len()
702            ));
703            return;
704        }
705        for action in actions {
706            match Self::apply_nfc_action(
707                ctx,
708                reader,
709                action,
710                depth + 1,
711                client_id,
712                app_state,
713                llm_client,
714                status_tx,
715            )
716            .await
717            {
718                Ok(NfcApplied::Disconnected) => return,
719                Ok(_) => trace!("NFC client {} follow-up action completed", client_id),
720                Err(e) => {
721                    error!("NFC client {} follow-up action failed: {:#}", client_id, e);
722                    let _ = status_tx.send(format!(
723                        "[CLIENT] nfc client {client_id} follow-up failed: {e}"
724                    ));
725                }
726            }
727        }
728    }
729
730    /// Returns an explicitly boxed future rather than being an `async fn`: the chain
731    /// `apply_nfc_action` -> `notify_apdu_response` -> `run_followups` -> `apply_nfc_action`
732    /// is a cycle of `async fn`s whose opaque return types cannot be inferred (E0391).
733    #[allow(clippy::too_many_arguments)]
734    fn apply_nfc_action<'a>(
735        ctx: &'a pcsc::Context,
736        reader: &'a std::ffi::CStr,
737        action: Value,
738        depth: u8,
739        client_id: ClientId,
740        app_state: &'a Arc<AppState>,
741        llm_client: &'a OllamaClient,
742        status_tx: &'a mpsc::UnboundedSender<String>,
743    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<NfcApplied>> + Send + 'a>> {
744        Box::pin(async move {
745            use crate::llm::actions::client_trait::{Client, ClientActionResult};
746
747            match NfcClientProtocol.execute_action(action)? {
748                ClientActionResult::Custom { name, data } if name == "send_apdu" => {
749                    let apdu_hex = data
750                        .get("apdu_hex")
751                        .and_then(|v| v.as_str())
752                        .context("Missing apdu_hex in action data")?
753                        .to_string();
754                    let apdu = hex::decode(apdu_hex.trim())
755                        .context("apdu_hex is not valid hexadecimal")?;
756                    let sent = apdu.len();
757
758                    // PC/SC is a blocking C API: connect to whatever card is on the
759                    // reader, transmit, and drop the card handle, all on a blocking
760                    // thread. The handle is not kept between commands, so a card that
761                    // was removed and re-presented still works.
762                    let card_ctx = ctx.clone();
763                    let card_reader = reader.to_owned();
764                    let transmit = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
765                        let card = card_ctx
766                            .connect(&card_reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY)
767                            .context("PC/SC connect failed (is a card on the reader?)")?;
768                        let mut rx = vec![0u8; pcsc::MAX_BUFFER_SIZE];
769                        let response = card
770                            .transmit(&apdu, &mut rx)
771                            .context("PC/SC transmit failed")?;
772                        Ok(response.to_vec())
773                    })
774                    .await
775                    .context("PC/SC blocking task panicked")?;
776
777                    match transmit {
778                        Ok(response) => {
779                            let response_hex = hex::encode_upper(&response);
780                            info!(
781                                "NFC client {} APDU {} bytes -> response {}",
782                                client_id, sent, response_hex
783                            );
784                            let _ = status_tx.send(format!(
785                                "[CLIENT] NFC client {} APDU response: {}",
786                                client_id, response_hex
787                            ));
788
789                            // `nfc_apdu_response` was declared and never emitted; an
790                            // injected APDU is the first thing that actually raises it.
791                            Self::notify_apdu_response(
792                                &response, &ctx, &reader, depth, client_id, app_state, llm_client,
793                                status_tx,
794                            )
795                            .await;
796
797                            Ok(NfcApplied::Sent {
798                                bytes_sent: sent,
799                                response_hex,
800                            })
801                        }
802                        Err(e) => {
803                            warn!("NFC client {} APDU failed: {:#}", client_id, e);
804                            Ok(NfcApplied::Executed(format!(
805                                "send_apdu did not reach a card: {e:#}"
806                            )))
807                        }
808                    }
809                }
810                ClientActionResult::Custom { name, data } if name == "read_ndef" => {
811                    let file_id = Self::ndef_file_id(&data);
812                    match Self::read_ndef(ctx, reader, file_id).await {
813                        Ok(message) => {
814                            let hex_msg = hex::encode_upper(&message);
815                            info!(
816                                "NFC client {} read {} byte NDEF message",
817                                client_id,
818                                message.len()
819                            );
820                            // NFC_NDEF_READ_EVENT was declared and never emitted; nothing
821                            // could read an NDEF message at all until now.
822                            Self::notify_ndef_read(
823                                &message, &ctx, &reader, depth, client_id, app_state, llm_client,
824                                status_tx,
825                            )
826                            .await;
827                            Ok(NfcApplied::Executed(format!(
828                                "read_ndef: {} bytes ({})",
829                                message.len(),
830                                if hex_msg.is_empty() {
831                                    "empty"
832                                } else {
833                                    &hex_msg
834                                }
835                            )))
836                        }
837                        Err(e) => Ok(NfcApplied::Executed(format!("read_ndef failed: {e:#}"))),
838                    }
839                }
840                ClientActionResult::Custom { name, data } if name == "write_ndef" => {
841                    let file_id = Self::ndef_file_id(&data);
842                    // `execute_action` encoded the model's typed records into NDEF bytes and
843                    // put them here. Before that existed this arm read `message_hex` or
844                    // `message`, which the action declared nowhere and `execute_action`
845                    // never produced - so every write_ndef ended in "write_ndef needs
846                    // message_hex or message" and the verb could not write anything at all.
847                    let record_count = data
848                        .get("records")
849                        .and_then(|v| v.as_array())
850                        .map(|r| r.len())
851                        .unwrap_or(0);
852                    let bytes =
853                        match data.get("message_hex").and_then(|v| v.as_str()) {
854                            Some(h) => match hex::decode(h.trim()) {
855                                Ok(b) => b,
856                                Err(e) => {
857                                    return Ok(NfcApplied::Executed(format!(
858                                        "write_ndef: encoded message is not valid hexadecimal: {e}"
859                                    )))
860                                }
861                            },
862                            None => return Ok(NfcApplied::Executed(
863                                "write_ndef: no encoded message; 'records' must be a non-empty \
864                                 array of typed NDEF records"
865                                    .to_string(),
866                            )),
867                        };
868                    match Self::write_ndef(ctx, reader, file_id, &bytes).await {
869                        Ok(()) => Ok(NfcApplied::Sent {
870                            bytes_sent: bytes.len(),
871                            response_hex: format!(
872                                "write_ndef ok ({record_count} record(s), {} NDEF byte(s))",
873                                bytes.len()
874                            ),
875                        }),
876                        Err(e) => Ok(NfcApplied::Executed(format!("write_ndef failed: {e:#}"))),
877                    }
878                }
879                ClientActionResult::Custom { name, .. } => Ok(NfcApplied::Executed(format!(
880                    "'{name}' is declared but not implemented by the NFC client; \
881                 send_apdu / send_apdu_raw / read_ndef / write_ndef reach the card \
882                 (see src/client/nfc/CLAUDE.md)"
883                ))),
884                ClientActionResult::Disconnect => {
885                    info!("NFC client {} disconnecting card session", client_id);
886                    app_state
887                        .update_client_status(client_id, crate::state::ClientStatus::Disconnected)
888                        .await;
889                    // Drop the command handle here rather than only in the command loop:
890                    // the LLM can disconnect too, and a handle left behind would offer
891                    // [ send ] into a client that is gone.
892                    app_state.remove_client_handle(client_id).await;
893                    let _ = status_tx.send("__UPDATE_UI__".to_string());
894                    Ok(NfcApplied::Disconnected)
895                }
896                ClientActionResult::WaitForMore => {
897                    Ok(NfcApplied::Executed("wait_for_more".to_string()))
898                }
899                other => Ok(NfcApplied::Executed(format!(
900                    "unhandled action result {other:?}"
901                ))),
902            }
903        })
904    }
905
906    /// Raise `nfc_ndef_read` so the model can act on the message that was read.
907    ///
908    /// The event was declared and never emitted, because nothing could read an NDEF
909    /// message at all.
910    #[allow(clippy::too_many_arguments)]
911    async fn notify_ndef_read(
912        message: &[u8],
913        ctx: &pcsc::Context,
914        reader: &std::ffi::CStr,
915        depth: u8,
916        client_id: ClientId,
917        app_state: &Arc<AppState>,
918        llm_client: &OllamaClient,
919        status_tx: &mpsc::UnboundedSender<String>,
920    ) {
921        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
922            return;
923        };
924        // `records` is what the event declares and what the model is told to expect; it was
925        // declared and never emitted, because nothing decoded NDEF. `ndef::decode_message`
926        // walks the records iteratively and never descends into a nested payload, so a
927        // hostile tag cannot stack-overflow the process here.
928        let records = ndef::decode_message(message).unwrap_or_default();
929        let event = Event::new(
930            &NFC_NDEF_READ_EVENT,
931            json!({
932                "records": records,
933                "length": message.len(),
934                // The hex stays authoritative: `records` is a best-effort decode of it.
935                "message_hex": hex::encode_upper(message),
936                "message_text": String::from_utf8_lossy(message).to_string(),
937            }),
938        );
939        let memory = app_state
940            .get_memory_for_client(client_id)
941            .await
942            .unwrap_or_default();
943        match call_llm_for_client(
944            llm_client,
945            app_state,
946            client_id.to_string(),
947            &instruction,
948            &memory,
949            Some(&event),
950            &NfcClientProtocol,
951            status_tx,
952        )
953        .await
954        {
955            Ok(result) => {
956                if let Some(mem) = result.memory_updates {
957                    app_state.set_memory_for_client(client_id, mem).await;
958                }
959                Self::run_followups(
960                    result.actions,
961                    ctx,
962                    reader,
963                    depth,
964                    client_id,
965                    app_state,
966                    llm_client,
967                    status_tx,
968                )
969                .await;
970            }
971            Err(e) => {
972                error!("NFC client {} LLM error on nfc_ndef_read: {}", client_id, e);
973            }
974        }
975    }
976
977    /// Raise `nfc_apdu_response` so the model can act on what the card answered.
978    #[allow(clippy::too_many_arguments)]
979    async fn notify_apdu_response(
980        response: &[u8],
981        ctx: &pcsc::Context,
982        reader: &std::ffi::CStr,
983        depth: u8,
984        client_id: ClientId,
985        app_state: &Arc<AppState>,
986        llm_client: &OllamaClient,
987        status_tx: &mpsc::UnboundedSender<String>,
988    ) {
989        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
990            return;
991        };
992        let (data, sw) = if response.len() >= 2 {
993            response.split_at(response.len() - 2)
994        } else {
995            (response, &[][..])
996        };
997        let event = Event::new(
998            &NFC_APDU_RESPONSE_EVENT,
999            json!({
1000                "response_hex": hex::encode_upper(response),
1001                "sw1": sw.first().map(|b| format!("{b:02X}")).unwrap_or_default(),
1002                "sw2": sw.get(1).map(|b| format!("{b:02X}")).unwrap_or_default(),
1003                "data_hex": hex::encode_upper(data),
1004            }),
1005        );
1006        let memory = app_state
1007            .get_memory_for_client(client_id)
1008            .await
1009            .unwrap_or_default();
1010        match call_llm_for_client(
1011            llm_client,
1012            app_state,
1013            client_id.to_string(),
1014            &instruction,
1015            &memory,
1016            Some(&event),
1017            &NfcClientProtocol,
1018            status_tx,
1019        )
1020        .await
1021        {
1022            Ok(result) => {
1023                if let Some(mem) = result.memory_updates {
1024                    app_state.set_memory_for_client(client_id, mem).await;
1025                }
1026                Self::run_followups(
1027                    result.actions,
1028                    ctx,
1029                    reader,
1030                    depth,
1031                    client_id,
1032                    app_state,
1033                    llm_client,
1034                    status_tx,
1035                )
1036                .await;
1037            }
1038            Err(e) => {
1039                error!("LLM error on nfc_apdu_response for {}: {}", client_id, e);
1040            }
1041        }
1042    }
1043}