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:
161
backend/internal/clients/moonraker.go
Normal file
161
backend/internal/clients/moonraker.go
Normal 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
|
||||
}
|
||||
119
backend/internal/clients/mqtt.go
Normal file
119
backend/internal/clients/mqtt.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Package clients provides third-party printer integrations.
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// MQTTClient wraps the Eclipse Paho MQTT client for printer telemetry.
|
||||
type MQTTClient struct {
|
||||
client mqtt.Client
|
||||
}
|
||||
|
||||
// MQTTConfig holds per-printer MQTT connection settings.
|
||||
type MQTTConfig struct {
|
||||
BrokerHost string
|
||||
BrokerPort int
|
||||
TopicPrefix string
|
||||
TLSEnabled bool
|
||||
ClientID string
|
||||
}
|
||||
|
||||
// BambuPrintStatus is the known Bambu Lab print-status payload shape.
|
||||
type BambuPrintStatus struct {
|
||||
Print struct {
|
||||
GcodeFile string `json:"gcode_file,omitempty"`
|
||||
Stage int `json:"stage,omitempty"`
|
||||
SubTaskName string `json:"subtask_name,omitempty"`
|
||||
PrintType string `json:"print_type,omitempty"`
|
||||
FilamentUsedMm float64 `json:"mc_percent,omitempty"` // placeholder; real telemetry varies
|
||||
} `json:"print,omitempty"`
|
||||
}
|
||||
|
||||
// NewMQTTClient creates an MQTT client connected to the given broker.
|
||||
func NewMQTTClient(cfg MQTTConfig) (*MQTTClient, error) {
|
||||
if cfg.BrokerPort == 0 {
|
||||
if cfg.TLSEnabled {
|
||||
cfg.BrokerPort = 8883
|
||||
} else {
|
||||
cfg.BrokerPort = 1883
|
||||
}
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
cfg.ClientID = fmt.Sprintf("extrudex-%d", time.Now().Unix())
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(fmt.Sprintf("tcp://%s:%d", cfg.BrokerHost, cfg.BrokerPort)).
|
||||
SetClientID(cfg.ClientID).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectTimeout(10 * time.Second).
|
||||
SetOrderMatters(false)
|
||||
|
||||
if cfg.TLSEnabled {
|
||||
opts = opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: false})
|
||||
}
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
token := client.Connect()
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return nil, fmt.Errorf("mqtt connect failed: %w", token.Error())
|
||||
}
|
||||
|
||||
slog.Info("mqtt client connected", "broker", cfg.BrokerHost, "port", cfg.BrokerPort, "tls", cfg.TLSEnabled)
|
||||
return &MQTTClient{client: client}, nil
|
||||
}
|
||||
|
||||
// Subscribe registers a callback for messages matching topic.
|
||||
func (c *MQTTClient) Subscribe(topic string, qos byte, callback func([]byte)) error {
|
||||
token := c.client.Subscribe(topic, qos, func(_ mqtt.Client, msg mqtt.Message) {
|
||||
callback(msg.Payload())
|
||||
})
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return fmt.Errorf("mqtt subscribe failed: %w", token.Error())
|
||||
}
|
||||
slog.Info("mqtt subscribed", "topic", topic, "qos", qos)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscription.
|
||||
func (c *MQTTClient) Unsubscribe(topics ...string) error {
|
||||
token := c.client.Unsubscribe(topics...)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return fmt.Errorf("mqtt unsubscribe failed: %w", token.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect cleanly disconnects the MQTT client.
|
||||
func (c *MQTTClient) Disconnect(quiesceMs uint) {
|
||||
c.client.Disconnect(quiesceMs)
|
||||
}
|
||||
|
||||
// IsConnected returns whether the underlying client is connected.
|
||||
func (c *MQTTClient) IsConnected() bool {
|
||||
return c.client.IsConnected()
|
||||
}
|
||||
|
||||
// ParseBambuTelemetry attempts to parse a Bambu Lab telemetry JSON payload.
|
||||
func ParseBambuTelemetry(payload []byte) (*BambuPrintStatus, error) {
|
||||
var msg BambuPrintStatus
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
return nil, fmt.Errorf("parse bambu telemetry failed: %w", err)
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// DefaultBambuTopics returns the default topic patterns for Bambu Lab printers.
|
||||
func DefaultBambuTopics(topicPrefix string) []string {
|
||||
return []string{
|
||||
topicPrefix + "/report",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user