Files
Extrudex/backend/internal/clients/moonraker.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

172 lines
6.0 KiB
Go

// 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
}