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