CUB-117: Port Moonraker + MQTT printer integrations to Go
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:
2026-05-12 01:02:49 -04:00
parent f1614029b5
commit 38722e54e6
9 changed files with 1169 additions and 32 deletions

View File

@@ -0,0 +1,171 @@
// Package clients provides client implementations for printer integrations:
// Moonraker REST + WebSocket (Klipper-based printers) and MQTT (Bambu Lab).
package clients
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ── Moonraker response types ────────────────────────────────────────────────
// moonrakerRPC is the generic JSON-RPC wrapper Moonraker uses for responses.
type moonrakerRPC struct {
Result json.RawMessage `json:"result"`
Error *moonrakerError `json:"error"`
}
type moonrakerError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ── Public DTOs ─────────────────────────────────────────────────────────────
// MoonrakerPrinterInfo represents the /printer/info response.
type MoonrakerPrinterInfo struct {
State string `json:"state"`
StateMessage string `json:"state_message"`
KlippyReady bool `json:"klippy_ready"`
}
// MoonrakerPrintStats represents the print_stats object from
// /printer/objects/query?print_stats.
type MoonrakerPrintStats struct {
State string `json:"state"`
Filename *string `json:"filename"`
FilamentUsedMm float64 `json:"filament_used"`
PrintDuration float64 `json:"print_duration"`
Message *string `json:"message"`
}
// MoonrakerPrintJob represents a single entry in /server/history/items.
type MoonrakerPrintJob struct {
JobID string `json:"job_id"`
Filename string `json:"filename"`
Status string `json:"status"`
FilamentUsedMm float64 `json:"filament_used"`
PrintDuration float64 `json:"print_duration"`
TotalDuration float64 `json:"total_duration"`
StartTime *float64 `json:"start_time"`
EndTime *float64 `json:"end_time"`
Metadata map[string]interface{} `json:"metadata"`
}
// MoonrakerHistoryResponse wraps the /server/history/items response.
type MoonrakerHistoryResponse struct {
Items []MoonrakerPrintJob `json:"items"`
TotalCount int `json:"count"`
}
// ── Client ──────────────────────────────────────────────────────────────────
// MoonrakerClient is an HTTP client for the Moonraker REST API on
// Klipper-based printers (e.g., Elegoo Centauri Carbon).
type MoonrakerClient struct {
baseURL string
httpClient *http.Client
}
// NewMoonrakerClient creates a MoonrakerClient that targets the given
// base URL (e.g., "http://192.168.1.50:7125"). The internal HTTP client
// uses a 15-second timeout.
func NewMoonrakerClient(baseURL string) *MoonrakerClient {
baseURL = strings.TrimRight(baseURL, "/")
return &MoonrakerClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// GetPrinterInfo calls GET /printer/info and returns the Klipper state.
// Returns nil when the printer is unreachable or the response cannot be parsed.
func (c *MoonrakerClient) GetPrinterInfo(ctx context.Context) (*MoonrakerPrinterInfo, error) {
var info MoonrakerPrinterInfo
if err := c.getJSON(ctx, "/printer/info", &info); err != nil {
return nil, err
}
return &info, nil
}
// GetPrintStats calls GET /printer/objects/query?print_stats and returns
// real-time print statistics including filament consumption.
// Returns nil when no print is active or the printer is unreachable.
func (c *MoonrakerClient) GetPrintStats(ctx context.Context) (*MoonrakerPrintStats, error) {
var stats MoonrakerPrintStats
// Moonraker wraps the object in status.print_stats
var wrapper struct {
Status struct {
PrintStats MoonrakerPrintStats `json:"print_stats"`
} `json:"status"`
}
if err := c.getJSON(ctx, "/printer/objects/query?print_stats", &wrapper); err != nil {
return nil, err
}
stats = wrapper.Status.PrintStats
return &stats, nil
}
// GetPrintHistory calls GET /server/history/items and returns recent print
// jobs. limit controls the maximum number of items (clamped 1-100).
func (c *MoonrakerClient) GetPrintHistory(ctx context.Context, limit int) (*MoonrakerHistoryResponse, error) {
if limit < 1 {
limit = 1
}
if limit > 100 {
limit = 100
}
var history MoonrakerHistoryResponse
if err := c.getJSON(ctx, fmt.Sprintf("/server/history/items?limit=%d", limit), &history); err != nil {
return nil, err
}
return &history, nil
}
// ── Internal helpers ────────────────────────────────────────────────────────
func (c *MoonrakerClient) getJSON(ctx context.Context, path string, target interface{}) error {
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("moonraker: failed to build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("moonraker: request failed (%s): %w", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("moonraker: failed to read body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("moonraker: %s returned HTTP %d: %s", url, resp.StatusCode, string(body))
}
// Moonraker wraps responses in {"result": ...}
var rpc moonrakerRPC
if err := json.Unmarshal(body, &rpc); err != nil {
return fmt.Errorf("moonraker: failed to parse response: %w", err)
}
if rpc.Error != nil && rpc.Error.Message != "" {
return fmt.Errorf("moonraker: api error: %s", rpc.Error.Message)
}
if err := json.Unmarshal(rpc.Result, target); err != nil {
return fmt.Errorf("moonraker: failed to unmarshal result: %w (raw: %s)", err, string(rpc.Result))
}
return nil
}

View File

@@ -0,0 +1,229 @@
package clients
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ── WebSocket message types ─────────────────────────────────────────────────
// moonrakerWSMessage is a single JSON-RPC frame from the Moonraker WebSocket.
type moonrakerWSMessage struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
ID *int `json:"id"`
}
// MoonrakerPrintEvent is the payload delivered by the "notify_status_update"
// subscription when print_stats or display_status change.
type MoonrakerPrintEvent struct {
PrintStats *MoonrakerPrintStats `json:"print_stats"`
DisplayStatus *MoonrakerDisplayStatus `json:"display_status"`
}
// MoonrakerDisplayStatus carries progress and the LCD message.
type MoonrakerDisplayStatus struct {
Progress float64 `json:"progress"`
Message string `json:"message"`
}
// MoonrakerStatusHandler is called for every status update received from the
// Moonraker WebSocket. It receives the parsed event and the raw JSON.
type MoonrakerStatusHandler func(event MoonrakerPrintEvent) error
// ── WebSocket client ────────────────────────────────────────────────────────
// MoonrakerWSClient maintains a persistent WebSocket connection to the
// Moonraker server and delivers parsed status updates to a handler.
type MoonrakerWSClient struct {
wsURL string
handler MoonrakerStatusHandler
dialer *websocket.Dialer
mu sync.Mutex
conn *websocket.Conn
done chan struct{}
once sync.Once
}
// NewMoonrakerWSClient creates a WebSocket client for the given Moonraker base
// URL. The handler is invoked on every status update.
func NewMoonrakerWSClient(baseURL string, handler MoonrakerStatusHandler) *MoonrakerWSClient {
baseURL = strings.TrimRight(baseURL, "/")
wsURL := strings.Replace(baseURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
wsURL += "/websocket"
return &MoonrakerWSClient{
wsURL: wsURL,
handler: handler,
dialer: &websocket.Dialer{
Proxy: http.ProxyFromEnvironment,
HandshakeTimeout: 10 * time.Second,
},
done: make(chan struct{}),
}
}
// Connect establishes the WebSocket, subscribes to status updates, and
// starts the read loop in a background goroutine. It retries on failure
// with exponential backoff up to a 60-second cap.
func (c *MoonrakerWSClient) Connect(ctx context.Context) {
go c.run(ctx)
}
// Shutdown gracefully closes the WebSocket and stops the read loop.
func (c *MoonrakerWSClient) Shutdown() {
c.once.Do(func() {
close(c.done)
})
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
}
// run is the main connection loop with reconnect backoff.
func (c *MoonrakerWSClient) run(ctx context.Context) {
backoff := 1 * time.Second
const maxBackoff = 60 * time.Second
for {
select {
case <-ctx.Done():
slog.Info("moonraker ws: context cancelled, stopping")
return
case <-c.done:
slog.Info("moonraker ws: shutdown requested")
return
default:
}
if err := c.connectAndRead(ctx); err != nil {
slog.Error("moonraker ws: connection error, retrying", "error", err, "backoff", backoff)
}
// Exponential backoff.
select {
case <-ctx.Done():
return
case <-c.done:
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func (c *MoonrakerWSClient) connectAndRead(ctx context.Context) error {
slog.Info("moonraker ws: connecting", "url", c.wsURL)
conn, _, err := c.dialer.DialContext(ctx, c.wsURL, nil)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
}
c.conn = conn
c.mu.Unlock()
defer func() {
c.mu.Lock()
if c.conn == conn {
c.conn = nil
}
c.mu.Unlock()
conn.Close()
}()
// Subscribe to status updates.
subReq := map[string]interface{}{
"jsonrpc": "2.0",
"method": "printer.objects.subscribe",
"params": map[string]interface{}{
"objects": map[string]interface{}{
"print_stats": nil,
"display_status": nil,
},
},
"id": 1,
}
if err := conn.WriteJSON(subReq); err != nil {
return fmt.Errorf("subscribe failed: %w", err)
}
slog.Info("moonraker ws: subscribed to status updates")
// Set read deadline to detect stale connections.
// 120s is long enough to avoid false positives.
pingPeriod := 60 * time.Second
for {
// Set read deadline.
if err := conn.SetReadDeadline(time.Now().Add(150 * time.Second)); err != nil {
return fmt.Errorf("set read deadline: %w", err)
}
_, raw, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read message: %w", err)
}
// Send periodic pings to keep the connection alive.
go func() {
time.Sleep(pingPeriod)
c.mu.Lock()
if c.conn == conn {
c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second))
}
c.mu.Unlock()
}()
var msg moonrakerWSMessage
if err := json.Unmarshal(raw, &msg); err != nil {
slog.Warn("moonraker ws: failed to parse message", "error", err)
continue
}
// Only process notify_status_update messages.
if msg.Method != "notify_status_update" {
continue
}
var statusWrapper []MoonrakerPrintEvent
if err := json.Unmarshal(msg.Params, &statusWrapper); err != nil {
// Params might be an object, not an array.
var singleEvent MoonrakerPrintEvent
if err2 := json.Unmarshal(msg.Params, &singleEvent); err2 != nil {
slog.Warn("moonraker ws: failed to unmarshal status params", "error", err2)
continue
}
statusWrapper = []MoonrakerPrintEvent{singleEvent}
}
for _, ev := range statusWrapper {
if c.handler != nil {
if err := c.handler(ev); err != nil {
slog.Error("moonraker ws: handler error", "error", err)
}
}
}
}
}

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