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