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).
This commit is contained in:
2026-08-23 15:27:11 -05:00
parent b38f7f643b
commit b67b37dc59
7 changed files with 110 additions and 169 deletions
+5 -4
View File
@@ -1,16 +1,17 @@
# bambu-nfc # 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 ```go
import bambunfc "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc" 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) 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 - `mifare` — Classic 1K layout helpers
- root package — Bambu Key-A HKDF, block parse, merge by `tray_uid` - root package — Bambu Key-A HKDF, block parse, merge by `tray_uid`
+2 -1
View File
@@ -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 package bambunfc
@@ -158,10 +158,10 @@ type Spool struct {
} }
func KeysA(uid []byte) ([16][6]byte, error) func KeysA(uid []byte) ([16][6]byte, error)
func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) func Parse(uid, image []byte) (Tag, error) // raw ISO UID + 1024-byte 1K image
func ParseDump(d dump.Dump) (Tag, error)
func ParseZip(r io.Reader) ([]Tag, error)
func Merge(tags []Tag) ([]Spool, error) 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 librarys input surface.
``` ```
### KeysA ### KeysA
@@ -194,9 +194,9 @@ B4AEB23473E3
BAF630CCBFD3 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 | | Block | Field |
|---|---| |---|---|
@@ -210,18 +210,12 @@ MIFARE Classic 1K Bambu map (little-endian). Required sectors: **0, 1, 2, 4, 5,
| 12 | Produced ASCII (optional) | | 12 | Produced ASCII (optional) |
| 14 | length meters `uint16` @4 (optional) | | 14 | length meters `uint16` @4 (optional) |
`chipUID` argument is normalized to lowercase hex. If image block 0 bytes 03 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`. Not Bambu: if Type and Material are both empty after NUL-trim → `ErrNotBambu`.
Brand is always `BrandBambuLab` on success. 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 ### Merge
Group by `Brand + TrayUID`. Empty `TrayUID``ErrIncomplete` (do not invent ids). Different brands never share a group. 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 ```go
var ( var (
ErrInvalidDump = errors.New("invalid dump")
ErrImageSize = errors.New("image size") ErrImageSize = errors.New("image size")
ErrIncomplete = errors.New("incomplete tag") ErrIncomplete = errors.New("incomplete tag")
ErrNotBambu = errors.New("not bambu") ErrNotBambu = errors.New("not bambu")
ErrUID = errors.New("uid") ErrUID = errors.New("uid")
ErrConflict = errors.New("tag conflict") 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. 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 | | Test | Expect |
|---|---| |---|---|
| KeysA(`11223344`) | 16 keys matching Android/Python golden list | | 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` | | Merge those tags | 3 spools; Translucent tray `2fb8e0e972084e74bf737393b28e7b12` has chips `52d60177`, `e27be276`; second Translucent `aa42f322172c48e99c0ece895346ea9f`; Basic `3cb568b7af4f41819412fe60afc5c446` |
| Colors | `#61B0FF80` and `#000000FF` | | Colors | `#61B0FF80` and `#000000FF` |
| Diameter | `1.75` | | Diameter | `1.75` |
| Types | `PETG Translucent` ×4 tags, `PETG Basic` ×2 | | Types | `PETG Translucent` ×4 tags, `PETG Basic` ×2 |
| 1023-byte image | `ErrImageSize` | | 1023-byte image | `ErrImageSize` |
| missing block 9 in fail list | `ErrIncomplete` | | image with block 9 all zeros | `ErrIncomplete` |
| empty UID KeysA | `ErrUID` | | empty UID KeysA | `ErrUID` |
| Merge two tags same tray different Type | `ErrConflict` | | Merge two tags same tray different Type | `ErrConflict` |
+5 -6
View File
@@ -3,10 +3,9 @@ package bambunfc
import "errors" import "errors"
var ( var (
ErrInvalidDump = errors.New("invalid dump") ErrImageSize = errors.New("image size")
ErrImageSize = errors.New("image size") ErrIncomplete = errors.New("incomplete tag")
ErrIncomplete = errors.New("incomplete tag") ErrNotBambu = errors.New("not bambu")
ErrNotBambu = errors.New("not bambu") ErrUID = errors.New("uid")
ErrUID = errors.New("uid") ErrConflict = errors.New("tag conflict")
ErrConflict = errors.New("tag conflict")
) )
+5 -8
View File
@@ -2,21 +2,18 @@ package bambunfc
import ( import (
"errors" "errors"
"os"
"slices" "slices"
"testing" "testing"
) )
func TestMergeFixtures(t *testing.T) { func TestMergeFixtures(t *testing.T) {
t.Parallel() t.Parallel()
f, err := os.Open("testdata/dumps.zip") uids := []string{
if err != nil { "e27be276", "52d60177", "92a80577", "b2d32177", "92b6fb31", "f28c58ed",
t.Fatal(err)
} }
defer f.Close() tags := make([]Tag, 0, len(uids))
tags, err := ParseZip(f) for _, id := range uids {
if err != nil { tags = append(tags, parseFile(t, id))
t.Fatal(err)
} }
spools, err := Merge(tags) spools, err := Merge(tags)
if err != nil { if err != nil {
+33 -80
View File
@@ -5,24 +5,18 @@ import (
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io"
"math" "math"
"strings"
"git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/dump"
"git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/mifare" "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/mifare"
) )
const BrandBambuLab = "Bambu Lab" const BrandBambuLab = "Bambu Lab"
// requiredSectors must not appear in SectorsFail (blocks 02, 46, 89). // Tag is one Bambu sticker parsed from a raw MIFARE Classic 1K image.
var requiredSectors = []int{0, 1, 2}
// Tag is one Bambu sticker after parsing the 1K image.
type Tag struct { type Tag struct {
Brand string Brand string
ChipUID string ChipUID string // lowercase hex of the ISO UID
TrayUID string TrayUID string // 32 lowercase hex chars (block 9)
Type string Type string
Material string Material string
MaterialID string MaterialID string
@@ -40,14 +34,6 @@ type Tag struct {
SpoolWidthRaw uint16 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 { func block(image []byte, n int) []byte {
off := n * mifare.BlockSize off := n * mifare.BlockSize
return image[off : off+mifare.BlockSize] return image[off : off+mifare.BlockSize]
@@ -60,34 +46,24 @@ func cstr(b []byte) string {
return string(b) return string(b)
} }
func normalizeUID(uid string) (string, error) { func allZero(b []byte) bool {
uid = strings.ToLower(strings.TrimSpace(uid)) for _, v := range b {
if uid == "" || len(uid)%2 != 0 { if v != 0 {
return "", fmt.Errorf("%w: %q", ErrUID, uid) return false
}
} }
if _, err := hex.DecodeString(uid); err != nil { return true
return "", fmt.Errorf("%w: %v", ErrUID, err)
}
return uid, nil
} }
// ParseImage parses a Bambu MIFARE Classic 1K image. // Parse reads a Bambu tag from raw NFC: ISO chip UID and the 1024-byte Classic 1K image.
// fail is the dump's sectors_fail list (sector indexes 015). func Parse(uid, image []byte) (Tag, error) {
func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) {
var zero Tag var zero Tag
uid, err := normalizeUID(chipUID) if len(uid) == 0 {
if err != nil { return zero, fmt.Errorf("%w: empty", ErrUID)
return zero, err
} }
if len(image) != mifare.Size1K { if len(image) != mifare.Size1K {
return zero, fmt.Errorf("%w: want %d got %d", ErrImageSize, mifare.Size1K, len(image)) 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) b1 := block(image, 1)
b2 := block(image, 2) b2 := block(image, 2)
@@ -95,16 +71,22 @@ func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) {
b5 := block(image, 5) b5 := block(image, 5)
b6 := block(image, 6) b6 := block(image, 6)
b9 := block(image, 9) b9 := block(image, 9)
b10 := block(image, 10)
b12 := block(image, 12)
b14 := block(image, 14)
material := cstr(b2) material := cstr(b2)
typ := cstr(b4) typ := cstr(b4)
if material == "" && typ == "" { if material == "" && typ == "" {
return zero, ErrNotBambu return zero, ErrNotBambu
} }
if allZero(b9) {
return zero, fmt.Errorf("%w: tray uid", ErrIncomplete)
}
tag := Tag{ return Tag{
Brand: BrandBambuLab, Brand: BrandBambuLab,
ChipUID: uid, ChipUID: hex.EncodeToString(uid),
VariantID: cstr(b1[0:8]), VariantID: cstr(b1[0:8]),
MaterialID: cstr(b1[8:16]), MaterialID: cstr(b1[8:16]),
Material: material, Material: material,
@@ -112,45 +94,16 @@ func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) {
Color: Color{ Color: Color{
R: b5[0], G: b5[1], B: b5[2], A: b5[3], R: b5[0], G: b5[1], B: b5[2], A: b5[3],
}, },
WeightG: int(binary.LittleEndian.Uint16(b5[4:6])), WeightG: int(binary.LittleEndian.Uint16(b5[4:6])),
DiameterMM: math.Float32frombits(binary.LittleEndian.Uint32(b5[8:12])), DiameterMM: math.Float32frombits(binary.LittleEndian.Uint32(b5[8:12])),
TrayUID: hex.EncodeToString(b9), TrayUID: hex.EncodeToString(b9),
DryC: int(binary.LittleEndian.Uint16(b6[0:2])), DryC: int(binary.LittleEndian.Uint16(b6[0:2])),
DryHours: int(binary.LittleEndian.Uint16(b6[2:4])), DryHours: int(binary.LittleEndian.Uint16(b6[2:4])),
BedC: int(binary.LittleEndian.Uint16(b6[6:8])), BedC: int(binary.LittleEndian.Uint16(b6[6:8])),
MaxHotendC: int(binary.LittleEndian.Uint16(b6[8:10])), MaxHotendC: int(binary.LittleEndian.Uint16(b6[8:10])),
MinHotendC: int(binary.LittleEndian.Uint16(b6[10:12])), MinHotendC: int(binary.LittleEndian.Uint16(b6[10:12])),
} SpoolWidthRaw: binary.LittleEndian.Uint16(b10[4:6]),
if !failed[2] { Produced: cstr(b12),
b10 := block(image, 10) LengthM: int(binary.LittleEndian.Uint16(b14[4:6])),
tag.SpoolWidthRaw = binary.LittleEndian.Uint16(b10[4:6]) }, nil
}
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
} }
+50 -55
View File
@@ -1,33 +1,51 @@
package bambunfc package bambunfc
import ( import (
"bytes" "encoding/hex"
"errors" "errors"
"os" "os"
"path/filepath"
"testing" "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() t.Parallel()
f, err := os.Open("testdata/dumps.zip") uids := []string{
if err != nil { "e27be276", "52d60177", "92a80577", "b2d32177", "92b6fb31", "f28c58ed",
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))
} }
var translucent, basic int var translucent, basic int
for _, tag := range tags { for _, id := range uids {
tag := parseFile(t, id)
if tag.Brand != BrandBambuLab { if tag.Brand != BrandBambuLab {
t.Errorf("chip %s brand %q", tag.ChipUID, tag.Brand) 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 { if tag.DiameterMM != 1.75 {
t.Errorf("chip %s diameter %v", tag.ChipUID, tag.DiameterMM) t.Errorf("chip %s diameter %v", tag.ChipUID, tag.DiameterMM)
} }
@@ -49,53 +67,30 @@ func TestParseZipFixtures(t *testing.T) {
if translucent != 4 || basic != 2 { if translucent != 4 || basic != 2 {
t.Errorf("translucent=%d basic=%d", translucent, basic) 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() t.Parallel()
raw, err := os.ReadFile("testdata/dumps/e27be276.bin") uid, image := loadRaw(t, "e27be276")
if err != nil { if _, err := Parse(uid, image[:1023]); !errors.Is(err, ErrImageSize) {
t.Fatal(err)
}
if _, err := ParseImage("e27be276", raw[:1023], nil); !errors.Is(err, ErrImageSize) {
t.Errorf("short image err = %v; want ErrImageSize", err) 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) 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) 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) { noTray := append([]byte(nil), image...)
t.Parallel() copy(noTray[9*16:10*16], make([]byte, 16))
f, err := os.Open("testdata/dumps/e27be276.json") if _, err := Parse(uid, noTray); !errors.Is(err, ErrIncomplete) {
if err != nil { t.Errorf("zero tray err = %v; want ErrIncomplete", err)
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)
} }
} }