From df934a0521cf338015b7f2e3e12e77914166edf8 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 15 Feb 2026 20:21:06 +0000 Subject: [PATCH] feature: add Bambu Lab firmware version extractor Extract firmware information from Bambu Lab's firmware download pages by parsing the __NEXT_DATA__ JSON blob embedded in the page. Supports all printer models (X1, P1, A1, A1 mini, H2D, H2S, P2S, X1E, H2D Pro). Provides GetLatestFirmware() and GetAllFirmware() methods that return version, release date, release notes, download URL, and MD5 checksum. Closes #45 Co-Authored-By: Claude Opus 4.6 --- sites/bambulab/bambulab.go | 167 ++++++++++++++++++++ sites/bambulab/bambulab_test.go | 267 ++++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 sites/bambulab/bambulab.go create mode 100644 sites/bambulab/bambulab_test.go diff --git a/sites/bambulab/bambulab.go b/sites/bambulab/bambulab.go new file mode 100644 index 0000000..318ee4c --- /dev/null +++ b/sites/bambulab/bambulab.go @@ -0,0 +1,167 @@ +package bambulab + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" +) + +// FirmwareInfo holds structured firmware information for a Bambu Lab printer. +type FirmwareInfo struct { + Model string // printer model slug (e.g. "x1", "p1", "a1-mini") + Version string // firmware version (e.g. "01.11.02.00") + ReleaseDate string // release date in YYYY-MM-DD format + ReleaseNotes string // English release notes (markdown) + DownloadURL string // direct download URL for the firmware zip + MD5 string // MD5 checksum of the firmware file +} + +// Config holds configuration for the Bambu Lab firmware extractor. +type Config struct{} + +// DefaultConfig is the default Bambu Lab configuration. +var DefaultConfig = Config{} + +func (c Config) validate() Config { + return c +} + +// firmwarePageURL returns the firmware download page URL for a given printer model. +func firmwarePageURL(model string) string { + return fmt.Sprintf("https://bambulab.com/en/support/firmware-download/%s", model) +} + +// GetLatestFirmware returns the most recent firmware for a given printer model. +func (c Config) GetLatestFirmware(ctx context.Context, b extractor.Browser, model string) (*FirmwareInfo, error) { + c = c.validate() + + all, err := c.GetAllFirmware(ctx, b, model) + if err != nil { + return nil, err + } + + if len(all) == 0 { + return nil, fmt.Errorf("no firmware found for model %q", model) + } + + return &all[0], nil +} + +// GetLatestFirmware is a convenience function using DefaultConfig. +func GetLatestFirmware(ctx context.Context, b extractor.Browser, model string) (*FirmwareInfo, error) { + return DefaultConfig.GetLatestFirmware(ctx, b, model) +} + +// GetAllFirmware returns all available firmware versions for a given printer model, +// sorted by release date descending (newest first). +func (c Config) GetAllFirmware(ctx context.Context, b extractor.Browser, model string) ([]FirmwareInfo, error) { + c = c.validate() + + u := firmwarePageURL(model) + + slog.Info("fetching bambulab firmware", "url", u, "model", model) + doc, err := b.Open(ctx, u, extractor.OpenPageOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to open bambulab page: %w", err) + } + defer extractor.DeferClose(doc) + + timeout := 10 * time.Second + if err := doc.WaitForNetworkIdle(&timeout); err != nil { + slog.Warn("WaitForNetworkIdle failed", "err", err) + } + + return extractAllFirmware(doc, model) +} + +// GetAllFirmware is a convenience function using DefaultConfig. +func GetAllFirmware(ctx context.Context, b extractor.Browser, model string) ([]FirmwareInfo, error) { + return DefaultConfig.GetAllFirmware(ctx, b, model) +} + +// nextData represents the top-level __NEXT_DATA__ JSON structure. +type nextData struct { + Props struct { + PageProps struct { + PrinterMap map[string]printerEntry `json:"printerMap"` + } `json:"pageProps"` + } `json:"props"` +} + +// printerEntry represents a single printer in the printerMap. +type printerEntry struct { + Key string `json:"key"` + DevModel string `json:"devModel"` + Name string `json:"name"` + Versions []firmwareVersion `json:"versions"` +} + +// firmwareVersion represents a single firmware version entry. +type firmwareVersion struct { + Version string `json:"version"` + URL string `json:"url"` + MD5 string `json:"md5"` + ReleaseTime string `json:"release_time"` + ReleaseNotesEN string `json:"release_notes_en"` +} + +func extractAllFirmware(doc extractor.Node, model string) ([]FirmwareInfo, error) { + scripts := doc.Select("script#__NEXT_DATA__") + if len(scripts) == 0 { + return nil, fmt.Errorf("no __NEXT_DATA__ script found") + } + + txt, err := scripts[0].Text() + if err != nil { + return nil, fmt.Errorf("failed to read __NEXT_DATA__: %w", err) + } + + var data nextData + if err := json.Unmarshal([]byte(txt), &data); err != nil { + return nil, fmt.Errorf("failed to parse __NEXT_DATA__: %w", err) + } + + printer, ok := data.Props.PageProps.PrinterMap[model] + if !ok { + return nil, fmt.Errorf("model %q not found in printer map", model) + } + + var results []FirmwareInfo + for _, v := range printer.Versions { + info := FirmwareInfo{ + Model: model, + Version: v.Version, + ReleaseDate: formatReleaseDate(v.ReleaseTime), + ReleaseNotes: strings.TrimSpace(v.ReleaseNotesEN), + DownloadURL: v.URL, + MD5: v.MD5, + } + results = append(results, info) + } + + // Sort by release date descending (newest first) + sort.Slice(results, func(i, j int) bool { + return results[i].ReleaseDate > results[j].ReleaseDate + }) + + return results, nil +} + +// formatReleaseDate converts an ISO 8601 timestamp to YYYY-MM-DD. +func formatReleaseDate(isoTime string) string { + t, err := time.Parse(time.RFC3339, isoTime) + if err != nil { + // Try date-only format + t, err = time.Parse("2006-01-02", isoTime) + if err != nil { + return isoTime + } + } + return t.Format("2006-01-02") +} diff --git a/sites/bambulab/bambulab_test.go b/sites/bambulab/bambulab_test.go new file mode 100644 index 0000000..1761ca1 --- /dev/null +++ b/sites/bambulab/bambulab_test.go @@ -0,0 +1,267 @@ +package bambulab + +import ( + "context" + "encoding/json" + "testing" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest" +) + +func makeNextDataJSON(printerMap map[string]printerEntry) string { + data := nextData{} + data.Props.PageProps.PrinterMap = printerMap + b, _ := json.Marshal(data) + return string(b) +} + +var samplePrinterMap = map[string]printerEntry{ + "x1": { + Key: "x1", + DevModel: "BL-P001", + Name: "Bambu Lab X1 Series", + Versions: []firmwareVersion{ + { + Version: "01.11.02.00", + URL: "https://public-cdn.bblmw.com/upgrade/device/offline/BL-P001/01.11.02.00/e63e21a87c/offline-ota-p001_v01.11.02.00-20251210083345.zip", + MD5: "1aa2c8400b4a3d599f8b4a09db60635c", + ReleaseTime: "2025-12-10T12:01:58Z", + ReleaseNotesEN: "# Version 01.11.02.00\n## Feature Optimization:\n* Enhanced accuracy", + }, + { + Version: "01.10.01.00", + URL: "https://public-cdn.bblmw.com/upgrade/device/offline/BL-P001/01.10.01.00/abc123/offline-ota-p001_v01.10.01.00.zip", + MD5: "abc123def456", + ReleaseTime: "2025-06-15T10:00:00Z", + ReleaseNotesEN: "# Version 01.10.01.00\n## Bug Fixes:\n* Fixed connectivity issue", + }, + { + Version: "01.09.00.00", + URL: "https://public-cdn.bblmw.com/upgrade/device/offline/BL-P001/01.09.00.00/def456/offline-ota-p001_v01.09.00.00.zip", + MD5: "def456ghi789", + ReleaseTime: "2025-03-01T08:00:00Z", + ReleaseNotesEN: "", + }, + }, + }, + "a1-mini": { + Key: "a1-mini", + DevModel: "BL-A001", + Name: "Bambu Lab A1 mini", + Versions: []firmwareVersion{ + { + Version: "01.05.00.00", + URL: "https://public-cdn.bblmw.com/upgrade/device/offline/BL-A001/01.05.00.00/xyz/firmware.zip", + MD5: "xyz789", + ReleaseTime: "2025-11-01T09:00:00Z", + }, + }, + }, +} + +func makeFirmwareDoc(model string) *extractortest.MockDocument { + return &extractortest.MockDocument{ + URLValue: firmwarePageURL(model), + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "script#__NEXT_DATA__": { + &extractortest.MockNode{TextValue: makeNextDataJSON(samplePrinterMap)}, + }, + }, + }, + } +} + +func TestExtractAllFirmware(t *testing.T) { + doc := makeFirmwareDoc("x1") + + results, err := extractAllFirmware(doc, "x1") + if err != nil { + t.Fatalf("extractAllFirmware() error: %v", err) + } + + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } + + // Should be sorted newest first + if results[0].Version != "01.11.02.00" { + t.Errorf("results[0].Version = %q, want %q", results[0].Version, "01.11.02.00") + } + if results[0].ReleaseDate != "2025-12-10" { + t.Errorf("results[0].ReleaseDate = %q, want %q", results[0].ReleaseDate, "2025-12-10") + } + if results[0].MD5 != "1aa2c8400b4a3d599f8b4a09db60635c" { + t.Errorf("results[0].MD5 = %q, want %q", results[0].MD5, "1aa2c8400b4a3d599f8b4a09db60635c") + } + if results[0].Model != "x1" { + t.Errorf("results[0].Model = %q, want %q", results[0].Model, "x1") + } + if results[0].DownloadURL == "" { + t.Error("results[0].DownloadURL is empty") + } + if results[0].ReleaseNotes == "" { + t.Error("results[0].ReleaseNotes is empty") + } + + // Second result + if results[1].Version != "01.10.01.00" { + t.Errorf("results[1].Version = %q, want %q", results[1].Version, "01.10.01.00") + } + if results[1].ReleaseDate != "2025-06-15" { + t.Errorf("results[1].ReleaseDate = %q, want %q", results[1].ReleaseDate, "2025-06-15") + } + + // Third result (oldest) + if results[2].Version != "01.09.00.00" { + t.Errorf("results[2].Version = %q, want %q", results[2].Version, "01.09.00.00") + } +} + +func TestExtractAllFirmware_UnknownModel(t *testing.T) { + doc := makeFirmwareDoc("x1") + + _, err := extractAllFirmware(doc, "nonexistent") + if err == nil { + t.Fatal("expected error for unknown model, got nil") + } +} + +func TestExtractAllFirmware_NoScript(t *testing.T) { + doc := &extractortest.MockDocument{ + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{}, + }, + } + + _, err := extractAllFirmware(doc, "x1") + if err == nil { + t.Fatal("expected error for missing __NEXT_DATA__, got nil") + } +} + +func TestGetLatestFirmware_MockBrowser(t *testing.T) { + doc := makeFirmwareDoc("x1") + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + firmwarePageURL("x1"): doc, + }, + } + + fw, err := DefaultConfig.GetLatestFirmware(context.Background(), browser, "x1") + if err != nil { + t.Fatalf("GetLatestFirmware() error: %v", err) + } + + if fw.Version != "01.11.02.00" { + t.Errorf("Version = %q, want %q", fw.Version, "01.11.02.00") + } + if fw.ReleaseDate != "2025-12-10" { + t.Errorf("ReleaseDate = %q, want %q", fw.ReleaseDate, "2025-12-10") + } + if fw.Model != "x1" { + t.Errorf("Model = %q, want %q", fw.Model, "x1") + } +} + +func TestGetAllFirmware_MockBrowser(t *testing.T) { + doc := makeFirmwareDoc("a1-mini") + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + firmwarePageURL("a1-mini"): doc, + }, + } + + results, err := DefaultConfig.GetAllFirmware(context.Background(), browser, "a1-mini") + if err != nil { + t.Fatalf("GetAllFirmware() error: %v", err) + } + + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + if results[0].Version != "01.05.00.00" { + t.Errorf("Version = %q, want %q", results[0].Version, "01.05.00.00") + } + if results[0].Model != "a1-mini" { + t.Errorf("Model = %q, want %q", results[0].Model, "a1-mini") + } +} + +func TestGetLatestFirmware_Convenience(t *testing.T) { + doc := makeFirmwareDoc("x1") + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + firmwarePageURL("x1"): doc, + }, + } + + fw, err := GetLatestFirmware(context.Background(), browser, "x1") + if err != nil { + t.Fatalf("GetLatestFirmware() error: %v", err) + } + + if fw.Version != "01.11.02.00" { + t.Errorf("Version = %q, want %q", fw.Version, "01.11.02.00") + } +} + +func TestGetAllFirmware_Convenience(t *testing.T) { + doc := makeFirmwareDoc("x1") + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + firmwarePageURL("x1"): doc, + }, + } + + results, err := GetAllFirmware(context.Background(), browser, "x1") + if err != nil { + t.Fatalf("GetAllFirmware() error: %v", err) + } + + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } +} + +func TestFirmwarePageURL(t *testing.T) { + tests := []struct { + model string + want string + }{ + {"x1", "https://bambulab.com/en/support/firmware-download/x1"}, + {"a1-mini", "https://bambulab.com/en/support/firmware-download/a1-mini"}, + {"p1", "https://bambulab.com/en/support/firmware-download/p1"}, + } + + for _, tt := range tests { + got := firmwarePageURL(tt.model) + if got != tt.want { + t.Errorf("firmwarePageURL(%q) = %q, want %q", tt.model, got, tt.want) + } + } +} + +func TestFormatReleaseDate(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"2025-12-10T12:01:58Z", "2025-12-10"}, + {"2025-06-15T10:00:00Z", "2025-06-15"}, + {"2025-03-01", "2025-03-01"}, + {"invalid", "invalid"}, + } + + for _, tt := range tests { + got := formatReleaseDate(tt.input) + if got != tt.want { + t.Errorf("formatReleaseDate(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} -- 2.49.1