Files
Extrudex/backend/internal/clients/mqtt.go
Joshua 38722e54e6
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m29s
CUB-117: Port Moonraker + MQTT printer integrations to Go
- Moonraker REST client with GetPrinterInfo, GetPrintStats, GetPrintHistory
- Moonraker WebSocket client with auto-reconnect + telemetry parsing
- MQTT client via paho.mqtt.golang with TLS support for Bambu Lab
- Moonraker poller worker: background polling, dedup, usage logging to PostgreSQL
- MQTT subscriber worker: Bambu telemetry parsing, print job tracking
- Config: 7 new env vars (MOONRAKER_URL, MQTT_BROKER, etc.)
- main.go: per-printer worker discovery, graceful shutdown
2026-05-12 01:02:49 -04:00

184 lines
5.9 KiB
Go

package clients
import (
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// ── Bambu Lab telemetry types ───────────────────────────────────────────────
// BambuPrintReport is the JSON payload published by Bambu Lab printers
// on the MQTT report topic. The structure varies by printer model;
// we extract the common fields needed for filament tracking.
type BambuPrintReport struct {
// Print holds the active print job data.
Print BambuPrintData `json:"print"`
// VtTray contains AMS tray info; the extruded length is per-tray.
VtTray *BambuVtTray `json:"vt_tray,omitempty"`
}
// BambuPrintData carries the active print state from a Bambu report.
type BambuPrintData struct {
// GcodeFile is the filename being printed.
GcodeFile string `json:"gcode_file"`
// GcodeState describes the current print state:
// "IDLE", "RUNNING", "PAUSE", "FINISH", "FAILED".
GcodeState string `json:"gcode_state"`
// McPercent is the progress as a percentage (0-100).
McPercent int `json:"mc_percent"`
// McRemainingTime is the estimated remaining time in minutes.
McRemainingTime int `json:"mc_remaining_time"`
}
// BambuVtTray holds AMS tray telemetry from Bambu printers.
type BambuVtTray struct {
ID string `json:"id"`
TagUID string `json:"tag_uid"`
TrayIDName string `json:"tray_id_name"`
// TrayInfoIdx is the hex color code for the tray's filament.
TrayInfoIdx string `json:"tray_info_idx"`
// TrayColor is a hex color string like "FF0000FF".
TrayColor string `json:"tray_color"`
// Remain is the percentage of filament remaining on this tray (0-100).
Remain int `json:"remain"`
// K is a temperature coefficient.
K float64 `json:"k"`
// N is a second temperature coefficient.
N float64 `json:"n"`
}
// BambuReportHandler is called for each parsed Bambu telemetry message.
type BambuReportHandler func(report BambuPrintReport) error
// ── MQTT client ─────────────────────────────────────────────────────────────
// MQTTClient wraps the Eclipse Paho MQTT client for Bambu Lab printer
// telemetry with optional TLS support.
type MQTTClient struct {
broker string
clientID string
topicPrefix string
tlsCert string
tlsKey string
handler BambuReportHandler
mu sync.Mutex
client mqtt.Client
}
// MQTTConfig holds the configuration for creating an MQTTClient.
type MQTTConfig struct {
Broker string // e.g., "ssl://192.168.1.50:8883"
ClientID string // unique MQTT client id, defaults to "extrudex"
TopicPrefix string // topic prefix, defaults to "device/+/report"
TLSCert string // path to TLS client certificate (optional)
TLSKey string // path to TLS client key (optional)
Handler BambuReportHandler
}
// NewMQTTClient creates a new MQTTClient. The connection is not established
// until Connect is called.
func NewMQTTClient(cfg MQTTConfig) *MQTTClient {
if cfg.ClientID == "" {
cfg.ClientID = "extrudex"
}
if cfg.TopicPrefix == "" {
cfg.TopicPrefix = "device/+/report"
}
return &MQTTClient{
broker: cfg.Broker,
clientID: cfg.ClientID,
topicPrefix: cfg.TopicPrefix,
tlsCert: cfg.TLSCert,
tlsKey: cfg.TLSKey,
handler: cfg.Handler,
}
}
// Connect establishes the MQTT connection and subscribes to the configured
// topic prefix. Returns an error if the initial connection fails.
func (c *MQTTClient) Connect() error {
opts := mqtt.NewClientOptions().
AddBroker(c.broker).
SetClientID(c.clientID).
SetAutoReconnect(true).
SetMaxReconnectInterval(30 * time.Second).
SetKeepAlive(30 * time.Second).
SetPingTimeout(10 * time.Second).
SetConnectTimeout(15 * time.Second).
SetOnConnectHandler(func(client mqtt.Client) {
slog.Info("mqtt: connected", "broker", c.broker)
// Subscribe on every reconnect.
token := client.Subscribe(c.topicPrefix, 0, c.messageHandler)
token.Wait()
if err := token.Error(); err != nil {
slog.Error("mqtt: subscribe failed on reconnect", "topic", c.topicPrefix, "error", err)
} else {
slog.Info("mqtt: subscribed", "topic", c.topicPrefix)
}
}).
SetConnectionLostHandler(func(client mqtt.Client, err error) {
slog.Warn("mqtt: connection lost", "error", err)
})
// Configure TLS if cert and key are provided.
if c.tlsCert != "" && c.tlsKey != "" {
cert, err := tls.LoadX509KeyPair(c.tlsCert, c.tlsKey)
if err != nil {
return fmt.Errorf("mqtt: failed to load TLS cert/key: %w", err)
}
opts.SetTLSConfig(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
slog.Info("mqtt: TLS configured", "cert", c.tlsCert)
}
c.client = mqtt.NewClient(opts)
token := c.client.Connect()
if !token.WaitTimeout(15 * time.Second) {
return fmt.Errorf("mqtt: connect timed out to %s", c.broker)
}
if err := token.Error(); err != nil {
return fmt.Errorf("mqtt: connect failed: %w", err)
}
slog.Info("mqtt: initial connection established", "broker", c.broker)
return nil
}
// Disconnect gracefully closes the MQTT connection.
func (c *MQTTClient) Disconnect() {
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(2500) // wait up to 2.5s
slog.Info("mqtt: disconnected")
}
}
// messageHandler is the MQTT callback invoked for every message received on
// the subscribed topic.
func (c *MQTTClient) messageHandler(_ mqtt.Client, msg mqtt.Message) {
if c.handler == nil {
return
}
var report BambuPrintReport
if err := json.Unmarshal(msg.Payload(), &report); err != nil {
slog.Warn("mqtt: failed to parse bambu report", "topic", msg.Topic(), "error", err)
return
}
if err := c.handler(report); err != nil {
slog.Error("mqtt: handler error", "topic", msg.Topic(), "error", err)
}
}