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
|
||||
}
|
||||
229
backend/internal/clients/moonraker_ws.go
Normal file
229
backend/internal/clients/moonraker_ws.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ── WebSocket message types ─────────────────────────────────────────────────
|
||||
|
||||
// moonrakerWSMessage is a single JSON-RPC frame from the Moonraker WebSocket.
|
||||
type moonrakerWSMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
ID *int `json:"id"`
|
||||
}
|
||||
|
||||
// MoonrakerPrintEvent is the payload delivered by the "notify_status_update"
|
||||
// subscription when print_stats or display_status change.
|
||||
type MoonrakerPrintEvent struct {
|
||||
PrintStats *MoonrakerPrintStats `json:"print_stats"`
|
||||
DisplayStatus *MoonrakerDisplayStatus `json:"display_status"`
|
||||
}
|
||||
|
||||
// MoonrakerDisplayStatus carries progress and the LCD message.
|
||||
type MoonrakerDisplayStatus struct {
|
||||
Progress float64 `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// MoonrakerStatusHandler is called for every status update received from the
|
||||
// Moonraker WebSocket. It receives the parsed event and the raw JSON.
|
||||
type MoonrakerStatusHandler func(event MoonrakerPrintEvent) error
|
||||
|
||||
// ── WebSocket client ────────────────────────────────────────────────────────
|
||||
|
||||
// MoonrakerWSClient maintains a persistent WebSocket connection to the
|
||||
// Moonraker server and delivers parsed status updates to a handler.
|
||||
type MoonrakerWSClient struct {
|
||||
wsURL string
|
||||
handler MoonrakerStatusHandler
|
||||
dialer *websocket.Dialer
|
||||
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewMoonrakerWSClient creates a WebSocket client for the given Moonraker base
|
||||
// URL. The handler is invoked on every status update.
|
||||
func NewMoonrakerWSClient(baseURL string, handler MoonrakerStatusHandler) *MoonrakerWSClient {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
wsURL := strings.Replace(baseURL, "http://", "ws://", 1)
|
||||
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
|
||||
wsURL += "/websocket"
|
||||
|
||||
return &MoonrakerWSClient{
|
||||
wsURL: wsURL,
|
||||
handler: handler,
|
||||
dialer: &websocket.Dialer{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the WebSocket, subscribes to status updates, and
|
||||
// starts the read loop in a background goroutine. It retries on failure
|
||||
// with exponential backoff up to a 60-second cap.
|
||||
func (c *MoonrakerWSClient) Connect(ctx context.Context) {
|
||||
go c.run(ctx)
|
||||
}
|
||||
|
||||
// Shutdown gracefully closes the WebSocket and stops the read loop.
|
||||
func (c *MoonrakerWSClient) Shutdown() {
|
||||
c.once.Do(func() {
|
||||
close(c.done)
|
||||
})
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// run is the main connection loop with reconnect backoff.
|
||||
func (c *MoonrakerWSClient) run(ctx context.Context) {
|
||||
backoff := 1 * time.Second
|
||||
const maxBackoff = 60 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("moonraker ws: context cancelled, stopping")
|
||||
return
|
||||
case <-c.done:
|
||||
slog.Info("moonraker ws: shutdown requested")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := c.connectAndRead(ctx); err != nil {
|
||||
slog.Error("moonraker ws: connection error, retrying", "error", err, "backoff", backoff)
|
||||
}
|
||||
|
||||
// Exponential backoff.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.done:
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MoonrakerWSClient) connectAndRead(ctx context.Context) error {
|
||||
slog.Info("moonraker ws: connecting", "url", c.wsURL)
|
||||
|
||||
conn, _, err := c.dialer.DialContext(ctx, c.wsURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial failed: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
if c.conn == conn {
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Subscribe to status updates.
|
||||
subReq := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "printer.objects.subscribe",
|
||||
"params": map[string]interface{}{
|
||||
"objects": map[string]interface{}{
|
||||
"print_stats": nil,
|
||||
"display_status": nil,
|
||||
},
|
||||
},
|
||||
"id": 1,
|
||||
}
|
||||
if err := conn.WriteJSON(subReq); err != nil {
|
||||
return fmt.Errorf("subscribe failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("moonraker ws: subscribed to status updates")
|
||||
|
||||
// Set read deadline to detect stale connections.
|
||||
// 120s is long enough to avoid false positives.
|
||||
pingPeriod := 60 * time.Second
|
||||
|
||||
for {
|
||||
// Set read deadline.
|
||||
if err := conn.SetReadDeadline(time.Now().Add(150 * time.Second)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
|
||||
// Send periodic pings to keep the connection alive.
|
||||
go func() {
|
||||
time.Sleep(pingPeriod)
|
||||
c.mu.Lock()
|
||||
if c.conn == conn {
|
||||
c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
var msg moonrakerWSMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
slog.Warn("moonraker ws: failed to parse message", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process notify_status_update messages.
|
||||
if msg.Method != "notify_status_update" {
|
||||
continue
|
||||
}
|
||||
|
||||
var statusWrapper []MoonrakerPrintEvent
|
||||
if err := json.Unmarshal(msg.Params, &statusWrapper); err != nil {
|
||||
// Params might be an object, not an array.
|
||||
var singleEvent MoonrakerPrintEvent
|
||||
if err2 := json.Unmarshal(msg.Params, &singleEvent); err2 != nil {
|
||||
slog.Warn("moonraker ws: failed to unmarshal status params", "error", err2)
|
||||
continue
|
||||
}
|
||||
statusWrapper = []MoonrakerPrintEvent{singleEvent}
|
||||
}
|
||||
|
||||
for _, ev := range statusWrapper {
|
||||
if c.handler != nil {
|
||||
if err := c.handler(ev); err != nil {
|
||||
slog.Error("moonraker ws: handler error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
backend/internal/clients/mqtt.go
Normal file
183
backend/internal/clients/mqtt.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// ── Bambu Lab telemetry types ───────────────────────────────────────────────
|
||||
|
||||
// BambuPrintReport is the JSON payload published by Bambu Lab printers
|
||||
// on the MQTT report topic. The structure varies by printer model;
|
||||
// we extract the common fields needed for filament tracking.
|
||||
type BambuPrintReport struct {
|
||||
// Print holds the active print job data.
|
||||
Print BambuPrintData `json:"print"`
|
||||
|
||||
// VtTray contains AMS tray info; the extruded length is per-tray.
|
||||
VtTray *BambuVtTray `json:"vt_tray,omitempty"`
|
||||
}
|
||||
|
||||
// BambuPrintData carries the active print state from a Bambu report.
|
||||
type BambuPrintData struct {
|
||||
// GcodeFile is the filename being printed.
|
||||
GcodeFile string `json:"gcode_file"`
|
||||
// GcodeState describes the current print state:
|
||||
// "IDLE", "RUNNING", "PAUSE", "FINISH", "FAILED".
|
||||
GcodeState string `json:"gcode_state"`
|
||||
// McPercent is the progress as a percentage (0-100).
|
||||
McPercent int `json:"mc_percent"`
|
||||
// McRemainingTime is the estimated remaining time in minutes.
|
||||
McRemainingTime int `json:"mc_remaining_time"`
|
||||
}
|
||||
|
||||
// BambuVtTray holds AMS tray telemetry from Bambu printers.
|
||||
type BambuVtTray struct {
|
||||
ID string `json:"id"`
|
||||
TagUID string `json:"tag_uid"`
|
||||
TrayIDName string `json:"tray_id_name"`
|
||||
// TrayInfoIdx is the hex color code for the tray's filament.
|
||||
TrayInfoIdx string `json:"tray_info_idx"`
|
||||
// TrayColor is a hex color string like "FF0000FF".
|
||||
TrayColor string `json:"tray_color"`
|
||||
// Remain is the percentage of filament remaining on this tray (0-100).
|
||||
Remain int `json:"remain"`
|
||||
// K is a temperature coefficient.
|
||||
K float64 `json:"k"`
|
||||
// N is a second temperature coefficient.
|
||||
N float64 `json:"n"`
|
||||
}
|
||||
|
||||
// BambuReportHandler is called for each parsed Bambu telemetry message.
|
||||
type BambuReportHandler func(report BambuPrintReport) error
|
||||
|
||||
// ── MQTT client ─────────────────────────────────────────────────────────────
|
||||
|
||||
// MQTTClient wraps the Eclipse Paho MQTT client for Bambu Lab printer
|
||||
// telemetry with optional TLS support.
|
||||
type MQTTClient struct {
|
||||
broker string
|
||||
clientID string
|
||||
topicPrefix string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
handler BambuReportHandler
|
||||
|
||||
mu sync.Mutex
|
||||
client mqtt.Client
|
||||
}
|
||||
|
||||
// MQTTConfig holds the configuration for creating an MQTTClient.
|
||||
type MQTTConfig struct {
|
||||
Broker string // e.g., "ssl://192.168.1.50:8883"
|
||||
ClientID string // unique MQTT client id, defaults to "extrudex"
|
||||
TopicPrefix string // topic prefix, defaults to "device/+/report"
|
||||
TLSCert string // path to TLS client certificate (optional)
|
||||
TLSKey string // path to TLS client key (optional)
|
||||
Handler BambuReportHandler
|
||||
}
|
||||
|
||||
// NewMQTTClient creates a new MQTTClient. The connection is not established
|
||||
// until Connect is called.
|
||||
func NewMQTTClient(cfg MQTTConfig) *MQTTClient {
|
||||
if cfg.ClientID == "" {
|
||||
cfg.ClientID = "extrudex"
|
||||
}
|
||||
if cfg.TopicPrefix == "" {
|
||||
cfg.TopicPrefix = "device/+/report"
|
||||
}
|
||||
return &MQTTClient{
|
||||
broker: cfg.Broker,
|
||||
clientID: cfg.ClientID,
|
||||
topicPrefix: cfg.TopicPrefix,
|
||||
tlsCert: cfg.TLSCert,
|
||||
tlsKey: cfg.TLSKey,
|
||||
handler: cfg.Handler,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the MQTT connection and subscribes to the configured
|
||||
// topic prefix. Returns an error if the initial connection fails.
|
||||
func (c *MQTTClient) Connect() error {
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(c.broker).
|
||||
SetClientID(c.clientID).
|
||||
SetAutoReconnect(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetPingTimeout(10 * time.Second).
|
||||
SetConnectTimeout(15 * time.Second).
|
||||
SetOnConnectHandler(func(client mqtt.Client) {
|
||||
slog.Info("mqtt: connected", "broker", c.broker)
|
||||
// Subscribe on every reconnect.
|
||||
token := client.Subscribe(c.topicPrefix, 0, c.messageHandler)
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
slog.Error("mqtt: subscribe failed on reconnect", "topic", c.topicPrefix, "error", err)
|
||||
} else {
|
||||
slog.Info("mqtt: subscribed", "topic", c.topicPrefix)
|
||||
}
|
||||
}).
|
||||
SetConnectionLostHandler(func(client mqtt.Client, err error) {
|
||||
slog.Warn("mqtt: connection lost", "error", err)
|
||||
})
|
||||
|
||||
// Configure TLS if cert and key are provided.
|
||||
if c.tlsCert != "" && c.tlsKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(c.tlsCert, c.tlsKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mqtt: failed to load TLS cert/key: %w", err)
|
||||
}
|
||||
opts.SetTLSConfig(&tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
slog.Info("mqtt: TLS configured", "cert", c.tlsCert)
|
||||
}
|
||||
|
||||
c.client = mqtt.NewClient(opts)
|
||||
token := c.client.Connect()
|
||||
if !token.WaitTimeout(15 * time.Second) {
|
||||
return fmt.Errorf("mqtt: connect timed out to %s", c.broker)
|
||||
}
|
||||
if err := token.Error(); err != nil {
|
||||
return fmt.Errorf("mqtt: connect failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("mqtt: initial connection established", "broker", c.broker)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect gracefully closes the MQTT connection.
|
||||
func (c *MQTTClient) Disconnect() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.client != nil && c.client.IsConnected() {
|
||||
c.client.Disconnect(2500) // wait up to 2.5s
|
||||
slog.Info("mqtt: disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// messageHandler is the MQTT callback invoked for every message received on
|
||||
// the subscribed topic.
|
||||
func (c *MQTTClient) messageHandler(_ mqtt.Client, msg mqtt.Message) {
|
||||
if c.handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var report BambuPrintReport
|
||||
if err := json.Unmarshal(msg.Payload(), &report); err != nil {
|
||||
slog.Warn("mqtt: failed to parse bambu report", "topic", msg.Topic(), "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.handler(report); err != nil {
|
||||
slog.Error("mqtt: handler error", "topic", msg.Topic(), "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user