NFC tag writer: local web page for ACR1252U (NDEF URL/text on Type 2 tags)

This commit is contained in:
grabowski committed 2026-10-29 08:57:36 +07:00
commit 449cdb7af5
8 files changed
+1496

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
mod ndef;
mod reader;
use axum::{
Json, Router,
extract::State,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use serde::Deserialize;
use serde_json::json;
use std::sync::{Arc, Mutex};
const INDEX: &str = include_str!("../static/index.html");
/// One reader, one operation at a time: the mutex serialises status polls and writes.
type Shared = Arc<Mutex<reader::Cache>>;
#[tokio::main]
async fn main() {
let port: u16 = std::env::var("NFC_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(7252);
let app = Router::new()
.route("/", get(|| async { Html(INDEX) }))
.route("/api/status", get(status))
.route("/api/program", post(program))
.with_state(Shared::default());
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
println!("NFC writer running on http://{addr}");
axum::serve(listener, app).await.expect("server error");
}
async fn with_reader<T: Send + 'static>(
s: Shared,
f: impl FnOnce(&mut reader::Cache) -> T + Send + 'static,
) -> Result<T, String> {
tokio::task::spawn_blocking(move || {
let mut cache = s.lock().unwrap_or_else(|e| e.into_inner());
f(&mut cache)
})
.await
.map_err(|e| format!("internal error: {e}"))
}
async fn status(State(s): State<Shared>) -> Json<reader::Status> {
Json(
with_reader(s, reader::status)
.await
.unwrap_or_else(|message| reader::Status::Error { message }),
)
}
#[derive(Deserialize)]
struct ProgramRequest {
text: String,
}
async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Response {
match with_reader(s, move |c| reader::program(c, &req.text)).await {
Ok(Ok(done)) => Json(done).into_response(),
Ok(Err(error)) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": error })),
)
.into_response(),
Err(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": error })),
)
.into_response(),
}
}
+358
View File
@@ -0,0 +1,358 @@
//! NDEF encoding/decoding for NFC Forum Type 2 tags (NTAG21x, MIFARE Ultralight).
use serde::Serialize;
/// URI identifier codes (NFC Forum URI RTD), index = code.
const URI_PREFIXES: [&str; 36] = [
"",
"http://www.",
"https://www.",
"http://",
"https://",
"tel:",
"mailto:",
"ftp://anonymous:anonymous@",
"ftp://ftp.",
"ftps://",
"sftp://",
"smb://",
"nfs://",
"ftp://",
"dav://",
"news:",
"telnet://",
"imap:",
"rtsp://",
"urn:",
"pop:",
"sip:",
"sips:",
"tftp:",
"btspp://",
"btl2cap://",
"btgoep://",
"tcpobex://",
"irdaobex://",
"file://",
"urn:epc:id:",
"urn:epc:tag:",
"urn:epc:pat:",
"urn:epc:raw:",
"urn:epc:",
"urn:nfc:",
];
/// Schemes without `//` that phones handle and that we treat as URLs.
const BARE_SCHEMES: [&str; 8] = [
"tel:", "mailto:", "sms:", "smsto:", "geo:", "sip:", "sips:", "urn:",
];
#[derive(Debug, Clone, PartialEq)]
pub enum Record {
Uri(String),
Text(String),
}
impl Record {
/// URL-looking input becomes a URI record, everything else a text record.
pub fn auto(input: &str) -> Record {
let s = input.trim();
let lower = s.to_ascii_lowercase();
if s.contains(char::is_whitespace) {
return Record::Text(s.to_string());
}
if lower.starts_with("www.") {
return Record::Uri(format!("https://{s}"));
}
if has_scheme_slashes(&lower) || BARE_SCHEMES.iter().any(|p| lower.starts_with(p)) {
return Record::Uri(s.to_string());
}
Record::Text(s.to_string())
}
pub fn view(&self) -> RecordView {
match self {
Record::Uri(u) => RecordView {
kind: "URL".into(),
value: u.clone(),
},
Record::Text(t) => RecordView {
kind: "Text".into(),
value: t.clone(),
},
}
}
}
fn has_scheme_slashes(s: &str) -> bool {
match s.find("://") {
Some(i) if i > 0 => {
let scheme = &s[..i];
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
}
_ => false,
}
}
/// A decoded record, ready for display.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RecordView {
pub kind: String,
pub value: String,
}
fn uri_abbreviate(uri: &str) -> (u8, &str) {
URI_PREFIXES
.iter()
.enumerate()
.skip(1)
.filter(|(_, p)| uri.starts_with(*p))
.max_by_key(|(_, p)| p.len())
.map(|(i, p)| (i as u8, &uri[p.len()..]))
.unwrap_or((0, uri))
}
/// Encode a single-record NDEF message.
pub fn encode(record: &Record) -> Vec<u8> {
let (typ, payload) = match record {
Record::Uri(u) => {
let (code, rest) = uri_abbreviate(u);
let mut p = vec![code];
p.extend_from_slice(rest.as_bytes());
(b'U', p)
}
Record::Text(t) => {
let mut p = vec![2u8]; // UTF-8, language code length 2
p.extend_from_slice(b"en");
p.extend_from_slice(t.as_bytes());
(b'T', p)
}
};
let short = payload.len() < 256;
// MB | ME | (SR) | TNF=well-known
let mut out = vec![0x80 | 0x40 | if short { 0x10 } else { 0 } | 0x01, 1];
if short {
out.push(payload.len() as u8);
} else {
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
}
out.push(typ);
out.extend_from_slice(&payload);
out
}
/// Wrap an NDEF message in an NDEF TLV followed by a terminator TLV.
pub fn tlv(message: &[u8]) -> Vec<u8> {
let mut out = vec![0x03];
if message.len() < 0xFF {
out.push(message.len() as u8);
} else {
out.push(0xFF);
out.extend_from_slice(&(message.len() as u16).to_be_bytes());
}
out.extend_from_slice(message);
out.push(0xFE);
out
}
/// Find the NDEF message in a Type 2 data area. Returns the message and the
/// number of bytes used up to the end of the NDEF TLV.
pub fn find_message(area: &[u8]) -> Result<Option<(&[u8], usize)>, String> {
let mut i = 0;
while i < area.len() {
match area[i] {
0x00 => i += 1, // NULL TLV
0xFE => return Ok(None), // terminator before any NDEF TLV
t => {
let (len, header) = match area.get(i + 1) {
Some(0xFF) => {
let b = area.get(i + 2..i + 4).ok_or("truncated TLV length")?;
(u16::from_be_bytes([b[0], b[1]]) as usize, 4)
}
Some(&l) => (l as usize, 2),
None => return Err("truncated TLV".into()),
};
let start = i + header;
let end = start + len;
if t == 0x03 {
let msg = area
.get(start..end)
.ok_or("NDEF TLV runs past end of tag")?;
return Ok(Some((msg, end)));
}
i = end; // lock/memory control or proprietary TLV
}
}
}
Ok(None)
}
/// Decode the records of an NDEF message.
pub fn decode(msg: &[u8]) -> Result<Vec<RecordView>, String> {
let mut out = Vec::new();
let mut i = 0;
let mut take = |n: usize| -> Result<&[u8], String> {
let s = msg.get(i..i + n).ok_or("NDEF record truncated")?;
i += n;
Ok(s)
};
if msg.is_empty() {
return Ok(out);
}
loop {
let h = take(1)?[0];
let tnf = h & 0x07;
let type_len = take(1)?[0] as usize;
let payload_len = if h & 0x10 != 0 {
take(1)?[0] as usize
} else {
let b = take(4)?;
u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize
};
let id_len = if h & 0x08 != 0 {
take(1)?[0] as usize
} else {
0
};
let typ = take(type_len)?.to_vec();
take(id_len)?;
let payload = take(payload_len)?;
out.push(view(tnf, &typ, payload));
if h & 0x40 != 0 {
break;
}
}
Ok(out)
}
fn view(tnf: u8, typ: &[u8], p: &[u8]) -> RecordView {
let (kind, value) = match (tnf, typ) {
(0, _) => ("Empty".to_string(), String::new()),
(1, b"U") if !p.is_empty() => {
let prefix = URI_PREFIXES.get(p[0] as usize).copied().unwrap_or("");
(
"URL".into(),
format!("{prefix}{}", String::from_utf8_lossy(&p[1..])),
)
}
(1, b"T") if !p.is_empty() => {
let lang_len = (p[0] & 0x3F) as usize;
let body = p.get(1 + lang_len..).unwrap_or_default();
let text = if p[0] & 0x80 != 0 {
let units: Vec<u16> = body
.as_chunks::<2>()
.0
.iter()
.map(|c| u16::from_be_bytes(*c))
.collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(body).into_owned()
};
("Text".into(), text)
}
(1, b"Sp") => ("Smart poster".into(), format!("{} bytes", p.len())),
(2, mime) => {
let mime = String::from_utf8_lossy(mime).into_owned();
let value = if mime.starts_with("text/") {
String::from_utf8_lossy(p).into_owned()
} else {
format!("{} bytes", p.len())
};
(format!("MIME {mime}"), value)
}
(4, ext) => (
format!("External {}", String::from_utf8_lossy(ext)),
format!("{} bytes", p.len()),
),
_ => (format!("TNF {tnf}"), format!("{} bytes", p.len())),
};
RecordView { kind, value }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_detects_urls() {
assert_eq!(
Record::auto("https://buildfor.life"),
Record::Uri("https://buildfor.life".into())
);
assert_eq!(
Record::auto("www.b4l.co.th"),
Record::Uri("https://www.b4l.co.th".into())
);
assert_eq!(
Record::auto("tel:+6653123456"),
Record::Uri("tel:+6653123456".into())
);
assert_eq!(
Record::auto("spotify://track/1"),
Record::Uri("spotify://track/1".into())
);
assert_eq!(
Record::auto("Note: hello"),
Record::Text("Note: hello".into())
);
assert_eq!(
Record::auto("Hello world"),
Record::Text("Hello world".into())
);
assert_eq!(Record::auto("Shelf-A3"), Record::Text("Shelf-A3".into()));
}
#[test]
fn encodes_uri_with_prefix_code() {
let msg = encode(&Record::Uri("https://buildfor.life".into()));
let mut want = vec![0xD1, 0x01, 14, b'U', 0x04];
want.extend_from_slice(b"buildfor.life");
assert_eq!(msg, want);
let t = tlv(&msg);
assert_eq!(&t[..2], &[0x03, msg.len() as u8]);
assert_eq!(*t.last().unwrap(), 0xFE);
}
#[test]
fn longest_prefix_wins() {
assert_eq!(uri_abbreviate("https://www.x.org"), (0x02, "x.org"));
assert_eq!(uri_abbreviate("urn:nfc:sn"), (0x23, "sn"));
assert_eq!(uri_abbreviate("geo:1,2"), (0x00, "geo:1,2"));
}
#[test]
fn round_trips_short_and_long_records() {
for r in [
Record::Uri("https://www.example.com/a?b=c".into()),
Record::Uri("geo:18.79,98.98".into()),
Record::Text("สวัสดี Chiang Mai".into()),
Record::Text("x".repeat(400)),
] {
let area = tlv(&encode(&r));
let (msg, used) = find_message(&area).unwrap().unwrap();
assert_eq!(used, area.len() - 1);
assert_eq!(decode(msg).unwrap(), vec![r.view()]);
}
}
#[test]
fn skips_lock_control_and_null_tlvs() {
let mut area = vec![0x00, 0x01, 0x03, 0xA0, 0x0C, 0x34];
area.extend(tlv(&encode(&Record::Text("hi".into()))));
let (msg, _) = find_message(&area).unwrap().unwrap();
assert_eq!(decode(msg).unwrap()[0].value, "hi");
}
#[test]
fn empty_tag_has_empty_message() {
let area = [0x03, 0x00, 0xFE, 0x00, 0x00];
let (msg, used) = find_message(&area).unwrap().unwrap();
assert!(decode(msg).unwrap().is_empty());
assert_eq!(used, 2);
assert_eq!(find_message(&[0xFE, 0, 0]).unwrap(), None);
}
}
+338
View File
@@ -0,0 +1,338 @@
//! PC/SC access to the reader (ACR1252U PICC slot) and Type 2 tag memory.
use crate::ndef::{self, Record, RecordView};
use pcsc::{
Attribute, Card, Context, Error as PcscError, MAX_BUFFER_SIZE, Protocols, Scope, ShareMode,
};
use serde::Serialize;
const GET_UID: [u8; 5] = [0xFF, 0xCA, 0x00, 0x00, 0x00];
/// Last inspected tag, so the status poll does not re-read the whole tag every second.
#[derive(Default)]
pub struct Cache {
tag: Option<TagInfo>,
}
#[derive(Clone, Serialize)]
pub struct TagInfo {
pub uid: String,
pub kind: String,
pub formatted: bool,
pub capacity: Option<usize>,
pub used: Option<usize>,
pub writable: bool,
pub records: Vec<RecordView>,
/// Why the tag cannot be programmed, or a read error.
pub note: Option<String>,
}
#[derive(Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Status {
NoReader { message: String },
NoCard { reader: String },
Card { reader: String, tag: Box<TagInfo> },
Error { message: String },
}
#[derive(Serialize)]
pub struct Programmed {
pub uid: String,
pub record: RecordView,
pub bytes: usize,
pub capacity: usize,
}
enum Kind {
Type2,
Unsupported(String),
}
struct Cc {
formatted: bool,
capacity: usize,
writable: bool,
}
fn open() -> Result<(String, Option<Card>), Status> {
let no_reader = || Status::NoReader {
message: "No NFC reader found. Is the ACR1252U plugged in?".into(),
};
let ctx = Context::establish(Scope::User).map_err(|e| match e {
PcscError::NoService | PcscError::ServiceStopped | PcscError::NoReadersAvailable => {
no_reader()
}
e => Status::Error {
message: format!("PC/SC: {e}"),
},
})?;
let readers = ctx.list_readers_owned().map_err(|e| match e {
PcscError::NoReadersAvailable | PcscError::NoService | PcscError::ServiceStopped => {
no_reader()
}
e => Status::Error {
message: format!("PC/SC: {e}"),
},
})?;
// The ACR1252U shows up as two readers: "... PICC 0" (contactless) and "... SAM 0".
let reader = readers
.iter()
.find(|r| r.to_string_lossy().contains("PICC"))
.or_else(|| readers.first())
.ok_or_else(no_reader)?;
let name = reader.to_string_lossy().into_owned();
match ctx.connect(reader, ShareMode::Shared, Protocols::ANY) {
Ok(card) => Ok((name, Some(card))),
Err(
PcscError::NoSmartcard
| PcscError::RemovedCard
| PcscError::UnpoweredCard
| PcscError::UnresponsiveCard
| PcscError::ResetCard,
) => Ok((name, None)),
Err(PcscError::ReaderUnavailable | PcscError::UnknownReader) => Err(no_reader()),
Err(e) => Err(Status::Error {
message: format!("Connecting to the tag failed: {e}"),
}),
}
}
fn apdu(card: &Card, cmd: &[u8]) -> Result<Vec<u8>, String> {
let mut buf = [0u8; MAX_BUFFER_SIZE];
let r = card.transmit(cmd, &mut buf).map_err(|e| {
format!("Tag communication failed ({e}). Keep the tag still on the reader.")
})?;
if r.len() < 2 {
return Err("Reader returned a short response.".into());
}
let (data, sw) = r.split_at(r.len() - 2);
if sw != [0x90, 0x00] {
return Err(format!(
"Tag rejected the command (status {:02X}{:02X}).",
sw[0], sw[1]
));
}
Ok(data.to_vec())
}
fn hex(b: &[u8]) -> String {
b.iter()
.map(|x| format!("{x:02X}"))
.collect::<Vec<_>>()
.join(":")
}
/// Card type from the PC/SC Part 3 storage-card ATR (3B 8F 80 01 80 4F 0C A0 00 00 03 06 SS NN NN ...).
fn kind(atr: &[u8]) -> Kind {
let storage = atr.len() >= 15
&& atr[..7] == [0x3B, 0x8F, 0x80, 0x01, 0x80, 0x4F, 0x0C]
&& atr[7..12] == [0xA0, 0x00, 0x00, 0x03, 0x06];
if !storage {
return Kind::Unsupported("ISO 14443-4 card (e.g. DESFire, bank card or phone)".into());
}
match (atr[13], atr[14]) {
(0x00, 0x03) | (0x00, 0x3A) => Kind::Type2,
(0x00, 0x01) => Kind::Unsupported("MIFARE Classic 1K".into()),
(0x00, 0x02) => Kind::Unsupported("MIFARE Classic 4K".into()),
(0x00, 0x26) => Kind::Unsupported("MIFARE Mini".into()),
(a, b) => Kind::Unsupported(format!("storage card type {a:02X}{b:02X}")),
}
}
fn type2_name(cc: &Cc) -> String {
let chip = match cc.capacity {
48 => " (MIFARE Ultralight)",
128 => " (Ultralight EV1)",
144 => " (NTAG213)",
496 => " (NTAG215)",
872 => " (NTAG216)",
_ => "",
};
format!("NFC Type 2{chip}")
}
fn read_cc(card: &Card) -> Result<Cc, String> {
let d = apdu(card, &[0xFF, 0xB0, 0x00, 0x03, 0x10])?; // pages 3..6
let cc = d
.get(..4)
.ok_or("Could not read the capability container.")?;
Ok(Cc {
formatted: cc[0] == 0xE1,
capacity: cc[2] as usize * 8,
// lower nibble of byte 3 = write access (0 = granted)
writable: cc[3] & 0x0F == 0,
})
}
/// Read `len` bytes starting at page 4, 16 bytes (4 pages) per command.
fn read_data(card: &Card, len: usize) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(len + 16);
let mut page = 4u8;
while out.len() < len {
out.extend(apdu(card, &[0xFF, 0xB0, 0x00, page, 0x10])?);
page = page.checked_add(4).ok_or("Tag memory too large.")?;
}
out.truncate(len);
Ok(out)
}
fn write_page(card: &Card, page: usize, data: &[u8]) -> Result<(), String> {
let page = u8::try_from(page).map_err(|_| "Page number out of range.")?;
let mut cmd = vec![0xFF, 0xD6, 0x00, page, 0x04];
cmd.extend_from_slice(data);
apdu(card, &cmd).map(|_| ())
}
fn inspect(card: &Card, uid: String) -> TagInfo {
let mut info = TagInfo {
uid,
kind: String::new(),
formatted: false,
capacity: None,
used: None,
writable: false,
records: vec![],
note: None,
};
let atr = card
.get_attribute_owned(Attribute::AtrString)
.unwrap_or_default();
if let Kind::Unsupported(name) = kind(&atr) {
info.kind = name;
info.note =
Some("Only NTAG21x / MIFARE Ultralight (NFC Type 2) tags can be programmed.".into());
return info;
}
let result = (|| -> Result<(), String> {
let cc = read_cc(card)?;
info.kind = type2_name(&cc);
info.formatted = cc.formatted;
info.writable = cc.writable;
if !cc.formatted {
info.note = Some("Tag is not NDEF formatted.".into());
return Ok(());
}
info.capacity = Some(cc.capacity);
if !cc.writable {
info.note = Some("Tag is locked (read-only).".into());
}
let area = read_data(card, cc.capacity)?;
match ndef::find_message(&area)? {
Some((msg, used)) => {
info.used = Some(used);
info.records = ndef::decode(msg)?;
}
None => info.used = Some(0),
}
Ok(())
})();
if let Err(e) = result {
if info.kind.is_empty() {
info.kind = "NFC Type 2".into();
}
info.note = Some(e);
}
info
}
pub fn status(cache: &mut Cache) -> Status {
let (reader, card) = match open() {
Ok(x) => x,
Err(s) => {
cache.tag = None;
return s;
}
};
let Some(card) = card else {
cache.tag = None;
return Status::NoCard { reader };
};
let uid = match apdu(&card, &GET_UID) {
Ok(u) => hex(&u),
Err(_) => return Status::NoCard { reader },
};
if let Some(tag) = cache.tag.as_ref().filter(|t| t.uid == uid) {
return Status::Card {
reader,
tag: Box::new(tag.clone()),
};
}
let tag = inspect(&card, uid);
// Don't cache a failed read (tag moved away mid-read): retry on the next poll.
let failed = tag
.note
.as_deref()
.is_some_and(|n| n.contains("communication") || n.contains("rejected"));
cache.tag = (!failed).then(|| tag.clone());
Status::Card {
reader,
tag: Box::new(tag),
}
}
fn status_message(s: Status) -> String {
match s {
Status::NoReader { message } | Status::Error { message } => message,
Status::NoCard { .. } => "No tag on the reader.".into(),
Status::Card { .. } => "Unexpected reader state.".into(),
}
}
pub fn program(cache: &mut Cache, input: &str) -> Result<Programmed, String> {
let input = input.trim();
if input.is_empty() {
return Err("Enter a URL or some text first.".into());
}
let record = Record::auto(input);
let tlv = ndef::tlv(&ndef::encode(&record));
let (_, card) = open().map_err(status_message)?;
let card =
card.ok_or("No tag on the reader. Place a tag on the reader and press Program again.")?;
let uid = hex(&apdu(&card, &GET_UID)?);
let atr = card
.get_attribute_owned(Attribute::AtrString)
.unwrap_or_default();
if let Kind::Unsupported(name) = kind(&atr) {
return Err(format!(
"{name} is not supported. Use an NTAG21x / MIFARE Ultralight tag."
));
}
let cc = read_cc(&card)?;
if !cc.formatted {
return Err("Tag is not NDEF formatted (no capability container).".into());
}
if !cc.writable {
return Err("Tag is locked (read-only).".into());
}
if tlv.len() > cc.capacity {
return Err(format!(
"Too long: needs {} bytes, this tag holds {}.",
tlv.len(),
cc.capacity
));
}
let mut data = tlv;
data.resize(data.len().div_ceil(4) * 4, 0);
// Empty the NDEF TLV first and write its header last, so an interrupted
// write leaves an empty tag instead of a corrupt message.
write_page(&card, 4, &[0x03, 0x00, 0xFE, 0x00])?;
for (i, chunk) in data.chunks(4).enumerate().skip(1) {
write_page(&card, 4 + i, chunk)?;
}
write_page(&card, 4, &data[..4])?;
let back = read_data(&card, data.len())?;
if back != data {
return Err("Verification failed: the tag content does not match what was written.".into());
}
cache.tag = None; // re-read on the next status poll
Ok(Programmed {
uid,
record: record.view(),
bytes: data.len(),
capacity: cc.capacity,
})
}