feature: add Bambu Lab firmware extractor #50

Merged
Claude merged 1 commits from feature/bambulab-extractor into main 2026-02-15 20:23:53 +00:00
2 changed files with 434 additions and 0 deletions

167
sites/bambulab/bambulab.go Normal file
View File

@@ -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")
}

View File

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