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
230 lines
6.0 KiB
Go
230 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|