From 9cd619b5eea7ba90724cb22b18830e80fde10ec5 Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 17:28:24 -0400 Subject: [PATCH] CUB-33: integrate Moonraker filament usage polling --- .../Interfaces/IFilamentUsageService.cs | 50 +++ .../Services/FilamentUsageService.cs | 79 ++++ .../Services/MoonrakerUsagePoller.cs | 390 ++++++++++++++++++ backend/Program.cs | 8 + backend/appsettings.json | 6 + 5 files changed, 533 insertions(+) create mode 100644 backend/Domain/Interfaces/IFilamentUsageService.cs create mode 100644 backend/Infrastructure/Services/FilamentUsageService.cs create mode 100644 backend/Infrastructure/Services/MoonrakerUsagePoller.cs diff --git a/backend/Domain/Interfaces/IFilamentUsageService.cs b/backend/Domain/Interfaces/IFilamentUsageService.cs new file mode 100644 index 0000000..1b3a63a --- /dev/null +++ b/backend/Domain/Interfaces/IFilamentUsageService.cs @@ -0,0 +1,50 @@ +using Extrudex.Domain.Entities; + +namespace Extrudex.Domain.Interfaces; + +/// +/// Service for persisting and querying filament usage records. +/// Tracks consumption per print job and per spool for COGS and inventory tracking. +/// +public interface IFilamentUsageService +{ + /// + /// Records a new filament usage entry for a print job. + /// + /// The print job that consumed the filament. + /// The spool that provided the filament. + /// The printer that executed the print. + /// Grams of filament consumed. + /// Millimeters of filament extruded. + /// Optional notes about this usage record. + /// Cancellation token. + /// The created FilamentUsage entity. + Task RecordUsageAsync( + Guid printJobId, + Guid spoolId, + Guid printerId, + decimal gramsUsed, + decimal mmExtruded, + string? notes = null, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all filament usage records for a specific print job. + /// + /// The print job ID. + /// Cancellation token. + /// Collection of filament usage records for the print job. + Task> GetByPrintJobAsync( + Guid printJobId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all filament usage records for a specific spool. + /// + /// The spool ID. + /// Cancellation token. + /// Collection of filament usage records for the spool. + Task> GetBySpoolAsync( + Guid spoolId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/FilamentUsageService.cs b/backend/Infrastructure/Services/FilamentUsageService.cs new file mode 100644 index 0000000..c4b95c8 --- /dev/null +++ b/backend/Infrastructure/Services/FilamentUsageService.cs @@ -0,0 +1,79 @@ +using Extrudex.Domain.Entities; +using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Services; + +/// +/// EF Core–backed implementation of the filament usage service. +/// Persists usage records to the database and provides query methods +/// for retrieving usage by print job or spool. +/// +public class FilamentUsageService : IFilamentUsageService +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + public FilamentUsageService( + ExtrudexDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + public async Task RecordUsageAsync( + Guid printJobId, + Guid spoolId, + Guid printerId, + decimal gramsUsed, + decimal mmExtruded, + string? notes = null, + CancellationToken cancellationToken = default) + { + var usage = new FilamentUsage + { + PrintJobId = printJobId, + SpoolId = spoolId, + PrinterId = printerId, + GramsUsed = gramsUsed, + MmExtruded = mmExtruded, + RecordedAt = DateTime.UtcNow, + Notes = notes + }; + + _dbContext.FilamentUsages.Add(usage); + await _dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Recorded filament usage: {Grams}g / {Mm}mm for print job {JobId} on spool {SpoolId}", + gramsUsed, mmExtruded, printJobId, spoolId); + + return usage; + } + + /// + public async Task> GetByPrintJobAsync( + Guid printJobId, + CancellationToken cancellationToken = default) + { + return await _dbContext.FilamentUsages + .Where(u => u.PrintJobId == printJobId) + .OrderByDescending(u => u.RecordedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetBySpoolAsync( + Guid spoolId, + CancellationToken cancellationToken = default) + { + return await _dbContext.FilamentUsages + .Where(u => u.SpoolId == spoolId) + .OrderByDescending(u => u.RecordedAt) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerUsagePoller.cs b/backend/Infrastructure/Services/MoonrakerUsagePoller.cs new file mode 100644 index 0000000..739bcbd --- /dev/null +++ b/backend/Infrastructure/Services/MoonrakerUsagePoller.cs @@ -0,0 +1,390 @@ +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 + }; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index d664d0c..4b01947 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -61,6 +61,14 @@ builder.Services.AddSingleton(); // ── Usage Logging ─────────────────────────────────────────── builder.Services.AddScoped(); +// ── Filament Usage Service ────────────────────────────────── +builder.Services.AddScoped(); + +// ── Moonraker Usage Poller (Background Service) ───────────── +builder.Services.Configure( + builder.Configuration.GetSection("MoonrakerPoller")); +builder.Services.AddHostedService(); + // ── FluentValidation ────────────────────────────────────── // Registers all validators from the API assembly into DI. builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); diff --git a/backend/appsettings.json b/backend/appsettings.json index a7c2fa0..00507a0 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -23,5 +23,11 @@ }, "FilamentAlerts": { "LowStockThresholdPercent": 20 + }, + "MoonrakerPoller": { + "Enabled": true, + "PollInterval": "00:00:30", + "RequestTimeout": "00:00:10" } +} } \ No newline at end of file