// Package workers provides background goroutines for printer telemetry. package workers import ( "context" "fmt" "log/slog" "strconv" "time" "github.com/CubeCraft-Creations/Extrudex/backend/internal/clients" "github.com/CubeCraft-Creations/Extrudex/backend/internal/models" "github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories" "github.com/CubeCraft-Creations/Extrudex/backend/internal/sse" "github.com/jackc/pgx/v5/pgxpool" ) // MoonrakerPollerConfig controls the background polling behaviour. type MoonrakerPollerConfig struct { PollInterval time.Duration RequestTimeout time.Duration } // DefaultMoonrakerPollerConfig returns sensible defaults. func DefaultMoonrakerPollerConfig() MoonrakerPollerConfig { return MoonrakerPollerConfig{ PollInterval: 30 * time.Second, RequestTimeout: 10 * time.Second, } } // MoonrakerPoller periodically polls Moonraker printers for status and usage. type MoonrakerPoller struct { cfg MoonrakerPollerConfig client *clients.MoonrakerClient printerRepo *repositories.PrinterRepository jobRepo *repositories.PrintJobRepository usageRepo *repositories.UsageLogRepository sseBC *sse.Broadcaster pool *pgxpool.Pool stop chan struct{} } // NewMoonrakerPoller creates a poller. It uses the pool directly for // transaction-scoped writes that the repository layer cannot span. func NewMoonrakerPoller( cfg MoonrakerPollerConfig, pool *pgxpool.Pool, printerRepo *repositories.PrinterRepository, jobRepo *repositories.PrintJobRepository, usageRepo *repositories.UsageLogRepository, sseBC *sse.Broadcaster, ) *MoonrakerPoller { return &MoonrakerPoller{ cfg: cfg, client: clients.NewMoonrakerClient(cfg.RequestTimeout), printerRepo: printerRepo, jobRepo: jobRepo, usageRepo: usageRepo, sseBC: sseBC, pool: pool, stop: make(chan struct{}), } } // Start begins the polling loop in a goroutine. func (p *MoonrakerPoller) Start() { go p.loop() } // Stop signals the loop to exit. func (p *MoonrakerPoller) Stop() { close(p.stop) } func (p *MoonrakerPoller) loop() { ticker := time.NewTicker(p.cfg.PollInterval) defer ticker.Stop() // Immediate first tick. p.pollCycle() for { select { case <-ticker.C: p.pollCycle() case <-p.stop: slog.Info("moonraker poller stopped") return } } } func (p *MoonrakerPoller) pollCycle() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() printers, err := p.printerRepo.GetAll(ctx) if err != nil { slog.Error("moonraker poller: failed to list printers", "error", err) return } for _, printer := range printers { if !printer.IsActive || printer.MoonrakerURL == nil || *printer.MoonrakerURL == "" { continue } if err := p.pollPrinter(ctx, printer); err != nil { slog.Warn("moonraker poller: poll failed", "printer", printer.Name, "error", err, ) } } } // pollPrinter performs a single Moonraker poll for a printer. func (p *MoonrakerPoller) pollPrinter(ctx context.Context, printer models.Printer) error { host := *printer.MoonrakerURL var apiKey string if printer.MoonrakerAPIKey != nil { apiKey = *printer.MoonrakerAPIKey } // Fetch printer info (status) info, err := p.client.GetPrinterInfo(ctx, host, 80, apiKey) if err != nil { p.broadcastStatus(printer.ID, printer.Name, "offline") return err } status := mapMoonrakerState(info.State) p.broadcastStatus(printer.ID, printer.Name, status) // Fetch print stats stats, err := p.client.GetPrintStats(ctx, host, 80, apiKey) if err != nil { return fmt.Errorf("getPrintStats failed: %w", err) } if status == "printing" && stats.Filename != "" { p.broadcastJobStarted(printer.ID, stats.Filename) } if isCompleteState(stats.State) && stats.FilamentUsedMm > 0 { // Record usage if err := p.recordUsage(ctx, printer, stats); err != nil { slog.Error("moonraker poller: record usage failed", "printer", printer.Name, "error", err) } else { p.broadcastJobCompleted(printer.ID, stats.Filename, stats.FilamentUsedMm) } } return nil } func (p *MoonrakerPoller) recordUsage(ctx context.Context, printer models.Printer, stats *clients.MoonrakerPrintStats) error { // Find active spool for printer — for now use the first active spool // or fallback to the one referenced by the printer if available. // In a real scenario we'd query AMS slots or fallback logic. // Here we simply look for the most recently used spool in usage_logs. var spoolID int row := p.pool.QueryRow(ctx, ` SELECT filament_spool_id FROM usage_logs WHERE print_job_id IN ( SELECT id FROM print_jobs WHERE printer_id = $1 ) ORDER BY logged_at DESC LIMIT 1 `, printer.ID) _ = row.Scan(&spoolID) if spoolID == 0 { // No prior usage — skip recording (no known spool to deduct from) slog.Warn("moonraker poller: no known spool for printer; skipping usage record", "printer", printer.Name) return nil } // Compute grams from mm extruded using defaults (1.75mm diameter, PLA density 1.24) grams := calculateGrams(stats.FilamentUsedMm, 1.75, 1.24) // Create a print job record var jobID int err := p.pool.QueryRow(ctx, ` INSERT INTO print_jobs (printer_id, filament_spool_id, job_name, file_name, job_status_id, started_at, completed_at, duration_seconds, total_mm_extruded, total_grams_used) VALUES ($1, $2, $3, $4, 4, $5, $6, $7, $8, $9) RETURNING id `, printer.ID, spoolID, stats.Filename, stats.Filename, time.Now().Add(-time.Duration(stats.TotalDuration)*time.Second), time.Now(), int(stats.TotalDuration), stats.FilamentUsedMm, grams, ).Scan(&jobID) if err != nil { return fmt.Errorf("insert print_job failed: %w", err) } // Create usage_log _, err = p.pool.Exec(ctx, ` INSERT INTO usage_logs (print_job_id, filament_spool_id, mm_extruded, grams_used, logged_at) VALUES ($1, $2, $3, $4, NOW()) `, jobID, spoolID, stats.FilamentUsedMm, grams) if err != nil { return fmt.Errorf("insert usage_log failed: %w", err) } slog.Info("moonraker poller: recorded usage", "printer", printer.Name, "job", stats.Filename, "mm", stats.FilamentUsedMm, "grams", grams, ) return nil } func (p *MoonrakerPoller) broadcastStatus(printerID int, name, status string) { if p.sseBC == nil { return } ev, err := sse.NewEvent(sse.EventPrinterStatus, sse.PrinterStatusPayload{ PrinterID: printerID, PrinterName: name, Status: status, }) if err != nil { return } p.sseBC.Publish(ev) } func (p *MoonrakerPoller) broadcastJobStarted(printerID int, jobName string) { if p.sseBC == nil { return } ev, err := sse.NewEvent(sse.EventJobStarted, sse.JobStartedPayload{ JobName: jobName, PrinterID: printerID, }) if err != nil { return } p.sseBC.Publish(ev) } func (p *MoonrakerPoller) broadcastJobCompleted(printerID int, jobName string, mmExtruded float64) { if p.sseBC == nil { return } grams := calculateGrams(mmExtruded, 1.75, 1.24) gramsInt := int(grams) ev, err := sse.NewEvent(sse.EventJobCompleted, sse.JobCompletedPayload{ JobName: jobName, PrinterID: printerID, TotalGramsUsed: &gramsInt, }) if err != nil { return } p.sseBC.Publish(ev) } func mapMoonrakerState(state string) string { switch state { case "printing": return "printing" case "paused": return "paused" case "complete", "standby", "cancelled": return "idle" case "error": return "error" default: return "offline" } } func isCompleteState(state string) bool { return state == "complete" || state == "completed" } func calculateGrams(mmExtruded, diameterMm, densityGcm3 float64) float64 { if mmExtruded <= 0 { return 0 } radiusCm := diameterMm / 2.0 / 10.0 crossSection := 3.141592653589793 * radiusCm * radiusCm volumeCm3 := (mmExtruded / 10.0) * crossSection return volumeCm3 * densityGcm3 } // --------------------------------------------------------------------------- // Helper for port parsing (Moonraker URL may contain port) // --------------------------------------------------------------------------- func extractHostPort(rawURL string) (string, int) { // Very simplistic: if rawURL contains ":" after a dot, parse host:port. // Otherwise assume host only and return port 80. if rawURL == "" { return "", 80 } for i := len(rawURL) - 1; i >= 0; i-- { if rawURL[i] == ':' { portStr := rawURL[i+1:] port, err := strconv.Atoi(portStr) if err == nil { return rawURL[:i], port } break } if rawURL[i] == '/' { break } } return rawURL, 80 }