CUB-117: Port Moonraker + MQTT printer integrations to Go
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m29s
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m29s
- 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
This commit is contained in:
183
backend/internal/clients/mqtt.go
Normal file
183
backend/internal/clients/mqtt.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user