using Extrudex.Domain.DTOs.Moonraker; using Extrudex.Domain.Entities; using Extrudex.Domain.Enums; using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Extrudex.Infrastructure.Services; /// /// Service that syncs filament usage data from Moonraker printers into the /// Extrudex database. Queries all active Moonraker printers, fetches their /// current filament usage metrics, persists usage entries to the UsageLog table, /// creates FilamentUsage records for completed jobs, and updates spool remaining weights. /// public class FilamentUsageSyncService : IFilamentUsageSyncService { private readonly ExtrudexDbContext _dbContext; private readonly IMoonrakerClient _moonrakerClient; private readonly IUsageLogService _usageLogService; private readonly ILogger _logger; /// /// Creates a new FilamentUsageSyncService. /// /// The EF Core database context for persisting updates. /// The Moonraker HTTP client for fetching printer data. /// The usage log service for persisting usage entries. /// Logger for diagnostic output. public FilamentUsageSyncService( ExtrudexDbContext dbContext, IMoonrakerClient moonrakerClient, IUsageLogService usageLogService, ILogger logger) { _dbContext = dbContext; _moonrakerClient = moonrakerClient; _usageLogService = usageLogService; _logger = logger; } /// public async Task SyncAllAsync(CancellationToken cancellationToken = default) { _logger.LogInformation("Starting filament usage 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 (HttpRequestException ex) { _logger.LogError(ex, "Connection error syncing filament usage from printer {PrinterName} ({Host}:{Port}) — printer may be offline", printer.Name, printer.HostnameOrIp, printer.Port); printer.Status = PrinterStatus.Offline; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Error syncing filament usage from printer {PrinterName} ({Host}:{Port})", printer.Name, printer.HostnameOrIp, printer.Port); } } await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation( "Filament usage sync cycle complete — {SyncedCount}/{TotalCount} printers synced", syncedCount, printers.Count); return syncedCount; } /// /// Syncs a single Moonraker printer: fetches print stats and history, /// persists usage data to UsageLog and FilamentUsage tables, and /// updates spool remaining weights. /// private async Task SyncPrinterAsync(Printer printer, CancellationToken cancellationToken) { // Step 1: Fetch current print stats for real-time filament usage var printStats = await _moonrakerClient.GetPrintStatsAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken); // Step 2: Fetch usage dictionary for backward-compatible metrics var usageData = await _moonrakerClient.GetFilamentUsageAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken); // Step 3: Update printer status based on print stats 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, _ => printer.Status }; } printer.LastSeenAt = DateTime.UtcNow; // Step 4: Update spool remaining weights from AMS data UpdateSpoolWeights(printer, usageData); // Step 5: If there's filament usage from print stats, persist it if (printStats != null && printStats.FilamentUsedMm > 0) { await PersistFilamentUsageAsync(printer, printStats, cancellationToken); } else if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0) { // Fall back to dictionary metrics if print stats aren't available _logger.LogInformation( "Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job (from usage dictionary)", printer.Name, mmExtruded); } _logger.LogInformation( "Successfully synced filament usage from printer {PrinterName}", printer.Name); } /// /// Persists filament usage data from print stats to the database. /// Creates a FilamentUsage record and a UsageLog entry, and deducts /// consumed grams from the spool's remaining weight. /// private async Task PersistFilamentUsageAsync( Printer printer, MoonrakerPrintStats printStats, CancellationToken cancellationToken) { // Find the default spool for this printer var defaultSpool = FindDefaultSpool(printer); if (defaultSpool == null) { _logger.LogWarning( "No default spool found for printer {PrinterName} — cannot persist filament usage of {MmExtruded}mm", printer.Name, printStats.FilamentUsedMm); return; } // Calculate derived grams var gramsDerived = CalculateGrams( printStats.FilamentUsedMm, defaultSpool.FilamentDiameterMm, defaultSpool.MaterialBase.DensityGperCm3); if (gramsDerived <= 0) { _logger.LogDebug( "No grams derived for printer {PrinterName} — skipping usage persistence", printer.Name); return; } // Deduct from spool remaining weight (floor at 0) var previousWeight = defaultSpool.WeightRemainingGrams; defaultSpool.WeightRemainingGrams = Math.Max(0, defaultSpool.WeightRemainingGrams - gramsDerived); _logger.LogInformation( "Deducted {Grams:F1}g from spool {SpoolSerial} (was {Previous:F1}g, now {Current:F1}g) for printer {PrinterName}", gramsDerived, defaultSpool.SpoolSerial, previousWeight, defaultSpool.WeightRemainingGrams, printer.Name); // Check if we already have a recent FilamentUsage for this printer // to avoid double-counting on repeated poll cycles for the same job var recentUsageThreshold = DateTime.UtcNow.AddMinutes(-10); var existingRecentUsage = await _dbContext.FilamentUsages .Where(fu => fu.PrinterId == printer.Id && fu.RecordedAt >= recentUsageThreshold) .AnyAsync(cancellationToken); if (existingRecentUsage) { _logger.LogDebug( "Recent FilamentUsage record exists for printer {PrinterName} — skipping to avoid double-counting", printer.Name); return; } // Create a FilamentUsage entity for the consumption var filamentUsage = new FilamentUsage { SpoolId = defaultSpool.Id, PrinterId = printer.Id, GramsUsed = gramsDerived, MmExtruded = printStats.FilamentUsedMm, RecordedAt = DateTime.UtcNow, Notes = $"Auto-recorded from Moonraker print stats (state: {printStats.State})" }; // If there's a matching print job, link it var matchingJob = FindMatchingPrintJob(printer, printStats); if (matchingJob != null) { filamentUsage.PrintJobId = matchingJob.Id; filamentUsage.PrintJob = matchingJob; } _dbContext.FilamentUsages.Add(filamentUsage); // Also persist to UsageLog via the usage logging service try { await _usageLogService.RecordUsageAsync( spoolId: defaultSpool.Id, gramsUsed: gramsDerived, dataSource: DataSource.Moonraker, printerId: printer.Id, printJobId: matchingJob?.Id, mmExtruded: printStats.FilamentUsedMm, notes: $"Auto-recorded from Moonraker print stats (state: {printStats.State})"); _logger.LogInformation( "Persisted usage log: {Grams:F1}g / {Mm:F1}mm for spool {SpoolSerial} on printer {PrinterName}", gramsDerived, printStats.FilamentUsedMm, defaultSpool.SpoolSerial, printer.Name); } catch (Exception ex) { _logger.LogError(ex, "Failed to persist usage log for printer {PrinterName} — FilamentUsage entity was still created", printer.Name); } } /// /// Updates spool remaining weights based on usage data received from Moonraker. /// For printers with AMS units, updates the remaining weight on each slot's spool. /// private void UpdateSpoolWeights( Printer printer, Dictionary usageData) { // Update AMS slot remaining weights if available foreach (var amsUnit in printer.AmsUnits) { foreach (var slot in amsUnit.Slots) { if (slot.Spool != null && slot.RemainingWeightG.HasValue) { // Sync the AMS-reported remaining weight to the spool slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value; _logger.LogDebug( "Updated spool {SpoolSerial} remaining weight to {Weight}g from AMS data", slot.Spool.SpoolSerial, slot.RemainingWeightG.Value); } } } // If usage data contains extruded mm, log it for observability if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0) { _logger.LogInformation( "Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job", printer.Name, mmExtruded); } } /// /// Finds the default spool for a printer. Returns the first active, non-archived spool /// loaded in an AMS slot, or null if no spool is available. /// private static Spool? FindDefaultSpool(Printer printer) { 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; } /// /// Finds a PrintJob on this printer that matches the current print stats. /// Matches by filename and non-completed status to avoid double-linking. /// private PrintJob? FindMatchingPrintJob(Printer printer, MoonrakerPrintStats printStats) { if (string.IsNullOrEmpty(printStats.Filename)) return null; return printer.PrintJobs .FirstOrDefault(pj => pj.PrintName == printStats.Filename && pj.Status != JobStatus.Completed && pj.Status != JobStatus.Cancelled); } /// /// 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; } }