- Read button (desktop and web): reads the tag now, bypassing the status cache, and puts its first link or text record into the input field so it can be copied to another tag. POST /api/read in the web version. - Raw memory: every 4-byte page from page 0 (UID, lock bytes, CC, data) in hex and ASCII, collapsed by default. - Content now shows the records and the note together (a locked tag showed only "read-only" before); read failures are flagged with read_failed instead of matching message text. - .gitea/workflows/release.yml: on a v* tag, tests and clippy, then Linux (ubuntu-22.04, glibc 2.35) and Windows (MinGW cross-build) packages attached to the tag's release with its CHANGELOG.md section. - README: reading, why the reader program runs on the reader's machine, releases.
This commit is contained in:
1 parent
cb1b1b04c8
commit
8b95d0d5d7
10 files changed
+610
-165
No files matched your search
@@ -26,6 +26,7 @@ async fn main() {
|
||||
.route("/", get(|| async { Html(INDEX) }))
|
||||
.route("/api/status", get(status))
|
||||
.route("/api/program", post(program))
|
||||
.route("/api/read", post(read))
|
||||
.with_state(Shared::default());
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
@@ -60,9 +61,9 @@ 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(),
|
||||
fn reply<T: serde::Serialize>(r: Result<Result<T, String>, String>) -> Response {
|
||||
match r {
|
||||
Ok(Ok(body)) => Json(body).into_response(),
|
||||
Ok(Err(error)) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": error })),
|
||||
@@ -75,3 +76,11 @@ async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Re
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn program(State(s): State<Shared>, Json(req): Json<ProgramRequest>) -> Response {
|
||||
reply(with_reader(s, move |c| reader::program(c, &req.text)).await)
|
||||
}
|
||||
|
||||
async fn read(State(s): State<Shared>) -> Response {
|
||||
reply(with_reader(s, reader::read).await)
|
||||
}
|
||||
+206
-93
@@ -8,27 +8,47 @@ use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
const POLL: Duration = Duration::from_millis(500);
|
||||
const HINT: &str = "Links (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.";
|
||||
const HINT: &str = "Links (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text. \
|
||||
Read puts the tag's content into the field, so you can copy it to another tag.";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum JobKind {
|
||||
Read,
|
||||
Program,
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Read,
|
||||
Program(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Outcome {
|
||||
Read(TagInfo),
|
||||
Wrote(Programmed),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
status: Option<Status>,
|
||||
busy: bool,
|
||||
result: Option<Result<Programmed, String>>,
|
||||
busy: Option<JobKind>,
|
||||
result: Option<Result<Outcome, String>>,
|
||||
/// Text for the input field after a read; the UI takes it on the next frame.
|
||||
fill: Option<String>,
|
||||
}
|
||||
|
||||
struct App {
|
||||
text: String,
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
jobs: mpsc::Sender<String>,
|
||||
jobs: mpsc::Sender<Job>,
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("NFC tag writer")
|
||||
.with_inner_size([540.0, 330.0])
|
||||
.with_min_inner_size([400.0, 300.0]),
|
||||
.with_inner_size([560.0, 390.0])
|
||||
.with_min_inner_size([420.0, 320.0]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
@@ -70,22 +90,36 @@ fn add_thai_font(ctx: &egui::Context) {
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// Owns the reader: polls the status and runs program jobs, one at a time.
|
||||
fn worker(jobs: mpsc::Receiver<String>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) {
|
||||
/// Text a read puts into the input field: the first link or text record.
|
||||
fn fill_text(tag: &TagInfo) -> Option<String> {
|
||||
tag.records
|
||||
.first()
|
||||
.filter(|r| r.kind == "URL" || r.kind == "Text")
|
||||
.map(|r| r.value.clone())
|
||||
}
|
||||
|
||||
/// Owns the reader: polls the status and runs read/program jobs, one at a time.
|
||||
fn worker(jobs: mpsc::Receiver<Job>, shared: Arc<Mutex<Shared>>, ctx: egui::Context) {
|
||||
let mut cache = Cache::default();
|
||||
loop {
|
||||
let job = match jobs.recv_timeout(POLL) {
|
||||
Ok(text) => Some(text),
|
||||
Ok(job) => Some(job),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
};
|
||||
let result = job.map(|text| reader::program(&mut cache, &text));
|
||||
let result = job.map(|job| match job {
|
||||
Job::Read => reader::read(&mut cache).map(Outcome::Read),
|
||||
Job::Program(text) => reader::program(&mut cache, &text).map(Outcome::Wrote),
|
||||
});
|
||||
let status = reader::status(&mut cache);
|
||||
let mut s = shared.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let changed = result.is_some() || s.status.as_ref() != Some(&status);
|
||||
if let Some(r) = result {
|
||||
if let Ok(Outcome::Read(tag)) = &r {
|
||||
s.fill = fill_text(tag);
|
||||
}
|
||||
s.result = Some(r);
|
||||
s.busy = false;
|
||||
s.busy = None;
|
||||
}
|
||||
s.status = Some(status);
|
||||
drop(s);
|
||||
@@ -108,14 +142,17 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn program(&mut self) {
|
||||
fn start(&mut self, kind: JobKind) {
|
||||
let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if s.busy {
|
||||
if s.busy.is_some() {
|
||||
return;
|
||||
}
|
||||
s.busy = true;
|
||||
s.busy = Some(kind);
|
||||
s.result = None;
|
||||
let _ = self.jobs.send(self.text.clone());
|
||||
let _ = self.jobs.send(match kind {
|
||||
JobKind::Read => Job::Read,
|
||||
JobKind::Program => Job::Program(self.text.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,101 +219,177 @@ fn tag_details(ui: &mut egui::Ui, tag: &TagInfo, p: &Palette) {
|
||||
ui.end_row();
|
||||
ui.weak("Content");
|
||||
ui.vertical(|ui| {
|
||||
for r in &tag.records {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(format!("{}:", r.kind));
|
||||
if r.kind == "URL" && r.value.starts_with("http") {
|
||||
ui.hyperlink(&r.value);
|
||||
} else {
|
||||
ui.label(&r.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
if tag.records.is_empty() && tag.formatted && !tag.read_failed {
|
||||
ui.label("empty");
|
||||
}
|
||||
if let Some(note) = &tag.note {
|
||||
ui.colored_label(p.warn, note);
|
||||
} else if tag.records.is_empty() {
|
||||
ui.label("empty");
|
||||
} else {
|
||||
for r in &tag.records {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(format!("{}:", r.kind));
|
||||
if r.kind == "URL" && r.value.starts_with("http") {
|
||||
ui.hyperlink(&r.value);
|
||||
} else {
|
||||
ui.label(&r.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
if !tag.raw.is_empty() {
|
||||
egui::CollapsingHeader::new(format!("Raw memory ({} pages)", tag.raw.len() / 4))
|
||||
.id_salt("raw")
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(160.0)
|
||||
.show(ui, |ui| {
|
||||
ui.monospace(reader::page_dump(&tag.raw).join("\n"));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn result_line(ui: &mut egui::Ui, result: &Result<Outcome, String>, p: &Palette) {
|
||||
match result {
|
||||
Ok(Outcome::Wrote(done)) => {
|
||||
ui.colored_label(
|
||||
p.ok,
|
||||
format!(
|
||||
"✔ Wrote {}: {} ({} of {} bytes, verified)",
|
||||
done.record.kind, done.record.value, done.bytes, done.capacity
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Read(tag)) => {
|
||||
let n = tag.records.len();
|
||||
let text = if fill_text(tag).is_some() {
|
||||
format!(
|
||||
"✔ Read {}: {n} record{}. The first one is now in the field: put another tag on and press Program to copy it.",
|
||||
tag.uid,
|
||||
if n == 1 { "" } else { "s" }
|
||||
)
|
||||
} else if n > 0 {
|
||||
format!(
|
||||
"✔ Read {}: {n} record(s), none of them a link or text.",
|
||||
tag.uid
|
||||
)
|
||||
} else if tag.note.is_some() {
|
||||
format!(
|
||||
"Read {} ({}): UID only, no NDEF content.",
|
||||
tag.uid, tag.kind
|
||||
)
|
||||
} else {
|
||||
format!("✔ Read {}: the tag is empty.", tag.uid)
|
||||
};
|
||||
ui.colored_label(if tag.note.is_some() { p.warn } else { p.ok }, text);
|
||||
}
|
||||
Err(e) => {
|
||||
ui.colored_label(p.err, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let (status, busy, result) = {
|
||||
let s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = self.shared.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(fill) = s.fill.take() {
|
||||
self.text = fill;
|
||||
}
|
||||
(s.status.clone(), s.busy, s.result.clone())
|
||||
};
|
||||
egui::CentralPanel::default_margins().show(ui, |ui| {
|
||||
let p = palette(ui.visuals().dark_mode);
|
||||
ui.add_space(4.0);
|
||||
ui.heading("NFC tag writer");
|
||||
ui.add_space(8.0);
|
||||
|
||||
let mut writable = false;
|
||||
match &status {
|
||||
None => status_line(ui, ui.visuals().weak_text_color(), "Looking for a reader…"),
|
||||
Some(Status::NoReader { message } | Status::Error { message }) => {
|
||||
status_line(ui, p.err, message)
|
||||
}
|
||||
Some(Status::NoCard { reader }) => {
|
||||
status_line(ui, p.warn, &format!("{reader} — place a tag on the reader"))
|
||||
}
|
||||
Some(Status::Card { reader, tag }) => {
|
||||
writable = can_write(tag);
|
||||
let color = if writable { p.ok } else { p.warn };
|
||||
status_line(ui, color, &format!("{reader} — tag present"));
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
let p = palette(ui.visuals().dark_mode);
|
||||
ui.add_space(4.0);
|
||||
tag_details(ui, tag, &p);
|
||||
}
|
||||
}
|
||||
ui.heading("NFC tag writer");
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.add_space(14.0);
|
||||
let mut submit = false;
|
||||
ui.horizontal(|ui| {
|
||||
let button_width = 110.0;
|
||||
let edit_width = ui.available_width() - button_width - ui.spacing().item_spacing.x;
|
||||
let edit = ui.add_sized(
|
||||
[edit_width, 30.0],
|
||||
egui::TextEdit::singleline(&mut self.text)
|
||||
.hint_text("https://buildfor.life or any text")
|
||||
.align(egui::Align2::LEFT_CENTER),
|
||||
);
|
||||
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
submit = true;
|
||||
edit.request_focus();
|
||||
}
|
||||
let label = if busy { "Programming…" } else { "Program" };
|
||||
let button = egui::Button::new(RichText::new(label).strong().color(Color32::WHITE))
|
||||
.fill(p.accent)
|
||||
.min_size(egui::vec2(button_width, 30.0));
|
||||
if ui.add_enabled(writable && !busy, button).clicked() {
|
||||
submit = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.small(RichText::new(HINT).weak());
|
||||
ui.add_space(8.0);
|
||||
match &result {
|
||||
Some(Ok(done)) => {
|
||||
ui.colored_label(
|
||||
p.ok,
|
||||
format!(
|
||||
"✔ Wrote {}: {} ({} of {} bytes, verified)",
|
||||
done.record.kind, done.record.value, done.bytes, done.capacity
|
||||
let mut writable = false;
|
||||
let mut present = false;
|
||||
match &status {
|
||||
None => {
|
||||
status_line(ui, ui.visuals().weak_text_color(), "Looking for a reader…")
|
||||
}
|
||||
Some(Status::NoReader { message } | Status::Error { message }) => {
|
||||
status_line(ui, p.err, message)
|
||||
}
|
||||
Some(Status::NoCard { reader }) => status_line(
|
||||
ui,
|
||||
p.warn,
|
||||
&format!("{reader} — place a tag on the reader"),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
ui.colored_label(p.err, e);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if submit && writable && !busy {
|
||||
self.program();
|
||||
}
|
||||
Some(Status::Card { reader, tag }) => {
|
||||
present = true;
|
||||
writable = can_write(tag);
|
||||
let color = if writable { p.ok } else { p.warn };
|
||||
status_line(ui, color, &format!("{reader} — tag present"));
|
||||
ui.add_space(4.0);
|
||||
tag_details(ui, tag, &p);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(14.0);
|
||||
let mut job = None;
|
||||
ui.horizontal(|ui| {
|
||||
let (read_width, program_width) = (80.0, 110.0);
|
||||
let edit_width = ui.available_width()
|
||||
- read_width
|
||||
- program_width
|
||||
- 2.0 * ui.spacing().item_spacing.x;
|
||||
let edit = ui.add_sized(
|
||||
[edit_width, 30.0],
|
||||
egui::TextEdit::singleline(&mut self.text)
|
||||
.hint_text("https://buildfor.life or any text")
|
||||
.align(egui::Align2::LEFT_CENTER),
|
||||
);
|
||||
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
if writable {
|
||||
job = Some(JobKind::Program);
|
||||
}
|
||||
edit.request_focus();
|
||||
}
|
||||
let read_label = if busy == Some(JobKind::Read) {
|
||||
"Reading…"
|
||||
} else {
|
||||
"Read"
|
||||
};
|
||||
let read =
|
||||
egui::Button::new(read_label).min_size(egui::vec2(read_width, 30.0));
|
||||
if ui.add_enabled(present && busy.is_none(), read).clicked() {
|
||||
job = Some(JobKind::Read);
|
||||
}
|
||||
let program_label = if busy == Some(JobKind::Program) {
|
||||
"Programming…"
|
||||
} else {
|
||||
"Program"
|
||||
};
|
||||
let program = egui::Button::new(
|
||||
RichText::new(program_label).strong().color(Color32::WHITE),
|
||||
)
|
||||
.fill(p.accent)
|
||||
.min_size(egui::vec2(program_width, 30.0));
|
||||
if ui
|
||||
.add_enabled(writable && busy.is_none(), program)
|
||||
.clicked()
|
||||
{
|
||||
job = Some(JobKind::Program);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.small(RichText::new(HINT).weak());
|
||||
ui.add_space(8.0);
|
||||
if let Some(result) = &result {
|
||||
result_line(ui, result, &p);
|
||||
}
|
||||
if let Some(kind) = job {
|
||||
self.start(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+68
-11
@@ -27,6 +27,10 @@ pub struct TagInfo {
|
||||
pub records: Vec<RecordView>,
|
||||
/// Why the tag cannot be programmed, or a read error.
|
||||
pub note: Option<String>,
|
||||
/// Tag memory from page 0 (UID, lock bytes, capability container, data area), 4 bytes per page.
|
||||
pub raw: Vec<u8>,
|
||||
/// The tag left the reader (or failed) mid-read: the info is incomplete.
|
||||
pub read_failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
@@ -230,11 +234,8 @@ fn type2_name(cc: &Cc) -> String {
|
||||
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.")?;
|
||||
fn parse_cc(cc: Option<&[u8]>) -> Result<Cc, String> {
|
||||
let cc = cc.ok_or("Could not read the capability container.")?;
|
||||
Ok(Cc {
|
||||
formatted: cc[0] == 0xE1,
|
||||
capacity: cc[2] as usize * 8,
|
||||
@@ -243,6 +244,11 @@ fn read_cc(card: &Card) -> Result<Cc, String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_cc(card: &Card) -> Result<Cc, String> {
|
||||
let d = apdu(card, &[0xFF, 0xB0, 0x00, 0x03, 0x10])?; // pages 3..6
|
||||
parse_cc(d.get(..4))
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -272,6 +278,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
|
||||
writable: false,
|
||||
records: vec![],
|
||||
note: None,
|
||||
raw: vec![],
|
||||
read_failed: false,
|
||||
};
|
||||
if let Kind::Unsupported(name) = kind(&atr(card)) {
|
||||
info.kind = name;
|
||||
@@ -280,7 +288,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
|
||||
return info;
|
||||
}
|
||||
let result = (|| -> Result<(), String> {
|
||||
let cc = read_cc(card)?;
|
||||
info.raw = apdu(card, &[0xFF, 0xB0, 0x00, 0x00, 0x10])?; // pages 0..3
|
||||
let cc = parse_cc(info.raw.get(12..16))?;
|
||||
info.kind = type2_name(&cc);
|
||||
info.formatted = cc.formatted;
|
||||
info.writable = cc.writable;
|
||||
@@ -293,6 +302,8 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
|
||||
info.note = Some("Tag is locked (read-only).".into());
|
||||
}
|
||||
let area = read_data(card, cc.capacity)?;
|
||||
info.raw.truncate(16);
|
||||
info.raw.extend_from_slice(&area);
|
||||
match ndef::find_message(&area)? {
|
||||
Some((msg, used)) => {
|
||||
info.used = Some(used);
|
||||
@@ -307,6 +318,7 @@ fn inspect(card: &Card, uid: String) -> TagInfo {
|
||||
info.kind = "NFC Type 2".into();
|
||||
}
|
||||
info.note = Some(e);
|
||||
info.read_failed = true;
|
||||
}
|
||||
info
|
||||
}
|
||||
@@ -335,17 +347,49 @@ pub fn status(cache: &mut Cache) -> Status {
|
||||
}
|
||||
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());
|
||||
cache.tag = (!tag.read_failed).then(|| tag.clone());
|
||||
Status::Card {
|
||||
reader,
|
||||
tag: Box::new(tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per 4-byte page: page number, hex bytes, printable ASCII.
|
||||
pub fn page_dump(raw: &[u8]) -> Vec<String> {
|
||||
raw.chunks(4)
|
||||
.enumerate()
|
||||
.map(|(page, bytes)| {
|
||||
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02X}")).collect();
|
||||
let ascii: String = bytes
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
if (0x20..0x7f).contains(&b) {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{page:>3} {:<11} {ascii}", hex.join(" "))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the tag on the reader now, bypassing the cache.
|
||||
pub fn read(cache: &mut Cache) -> Result<TagInfo, String> {
|
||||
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 Read again.")?;
|
||||
let uid = hex(&apdu(&card, &GET_UID)?);
|
||||
let tag = inspect(&card, uid);
|
||||
if tag.read_failed {
|
||||
cache.tag = None;
|
||||
return Err(tag.note.unwrap_or_else(|| "Reading the tag failed.".into()));
|
||||
}
|
||||
cache.tag = Some(tag.clone());
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
fn status_message(s: Status) -> String {
|
||||
match s {
|
||||
Status::NoReader { message } | Status::Error { message } => message,
|
||||
@@ -417,6 +461,19 @@ mod tests {
|
||||
n.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_dump_shows_hex_and_ascii() {
|
||||
let lines = page_dump(&[0x03, 0x12, 0xD1, 0x01, b'U', 0x04, b'b', b'u', 0xFE]);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
" 0 03 12 D1 01 ....",
|
||||
" 1 55 04 62 75 U.bu",
|
||||
" 2 FE ."
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_picc_slot_on_every_platform() {
|
||||
// Windows, Linux/macOS (pcsc-lite naming)
|
||||
|
||||
Reference in new issue
Block a user