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