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 <noreply@anthropic.com>
268 lines
7.2 KiB
Go
268 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|