package workers import ( "context" "fmt" "log/slog" "sync" "time" "github.com/CubeCraft-Creations/Extrudex/backend/internal/clients" "github.com/jackc/pgx/v5/pgxpool" ) // ── deduplication ─────────────────────────────────────────────────────────── // jobTrack holds the last-seen filename and filament_used for dedup. type jobTrack struct { filename string filamentUsed float64 } // MoonrakerPoller periodically queries the Moonraker REST API for print stats // and logs filament usage to PostgreSQL. It deduplicates by tracking the // last-known filament_used value for the active job on this printer. type MoonrakerPoller struct { client *clients.MoonrakerClient pool *pgxpool.Pool pollInterval time.Duration printerID int printerName string mu sync.Mutex track jobTrack } // MoonrakerPollerConfig holds configuration for the Moonraker polling worker. type MoonrakerPollerConfig struct { Client *clients.MoonrakerClient Pool *pgxpool.Pool PollInterval time.Duration PrinterID int PrinterName string } // NewMoonrakerPoller creates a new MoonrakerPoller worker. func NewMoonrakerPoller(cfg MoonrakerPollerConfig) *MoonrakerPoller { if cfg.PollInterval <= 0 { cfg.PollInterval = 10 * time.Second } return &MoonrakerPoller{ client: cfg.Client, pool: cfg.Pool, pollInterval: cfg.PollInterval, printerID: cfg.PrinterID, printerName: cfg.PrinterName, } } // Run starts the polling loop. It blocks until ctx is cancelled. func (w *MoonrakerPoller) Run(ctx context.Context) { slog.Info("moonraker poller: starting", "printer_id", w.printerID, "printer_name", w.printerName, "interval", w.pollInterval, ) ticker := time.NewTicker(w.pollInterval) defer ticker.Stop() w.poll(ctx) for { select { case <-ctx.Done(): slog.Info("moonraker poller: stopping", "printer_id", w.printerID) return case <-ticker.C: w.poll(ctx) } } } func (w *MoonrakerPoller) poll(ctx context.Context) { stats, err := w.client.GetPrintStats(ctx) if err != nil { slog.Warn("moonraker poller: failed to get print stats", "printer_id", w.printerID, "error", err) return } if stats.State == "" { return } jobName := "unknown" if stats.Filename != nil { jobName = *stats.Filename } // Compute delta under lock; release before I/O. w.mu.Lock() prevName := w.track.filename prevUsed := w.track.filamentUsed if jobName != prevName { w.track.filename = jobName w.track.filamentUsed = 0 prevUsed = 0 } deltaMM := stats.FilamentUsedMm - prevUsed totalMM := stats.FilamentUsedMm if deltaMM <= 0 && jobName == prevName { w.mu.Unlock() return } w.mu.Unlock() slog.Info("moonraker poller: filament usage", "printer_id", w.printerID, "job", jobName, "delta_mm", deltaMM, "total_mm", totalMM, "state", stats.State, ) jobID, err := w.ensurePrintJob(ctx, jobName, stats.State) if err != nil { slog.Error("moonraker poller: failed to ensure print job", "printer_id", w.printerID, "error", err) return } spoolID, density := lookupActiveSpool(ctx, w.pool, w.printerID) if err := insertUsageLog(ctx, w.pool, jobID, spoolID, deltaMM, density); err != nil { slog.Error("moonraker poller: failed to log usage", "printer_id", w.printerID, "error", err) return } w.mu.Lock() w.track.filamentUsed = totalMM w.mu.Unlock() } func (w *MoonrakerPoller) ensurePrintJob(ctx context.Context, jobName, state string) (int, error) { var jobID int err := w.pool.QueryRow(ctx, ` SELECT pj.id FROM print_jobs pj JOIN job_statuses js ON pj.job_status_id = js.id WHERE pj.printer_id = $1 AND pj.job_name = $2 AND pj.deleted_at IS NULL AND js.name IN ('printing', 'pending') ORDER BY pj.created_at DESC LIMIT 1 `, w.printerID, jobName).Scan(&jobID) if err == nil { _, _ = w.pool.Exec(ctx, ` UPDATE print_jobs SET job_status_id = (SELECT id FROM job_statuses WHERE name = 'printing'), started_at = COALESCE(started_at, NOW()), updated_at = NOW() WHERE id = $1 AND job_status_id = (SELECT id FROM job_statuses WHERE name = 'pending') `, jobID) return jobID, nil } var statusID int err = w.pool.QueryRow(ctx, `SELECT id FROM job_statuses WHERE name = 'printing'`).Scan(&statusID) if err != nil { return 0, fmt.Errorf("moonraker poller: missing 'printing' job status: %w", err) } err = w.pool.QueryRow(ctx, ` INSERT INTO print_jobs (printer_id, job_name, file_name, job_status_id, started_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING id `, w.printerID, jobName, jobName, statusID).Scan(&jobID) if err != nil { return 0, fmt.Errorf("moonraker poller: failed to create print job: %w", err) } slog.Info("moonraker poller: created print job", "job_id", jobID, "job_name", jobName) return jobID, nil } // ── Package-level helpers (shared by both workers) ────────────────────────── // lookupActiveSpool finds the most recently used spool for a given printer. func lookupActiveSpool(ctx context.Context, pool *pgxpool.Pool, printerID int) (int, float64) { type result struct { id int density float64 } var res result err := pool.QueryRow(ctx, ` SELECT fs.id, COALESCE(mb.density_g_cm3, 1.24) FROM filament_spools fs JOIN material_bases mb ON fs.material_base_id = mb.id JOIN print_jobs pj ON pj.filament_spool_id = fs.id WHERE pj.printer_id = $1 AND fs.deleted_at IS NULL ORDER BY pj.created_at DESC LIMIT 1 `, printerID).Scan(&res.id, &res.density) if err == nil { return res.id, res.density } err = pool.QueryRow(ctx, ` SELECT fs.id, COALESCE(mb.density_g_cm3, 1.24) FROM filament_spools fs JOIN material_bases mb ON fs.material_base_id = mb.id WHERE fs.deleted_at IS NULL ORDER BY fs.created_at DESC LIMIT 1 `).Scan(&res.id, &res.density) if err == nil { return res.id, res.density } return 1, 1.24 } // insertUsageLog inserts a usage_log entry and decrements the spool's remaining grams. func insertUsageLog(ctx context.Context, pool *pgxpool.Pool, jobID, spoolID int, deltaMM, densityGCm3 float64) error { const crossSectionCm2 = 0.02405 // π * (0.0875cm)² for 1.75mm filament gramsUsed := crossSectionCm2 * (deltaMM / 10.0) * densityGCm3 if gramsUsed <= 0 || deltaMM <= 0 { return nil } if _, err := 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, deltaMM, gramsUsed); err != nil { return fmt.Errorf("usage_log insert failed: %w", err) } _, _ = pool.Exec(ctx, ` UPDATE filament_spools SET remaining_grams = GREATEST(remaining_grams - $2::int, 0), updated_at = NOW() WHERE id = $1 `, spoolID, int(gramsUsed)) slog.Debug("moonraker poller: logged usage", "job_id", jobID, "spool_id", spoolID, "mm_extruded", deltaMM, "grams_used", gramsUsed, ) return nil }