From 6e0ca7f4258c5454b6bb464440ad9b960e7e9575 Mon Sep 17 00:00:00 2001 From: Dex Date: Wed, 29 Apr 2026 13:13:12 -0400 Subject: [PATCH] CUB-33: Integrate Moonraker filament usage polling with UsageLog persistence --- backend/API/Jobs/FilamentUsageSyncJob.cs | 20 +- backend/API/Jobs/MoonrakerPrinterSyncJob.cs | 13 +- .../Services/FilamentUsageSyncService.cs | 263 +++++++++++++++--- .../Services/MoonrakerPrinterSyncService.cs | 2 +- 4 files changed, 255 insertions(+), 43 deletions(-) diff --git a/backend/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs index 19c991d..9be13b6 100644 --- a/backend/API/Jobs/FilamentUsageSyncJob.cs +++ b/backend/API/Jobs/FilamentUsageSyncJob.cs @@ -1,5 +1,6 @@ using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,25 +16,30 @@ namespace Extrudex.API.Jobs; /// Configuration is bound from the "FilamentUsageSync" section in /// appsettings.json. Set Enabled=false to disable without removing /// the service registration. +/// +/// Uses an IServiceScopeFactory to resolve scoped dependencies +/// (IFilamentUsageSyncService, IUsageLogService) on each sync cycle, +/// avoiding captive-dependency issues from injecting scoped services +/// into the singleton BackgroundService lifetime. /// public class FilamentUsageSyncJob : BackgroundService { - private readonly IFilamentUsageSyncService _syncService; + private readonly IServiceScopeFactory _scopeFactory; private readonly FilamentUsageSyncOptions _options; private readonly ILogger _logger; /// /// Creates a new FilamentUsageSyncJob. /// - /// The service that performs the actual sync logic. + /// Factory for creating DI scopes to resolve scoped services. /// Configuration options for polling interval and timeouts. /// Logger for diagnostic output. public FilamentUsageSyncJob( - IFilamentUsageSyncService syncService, + IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) { - _syncService = syncService; + _scopeFactory = scopeFactory; _options = options.Value; _logger = logger; } @@ -57,8 +63,10 @@ public class FilamentUsageSyncJob : BackgroundService while (!stoppingToken.IsCancellationRequested) { try - { - var syncedCount = await _syncService.SyncAllAsync(stoppingToken); + { + using var scope = _scopeFactory.CreateScope(); + var syncService = scope.ServiceProvider.GetRequiredService(); + var syncedCount = await syncService.SyncAllAsync(stoppingToken); _logger.LogInformation( "Filament usage sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}", diff --git a/backend/API/Jobs/MoonrakerPrinterSyncJob.cs b/backend/API/Jobs/MoonrakerPrinterSyncJob.cs index f227c36..10a6a53 100644 --- a/backend/API/Jobs/MoonrakerPrinterSyncJob.cs +++ b/backend/API/Jobs/MoonrakerPrinterSyncJob.cs @@ -1,5 +1,6 @@ using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -19,22 +20,22 @@ namespace Extrudex.API.Jobs; /// public class MoonrakerPrinterSyncJob : BackgroundService { - private readonly IMoonrakerPrinterSyncService _syncService; + private readonly IServiceScopeFactory _scopeFactory; private readonly MoonrakerPrinterSyncOptions _options; private readonly ILogger _logger; /// /// Creates a new MoonrakerPrinterSyncJob. /// - /// The service that performs the actual sync logic. + /// Factory for creating DI scopes to resolve scoped services. /// Configuration options for polling interval and timeouts. /// Logger for diagnostic output. public MoonrakerPrinterSyncJob( - IMoonrakerPrinterSyncService syncService, + IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) { - _syncService = syncService; + _scopeFactory = scopeFactory; _options = options.Value; _logger = logger; } @@ -59,7 +60,9 @@ public class MoonrakerPrinterSyncJob : BackgroundService { try { - var syncedCount = await _syncService.SyncAllAsync(stoppingToken); + using var scope = _scopeFactory.CreateScope(); + var syncService = scope.ServiceProvider.GetRequiredService(); + var syncedCount = await syncService.SyncAllAsync(stoppingToken); _logger.LogInformation( "Moonraker printer sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}", diff --git a/backend/Infrastructure/Services/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs index c2e305b..679de6e 100644 --- a/backend/Infrastructure/Services/FilamentUsageSyncService.cs +++ b/backend/Infrastructure/Services/FilamentUsageSyncService.cs @@ -1,21 +1,24 @@ +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.Configuration; +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, and updates spool remaining weights and -/// print job records. +/// 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; /// @@ -23,14 +26,17 @@ public class FilamentUsageSyncService : IFilamentUsageSyncService /// /// 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; } @@ -43,7 +49,9 @@ public class FilamentUsageSyncService : IFilamentUsageSyncService .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) .Include(p => p.AmsUnits) .ThenInclude(u => u.Slots) - .ThenInclude(s => s.Spool) + .ThenInclude(s => s.Spool!) + .ThenInclude(s => s.MaterialBase) + .Include(p => p.PrintJobs) .ToListAsync(cancellationToken); if (printers.Count == 0) @@ -60,33 +68,18 @@ public class FilamentUsageSyncService : IFilamentUsageSyncService { try { - var usageData = await _moonrakerClient.GetFilamentUsageAsync( - printer.HostnameOrIp, - printer.Port, - printer.ApiKey, - cancellationToken); - - if (usageData.Count == 0) - { - _logger.LogWarning( - "No usage data returned from printer {PrinterName} ({Host}:{Port})", - printer.Name, printer.HostnameOrIp, printer.Port); - continue; - } - - // Update spool remaining weights from AMS data - UpdateSpoolWeights(printer, usageData); - - // Mark printer as seen and idle (reachable = idle, not printing) - printer.LastSeenAt = DateTime.UtcNow; - printer.Status = PrinterStatus.Idle; - + await SyncPrinterAsync(printer, cancellationToken); syncedCount++; - _logger.LogInformation( - "Successfully synced filament usage from printer {PrinterName}", - printer.Name); } - catch (Exception ex) + 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})", @@ -103,12 +96,168 @@ public class FilamentUsageSyncService : IFilamentUsageSyncService 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( - Domain.Entities.Printer printer, + Printer printer, Dictionary usageData) { // Update AMS slot remaining weights if available @@ -122,7 +271,7 @@ public class FilamentUsageSyncService : IFilamentUsageSyncService slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value; _logger.LogDebug( - "Updated spool {SpoolSerial} remaining weight to {Weight}g", + "Updated spool {SpoolSerial} remaining weight to {Weight}g from AMS data", slot.Spool.SpoolSerial, slot.RemainingWeightG.Value); } } @@ -136,4 +285,56 @@ public class FilamentUsageSyncService : IFilamentUsageSyncService 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; + } } \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs b/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs index 48c20a2..5e1ebf1 100644 --- a/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs +++ b/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs @@ -46,7 +46,7 @@ public class MoonrakerPrinterSyncService : IMoonrakerPrinterSyncService .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) .Include(p => p.AmsUnits) .ThenInclude(u => u.Slots) - .ThenInclude(s => s.Spool) + .ThenInclude(s => s.Spool!) .ThenInclude(s => s.MaterialBase) .Include(p => p.PrintJobs) .ToListAsync(cancellationToken);