CUB-117: Port Moonraker + MQTT printer integrations to Go

- Add internal/clients/moonraker.go: HTTP client for Moonraker REST API
- Add internal/clients/mqtt.go: MQTT client via paho.mqtt.golang with TLS support
- Add internal/workers/moonraker_poller.go: background polling, usage logging, SSE broadcasts
- Add internal/workers/mqtt_subscriber.go: per-printer MQTT subscriber, Bambu telemetry parsing, SSE broadcasts
- Wire workers into cmd/server/main.go (Start/Stop with graceful shutdown)

Blocked at step 4: go toolchain not installed on build host
This commit is contained in:
2026-05-10 16:14:35 -04:00
parent f1614029b5
commit 90fd028bfc
5 changed files with 844 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
// Package clients provides third-party printer integrations.
package clients
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// MoonrakerPrinterInfo represents the response from /api/printer/info.
type MoonrakerPrinterInfo struct {
State string `json:"state"`
Hostname string `json:"hostname,omitempty"`
SoftwareVersion string `json:"software_version,omitempty"`
}
// MoonrakerPrintStats represents the response from /api/printer/print_stats.
type MoonrakerPrintStats struct {
State string `json:"state"`
Filename string `json:"filename,omitempty"`
FilamentUsedMm float64 `json:"filament_used,omitempty"`
TotalDuration float64 `json:"total_duration,omitempty"`
PrintDuration float64 `json:"print_duration,omitempty"`
Message string `json:"message,omitempty"`
}
// MoonrakerPrintJob represents a single job from the history API.
type MoonrakerPrintJob struct {
JobID string `json:"job_id,omitempty"`
Filename string `json:"filename"`
Status string `json:"status"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time,omitempty"`
FilamentUsedMm float64 `json:"filament_used,omitempty"`
TotalDuration float64 `json:"total_duration,omitempty"`
}
// MoonrakerHistoryResponse represents the response from /api/server/history/job.
type MoonrakerHistoryResponse struct {
Items []MoonrakerPrintJob `json:"jobs"`
}
// MoonrakerClient is an HTTP client for the Moonraker API.
type MoonrakerClient struct {
HTTPClient *http.Client
}
// NewMoonrakerClient creates a MoonrakerClient with the given request timeout.
func NewMoonrakerClient(timeout time.Duration) *MoonrakerClient {
return &MoonrakerClient{
HTTPClient: &http.Client{Timeout: timeout},
}
}
// baseURL builds the Moonraker base URL from host and port.
func (c *MoonrakerClient) baseURL(host string, port int) string {
if port == 0 {
port = 80
}
return fmt.Sprintf("http://%s:%d", host, port)
}
// GetPrinterInfo fetches printer info from Moonraker.
func (c *MoonrakerClient) GetPrinterInfo(ctx context.Context, host string, port int, apiKey string) (*MoonrakerPrinterInfo, error) {
url := c.baseURL(host, port) + "/api/printer/info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if apiKey != "" {
req.Header.Set("X-Api-Key", apiKey)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("moonraker getPrinterInfo request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("moonraker getPrinterInfo returned status %d", resp.StatusCode)
}
var body struct {
Result MoonrakerPrinterInfo `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("moonraker getPrinterInfo decode failed: %w", err)
}
slog.Debug("moonraker printer info", "host", host, "state", body.Result.State)
return &body.Result, nil
}
// GetPrintStats fetches current print statistics from Moonraker.
func (c *MoonrakerClient) GetPrintStats(ctx context.Context, host string, port int, apiKey string) (*MoonrakerPrintStats, error) {
url := c.baseURL(host, port) + "/api/printer/print_stats"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if apiKey != "" {
req.Header.Set("X-Api-Key", apiKey)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("moonraker getPrintStats request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("moonraker getPrintStats returned status %d", resp.StatusCode)
}
var body struct {
Result MoonrakerPrintStats `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("moonraker getPrintStats decode failed: %w", err)
}
slog.Debug("moonraker print stats", "host", host, "state", body.Result.State, "filename", body.Result.Filename)
return &body.Result, nil
}
// GetPrintHistory fetches completed print job history from Moonraker.
func (c *MoonrakerClient) GetPrintHistory(ctx context.Context, host string, port int, apiKey string, limit int) (*MoonrakerHistoryResponse, error) {
if limit <= 0 {
limit = 25
}
url := fmt.Sprintf("%s/api/server/history/job?limit=%d", c.baseURL(host, port), limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if apiKey != "" {
req.Header.Set("X-Api-Key", apiKey)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("moonraker getPrintHistory request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("moonraker getPrintHistory returned status %d", resp.StatusCode)
}
var body MoonrakerHistoryResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("moonraker getPrintHistory decode failed: %w", err)
}
slog.Debug("moonraker print history", "host", host, "count", len(body.Items))
return &body, nil
}