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) + } + } +}