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:
171
backend/internal/clients/moonraker.go
Normal file
171
backend/internal/clients/moonraker.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user