From b67b37dc594bc5a27636124597c2ce1c537f1538 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Sun, 23 Aug 2026 15:27:11 -0500 Subject: [PATCH] Make Parse take raw NFC UID and 1K image. Drop zip/JSON from the root API. dump remains an adapter for the Android app format; inventory and an AMS wand both call Parse(uid, image). --- README.md | 9 +- doc.go | 3 +- .../2026-08-23-bambu-nfc-library-design.md | 25 ++-- errors.go | 11 +- merge_test.go | 13 +- parse.go | 113 +++++------------- parse_test.go | 105 ++++++++-------- 7 files changed, 110 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index ccb47fb..6d1a10a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # bambu-nfc -Go library for Bambu Lab filament NFC dumps produced by the Filament NFC dump Android app. +Go library for Bambu Lab filament NFC. Input is **raw NFC**: chip UID bytes and the 1024-byte MIFARE Classic 1K image — not a phone dump format. ```go import bambunfc "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc" -tags, err := bambunfc.ParseZip(r) +tag, err := bambunfc.Parse(uid, image) // uid from ISO 14443, image is 1K +keys, err := bambunfc.KeysA(uid) // 16 Key-A values to dump the tag spools, err := bambunfc.Merge(tags) -keys, err := bambunfc.KeysA(uid) ``` -- `dump` — brand-blind `filanfc-dump/v1` JSON/zip +`dump` can still decode the Android app's `filanfc-dump/v1` zip if you need that adapter; the inventory site should call `Parse` with UID + image (phone, AMS wand, or anything else). + - `mifare` — Classic 1K layout helpers - root package — Bambu Key-A HKDF, block parse, merge by `tray_uid` diff --git a/doc.go b/doc.go index cb15ae7..ae74ecf 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,3 @@ -// Package bambunfc parses Bambu Lab filament NFC dumps and derives MIFARE Key-A from a chip UID. +// Package bambunfc parses raw Bambu Lab filament NFC (ISO UID + MIFARE Classic 1K image) +// and derives MIFARE Key-A from a chip UID. package bambunfc diff --git a/docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md b/docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md index eea6463..7271aef 100644 --- a/docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md +++ b/docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md @@ -158,10 +158,10 @@ type Spool struct { } func KeysA(uid []byte) ([16][6]byte, error) -func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) -func ParseDump(d dump.Dump) (Tag, error) -func ParseZip(r io.Reader) ([]Tag, error) +func Parse(uid, image []byte) (Tag, error) // raw ISO UID + 1024-byte 1K image func Merge(tags []Tag) ([]Spool, error) + +The website and any AMS wand feed `Parse` with raw NFC. The `dump` package is only an adapter for the Android zip/JSON format; it is not the library’s input surface. ``` ### KeysA @@ -194,9 +194,9 @@ B4AEB23473E3 BAF630CCBFD3 ``` -### ParseImage +### Parse -MIFARE Classic 1K Bambu map (little-endian). Required sectors: **0, 1, 2, 4, 5, 9**. If any of those are in `fail` → `ErrIncomplete`. +MIFARE Classic 1K Bambu map (little-endian). Input is raw NFC only: `uid` is the ISO 14443 chip ID; `image` is 1024 bytes. Empty UID → `ErrUID`. Wrong image length → `ErrImageSize`. All-zero tray UID (block 9) → `ErrIncomplete`. No dump `sectors_fail` list — missing data is whatever is in the image (typically zeros). | Block | Field | |---|---| @@ -210,18 +210,12 @@ MIFARE Classic 1K Bambu map (little-endian). Required sectors: **0, 1, 2, 4, 5, | 12 | Produced ASCII (optional) | | 14 | length meters `uint16` @4 (optional) | -`chipUID` argument is normalized to lowercase hex. If image block 0 bytes 0–3 decode to a different UID than `chipUID` (when sector 0 is OK), still trust `chipUID` from the dump metadata and keep parsing (cloned magic tags can rewrite block 0; v1 does not error). +`uid` is encoded to lowercase hex on `Tag.ChipUID`. Block 0 UID is not compared (cloned magic tags can rewrite it). Not Bambu: if Type and Material are both empty after NUL-trim → `ErrNotBambu`. Brand is always `BrandBambuLab` on success. -Optional sectors missing → leave those fields zero/empty, not an error. - -### ParseDump / ParseZip - -`ParseDump` uses `d.Image` and `d.SectorsFail` (`nil` fail list means none failed). `ParseZip` is `dump.ParseZip` then `ParseDump` each entry. A zip with one bad sticker returns error (no partial zip success in v1). - ### Merge Group by `Brand + TrayUID`. Empty `TrayUID` → `ErrIncomplete` (do not invent ids). Different brands never share a group. @@ -234,13 +228,14 @@ Output spool order: sorted by TrayUID. ```go var ( - ErrInvalidDump = errors.New("invalid dump") ErrImageSize = errors.New("image size") ErrIncomplete = errors.New("incomplete tag") ErrNotBambu = errors.New("not bambu") ErrUID = errors.New("uid") ErrConflict = errors.New("tag conflict") ) + +Dump JSON/zip errors live in package `dump` (`dump.ErrInvalidDump`, `dump.ErrImageSize`). ``` Wrap with `%w`. Error strings lowercase, no punctuation. @@ -254,13 +249,13 @@ Fixtures: copy from dump-app `docs/dumps/2026-08-23-filanfc-last-scans/` (and th | Test | Expect | |---|---| | KeysA(`11223344`) | 16 keys matching Android/Python golden list | -| ParseZip of that testdata zip | 6 tags, all `BrandBambuLab` | +| Parse each testdata `.bin` with UID from the filename | 6 tags, all `BrandBambuLab` | | Merge those tags | 3 spools; Translucent tray `2fb8e0e972084e74bf737393b28e7b12` has chips `52d60177`, `e27be276`; second Translucent `aa42f322172c48e99c0ece895346ea9f`; Basic `3cb568b7af4f41819412fe60afc5c446` | | Colors | `#61B0FF80` and `#000000FF` | | Diameter | `1.75` | | Types | `PETG Translucent` ×4 tags, `PETG Basic` ×2 | | 1023-byte image | `ErrImageSize` | -| missing block 9 in fail list | `ErrIncomplete` | +| image with block 9 all zeros | `ErrIncomplete` | | empty UID KeysA | `ErrUID` | | Merge two tags same tray different Type | `ErrConflict` | diff --git a/errors.go b/errors.go index b36bda1..5c23788 100644 --- a/errors.go +++ b/errors.go @@ -3,10 +3,9 @@ package bambunfc import "errors" var ( - ErrInvalidDump = errors.New("invalid dump") - ErrImageSize = errors.New("image size") - ErrIncomplete = errors.New("incomplete tag") - ErrNotBambu = errors.New("not bambu") - ErrUID = errors.New("uid") - ErrConflict = errors.New("tag conflict") + ErrImageSize = errors.New("image size") + ErrIncomplete = errors.New("incomplete tag") + ErrNotBambu = errors.New("not bambu") + ErrUID = errors.New("uid") + ErrConflict = errors.New("tag conflict") ) diff --git a/merge_test.go b/merge_test.go index 87a25d1..ef0323c 100644 --- a/merge_test.go +++ b/merge_test.go @@ -2,21 +2,18 @@ package bambunfc import ( "errors" - "os" "slices" "testing" ) func TestMergeFixtures(t *testing.T) { t.Parallel() - f, err := os.Open("testdata/dumps.zip") - if err != nil { - t.Fatal(err) + uids := []string{ + "e27be276", "52d60177", "92a80577", "b2d32177", "92b6fb31", "f28c58ed", } - defer f.Close() - tags, err := ParseZip(f) - if err != nil { - t.Fatal(err) + tags := make([]Tag, 0, len(uids)) + for _, id := range uids { + tags = append(tags, parseFile(t, id)) } spools, err := Merge(tags) if err != nil { diff --git a/parse.go b/parse.go index 66bfef2..f7d0ab0 100644 --- a/parse.go +++ b/parse.go @@ -5,24 +5,18 @@ import ( "encoding/binary" "encoding/hex" "fmt" - "io" "math" - "strings" - "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/dump" "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/mifare" ) const BrandBambuLab = "Bambu Lab" -// requiredSectors must not appear in SectorsFail (blocks 0–2, 4–6, 8–9). -var requiredSectors = []int{0, 1, 2} - -// Tag is one Bambu sticker after parsing the 1K image. +// Tag is one Bambu sticker parsed from a raw MIFARE Classic 1K image. type Tag struct { Brand string - ChipUID string - TrayUID string + ChipUID string // lowercase hex of the ISO UID + TrayUID string // 32 lowercase hex chars (block 9) Type string Material string MaterialID string @@ -40,14 +34,6 @@ type Tag struct { SpoolWidthRaw uint16 } -func failedSet(fail []int) map[int]bool { - s := make(map[int]bool, len(fail)) - for _, n := range fail { - s[n] = true - } - return s -} - func block(image []byte, n int) []byte { off := n * mifare.BlockSize return image[off : off+mifare.BlockSize] @@ -60,34 +46,24 @@ func cstr(b []byte) string { return string(b) } -func normalizeUID(uid string) (string, error) { - uid = strings.ToLower(strings.TrimSpace(uid)) - if uid == "" || len(uid)%2 != 0 { - return "", fmt.Errorf("%w: %q", ErrUID, uid) +func allZero(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } } - if _, err := hex.DecodeString(uid); err != nil { - return "", fmt.Errorf("%w: %v", ErrUID, err) - } - return uid, nil + return true } -// ParseImage parses a Bambu MIFARE Classic 1K image. -// fail is the dump's sectors_fail list (sector indexes 0–15). -func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) { +// Parse reads a Bambu tag from raw NFC: ISO chip UID and the 1024-byte Classic 1K image. +func Parse(uid, image []byte) (Tag, error) { var zero Tag - uid, err := normalizeUID(chipUID) - if err != nil { - return zero, err + if len(uid) == 0 { + return zero, fmt.Errorf("%w: empty", ErrUID) } if len(image) != mifare.Size1K { return zero, fmt.Errorf("%w: want %d got %d", ErrImageSize, mifare.Size1K, len(image)) } - failed := failedSet(fail) - for _, s := range requiredSectors { - if failed[s] { - return zero, fmt.Errorf("%w: sector %d", ErrIncomplete, s) - } - } b1 := block(image, 1) b2 := block(image, 2) @@ -95,16 +71,22 @@ func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) { b5 := block(image, 5) b6 := block(image, 6) b9 := block(image, 9) + b10 := block(image, 10) + b12 := block(image, 12) + b14 := block(image, 14) material := cstr(b2) typ := cstr(b4) if material == "" && typ == "" { return zero, ErrNotBambu } + if allZero(b9) { + return zero, fmt.Errorf("%w: tray uid", ErrIncomplete) + } - tag := Tag{ + return Tag{ Brand: BrandBambuLab, - ChipUID: uid, + ChipUID: hex.EncodeToString(uid), VariantID: cstr(b1[0:8]), MaterialID: cstr(b1[8:16]), Material: material, @@ -112,45 +94,16 @@ func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) { Color: Color{ R: b5[0], G: b5[1], B: b5[2], A: b5[3], }, - WeightG: int(binary.LittleEndian.Uint16(b5[4:6])), - DiameterMM: math.Float32frombits(binary.LittleEndian.Uint32(b5[8:12])), - TrayUID: hex.EncodeToString(b9), - DryC: int(binary.LittleEndian.Uint16(b6[0:2])), - DryHours: int(binary.LittleEndian.Uint16(b6[2:4])), - BedC: int(binary.LittleEndian.Uint16(b6[6:8])), - MaxHotendC: int(binary.LittleEndian.Uint16(b6[8:10])), - MinHotendC: int(binary.LittleEndian.Uint16(b6[10:12])), - } - if !failed[2] { - b10 := block(image, 10) - tag.SpoolWidthRaw = binary.LittleEndian.Uint16(b10[4:6]) - } - if !failed[3] { - tag.Produced = cstr(block(image, 12)) - b14 := block(image, 14) - tag.LengthM = int(binary.LittleEndian.Uint16(b14[4:6])) - } - return tag, nil -} - -// ParseDump parses one dump JSON record as a Bambu tag. -func ParseDump(d dump.Dump) (Tag, error) { - return ParseImage(d.ChipUID, d.Image, d.SectorsFail) -} - -// ParseZip reads a filanfc-dump zip and parses every sticker as Bambu. -func ParseZip(r io.Reader) ([]Tag, error) { - dumps, err := dump.ParseZip(r) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidDump, err) - } - tags := make([]Tag, 0, len(dumps)) - for _, d := range dumps { - tag, err := ParseDump(d) - if err != nil { - return nil, fmt.Errorf("chip %s: %w", d.ChipUID, err) - } - tags = append(tags, tag) - } - return tags, nil + WeightG: int(binary.LittleEndian.Uint16(b5[4:6])), + DiameterMM: math.Float32frombits(binary.LittleEndian.Uint32(b5[8:12])), + TrayUID: hex.EncodeToString(b9), + DryC: int(binary.LittleEndian.Uint16(b6[0:2])), + DryHours: int(binary.LittleEndian.Uint16(b6[2:4])), + BedC: int(binary.LittleEndian.Uint16(b6[6:8])), + MaxHotendC: int(binary.LittleEndian.Uint16(b6[8:10])), + MinHotendC: int(binary.LittleEndian.Uint16(b6[10:12])), + SpoolWidthRaw: binary.LittleEndian.Uint16(b10[4:6]), + Produced: cstr(b12), + LengthM: int(binary.LittleEndian.Uint16(b14[4:6])), + }, nil } diff --git a/parse_test.go b/parse_test.go index 1b7f8c4..3fb1083 100644 --- a/parse_test.go +++ b/parse_test.go @@ -1,33 +1,51 @@ package bambunfc import ( - "bytes" + "encoding/hex" "errors" "os" + "path/filepath" "testing" - - "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/dump" ) -func TestParseZipFixtures(t *testing.T) { +func loadRaw(t *testing.T, uidHex string) (uid, image []byte) { + t.Helper() + var err error + uid, err = hex.DecodeString(uidHex) + if err != nil { + t.Fatal(err) + } + image, err = os.ReadFile(filepath.Join("testdata", "dumps", uidHex+".bin")) + if err != nil { + t.Fatal(err) + } + return uid, image +} + +func parseFile(t *testing.T, uidHex string) Tag { + t.Helper() + uid, image := loadRaw(t, uidHex) + tag, err := Parse(uid, image) + if err != nil { + t.Fatal(err) + } + return tag +} + +func TestParseRawFixtures(t *testing.T) { t.Parallel() - f, err := os.Open("testdata/dumps.zip") - if err != nil { - t.Fatal(err) - } - defer f.Close() - tags, err := ParseZip(f) - if err != nil { - t.Fatal(err) - } - if len(tags) != 6 { - t.Fatalf("len(tags) = %d; want 6", len(tags)) + uids := []string{ + "e27be276", "52d60177", "92a80577", "b2d32177", "92b6fb31", "f28c58ed", } var translucent, basic int - for _, tag := range tags { + for _, id := range uids { + tag := parseFile(t, id) if tag.Brand != BrandBambuLab { t.Errorf("chip %s brand %q", tag.ChipUID, tag.Brand) } + if tag.ChipUID != id { + t.Errorf("chip UID %q; want %q", tag.ChipUID, id) + } if tag.DiameterMM != 1.75 { t.Errorf("chip %s diameter %v", tag.ChipUID, tag.DiameterMM) } @@ -49,53 +67,30 @@ func TestParseZipFixtures(t *testing.T) { if translucent != 4 || basic != 2 { t.Errorf("translucent=%d basic=%d", translucent, basic) } + + a := parseFile(t, "e27be276") + if a.TrayUID != "2fb8e0e972084e74bf737393b28e7b12" { + t.Errorf("TrayUID = %s", a.TrayUID) + } } -func TestParseImageErrors(t *testing.T) { +func TestParseErrors(t *testing.T) { t.Parallel() - raw, err := os.ReadFile("testdata/dumps/e27be276.bin") - if err != nil { - t.Fatal(err) - } - if _, err := ParseImage("e27be276", raw[:1023], nil); !errors.Is(err, ErrImageSize) { + uid, image := loadRaw(t, "e27be276") + if _, err := Parse(uid, image[:1023]); !errors.Is(err, ErrImageSize) { t.Errorf("short image err = %v; want ErrImageSize", err) } - if _, err := ParseImage("e27be276", raw, []int{2}); !errors.Is(err, ErrIncomplete) { - t.Errorf("fail sector 2 err = %v; want ErrIncomplete", err) - } blank := make([]byte, 1024) - if _, err := ParseImage("e27be276", blank, nil); !errors.Is(err, ErrNotBambu) { + if _, err := Parse(uid, blank); !errors.Is(err, ErrNotBambu) { t.Errorf("blank image err = %v; want ErrNotBambu", err) } -} + if _, err := Parse(nil, image); !errors.Is(err, ErrUID) { + t.Errorf("empty uid err = %v; want ErrUID", err) + } -func TestParseDumpJSON(t *testing.T) { - t.Parallel() - f, err := os.Open("testdata/dumps/e27be276.json") - if err != nil { - t.Fatal(err) - } - defer f.Close() - d, err := dump.ParseJSON(f) - if err != nil { - t.Fatal(err) - } - tag, err := ParseDump(d) - if err != nil { - t.Fatal(err) - } - if tag.TrayUID != "2fb8e0e972084e74bf737393b28e7b12" { - t.Errorf("TrayUID = %s", tag.TrayUID) - } - if tag.ChipUID != "e27be276" { - t.Errorf("ChipUID = %s", tag.ChipUID) - } -} - -func TestParseImageEmptyUID(t *testing.T) { - t.Parallel() - _, err := ParseImage("", bytes.Repeat([]byte{1}, 1024), nil) - if !errors.Is(err, ErrUID) { - t.Errorf("err = %v; want ErrUID", err) + noTray := append([]byte(nil), image...) + copy(noTray[9*16:10*16], make([]byte, 16)) + if _, err := Parse(uid, noTray); !errors.Is(err, ErrIncomplete) { + t.Errorf("zero tray err = %v; want ErrIncomplete", err) } }