# 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`): ```json { "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": "" } ``` ```go 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 ```go 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 ```go 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 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 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 ``` ### Parse 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 | |---|---| | 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) | `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. ### Merge Group by `Brand + TrayUID`. Empty `TrayUID` → `ErrIncomplete` (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 ```go var ( 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. `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 | | 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` | | image with block 9 all zeros | `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