using Extrudex.Domain.DTOs.Moonraker; using Extrudex.Domain.Entities; using Extrudex.Domain.Enums; using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Configuration; using Extrudex.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Extrudex.Infrastructure.Services; /// /// Service that syncs Moonraker printer status and print job history into the /// Extrudex database. Queries all active Moonraker printers, fetches their /// current operational state, and maps completed print jobs to PrintJob and /// FilamentUsage entities with derived gram calculations. /// public class MoonrakerPrinterSyncService : IMoonrakerPrinterSyncService { private readonly ExtrudexDbContext _dbContext; private readonly IMoonrakerClient _moonrakerClient; private readonly ILogger _logger; /// /// Creates a new MoonrakerPrinterSyncService. /// /// The EF Core database context for persisting updates. /// The Moonraker HTTP client for fetching printer data. /// Logger for diagnostic output. public MoonrakerPrinterSyncService( ExtrudexDbContext dbContext, IMoonrakerClient moonrakerClient, ILogger logger) { _dbContext = dbContext; _moonrakerClient = moonrakerClient; _logger = logger; } /// public async Task SyncAllAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("Starting Moonraker printer sync cycle"); var printers = await _dbContext.Printers .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) .Include(p => p.AmsUnits) .ThenInclude(u => u.Slots) .ThenInclude(s => s.Spool) .ThenInclude(s => s.MaterialBase) .Include(p => p.PrintJobs) .ToListAsync(cancellationToken); if (printers.Count == 0) { _logger.LogInformation("No active Moonraker printers found — skipping sync"); return 0; } _logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count); var syncedCount = 0; foreach (var printer in printers) { try { await SyncPrinterAsync(printer, cancellationToken); syncedCount++; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Error syncing printer {PrinterName} ({Host}:{Port})", printer.Name, printer.HostnameOrIp, printer.Port); // Mark printer as offline if we can't reach it printer.Status = PrinterStatus.Offline; } } await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation( "Moonraker printer sync cycle complete — {SyncedCount}/{TotalCount} printers synced", syncedCount, printers.Count); return syncedCount; } /// /// Syncs a single Moonraker printer: updates its status, fetches print history, /// and maps new print jobs to database entities. /// private async Task SyncPrinterAsync(Printer printer, CancellationToken cancellationToken) { // Step 1: Fetch printer status var printerInfo = await _moonrakerClient.GetPrinterInfoAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken); var printStats = await _moonrakerClient.GetPrintStatsAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken); // Step 2: Update printer status UpdatePrinterStatus(printer, printerInfo, printStats); printer.LastSeenAt = DateTime.UtcNow; _logger.LogDebug( "Printer {PrinterName} status updated to {Status}", printer.Name, printer.Status); // Step 3: Fetch and map print job history var history = await _moonrakerClient.GetPrintHistoryAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, limit: 25, cancellationToken); if (history.Items.Count == 0) { _logger.LogDebug("No print history returned for printer {PrinterName}", printer.Name); return; } var newJobsCount = await MapPrintJobsAsync(printer, history.Items, cancellationToken); if (newJobsCount > 0) { _logger.LogInformation( "Mapped {NewJobsCount} new print job(s) from printer {PrinterName}", newJobsCount, printer.Name); } } /// /// Updates the printer's operational status based on Moonraker telemetry. /// Maps Klipper/Moonraker state strings to the PrinterStatus enum. /// private void UpdatePrinterStatus( Printer printer, MoonrakerPrinterInfo? printerInfo, MoonrakerPrintStats? printStats) { // Prefer print_stats state — it's the most authoritative if (printStats != null) { printer.Status = printStats.State.ToLowerInvariant() switch { "printing" => PrinterStatus.Printing, "paused" => PrinterStatus.Paused, "complete" => PrinterStatus.Idle, "standby" => PrinterStatus.Idle, "cancelled" => PrinterStatus.Idle, "error" => PrinterStatus.Error, _ => PrinterStatus.Idle }; return; } // Fall back to printer_info state if (printerInfo != null) { printer.Status = printerInfo.State.ToLowerInvariant() switch { "ready" => PrinterStatus.Idle, "startup" => PrinterStatus.Idle, "shutdown" => PrinterStatus.Offline, "error" => PrinterStatus.Error, "cancelled" => PrinterStatus.Idle, _ => printer.Status // Preserve existing status if unknown }; } } /// /// Maps Moonraker print job history items to Extrudex PrintJob and FilamentUsage entities. /// Only creates records for jobs not already tracked (by Moonraker JobId stored in GcodeFilePath). /// private async Task MapPrintJobsAsync( Printer printer, List historyItems, CancellationToken cancellationToken) { // Build a set of already-tracked Moonraker JobIds for this printer // We store the Moonraker JobId in the GcodeFilePath field with a "moonraker:" prefix var trackedJobIds = await _dbContext.PrintJobs .Where(pj => pj.PrinterId == printer.Id && pj.GcodeFilePath != null && pj.GcodeFilePath.StartsWith("moonraker:")) .Select(pj => pj.GcodeFilePath!) .ToListAsync(cancellationToken); var trackedIdSet = new HashSet(trackedJobIds); var newJobsCount = 0; // Find the default spool for this printer (first active spool in AMS, or first active spool overall) var defaultSpool = FindDefaultSpool(printer); foreach (var moonrakerJob in historyItems) { var jobIdKey = $"moonraker:{moonrakerJob.JobId}"; if (trackedIdSet.Contains(jobIdKey)) { continue; // Already tracked — skip } // Only map completed, cancelled, or errored jobs (not in_progress) // In-progress jobs will be captured on the next cycle once they finish if (moonrakerJob.Status == "in_progress") { continue; } // Map Moonraker job status to JobStatus enum var jobStatus = moonrakerJob.Status.ToLowerInvariant() switch { "completed" => JobStatus.Completed, "cancelled" => JobStatus.Cancelled, "error" => JobStatus.Failed, _ => JobStatus.Completed }; // Calculate derived grams if we have a spool and filament data decimal gramsDerived = 0m; decimal filamentDiameterMm = 1.75m; decimal materialDensity = 1.24m; // PLA default if (defaultSpool != null) { filamentDiameterMm = defaultSpool.FilamentDiameterMm; materialDensity = defaultSpool.MaterialBase.DensityGperCm3; gramsDerived = CalculateGrams(moonrakerJob.FilamentUsedMm, filamentDiameterMm, materialDensity); } else if (moonrakerJob.FilamentUsedMm > 0) { gramsDerived = CalculateGrams(moonrakerJob.FilamentUsedMm, 1.75m, 1.24m); _logger.LogWarning( "No default spool found for printer {PrinterName} — using PLA defaults for grams derivation on job {JobId}", printer.Name, moonrakerJob.JobId); } var printJob = new PrintJob { PrinterId = printer.Id, SpoolId = defaultSpool?.Id ?? Guid.Empty, PrintName = moonrakerJob.Filename, GcodeFilePath = jobIdKey, MmExtruded = moonrakerJob.FilamentUsedMm, GramsDerived = gramsDerived, StartedAt = moonrakerJob.StartTime, CompletedAt = moonrakerJob.EndTime, Status = jobStatus, DataSource = DataSource.Moonraker, FilamentDiameterAtPrintMm = filamentDiameterMm, MaterialDensityAtPrint = materialDensity, Notes = $"Auto-imported from Moonraker (JobId: {moonrakerJob.JobId})" }; _dbContext.PrintJobs.Add(printJob); // Create a FilamentUsage record if filament was consumed if (moonrakerJob.FilamentUsedMm > 0 && defaultSpool != null) { var usage = new FilamentUsage { PrintJob = printJob, SpoolId = defaultSpool.Id, PrinterId = printer.Id, GramsUsed = gramsDerived, MmExtruded = moonrakerJob.FilamentUsedMm, RecordedAt = DateTime.UtcNow, Notes = $"Auto-imported from Moonraker history (JobId: {moonrakerJob.JobId})" }; _dbContext.FilamentUsages.Add(usage); } newJobsCount++; trackedIdSet.Add(jobIdKey); // Prevent duplicates within this batch } return newJobsCount; } /// /// Finds the default spool for a printer. Returns the first spool loaded /// in an AMS slot, or null if no spool is available. /// private static Spool? FindDefaultSpool(Printer printer) { // Prefer the first active spool in an AMS slot foreach (var amsUnit in printer.AmsUnits) { foreach (var slot in amsUnit.Slots) { if (slot.Spool != null && slot.Spool.IsActive && !slot.Spool.IsArchived) { return slot.Spool; } } } return null; } /// /// Calculates derived grams from millimeters extruded using the standard formula: /// grams = mm_extruded × cross_section_area × material_density /// where cross_section_area = π × (diameter / 2)² /// private static decimal CalculateGrams(decimal mmExtruded, decimal diameterMm, decimal densityGperCm3) { if (mmExtruded <= 0) return 0m; var radiusCm = (double)diameterMm / 2.0 / 10.0; // mm to cm var crossSectionAreaCm2 = Math.PI * radiusCm * radiusCm; var mmToCm = (double)mmExtruded / 10.0; var grams = mmToCm * crossSectionAreaCm2 * (double)densityGperCm3; return (decimal)grams; } }