feature: add Bambu Lab firmware extractor #50
167
sites/bambulab/bambulab.go
Normal file
167
sites/bambulab/bambulab.go
Normal 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")
|
||||
}
|
||||
267
sites/bambulab/bambulab_test.go
Normal file
267
sites/bambulab/bambulab_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user