NFC tag writer: local web page for ACR1252U (NDEF URL/text on Type 2 tags)
This commit is contained in:
commit
449cdb7af5
8 files changed
+1496
No files matched your search
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+521
@@ -0,0 +1,521 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfc-writer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"pcsc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pcsc"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"pcsc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pcsc-sys"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "nfc-writer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Local web page that programs NFC tags (NDEF URL/text) through a PC/SC reader such as the ACS ACR1252U"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
pcsc = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
||||
@@ -0,0 +1,14 @@
|
||||
# nfc-writer
|
||||
|
||||
Small local web page that programs NFC tags through a PC/SC reader (tested with the ACS ACR1252U-M1).
|
||||
Type a URL or some text, put a tag on the reader, press **Program**.
|
||||
|
||||
```
|
||||
cargo run --release # http://127.0.0.1:7252 (port: NFC_PORT)
|
||||
```
|
||||
|
||||
- Browsers can't talk to a USB smart-card reader, so a small Rust server (axum + `pcsc`, Windows `winscard`) does the reader access; the page polls `/api/status` and posts to `/api/program`.
|
||||
- Supported tags: NFC Forum Type 2: NTAG213/215/216, MIFARE Ultralight / EV1 / C. MIFARE Classic and DESFire are detected and refused.
|
||||
- Input starting with `http(s)://`, `www.`, `tel:`, `mailto:`, `sms:`, `geo:` or any `scheme://` is written as an NDEF URI record (`www.` gets `https://` in front); anything else as an NDEF text record (`en`).
|
||||
- The write empties the NDEF TLV first and writes its header last, so an interrupted write leaves an empty tag rather than a corrupt one. After writing, the data is read back and compared.
|
||||
- Bound to 127.0.0.1 only.
|
||||
+78
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NFC tag writer</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f5f6f8; --card: #fff; --fg: #1d2330; --muted: #6b7280; --border: #d9dde3;
|
||||
--accent: #2563eb; --ok: #15803d; --err: #b91c1c; --warn: #b45309;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1218; --card: #171b23; --fg: #e5e7eb; --muted: #9aa3b2; --border: #2a303c;
|
||||
--accent: #3b82f6; --ok: #22c55e; --err: #f87171; --warn: #fbbf24;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
margin: 0; padding: 48px 16px; display: flex; justify-content: center;
|
||||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: var(--bg); color: var(--fg);
|
||||
}
|
||||
main {
|
||||
width: 100%; max-width: 560px; padding: 20px 22px;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
}
|
||||
h1 { font-size: 18px; margin: 0 0 14px; }
|
||||
.status {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
font-size: 14px; border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
.dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.err { background: var(--err); }
|
||||
dl {
|
||||
display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px;
|
||||
margin: 10px 4px 0; font-size: 13px;
|
||||
}
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.mono { font-family: ui-monospace, Consolas, monospace; }
|
||||
.note { color: var(--warn); }
|
||||
form { display: flex; gap: 8px; margin-top: 18px; }
|
||||
input {
|
||||
flex: 1; min-width: 0; padding: 9px 11px; font: inherit; color: inherit;
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
button {
|
||||
padding: 9px 18px; font: inherit; font-weight: 600; color: #fff;
|
||||
background: var(--accent); border: 0; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.hint { margin: 6px 2px 0; font-size: 12px; color: var(--muted); }
|
||||
.msg { min-height: 1.4em; margin-top: 12px; font-size: 14px; }
|
||||
.msg.ok { color: var(--ok); }
|
||||
.msg.err { color: var(--err); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>NFC tag writer</h1>
|
||||
<div class="status"><span class="dot" id="dot"></span><span id="status">Connecting…</span></div>
|
||||
<dl id="tag" hidden>
|
||||
<dt>UID</dt><dd class="mono" id="uid"></dd>
|
||||
<dt>Type</dt><dd id="kind"></dd>
|
||||
<dt>Memory</dt><dd id="memory"></dd>
|
||||
<dt>Content</dt><dd id="content"></dd>
|
||||
</dl>
|
||||
<form id="form">
|
||||
<input id="text" placeholder="https://buildfor.life or any text" autocomplete="off" autofocus>
|
||||
<button id="program" type="submit" disabled>Program</button>
|
||||
</form>
|
||||
<p class="hint">URLs (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.</p>
|
||||
<div class="msg" id="msg" role="status"></div>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
let busy = false, canWrite = false;
|
||||
|
||||
function setStatus(level, text) {
|
||||
$("dot").className = "dot " + level;
|
||||
$("status").textContent = text;
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
$("tag").hidden = s.state !== "card";
|
||||
canWrite = false;
|
||||
if (s.state === "no_reader" || s.state === "error") {
|
||||
setStatus("err", s.message);
|
||||
} else if (s.state === "no_card") {
|
||||
setStatus("warn", s.reader + " — place a tag on the reader");
|
||||
} else {
|
||||
const t = s.tag;
|
||||
canWrite = t.formatted && t.writable && t.capacity != null && !t.note;
|
||||
setStatus(canWrite ? "ok" : "warn", s.reader + " — tag present");
|
||||
$("uid").textContent = t.uid;
|
||||
$("kind").textContent = t.kind;
|
||||
$("memory").textContent = t.capacity != null
|
||||
? `${t.used ?? "?"} of ${t.capacity} bytes used${t.writable ? "" : ", read-only"}`
|
||||
: "—";
|
||||
const c = $("content");
|
||||
c.replaceChildren();
|
||||
if (t.note) {
|
||||
c.append(Object.assign(document.createElement("span"), { className: "note", textContent: t.note }));
|
||||
} else if (!t.records.length) {
|
||||
c.textContent = "empty";
|
||||
} else {
|
||||
t.records.forEach((r, i) => {
|
||||
if (i) c.append(document.createElement("br"));
|
||||
c.append(`${r.kind}: `);
|
||||
if (r.kind === "URL" && /^https?:\/\//i.test(r.value)) {
|
||||
c.append(Object.assign(document.createElement("a"), { href: r.value, target: "_blank", textContent: r.value }));
|
||||
} else {
|
||||
c.append(r.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
$("program").disabled = busy || !canWrite;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!busy) {
|
||||
try {
|
||||
render(await (await fetch("/api/status")).json());
|
||||
} catch {
|
||||
setStatus("err", "Cannot reach the local NFC service — is nfc-writer.exe running?");
|
||||
$("tag").hidden = true;
|
||||
$("program").disabled = true;
|
||||
}
|
||||
}
|
||||
setTimeout(poll, 800);
|
||||
}
|
||||
|
||||
$("form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
if (busy || !canWrite) return;
|
||||
busy = true;
|
||||
$("program").disabled = true;
|
||||
$("program").textContent = "Programming…";
|
||||
$("msg").className = "msg";
|
||||
$("msg").textContent = "";
|
||||
try {
|
||||
const res = await fetch("/api/program", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: $("text").value }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
$("msg").className = "msg ok";
|
||||
$("msg").textContent = `✓ Wrote ${body.record.kind}: ${body.record.value} (${body.bytes} of ${body.capacity} bytes, verified)`;
|
||||
} else {
|
||||
$("msg").className = "msg err";
|
||||
$("msg").textContent = body.error || `Error ${res.status}`;
|
||||
}
|
||||
} catch (err) {
|
||||
$("msg").className = "msg err";
|
||||
$("msg").textContent = "Request failed: " + err;
|
||||
}
|
||||
busy = false;
|
||||
$("program").textContent = "Program";
|
||||
try { render(await (await fetch("/api/status")).json()); } catch {}
|
||||
});
|
||||
|
||||
poll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user