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
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`
+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
@@ -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 librarys 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 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`.
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` |
-1
View File
@@ -3,7 +3,6 @@ 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")
+5 -8
View File
@@ -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 {
+24 -71
View File
@@ -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 02, 46, 89).
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 015).
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,
@@ -120,37 +102,8 @@ func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) {
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
SpoolWidthRaw: binary.LittleEndian.Uint16(b10[4:6]),
Produced: cstr(b12),
LengthM: int(binary.LittleEndian.Uint16(b14[4:6])),
}, nil
}
+50 -55
View File
@@ -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)
}
}