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