From b38f7f643b376e814c794d2cdd4afe97f19c1d2b Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Sun, 23 Aug 2026 15:23:11 -0500 Subject: [PATCH] Add bambu-nfc dump parse, Key-A HKDF, and spool merge. Parse filanfc-dump/v1 zip/JSON, Bambu 1K images, and merge stickers by tray UID. dump and mifare packages stay importable for a later common library. --- README.md | 21 +++ color.go | 13 ++ doc.go | 2 + .../plans/2026-08-23-bambu-nfc-library.md | 23 +++ dump/dump.go | 160 ++++++++++++++++++ dump/dump_test.go | 58 +++++++ errors.go | 12 ++ go.mod | 3 + kdf.go | 30 ++++ kdf_test.go | 48 ++++++ merge.go | 107 ++++++++++++ merge_test.go | 87 ++++++++++ mifare/mifare.go | 36 ++++ mifare/mifare_test.go | 30 ++++ parse.go | 156 +++++++++++++++++ parse_test.go | 101 +++++++++++ testdata/dumps.zip | Bin 0 -> 11579 bytes testdata/dumps/52d60177.bin | Bin 0 -> 1024 bytes testdata/dumps/52d60177.json | 1 + testdata/dumps/92a80577.bin | Bin 0 -> 1024 bytes testdata/dumps/92a80577.json | 1 + testdata/dumps/92b6fb31.bin | Bin 0 -> 1024 bytes testdata/dumps/92b6fb31.json | 1 + testdata/dumps/b2d32177.bin | Bin 0 -> 1024 bytes testdata/dumps/b2d32177.json | 1 + testdata/dumps/e27be276.bin | Bin 0 -> 1024 bytes testdata/dumps/e27be276.json | 1 + testdata/dumps/f28c58ed.bin | Bin 0 -> 1024 bytes testdata/dumps/f28c58ed.json | 1 + 29 files changed, 893 insertions(+) create mode 100644 README.md create mode 100644 color.go create mode 100644 doc.go create mode 100644 docs/superpowers/plans/2026-08-23-bambu-nfc-library.md create mode 100644 dump/dump.go create mode 100644 dump/dump_test.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 kdf.go create mode 100644 kdf_test.go create mode 100644 merge.go create mode 100644 merge_test.go create mode 100644 mifare/mifare.go create mode 100644 mifare/mifare_test.go create mode 100644 parse.go create mode 100644 parse_test.go create mode 100644 testdata/dumps.zip create mode 100644 testdata/dumps/52d60177.bin create mode 100644 testdata/dumps/52d60177.json create mode 100644 testdata/dumps/92a80577.bin create mode 100644 testdata/dumps/92a80577.json create mode 100644 testdata/dumps/92b6fb31.bin create mode 100644 testdata/dumps/92b6fb31.json create mode 100644 testdata/dumps/b2d32177.bin create mode 100644 testdata/dumps/b2d32177.json create mode 100644 testdata/dumps/e27be276.bin create mode 100644 testdata/dumps/e27be276.json create mode 100644 testdata/dumps/f28c58ed.bin create mode 100644 testdata/dumps/f28c58ed.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccb47fb --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# bambu-nfc + +Go library for Bambu Lab filament NFC dumps produced by the Filament NFC dump Android app. + +```go +import bambunfc "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc" + +tags, err := bambunfc.ParseZip(r) +spools, err := bambunfc.Merge(tags) +keys, err := bambunfc.KeysA(uid) +``` + +- `dump` — brand-blind `filanfc-dump/v1` JSON/zip +- `mifare` — Classic 1K layout helpers +- root package — Bambu Key-A HKDF, block parse, merge by `tray_uid` + +`Brand` is `"Bambu Lab"` on a successful parse. + +```bash +go test -race ./... +``` diff --git a/color.go b/color.go new file mode 100644 index 0000000..408d1ae --- /dev/null +++ b/color.go @@ -0,0 +1,13 @@ +package bambunfc + +import "fmt" + +// Color is an RGBA filament color from a Bambu tag. +type Color struct { + R, G, B, A uint8 +} + +// Hex returns "#RRGGBBAA" with uppercase hex digits. +func (c Color) Hex() string { + return fmt.Sprintf("#%02X%02X%02X%02X", c.R, c.G, c.B, c.A) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..cb15ae7 --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// Package bambunfc parses Bambu Lab filament NFC dumps and derives MIFARE Key-A from a chip UID. +package bambunfc diff --git a/docs/superpowers/plans/2026-08-23-bambu-nfc-library.md b/docs/superpowers/plans/2026-08-23-bambu-nfc-library.md new file mode 100644 index 0000000..a64c51a --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-bambu-nfc-library.md @@ -0,0 +1,23 @@ +# bambu-nfc Library Implementation Plan + +> **For agentic workers:** Use TDD. Spec: `docs/superpowers/specs/2026-08-23-bambu-nfc-library-design.md` + +**Goal:** Go module that parses filanfc-dump/v1 zips into Bambu tags/spools and derives MIFARE Key-A from a chip UID. + +**Architecture:** Public packages `dump` (JSON/zip), `mifare` (1K layout), root `bambunfc` (HKDF, block parse, Merge). + +**Tech Stack:** Go 1.25, stdlib `crypto/hkdf`, `encoding/json`, `archive/zip`. + +## Global Constraints + +- Module `git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc` +- Brand on success: `"Bambu Lab"` +- HKDF: IKM=UID, salt=`9a759cf2c4f7caff222cb9769b41bc96`, info=`RFID-A\0`, 16×6 Key-A +- Diameter float32 at block 5 offset 8; tray UID 16 raw bytes hex +- Required dump sectors for a Bambu parse: 0, 1, 2 (`sectors_fail` is sector indexes) +- `go test -race ./...` +- Fixtures from dump-app `docs/dumps/` → `testdata/` + +Tasks: (1) module + mifare (2) dump JSON/zip (3) KeysA (4) ParseImage/ParseDump/ParseZip (5) Merge (6) testdata + README. + +Executing in this session per user "do it". diff --git a/dump/dump.go b/dump/dump.go new file mode 100644 index 0000000..41db6af --- /dev/null +++ b/dump/dump.go @@ -0,0 +1,160 @@ +// Package dump reads brand-blind filanfc-dump/v1 JSON and zip archives. +package dump + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/mifare" +) + +var ( + ErrInvalidDump = errors.New("invalid dump") + ErrImageSize = errors.New("image size") +) + +const FormatV1 = "filanfc-dump/v1" + +// Dump is one sticker: chip UID plus a MIFARE Classic 1K image. +type Dump struct { + Format string + ScannedAt time.Time + ChipUID string + SAK string + ATQA string + Tech []string + SectorsOK []int + SectorsFail []int + Image []byte +} + +type fileDump struct { + Format string `json:"format"` + ScannedAt string `json:"scanned_at"` + ChipUID string `json:"chip_uid"` + SAK string `json:"sak"` + ATQA string `json:"atqa"` + Tech []string `json:"tech"` + SectorsOK []int `json:"sectors_ok"` + SectorsFail []int `json:"sectors_fail"` + DumpHex string `json:"dump_hex"` + DumpB64 string `json:"dump_b64"` +} + +// ParseJSON reads one filanfc-dump/v1 object. +func ParseJSON(r io.Reader) (Dump, error) { + var raw fileDump + dec := json.NewDecoder(r) + if err := dec.Decode(&raw); err != nil { + return Dump{}, fmt.Errorf("%w: %v", ErrInvalidDump, err) + } + return parseRaw(raw) +} + +func parseRaw(raw fileDump) (Dump, error) { + if raw.Format != FormatV1 { + return Dump{}, fmt.Errorf("%w: format %q", ErrInvalidDump, raw.Format) + } + uid := strings.ToLower(strings.TrimSpace(raw.ChipUID)) + if uid == "" || len(uid)%2 != 0 { + return Dump{}, fmt.Errorf("%w: chip_uid %q", ErrInvalidDump, raw.ChipUID) + } + if _, err := hex.DecodeString(uid); err != nil { + return Dump{}, fmt.Errorf("%w: chip_uid: %v", ErrInvalidDump, err) + } + + var imgHex, imgB64 []byte + var err error + if raw.DumpHex != "" { + imgHex, err = hex.DecodeString(raw.DumpHex) + if err != nil { + return Dump{}, fmt.Errorf("%w: dump_hex: %v", ErrInvalidDump, err) + } + } + if raw.DumpB64 != "" { + imgB64, err = base64.StdEncoding.DecodeString(raw.DumpB64) + if err != nil { + return Dump{}, fmt.Errorf("%w: dump_b64: %v", ErrInvalidDump, err) + } + } + var image []byte + switch { + case len(imgHex) != 0 && len(imgB64) != 0: + if !bytes.Equal(imgHex, imgB64) { + return Dump{}, fmt.Errorf("%w: dump_hex and dump_b64 disagree", ErrInvalidDump) + } + image = imgHex + case len(imgHex) != 0: + image = imgHex + case len(imgB64) != 0: + image = imgB64 + default: + return Dump{}, fmt.Errorf("%w: missing image", ErrInvalidDump) + } + if len(image) != mifare.Size1K { + return Dump{}, fmt.Errorf("%w: want %d got %d", ErrImageSize, mifare.Size1K, len(image)) + } + + var scanned time.Time + if raw.ScannedAt != "" { + scanned, err = time.Parse(time.RFC3339Nano, raw.ScannedAt) + if err != nil { + scanned, err = time.Parse(time.RFC3339, raw.ScannedAt) + if err != nil { + return Dump{}, fmt.Errorf("%w: scanned_at: %v", ErrInvalidDump, err) + } + } + } + + return Dump{ + Format: raw.Format, + ScannedAt: scanned, + ChipUID: uid, + SAK: raw.SAK, + ATQA: raw.ATQA, + Tech: raw.Tech, + SectorsOK: raw.SectorsOK, + SectorsFail: raw.SectorsFail, + Image: image, + }, nil +} + +// ParseZip reads every {chip_uid}.json entry in a dump zip. +func ParseZip(r io.Reader) ([]Dump, error) { + body, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("%w: read zip: %v", ErrInvalidDump, err) + } + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return nil, fmt.Errorf("%w: zip: %v", ErrInvalidDump, err) + } + var dumps []Dump + for _, f := range zr.File { + if !strings.HasSuffix(strings.ToLower(f.Name), ".json") { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("%w: open %s: %v", ErrInvalidDump, f.Name, err) + } + d, err := ParseJSON(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("%w: %s: %v", ErrInvalidDump, f.Name, err) + } + dumps = append(dumps, d) + } + if len(dumps) == 0 { + return nil, fmt.Errorf("%w: zip has no json entries", ErrInvalidDump) + } + return dumps, nil +} diff --git a/dump/dump_test.go b/dump/dump_test.go new file mode 100644 index 0000000..874191f --- /dev/null +++ b/dump/dump_test.go @@ -0,0 +1,58 @@ +package dump + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseJSONFixture(t *testing.T) { + t.Parallel() + f, err := os.Open(filepath.Join("..", "testdata", "dumps", "e27be276.json")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + d, err := ParseJSON(f) + if err != nil { + t.Fatal(err) + } + if d.ChipUID != "e27be276" { + t.Errorf("ChipUID = %q", d.ChipUID) + } + if d.Format != FormatV1 { + t.Errorf("Format = %q", d.Format) + } + if len(d.Image) != 1024 { + t.Errorf("Image len = %d", len(d.Image)) + } + if len(d.SectorsFail) != 0 { + t.Errorf("SectorsFail = %v", d.SectorsFail) + } +} + +func TestParseZipFixture(t *testing.T) { + t.Parallel() + f, err := os.Open(filepath.Join("..", "testdata", "dumps.zip")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + dumps, err := ParseZip(f) + if err != nil { + t.Fatal(err) + } + if len(dumps) != 6 { + t.Errorf("len = %d; want 6", len(dumps)) + } +} + +func TestParseJSONRejectsBadFormat(t *testing.T) { + t.Parallel() + _, err := ParseJSON(strings.NewReader(`{"format":"nope","chip_uid":"aa","dump_hex":"` + strings.Repeat("00", 1024) + `"}`)) + if !errors.Is(err, ErrInvalidDump) { + t.Fatalf("err = %v; want ErrInvalidDump", err) + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..b36bda1 --- /dev/null +++ b/errors.go @@ -0,0 +1,12 @@ +package bambunfc + +import "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") +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47e55c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc + +go 1.25 diff --git a/kdf.go b/kdf.go new file mode 100644 index 0000000..7ea79f2 --- /dev/null +++ b/kdf.go @@ -0,0 +1,30 @@ +package bambunfc + +import ( + "crypto/hkdf" + "crypto/sha256" + "fmt" +) + +var kdfSalt = []byte{ + 0x9a, 0x75, 0x9c, 0xf2, 0xc4, 0xf7, 0xca, 0xff, + 0x22, 0x2c, 0xb9, 0x76, 0x9b, 0x41, 0xbc, 0x96, +} + +const kdfInfo = "RFID-A\x00" + +// KeysA derives the 16 MIFARE Key-A values for a Bambu tag from its chip UID. +func KeysA(uid []byte) ([16][6]byte, error) { + var keys [16][6]byte + if len(uid) == 0 { + return keys, fmt.Errorf("%w: empty", ErrUID) + } + okm, err := hkdf.Key(sha256.New, uid, kdfSalt, kdfInfo, 16*6) + if err != nil { + return keys, fmt.Errorf("%w: hkdf: %v", ErrUID, err) + } + for i := range 16 { + copy(keys[i][:], okm[i*6:i*6+6]) + } + return keys, nil +} diff --git a/kdf_test.go b/kdf_test.go new file mode 100644 index 0000000..bb41c46 --- /dev/null +++ b/kdf_test.go @@ -0,0 +1,48 @@ +package bambunfc + +import ( + "encoding/hex" + "errors" + "testing" +) + +func TestKeysAGolden11223344(t *testing.T) { + t.Parallel() + uid, err := hex.DecodeString("11223344") + if err != nil { + t.Fatal(err) + } + want := []string{ + "0729F3B2D37A", "2027210D85E7", "D77B2E7C92BC", "29126A53A2EE", + "E49850F62778", "9A9128B882BD", "45F69B980786", "90DD67095D6B", + "EB994746CD69", "B4AEB23473E3", "445E850D699C", "5C9BD5BD9FD7", + "1062AFE9F5F5", "81EE5259CF6B", "0920CEEEE2BC", "BAF630CCBFD3", + } + got, err := KeysA(uid) + if err != nil { + t.Fatal(err) + } + for i, w := range want { + if h := hex.EncodeToString(got[i][:]); h != toLower(w) { + t.Errorf("key[%d] = %s; want %s", i, h, toLower(w)) + } + } +} + +func toLower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'F' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +func TestKeysAEmpty(t *testing.T) { + t.Parallel() + _, err := KeysA(nil) + if !errors.Is(err, ErrUID) { + t.Errorf("KeysA(nil) err = %v; want ErrUID", err) + } +} diff --git a/merge.go b/merge.go new file mode 100644 index 0000000..6c58f18 --- /dev/null +++ b/merge.go @@ -0,0 +1,107 @@ +package bambunfc + +import ( + "fmt" + "slices" +) + +// Spool is one physical filament roll (both stickers merged). +type Spool struct { + Brand string + TrayUID string + ChipUIDs []string + 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 spoolKey(t Tag) string { + return t.Brand + "\x00" + t.TrayUID +} + +func sameFilament(a, b Tag) bool { + return a.Type == b.Type && + a.Material == b.Material && + a.Color == b.Color && + a.WeightG == b.WeightG +} + +func tagToSpool(t Tag) Spool { + return Spool{ + Brand: t.Brand, + TrayUID: t.TrayUID, + ChipUIDs: []string{t.ChipUID}, + Type: t.Type, + Material: t.Material, + MaterialID: t.MaterialID, + VariantID: t.VariantID, + Color: t.Color, + WeightG: t.WeightG, + DiameterMM: t.DiameterMM, + LengthM: t.LengthM, + MinHotendC: t.MinHotendC, + MaxHotendC: t.MaxHotendC, + BedC: t.BedC, + DryC: t.DryC, + DryHours: t.DryHours, + Produced: t.Produced, + SpoolWidthRaw: t.SpoolWidthRaw, + } +} + +// Merge groups tags by brand + tray UID into spools. +func Merge(tags []Tag) ([]Spool, error) { + order := make([]string, 0) + groups := make(map[string][]Tag) + for _, t := range tags { + if t.TrayUID == "" { + return nil, fmt.Errorf("%w: empty tray uid chip %s", ErrIncomplete, t.ChipUID) + } + k := spoolKey(t) + if _, ok := groups[k]; !ok { + order = append(order, k) + } + groups[k] = append(groups[k], t) + } + + spools := make([]Spool, 0, len(order)) + for _, k := range order { + g := groups[k] + s := tagToSpool(g[0]) + uids := map[string]struct{}{g[0].ChipUID: {}} + for _, t := range g[1:] { + if !sameFilament(g[0], t) { + return nil, fmt.Errorf("%w: tray %s", ErrConflict, t.TrayUID) + } + uids[t.ChipUID] = struct{}{} + } + s.ChipUIDs = make([]string, 0, len(uids)) + for u := range uids { + s.ChipUIDs = append(s.ChipUIDs, u) + } + slices.Sort(s.ChipUIDs) + spools = append(spools, s) + } + slices.SortFunc(spools, func(a, b Spool) int { + if a.TrayUID < b.TrayUID { + return -1 + } + if a.TrayUID > b.TrayUID { + return 1 + } + return 0 + }) + return spools, nil +} diff --git a/merge_test.go b/merge_test.go new file mode 100644 index 0000000..87a25d1 --- /dev/null +++ b/merge_test.go @@ -0,0 +1,87 @@ +package bambunfc + +import ( + "errors" + "os" + "slices" + "testing" +) + +func TestMergeFixtures(t *testing.T) { + t.Parallel() + f, err := os.Open("testdata/dumps.zip") + if err != nil { + t.Fatal(err) + } + defer f.Close() + tags, err := ParseZip(f) + if err != nil { + t.Fatal(err) + } + spools, err := Merge(tags) + if err != nil { + t.Fatal(err) + } + if len(spools) != 3 { + t.Fatalf("len(spools) = %d; want 3", len(spools)) + } + + byTray := map[string]Spool{} + for _, s := range spools { + byTray[s.TrayUID] = s + } + + a, ok := byTray["2fb8e0e972084e74bf737393b28e7b12"] + if !ok { + t.Fatal("missing spool A") + } + if !slices.Equal(a.ChipUIDs, []string{"52d60177", "e27be276"}) { + t.Errorf("spool A chips %v", a.ChipUIDs) + } + if a.Type != "PETG Translucent" || a.Brand != BrandBambuLab { + t.Errorf("spool A type/brand %s %s", a.Type, a.Brand) + } + + b, ok := byTray["aa42f322172c48e99c0ece895346ea9f"] + if !ok { + t.Fatal("missing spool B") + } + if !slices.Equal(b.ChipUIDs, []string{"92a80577", "b2d32177"}) { + t.Errorf("spool B chips %v", b.ChipUIDs) + } + + c, ok := byTray["3cb568b7af4f41819412fe60afc5c446"] + if !ok { + t.Fatal("missing spool C") + } + if !slices.Equal(c.ChipUIDs, []string{"92b6fb31", "f28c58ed"}) { + t.Errorf("spool C chips %v", c.ChipUIDs) + } + if c.Type != "PETG Basic" || c.Color.Hex() != "#000000FF" { + t.Errorf("spool C %s %s", c.Type, c.Color.Hex()) + } + + for i := 1; i < len(spools); i++ { + if spools[i-1].TrayUID > spools[i].TrayUID { + t.Fatalf("spools not sorted by TrayUID") + } + } +} + +func TestMergeConflict(t *testing.T) { + t.Parallel() + a := Tag{Brand: BrandBambuLab, TrayUID: "aa", ChipUID: "01", Type: "PLA", Material: "PLA", WeightG: 1000} + b := Tag{Brand: BrandBambuLab, TrayUID: "aa", ChipUID: "02", Type: "PETG", Material: "PETG", WeightG: 1000} + _, err := Merge([]Tag{a, b}) + if !errors.Is(err, ErrConflict) { + t.Errorf("err = %v; want ErrConflict", err) + } +} + +func TestMergeEmptyTray(t *testing.T) { + t.Parallel() + _, err := Merge([]Tag{{Brand: BrandBambuLab, ChipUID: "01", Type: "PLA"}}) + if !errors.Is(err, ErrIncomplete) { + t.Errorf("err = %v; want ErrIncomplete", err) + } +} diff --git a/mifare/mifare.go b/mifare/mifare.go new file mode 100644 index 0000000..feb2d25 --- /dev/null +++ b/mifare/mifare.go @@ -0,0 +1,36 @@ +// Package mifare holds MIFARE Classic 1K layout helpers with no brand-specific memory map. +package mifare + +import "fmt" + +const ( + Sectors = 16 + Blocks = 64 + BlockSize = 16 + SectorSize = 64 + Size1K = 1024 +) + +// SectorOf returns the sector index for a block number (0–63). +func SectorOf(block int) int { + return block / 4 +} + +// BlockRange returns the half-open block range [first, last) for a sector. +func BlockRange(sector int) (first, last int) { + first = sector * 4 + last = first + 4 + return first, last +} + +// SectorBytes returns the 64-byte slice for sector (0–15) from a 1K image. +func SectorBytes(image []byte, sector int) ([]byte, error) { + if len(image) != Size1K { + return nil, fmt.Errorf("image size: want %d got %d", Size1K, len(image)) + } + if sector < 0 || sector >= Sectors { + return nil, fmt.Errorf("sector %d out of range", sector) + } + off := sector * SectorSize + return image[off : off+SectorSize], nil +} diff --git a/mifare/mifare_test.go b/mifare/mifare_test.go new file mode 100644 index 0000000..222b3b0 --- /dev/null +++ b/mifare/mifare_test.go @@ -0,0 +1,30 @@ +package mifare + +import "testing" + +func TestSectorOfAndBlockRange(t *testing.T) { + t.Parallel() + if got := SectorOf(9); got != 2 { + t.Errorf("SectorOf(9) = %d; want 2", got) + } + first, last := BlockRange(2) + if first != 8 || last != 12 { + t.Errorf("BlockRange(2) = %d,%d; want 8,12", first, last) + } +} + +func TestSectorBytes(t *testing.T) { + t.Parallel() + img := make([]byte, Size1K) + img[2*SectorSize] = 0xAB + got, err := SectorBytes(img, 2) + if err != nil { + t.Fatal(err) + } + if got[0] != 0xAB || len(got) != SectorSize { + t.Errorf("SectorBytes sector 2: len=%d first=%02x", len(got), got[0]) + } + if _, err := SectorBytes(img[:1023], 0); err == nil { + t.Fatal("short image: want error") + } +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..66bfef2 --- /dev/null +++ b/parse.go @@ -0,0 +1,156 @@ +package bambunfc + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "math" + "strings" + + "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/dump" + "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/mifare" +) + +const BrandBambuLab = "Bambu Lab" + +// requiredSectors must not appear in SectorsFail (blocks 0–2, 4–6, 8–9). +var requiredSectors = []int{0, 1, 2} + +// Tag is one Bambu sticker after parsing the 1K image. +type Tag struct { + Brand string + ChipUID string + TrayUID string + 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 failedSet(fail []int) map[int]bool { + s := make(map[int]bool, len(fail)) + for _, n := range fail { + s[n] = true + } + return s +} + +func block(image []byte, n int) []byte { + off := n * mifare.BlockSize + return image[off : off+mifare.BlockSize] +} + +func cstr(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + b = b[:i] + } + return string(b) +} + +func normalizeUID(uid string) (string, error) { + uid = strings.ToLower(strings.TrimSpace(uid)) + if uid == "" || len(uid)%2 != 0 { + return "", fmt.Errorf("%w: %q", ErrUID, uid) + } + if _, err := hex.DecodeString(uid); err != nil { + return "", fmt.Errorf("%w: %v", ErrUID, err) + } + return uid, nil +} + +// ParseImage parses a Bambu MIFARE Classic 1K image. +// fail is the dump's sectors_fail list (sector indexes 0–15). +func ParseImage(chipUID string, image []byte, fail []int) (Tag, error) { + var zero Tag + uid, err := normalizeUID(chipUID) + if err != nil { + return zero, err + } + if len(image) != mifare.Size1K { + return zero, fmt.Errorf("%w: want %d got %d", ErrImageSize, mifare.Size1K, len(image)) + } + failed := failedSet(fail) + for _, s := range requiredSectors { + if failed[s] { + return zero, fmt.Errorf("%w: sector %d", ErrIncomplete, s) + } + } + + b1 := block(image, 1) + b2 := block(image, 2) + b4 := block(image, 4) + b5 := block(image, 5) + b6 := block(image, 6) + b9 := block(image, 9) + + material := cstr(b2) + typ := cstr(b4) + if material == "" && typ == "" { + return zero, ErrNotBambu + } + + tag := Tag{ + Brand: BrandBambuLab, + ChipUID: uid, + VariantID: cstr(b1[0:8]), + MaterialID: cstr(b1[8:16]), + Material: material, + Type: typ, + Color: Color{ + R: b5[0], G: b5[1], B: b5[2], A: b5[3], + }, + WeightG: int(binary.LittleEndian.Uint16(b5[4:6])), + DiameterMM: math.Float32frombits(binary.LittleEndian.Uint32(b5[8:12])), + TrayUID: hex.EncodeToString(b9), + DryC: int(binary.LittleEndian.Uint16(b6[0:2])), + DryHours: int(binary.LittleEndian.Uint16(b6[2:4])), + BedC: int(binary.LittleEndian.Uint16(b6[6:8])), + MaxHotendC: int(binary.LittleEndian.Uint16(b6[8:10])), + MinHotendC: int(binary.LittleEndian.Uint16(b6[10:12])), + } + if !failed[2] { + b10 := block(image, 10) + tag.SpoolWidthRaw = binary.LittleEndian.Uint16(b10[4:6]) + } + if !failed[3] { + tag.Produced = cstr(block(image, 12)) + b14 := block(image, 14) + tag.LengthM = int(binary.LittleEndian.Uint16(b14[4:6])) + } + return tag, nil +} + +// ParseDump parses one dump JSON record as a Bambu tag. +func ParseDump(d dump.Dump) (Tag, error) { + return ParseImage(d.ChipUID, d.Image, d.SectorsFail) +} + +// ParseZip reads a filanfc-dump zip and parses every sticker as Bambu. +func ParseZip(r io.Reader) ([]Tag, error) { + dumps, err := dump.ParseZip(r) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidDump, err) + } + tags := make([]Tag, 0, len(dumps)) + for _, d := range dumps { + tag, err := ParseDump(d) + if err != nil { + return nil, fmt.Errorf("chip %s: %w", d.ChipUID, err) + } + tags = append(tags, tag) + } + return tags, nil +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..1b7f8c4 --- /dev/null +++ b/parse_test.go @@ -0,0 +1,101 @@ +package bambunfc + +import ( + "bytes" + "errors" + "os" + "testing" + + "git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/dump" +) + +func TestParseZipFixtures(t *testing.T) { + t.Parallel() + f, err := os.Open("testdata/dumps.zip") + if err != nil { + t.Fatal(err) + } + defer f.Close() + tags, err := ParseZip(f) + if err != nil { + t.Fatal(err) + } + if len(tags) != 6 { + t.Fatalf("len(tags) = %d; want 6", len(tags)) + } + var translucent, basic int + for _, tag := range tags { + if tag.Brand != BrandBambuLab { + t.Errorf("chip %s brand %q", tag.ChipUID, tag.Brand) + } + if tag.DiameterMM != 1.75 { + t.Errorf("chip %s diameter %v", tag.ChipUID, tag.DiameterMM) + } + switch tag.Type { + case "PETG Translucent": + translucent++ + if tag.Color.Hex() != "#61B0FF80" { + t.Errorf("translucent color %s", tag.Color.Hex()) + } + case "PETG Basic": + basic++ + if tag.Color.Hex() != "#000000FF" { + t.Errorf("basic color %s", tag.Color.Hex()) + } + default: + t.Errorf("unexpected type %q", tag.Type) + } + } + if translucent != 4 || basic != 2 { + t.Errorf("translucent=%d basic=%d", translucent, basic) + } +} + +func TestParseImageErrors(t *testing.T) { + t.Parallel() + raw, err := os.ReadFile("testdata/dumps/e27be276.bin") + if err != nil { + t.Fatal(err) + } + if _, err := ParseImage("e27be276", raw[:1023], nil); !errors.Is(err, ErrImageSize) { + t.Errorf("short image err = %v; want ErrImageSize", err) + } + if _, err := ParseImage("e27be276", raw, []int{2}); !errors.Is(err, ErrIncomplete) { + t.Errorf("fail sector 2 err = %v; want ErrIncomplete", err) + } + blank := make([]byte, 1024) + if _, err := ParseImage("e27be276", blank, nil); !errors.Is(err, ErrNotBambu) { + t.Errorf("blank image err = %v; want ErrNotBambu", err) + } +} + +func TestParseDumpJSON(t *testing.T) { + t.Parallel() + f, err := os.Open("testdata/dumps/e27be276.json") + if err != nil { + t.Fatal(err) + } + defer f.Close() + d, err := dump.ParseJSON(f) + if err != nil { + t.Fatal(err) + } + tag, err := ParseDump(d) + if err != nil { + t.Fatal(err) + } + if tag.TrayUID != "2fb8e0e972084e74bf737393b28e7b12" { + t.Errorf("TrayUID = %s", tag.TrayUID) + } + if tag.ChipUID != "e27be276" { + t.Errorf("ChipUID = %s", tag.ChipUID) + } +} + +func TestParseImageEmptyUID(t *testing.T) { + t.Parallel() + _, err := ParseImage("", bytes.Repeat([]byte{1}, 1024), nil) + if !errors.Is(err, ErrUID) { + t.Errorf("err = %v; want ErrUID", err) + } +} diff --git a/testdata/dumps.zip b/testdata/dumps.zip new file mode 100644 index 0000000000000000000000000000000000000000..37634c7b2f773213cab6cafc515ee5b9de4a8734 GIT binary patch literal 11579 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 H>(Kol(hn+D literal 0 HcmV?d00001 diff --git a/testdata/dumps/52d60177.bin b/testdata/dumps/52d60177.bin new file mode 100644 index 0000000000000000000000000000000000000000..4ddaf87934785cce13627214b73840df464049ae GIT binary patch literal 1024 zcmWH###sJ|gN1>W-+R+;!?+3V28Oy$1`G`DZa@|T14Dpoh&zx%1MThYnGhk6yh2D( zVqS4hX>w{_N#ch84KJ8Mau4i5q$2|dh+<%2dgW)kth7%`$Pv=vN+h`rwp4=YepZj!6L9eG- z7G7o1vP&+!Vw69gvA%15aN3soqx(0;*!xZY9B}`{QDKP(d^=C|3w4>M)u8L|3!mL_ z)NJ)BRlmf<`-|UfE)Gv(S#1$9=SaeJ#)kC0&MW4N-cGskblruO&2wD4v#rtf+Xj35 z=?-0_s0M4veYKH8yM<3889%oy8&4Y3=9FTA?`p54YaqnXF`NP@(LkE ziFw62rOByzC5apUH@sj5$vvSgwUvdSo$Fp`n~phqxg0**BJKBm}vPoLJbnQi153lQ|%tU zcF&1EyWK}m3Az@_O#kNmdRsxpvyJ=Tg|OD$N&bE~k5zP{yQogmrh?L5c69wmw0AG% z&^JuYnHgqi<(V);KyhC5?$1Rhls-hyFurrnH+G(~&Zc!2XE^a!Z#s~izJqs?BKL&$ih1iim`v8n2hKOo|5$~t|H+?E z>aHR?-5eud9DIMo$>xvKf`^w>XD@31rn{vsbe2Q>dqvMo)*t&#i^(8LO?XQ3TzOYSFXMfVMQgrnI|MrQE_M)5|b@rGvchL%V?xF#<~I1|c1=x1WUkVIFEg^$pWMUn`i zQT?#=Ln!p!qaP|wUj9>kztgX3spV6=Ac2brzxu*2RZ6pBud>z~uhM@fYACLuc|H2G zRnB}nN3$ro7XL3lf|`ZD^uJ#vm&;TZ*R>yAf9rPb1--Wu_krj47 zTQvX7EncuTGDUseg+peF>mGbR6fU&lsl+25bp4MtKFWTGaGHDj`r7o!JeCHgq!%KS zj?NG6=)SC%TIR04R(Z{-y6em2b;3fYUY_SyiLPH?@SUW@2Q!JkHcp*_zpE3^zxDiG zw^!A@@BO0(`OAzi8AJwLICp*avbWDy8oycJ_!V8h$?WDmk40|e`!n6$G5xjkbCKI` lrgsX5XC$VcPCH~UwX6I|%-4gj3$L%5Z0X;P-GD3x28ICF5O*Mj2HM-(Ga*7Cd4-Uo z#Ju91(&W^My#>Vz%&-mD_ za{8<04dz3Y?L9oL_xk$mNedg8;Ya||@~Tz7DEpFr3D>yG>5 ztNQIq_v6ZhPrAhT&*G}GYB$sJ?_J=3@ArqMl^xS>PE>jMO+fL{oH&i6|F2@`XMVXS z#j)p|-5QBCDhv7=r@p>DBig)SX~7PwqlY#>3D0X-dUVUzCB6-rF(((kGHOTHzchdA z6Z?eoE+R`J!c(UiR4Zugy;|ooFZ1k~y+_Y)-6;9`*vE=i&%eL_d~omKpKFnhuHP{| zBce=GV(xdz*Vz+W0;@m9pyXPmk8yM<3889%oy8&4Y3=9FTA?`p54YaqnXF`NP@(LkE ziFw62rOByzC5apUH@sj5$vvI?&_NQ==%MG-qo+N zy3c7X+35G{kdS@ag0oX!-m==$aXM9B!~0}kuE2~!EtMy;9$Y!rRAIAi3%Y)1tH{GV zoeHe8Z5f;sE><5(XBJtwQhm*bprQ!P+RnX22EyhhRgv}Ag=1%ZSYr0X99@6@wKLXD zXJd@=HvDfF@ZIdfthDiI#d!lqe*I%fg}0LSCKbE?TiU$P?7QmBl?-P-`l0LBV?VYf z{Cc|9LcWX99TOYw-P^b*|J+v2f75P-I~+Y^a_iyB{3lV3v%JplU8NLhICBQNegNi= B!IA&~ literal 0 HcmV?d00001 diff --git a/testdata/dumps/e27be276.json b/testdata/dumps/e27be276.json new file mode 100644 index 0000000..8d0de9e --- /dev/null +++ b/testdata/dumps/e27be276.json @@ -0,0 +1 @@ +{"format":"filanfc-dump/v1","scanned_at":"2026-08-23T19:52:02.388908Z","chip_uid":"e27be276","sak":"08","atqa":"0004","tech":["android.nfc.tech.MifareClassic","android.nfc.tech.NfcA"],"sectors_ok":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15],"sectors_fail":[],"dump_hex":"e27be2760d08040005919d6a17ddcf904730312d423000004746473031000000504554470000000000000000000000000000000000008787876900000000000050455447205472616e736c7563656e7461b0ff80e80300000000e03f0000000041000800000000000401e6000000000000000000000087878769000000000000342134218403e8033333333fcdcc4c3e2fb8e0e972084e74bf737393b28e7b1200000000c9000000000000000000000000000000000087878769000000000000323032365f30345f30325f31335f323132303236303430320000000000000000000000004a0100000000000000000000000000000000878787690000000000000200010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008787876900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000087878769000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878787690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008787876900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000087878769000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878787690000000000000100000000000000000000000000000000000000000000000000000000000000630fe06e572f8f0f94e2ff3bfba403190000000000008787876900000000000074eaa3ac7501ae371180023e3e834388526896deb1450667081e07af5ad55257dabb43981c79bd7263072f7d4727292f000000000000878787690000000000004f52ee7faa3adf093b19814efac2123f66a0cd95e9da3abc88cb652f284bc98e6d1098712a24e49ae0d4c682783cb6b400000000000087878769000000000000433a59c30c8920059b3d004360d17bc2670314a1a927acf0527258297d89bd72301337347a597fd7135d9af0a436e437000000000000878787690000000000006fd6cc3b82cd5c326eb0ff87104db3440322b1e578cf30410f2fc66271da62bd627347fea583a136f72599a900ccf14e000000000000878787690000000000002e07c6b457d7672aa10ed15b889180dedeb1a26fceb509fe96da5740c5c234dae1a96fe45a819a4acfbdaa225931999800000000000087878769000000000000","dump_b64":"4nvidg0IBAAFkZ1qF93PkEcwMS1CMAAAR0ZHMDEAAABQRVRHAAAAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAFBFVEcgVHJhbnNsdWNlbnRhsP+A6AMAAAAA4D8AAAAAQQAIAAAAAAAEAeYAAAAAAAAAAAAAAIeHh2kAAAAAAAA0ITQhhAPoAzMzMz/NzEw+L7jg6XIITnS/c3OTso57EgAAAADJAAAAAAAAAAAAAAAAAAAAAACHh4dpAAAAAAAAMjAyNl8wNF8wMl8xM18yMTIwMjYwNDAyAAAAAAAAAAAAAAAASgEAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIeHh2kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHh4dpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIeHh2kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHh4dpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYw/gblcvjw+U4v87+6QDGQAAAAAAAIeHh2kAAAAAAAB06qOsdQGuNxGAAj4+g0OIUmiW3rFFBmcIHgevWtVSV9q7Q5gceb1yYwcvfUcnKS8AAAAAAACHh4dpAAAAAAAAT1Luf6o63wk7GYFO+sISP2agzZXp2jq8iMtlLyhLyY5tEJhxKiTkmuDUxoJ4PLa0AAAAAAAAh4eHaQAAAAAAAEM6WcMMiSAFmz0AQ2DRe8JnAxShqSes8FJyWCl9ib1yMBM3NHpZf9cTXZrwpDbkNwAAAAAAAIeHh2kAAAAAAABv1sw7gs1cMm6w/4cQTbNEAyKx5XjPMEEPL8ZicdpivWJzR/6lg6E29yWZqQDM8U4AAAAAAACHh4dpAAAAAAAALgfGtFfXZyqhDtFbiJGA3t6xom/OtQn+ltpXQMXCNNrhqW/kWoGaSs+9qiJZMZmYAAAAAAAAh4eHaQAAAAAAAA=="} \ No newline at end of file diff --git a/testdata/dumps/f28c58ed.bin b/testdata/dumps/f28c58ed.bin new file mode 100644 index 0000000000000000000000000000000000000000..55d90764c9f2f31f9bc4834f29cc8287922f8aeb GIT binary patch literal 1024 zcmey=6Y=&m2MYu1?+4$tm#&@QZeXD6ZD7FQ?gnHrFfatThPVSMG|=AOo(T~G$tyS| z7H1~ImHdCf3}Qd92a%2p93YB;h4C4fh7bt-NP-M!&-mEcY|Yrd-ruotiqOA=^+%7m zf#g^7BdY?j5&De`jLhN5$izE?3 zqxxa#hfwGTyc3I_b#zL3VoyosG)qqxNZ=yEk3;dPW}d~{^?u=Vxa=&V3VUm=pL((9 zV@K3h*_GFvu5Z1-eSQBVrHf~;78iB3=uXx^*Y7=PD$}d(JI7p?UlBe3I)}xRGx}?i z`t|b3StgSng+}lx_<4?b@hG9%;7g5X)McZoENTs ze^dK|l*#V5*m4#XUHzQ%Wpo5js9KmPl=@xkTGZb!F5w!SWorH5%rm+@w_BN@>jwZKQpr>R literal 0 HcmV?d00001 diff --git a/testdata/dumps/f28c58ed.json b/testdata/dumps/f28c58ed.json new file mode 100644 index 0000000..4361666 --- /dev/null +++ b/testdata/dumps/f28c58ed.json @@ -0,0 +1 @@ +{"format":"filanfc-dump/v1","scanned_at":"2026-08-23T19:53:49.531896Z","chip_uid":"f28c58ed","sak":"08","atqa":"0004","tech":["android.nfc.tech.MifareClassic","android.nfc.tech.NfcA"],"sectors_ok":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15],"sectors_fail":[],"dump_hex":"f28c58edcb08040005fbe0f72ba5ad904730302d4b3030004746473030000000504554470000000000000000000000000000000000008787876900000000000050455447204261736963000000000000000000ffe80300000000e03f0000000041000800000000000401e6000000000000000000000087878769000000000000000000000000000000000000cdcc4c3e3cb568b7af4f41819412fe60afc5c44600000000ab0f0000000000000000000000000000000087878769000000000000323032365f30325f32355f31365f313932365f30325f32355f31360000000000000000004a010000000000000000000000000000000087878769000000000000020001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878787690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008787876900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000087878769000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878787690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008787876900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000087878769000000000000010000000000000000000000000000000000000000000000000000000000000050ee165b9ac59477618c747996394944000000000000878787690000000000000821e5296e38edaf4e579c0a3e385a718d7cd7cae8bcf1885ab51da9d642d7b5d00bd7bf9222d1cdd573728a842d9328000000000000878787690000000000004b929502ea8bdcc645a7d415cfeb6c0449095bf56227d777936a3492e256583dbd494e976e67c421c7293696fea9380700000000000087878769000000000000b96ad7d6fcd727be230357d9e228a669819e5727dfb287e0649347da066ca2e1e6b043aea72f43b7aae856d3fd1cc30b00000000000087878769000000000000be81eea3831737d3e3fdeb81e0b360206d3db6f50864effbf3e784a8547f0a9a730e3af09b052b0de6f7fe87959cee4f00000000000087878769000000000000e3f7d07cc16a75a374b81b53a73256b34921a3fcf6e6688a9925c40e2acb6786bf4457280976268f5703988b49b73a3400000000000087878769000000000000","dump_b64":"8oxY7csIBAAF++D3K6WtkEcwMC1LMDAAR0ZHMDAAAABQRVRHAAAAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAFBFVEcgQmFzaWMAAAAAAAAAAAD/6AMAAAAA4D8AAAAAQQAIAAAAAAAEAeYAAAAAAAAAAAAAAIeHh2kAAAAAAAAAAAAAAAAAAAAAAADNzEw+PLVot69PQYGUEv5gr8XERgAAAACrDwAAAAAAAAAAAAAAAAAAAACHh4dpAAAAAAAAMjAyNl8wMl8yNV8xNl8xOTI2XzAyXzI1XzE2AAAAAAAAAAAASgEAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAAIAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIeHh2kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHh4dpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIeHh2kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHh4dpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAh4eHaQAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUO4WW5rFlHdhjHR5ljlJRAAAAAAAAIeHh2kAAAAAAAAIIeUpbjjtr05XnAo+OFpxjXzXyui88YhatR2p1kLXtdAL17+SItHN1XNyioQtkygAAAAAAACHh4dpAAAAAAAAS5KVAuqL3MZFp9QVz+tsBEkJW/ViJ9d3k2o0kuJWWD29SU6XbmfEIccpNpb+qTgHAAAAAAAAh4eHaQAAAAAAALlq19b81ye+IwNX2eIopmmBnlcn37KH4GSTR9oGbKLh5rBDrqcvQ7eq6FbT/RzDCwAAAAAAAIeHh2kAAAAAAAC+ge6jgxc30+P964Hgs2AgbT229Qhk7/vz54SoVH8KmnMOOvCbBSsN5vf+h5Wc7k8AAAAAAACHh4dpAAAAAAAA4/fQfMFqdaN0uBtTpzJWs0kho/z25miKmSXEDirLZ4a/RFcoCXYmj1cDmItJtzo0AAAAAAAAh4eHaQAAAAAAAA=="} \ No newline at end of file