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; using Microsoft.Extensions.Options; namespace Extrudex.Infrastructure.Services; /// /// Configuration options for the Moonraker usage polling service. /// public class MoonrakerPollerOptions { /// /// How often to poll each Moonraker printer for filament usage data. /// Default: 30 seconds. /// public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(30); /// /// Timeout for individual Moonraker HTTP requests. /// Default: 10 seconds. /// public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10); /// /// Whether the polling service is enabled. Default: true. /// Set to false to disable polling (e.g., in development or testing). /// public bool Enabled { get; set; } = true; } /// /// Background service that periodically polls Moonraker-connected printers /// for filament usage data. When a print job is detected as complete, /// the usage data is persisted to the FilamentUsage table via /// . /// /// Polling logic: /// /// Query the database for all active printers with ConnectionType == Moonraker. /// For each printer, call for live data /// and for completed job history. /// If usage data is available and the print state is "complete", /// create or update a FilamentUsage record. /// If the printer is unreachable or returns malformed data, log a warning /// and continue to the next printer (no crash). /// /// /// Error handling: /// /// API unreachable: logged as warning, poller continues for other printers. /// Malformed response: logged as warning, poller continues. /// Database errors: logged as error, poller continues. /// /// public class MoonrakerUsagePoller : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly MoonrakerPollerOptions _options; /// /// Tracks which Moonraker print jobs have already been recorded, /// keyed by "printerId:gcodeFileName" to avoid duplicate recording. /// private readonly HashSet _recordedJobs = new(); public MoonrakerUsagePoller( IServiceScopeFactory scopeFactory, ILogger logger, IOptions options) { _scopeFactory = scopeFactory; _logger = logger; _options = options.Value; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (!_options.Enabled) { _logger.LogInformation("Moonraker usage poller is disabled via configuration."); return; } _logger.LogInformation( "Moonraker usage poller starting. Poll interval: {Interval}", _options.PollInterval); while (!stoppingToken.IsCancellationRequested) { try { await PollAllPrintersAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error in Moonraker usage poller cycle. Continuing."); } await Task.Delay(_options.PollInterval, stoppingToken); } _logger.LogInformation("Moonraker usage poller stopping."); } private async Task PollAllPrintersAsync(CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var moonrakerClient = scope.ServiceProvider.GetRequiredService(); var usageService = scope.ServiceProvider.GetRequiredService(); var printers = await dbContext.Printers .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) .ToListAsync(cancellationToken); if (printers.Count == 0) { _logger.LogDebug("No active Moonraker printers found."); return; } _logger.LogDebug("Polling {Count} Moonraker printer(s).", printers.Count); foreach (var printer in printers) { await PollPrinterAsync( printer, moonrakerClient, usageService, dbContext, cancellationToken); } } private async Task PollPrinterAsync( Printer printer, IMoonrakerClient moonrakerClient, IFilamentUsageService usageService, ExtrudexDbContext dbContext, CancellationToken cancellationToken) { _logger.LogDebug( "Polling Moonraker printer {PrinterName} ({Host}:{Port})", printer.Name, printer.HostnameOrIp, printer.Port); try { var printStats = await moonrakerClient.GetPrintStatsAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken); if (printStats is null) { _logger.LogDebug( "No print stats available from printer {PrinterName}.", printer.Name); return; } printer.LastSeenAt = DateTime.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); _logger.LogDebug( "Printer {PrinterName}: state={State}, filament={Mm}mm, file={File}", printer.Name, printStats.State, printStats.FilamentUsedMm, printStats.Filename); decimal mmExtruded = printStats.FilamentUsedMm; if (mmExtruded <= 0) { _logger.LogDebug( "Printer {PrinterName} has no filament usage to record.", printer.Name); return; } if (!IsCompleteState(printStats.State)) { _logger.LogDebug( "Printer {PrinterName} print state '{State}' is not complete; skipping.", printer.Name, printStats.State); return; } string gcodeFileName = printStats.Filename ?? $"unknown-{Guid.NewGuid():N}"; var deduplicationKey = $"{printer.Id}:{gcodeFileName}"; if (_recordedJobs.Contains(deduplicationKey)) { _logger.LogDebug( "Printer {PrinterName} job '{File}' already recorded; skipping.", printer.Name, gcodeFileName); return; } DateTime? startedAt = null; DateTime? completedAt = null; try { var history = await moonrakerClient.GetPrintHistoryAsync( printer.HostnameOrIp, printer.Port, printer.ApiKey, limit: 1, cancellationToken); if (history.Items.Count > 0) { var latestJob = history.Items[0]; startedAt = latestJob.StartTime; completedAt = latestJob.EndTime; } } catch (Exception ex) { _logger.LogDebug(ex, "Could not fetch history for printer {PrinterName}; proceeding with stats only.", printer.Name); } var printJob = await FindOrCreatePrintJobAsync( dbContext, printer, mmExtruded, gcodeFileName, startedAt, completedAt, cancellationToken); if (printJob is null) { _logger.LogWarning( "Could not find or create print job for printer {PrinterName}. No active spool found.", printer.Name); return; } var spool = await dbContext.Spools.FindAsync( new object[] { printJob.SpoolId }, cancellationToken); var gramsUsed = CalculateGramsUsed(mmExtruded, spool); await usageService.RecordUsageAsync( printJobId: printJob.Id, spoolId: printJob.SpoolId, printerId: printer.Id, gramsUsed: gramsUsed, mmExtruded: mmExtruded, notes: $"Moonraker auto-recorded: {gcodeFileName}", cancellationToken: cancellationToken); _recordedJobs.Add(deduplicationKey); _logger.LogInformation( "Recorded Moonraker usage for printer {PrinterName}: {Mm}mm / {Grams}g, job '{File}'", printer.Name, mmExtruded, gramsUsed, gcodeFileName); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Moonraker API unreachable for printer {PrinterName} ({Host}:{Port}). Will retry next cycle.", printer.Name, printer.HostnameOrIp, printer.Port); } catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (TaskCanceledException ex) { _logger.LogWarning(ex, "Moonraker request timed out for printer {PrinterName} ({Host}:{Port}).", printer.Name, printer.HostnameOrIp, printer.Port); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error polling Moonraker printer {PrinterName}. Continuing to next printer.", printer.Name); } } private static bool IsCompleteState(string state) => state.Equals("complete", StringComparison.OrdinalIgnoreCase) || state.Equals("completed", StringComparison.OrdinalIgnoreCase); private async Task FindOrCreatePrintJobAsync( ExtrudexDbContext dbContext, Printer printer, decimal mmExtruded, string gcodeFileName, DateTime? startedAt, DateTime? completedAt, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(gcodeFileName)) { var existingJob = await dbContext.PrintJobs .Where(j => j.PrinterId == printer.Id && j.GcodeFilePath == gcodeFileName && j.DataSource == DataSource.Moonraker && j.Status != JobStatus.Cancelled) .OrderByDescending(j => j.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (existingJob is not null) { existingJob.MmExtruded = mmExtruded; existingJob.GramsDerived = CalculateGramsUsed( mmExtruded, await dbContext.Spools.FindAsync( new object[] { existingJob.SpoolId }, cancellationToken)); existingJob.Status = JobStatus.Completed; existingJob.CompletedAt = completedAt ?? DateTime.UtcNow; existingJob.StartedAt ??= startedAt; await dbContext.SaveChangesAsync(cancellationToken); return existingJob; } } var spool = await FindActiveSpoolForPrinterAsync(dbContext, printer, cancellationToken); if (spool is null) return null; var gramsDerived = CalculateGramsUsed(mmExtruded, spool); var newJob = new PrintJob { PrinterId = printer.Id, SpoolId = spool.Id, PrintName = gcodeFileName ?? "Moonraker Print", GcodeFilePath = gcodeFileName, MmExtruded = mmExtruded, GramsDerived = gramsDerived, FilamentDiameterAtPrintMm = spool.FilamentDiameterMm, MaterialDensityAtPrint = GetMaterialDensity(spool), DataSource = DataSource.Moonraker, Status = JobStatus.Completed, StartedAt = startedAt ?? DateTime.UtcNow, CompletedAt = completedAt ?? DateTime.UtcNow, Notes = "Auto-created by Moonraker usage poller" }; dbContext.PrintJobs.Add(newJob); await dbContext.SaveChangesAsync(cancellationToken); return newJob; } private static async Task FindActiveSpoolForPrinterAsync( ExtrudexDbContext dbContext, Printer printer, CancellationToken cancellationToken) { var amsSpool = await dbContext.AmsSlots .Include(s => s.Spool) .ThenInclude(s => s!.MaterialBase) .Include(s => s.AmsUnit) .Where(s => s.AmsUnit.PrinterId == printer.Id && s.Spool != null && s.Spool.IsActive) .Select(s => s.Spool) .FirstOrDefaultAsync(cancellationToken); if (amsSpool is not null) return amsSpool; return await dbContext.Spools .Include(s => s.MaterialBase) .Where(s => s.IsActive) .OrderByDescending(s => s.WeightRemainingGrams) .FirstOrDefaultAsync(cancellationToken); } private static decimal CalculateGramsUsed(decimal mmExtruded, Spool? spool) { if (spool is null) return 0m; var diameterMm = spool.FilamentDiameterMm; var densityGcm3 = GetMaterialDensity(spool); var radiusMm = diameterMm / 2m; var crossSectionArea = Math.PI * (double)radiusMm * (double)radiusMm; var volumeMm3 = (double)mmExtruded * crossSectionArea; var volumeCm3 = volumeMm3 / 1000.0; var grams = volumeCm3 * (double)densityGcm3; return Math.Round((decimal)grams, 2); } private static decimal GetMaterialDensity(Spool? spool) { return spool?.MaterialBase?.Name?.ToUpperInvariant() switch { "PLA" => 1.24m, "PETG" => 1.27m, "ABS" => 1.04m, "ASA" => 1.07m, "TPU" => 1.21m, "NYLON" or "PA" => 1.13m, "PC" => 1.20m, _ => 1.24m }; } }