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