commit a794db48381fdb78c4ea1d58910945bf216e9c0b Author: Justin Harms Date: Sun Aug 23 15:15:56 2026 -0500 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. 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 new file mode 100644 index 0000000..eea6463 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md @@ -0,0 +1,298 @@ +# 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 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 `fail` → `ErrIncomplete`. + +| 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 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). + +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. + +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 ( + 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