Files
bambu-nfc/docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md
T
s1d3sw1ped a794db4838 Add design spec for the bambu-nfc Go library.
Parse filanfc-dump/v1 zip/JSON, Bambu 1K images, Key-A HKDF, and merge stickers by tray UID. dump and mifare packages are the later common-library seam.
2026-08-23 15:15:56 -05:00

8.8 KiB
Raw Blame History

bambu-nfc library

Date: 2026-08-23

Go module that parses Bambu Lab filament NFC dumps (from the phone dump app) and derives MIFARE Key-A from a chip UID. The inventory website will import this module. Other filament brands are out of scope for v1, but dump I/O and MIFARE 1K layout live in their own packages so they can move to a common module later.

Repo: ssh://gitea@git.s1d3sw1ped.com:2222/s1d3sw1ped/bambu-nfc.git
Module: git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc
Go: 1.25

Goal

Given a filanfc-dump/v1 zip, JSON, or 1024-byte image, return typed Bambu tags and merge two stickers on the same spool. Given a chip UID, return the 16 Key-A values used to dump a Bambu MIFARE Classic 1K tag.

Success: go test -race ./... passes against the six real dumps in testdata/; Merge yields three spools (two PETG Translucent, one PETG Basic Black).

Non-goals

  • HTTP, inventory, remaining-weight tracking
  • AMS SPI / PN532 / wand hardware
  • Creality, OpenTag, Elegoo, NDEF
  • A filamentnfc common module (only package seams)
  • CLI
  • Writing or cloning tags
  • Brand auto-detect across vendors

Packages

bambunfc/          // package bambunfc — Bambu parse, KeysA, Merge
  dump/            // filanfc-dump/v1 JSON + zip; no filament fields
  mifare/          // 1K constants and sector/block helpers; no brand map

Callers of the website import git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc only. dump and mifare are public so they can be copied into a common module without rewriting their APIs.

Bambu-only: HKDF salt/info, block map, tray_uid, Brand: "Bambu Lab".

dump

Brand-blind. A dump is one sticker: chip UID + 1K image.

JSON matches the Android app (filanfc-dump/v1):

{
  "format": "filanfc-dump/v1",
  "scanned_at": "2026-08-23T19:52:02.388908Z",
  "chip_uid": "e27be276",
  "sak": "08",
  "atqa": "0004",
  "tech": ["android.nfc.tech.MifareClassic"],
  "sectors_ok": [0, 1, , 15],
  "sectors_fail": [],
  "dump_hex": "<2048 hex chars>",
  "dump_b64": "<base64>"
}
package dump

const FormatV1 = "filanfc-dump/v1"

type Dump struct {
    Format    string
    ScannedAt time.Time
    ChipUID   string // lowercase hex, no separators
    SAK       string
    ATQA      string
    Tech      []string
    SectorsOK []int
    SectorsFail []int
    Image     []byte // always 1024 when valid
}

func ParseJSON(r io.Reader) (Dump, error)
func ParseZip(r io.Reader) ([]Dump, error)
  • dump_hex and dump_b64 must decode to the same 1024 bytes; if both present and they disagree → ErrInvalidDump.
  • Zip entries {chip_uid}.json are parsed; .bin files are ignored if JSON is present (JSON already carries the image). A .bin without JSON is not required in v1.
  • Chip UID in JSON is normalized to lowercase hex.

mifare

package mifare

const (
    Sectors    = 16
    Blocks     = 64
    BlockSize  = 16
    SectorSize = 64
    Size1K     = 1024
)

func SectorOf(block int) int
func BlockRange(sector int) (first, last int) // last exclusive
func SectorBytes(image []byte, sector int) ([]byte, error)

No keys, no brand parse.

bambunfc

package bambunfc

const BrandBambuLab = "Bambu Lab"

type Color struct {
    R, G, B, A uint8
}

func (c Color) Hex() string // "#RRGGBBAA" uppercase hex

type Tag struct {
    Brand      string
    ChipUID    string
    TrayUID    string // 32 lowercase hex chars
    Type       string // detailed type, e.g. "PETG Translucent"
    Material   string // block 2, e.g. "PETG"
    MaterialID string // e.g. "GFG01"
    VariantID  string // e.g. "G01-B0"
    Color      Color
    WeightG    int
    DiameterMM float32
    LengthM    int
    MinHotendC int
    MaxHotendC int
    BedC       int
    DryC       int
    DryHours   int
    Produced   string // block 12 ASCII, may be empty
    SpoolWidthRaw uint16 // block 10 offset 4; encoding not trusted
}

type Spool struct {
    Brand    string
    TrayUID  string
    ChipUIDs []string // unique, sorted
    Type     string
    Material string
    MaterialID string
    VariantID  string
    Color    Color
    WeightG  int
    DiameterMM float32
    LengthM  int
    MinHotendC int
    MaxHotendC int
    BedC     int
    DryC     int
    DryHours int
    Produced string
    SpoolWidthRaw uint16
}

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 Merge(tags []Tag) ([]Spool, error)

KeysA

RFC 5869 HKDF-SHA256:

  • IKM = raw UID bytes (4 or 7; length from the caller)
  • salt = 9a759cf2c4f7caff222cb9769b41bc96
  • info = RFID-A\0
  • output 96 bytes → 16 keys of 6 bytes, sector 0 first

Empty UID → ErrUID. Matches python3 deriveKeys.py 11223344 / the Android app. Golden Key-A hex (uppercase) for UID 11223344:

0729F3B2D37A
2027210D85E7
D77B2E7C92BC
29126A53A2EE
E49850F62778
9A9128B882BD
45F69B980786
90DD67095D6B
EB994746CD69
B4AEB23473E3
445E850D699C
5C9BD5BD9FD7
1062AFE9F5F5
81EE5259CF6B
0920CEEEE2BC
BAF630CCBFD3

ParseImage

MIFARE Classic 1K Bambu map (little-endian). Required sectors: 0, 1, 2, 4, 5, 9. If any of those are in failErrIncomplete.

Block Field
2 Material, NUL-trimmed ASCII
4 Type (detailed), NUL-trimmed ASCII
1 VariantID 8 + MaterialID 8, NUL-trimmed
5 RGBA 4; weight uint16 @4; diameter float32 @8
6 dry °C, dry hours, bed type (ignored), bed °C, max hotend, min hotend — all uint16 LE
9 TrayUID 16 raw bytes → hex
10 uint16 @4 → SpoolWidthRaw (optional; zero if sector 2 failed)
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).

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 TrayUIDErrIncomplete (do not invent ids). Different brands never share a group.

Within a group, ChipUIDs is the sorted unique set. Filament fields come from the first tag; if two tags in the group disagree on Type, Material, Color, or WeightG → ErrConflict.

Output spool order: sorted by TrayUID.

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")
)

Wrap with %w. Error strings lowercase, no punctuation.

mifare.SectorBytes on short image → ErrImageSize from bambunfc via wrap, or dump parse fails first.

Tests

Fixtures: copy from dump-app docs/dumps/2026-08-23-filanfc-last-scans/ (and the zip docs/dumps/2026-08-23-filanfc-last-scans.zip) into this repo as testdata/dumps/ (json+bin) and testdata/dumps.zip. Source machine path: /fast/projects/scratch/New Folder (2)/docs/dumps/.

Test Expect
KeysA(11223344) 16 keys matching Android/Python golden list
ParseZip of that testdata zip 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
empty UID KeysA ErrUID
Merge two tags same tray different Type ErrConflict

Table-driven tests, t.Parallel() on subtests, go test -race ./....

Layout on disk

go.mod
dump/dump.go
dump/dump_test.go
mifare/mifare.go
mifare/mifare_test.go
kdf.go
parse.go
merge.go
color.go
errors.go
kdf_test.go
parse_test.go
merge_test.go
testdata/dumps/
README.md

No pkg/, src/, or internal/ for these three packages (dump/mifare must stay importable).

Resolved decisions

  • Brand field always "Bambu Lab" on successful parse; no multi-brand detect in v1
  • Common module deferred; dump + mifare are the extraction boundary
  • Diameter is float32 at block 5 offset 8 (confirmed by real dumps)
  • Tray UID is 16 binary bytes, hex-encoded
  • Spool width left as raw uint16
  • No CLI in v1