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