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.
This commit is contained in:
@@ -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": "<base64>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```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
|
||||||
Reference in New Issue
Block a user