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/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs
new file mode 100644
index 0000000..6feff80
--- /dev/null
+++ b/backend/Domain/Interfaces/IMoonrakerClient.cs
@@ -0,0 +1,76 @@
+namespace Extrudex.Domain.Interfaces;
+
+///
+/// Client for communicating with Moonraker REST API on Klipper-based printers
+/// (e.g., Elegoo Centauri Carbon). Retrieves print job metadata including
+/// filament usage data.
+///
+public interface IMoonrakerClient
+{
+ ///
+ /// Retrieves the current printer status from Moonraker.
+ ///
+ /// Printer hostname or IP address.
+ /// Moonraker port (default: 7125).
+ /// Optional API key for authentication.
+ /// Cancellation token.
+ /// The printer status string (e.g., "idle", "printing", "paused", "error").
+ Task GetPrinterStatusAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves filament usage data from the current or most recent print job.
+ /// Moonraker exposes this via the /api/objects endpoint querying
+ /// "history" and "print_stats" objects.
+ ///
+ /// Printer hostname or IP address.
+ /// Moonraker port (default: 7125).
+ /// Optional API key for authentication.
+ /// Cancellation token.
+ /// Filament usage data from Moonraker, or null if unavailable.
+ Task GetFilamentUsageAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey = null,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Represents filament usage data retrieved from a Moonraker-equipped printer.
+/// Maps to Moonraker's print_stats and history objects.
+///
+public class MoonrakerFilamentUsage
+{
+ ///
+ /// Millimeters of filament extruded during the print job.
+ ///
+ public decimal MmExtruded { get; set; }
+
+ ///
+ /// The filename of the G-code file being or recently printed.
+ ///
+ public string? GcodeFileName { get; set; }
+
+ ///
+ /// Current print state from Moonraker (e.g., "printing", "complete", "error").
+ ///
+ public string PrintState { get; set; } = string.Empty;
+
+ ///
+ /// Total print time in seconds, if available from Moonraker.
+ ///
+ public double? PrintDurationSeconds { get; set; }
+
+ ///
+ /// Timestamp (UTC) when the print job was started, if available.
+ ///
+ public DateTime? StartedAt { get; set; }
+
+ ///
+ /// Timestamp (UTC) when the print job completed, if available.
+ ///
+ public DateTime? CompletedAt { get; set; }
+}
\ 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/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs
new file mode 100644
index 0000000..beb4ec3
--- /dev/null
+++ b/backend/Infrastructure/Services/MoonrakerClient.cs
@@ -0,0 +1,303 @@
+using System.Globalization;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Extrudex.Domain.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace Extrudex.Infrastructure.Services;
+
+///
+/// HTTP client for communicating with the Moonraker REST API on
+/// Klipper-based printers (e.g., Elegoo Centauri Carbon).
+///
+/// Moonraker endpoints used:
+/// - GET /api/objects?print_stats — current print stats including filament used
+/// - GET /api/objects?history — job history with filament usage per job
+///
+/// Authentication: optional X-Api-Key header when API key is configured.
+///
+public class MoonrakerClient : IMoonrakerClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new MoonrakerClient with the specified HTTP client and logger.
+ ///
+ /// The HTTP client used for API calls.
+ /// Logger for diagnostic output.
+ public MoonrakerClient(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ ///
+ public async Task GetPrinterStatusAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey = null,
+ CancellationToken cancellationToken = default)
+ {
+ var baseUrl = BuildBaseUrl(hostnameOrIp, port);
+ var requestUrl = $"{baseUrl}/api/objects?print_stats";
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
+ ApplyApiKey(request, apiKey);
+
+ try
+ {
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var doc = JsonDocument.Parse(json);
+
+ // Moonraker returns: { "result": { "print_stats": { "state": "idle", ... } } }
+ var state = doc.RootElement
+ .GetProperty("result")
+ .GetProperty("print_stats")
+ .GetProperty("state")
+ .GetString();
+
+ return state ?? "unknown";
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex,
+ "Moonraker printer status request failed for {Host}:{Port}",
+ hostnameOrIp, port);
+ return "offline";
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex,
+ "Malformed Moonraker response from {Host}:{Port}",
+ hostnameOrIp, port);
+ return "error";
+ }
+ }
+
+ ///
+ public async Task GetFilamentUsageAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey = null,
+ CancellationToken cancellationToken = default)
+ {
+ var baseUrl = BuildBaseUrl(hostnameOrIp, port);
+
+ // Fetch current print_stats (has live filament_used for active/recent job)
+ PrintStatsResult? printStats = null;
+ try
+ {
+ printStats = await FetchPrintStatsAsync(baseUrl, apiKey, cancellationToken);
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex,
+ "Moonraker print_stats request failed for {Host}:{Port}",
+ hostnameOrIp, port);
+ return null;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex,
+ "Malformed Moonraker print_stats response from {Host}:{Port}",
+ hostnameOrIp, port);
+ return null;
+ }
+
+ if (printStats is null)
+ return null;
+
+ // Attempt to enrich with history data for timing info
+ HistoryResult? history = null;
+ try
+ {
+ history = await FetchHistoryAsync(baseUrl, apiKey, cancellationToken);
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex,
+ "Moonraker history request failed for {Host}:{Port}",
+ hostnameOrIp, port);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex,
+ "Malformed Moonraker history response from {Host}:{Port}",
+ hostnameOrIp, port);
+ }
+
+ DateTime? startedAt = null;
+ DateTime? completedAt = null;
+ double? printDurationSeconds = null;
+
+ if (history is not null)
+ {
+ startedAt = history.StartTime;
+ completedAt = history.EndTime;
+ printDurationSeconds = history.PrintDuration;
+ }
+
+ return new MoonrakerFilamentUsage
+ {
+ MmExtruded = printStats.FilamentUsedMm ?? 0m,
+ GcodeFileName = printStats.FileName,
+ PrintState = printStats.State ?? "unknown",
+ PrintDurationSeconds = printDurationSeconds,
+ StartedAt = startedAt,
+ CompletedAt = completedAt
+ };
+ }
+
+ ///
+ /// Fetches and parses print_stats from the Moonraker API.
+ ///
+ private async Task FetchPrintStatsAsync(
+ string baseUrl,
+ string? apiKey,
+ CancellationToken cancellationToken)
+ {
+ var requestUrl = $"{baseUrl}/api/objects?print_stats";
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
+ ApplyApiKey(request, apiKey);
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var doc = JsonDocument.Parse(json);
+
+ if (!doc.RootElement.TryGetProperty("result", out var result) ||
+ !result.TryGetProperty("print_stats", out var stats))
+ {
+ _logger.LogWarning("Moonraker response missing 'print_stats' object");
+ return null;
+ }
+
+ return new PrintStatsResult
+ {
+ State = stats.TryGetProperty("state", out var stateEl) ? stateEl.GetString() : null,
+ FilamentUsedMm = stats.TryGetProperty("filament_used", out var filamentEl) &&
+ filamentEl.ValueKind == JsonValueKind.Number
+ ? filamentEl.GetDecimal() : (decimal?)null,
+ FileName = stats.TryGetProperty("filename", out var fileEl) ? fileEl.GetString() : null
+ };
+ }
+
+ ///
+ /// Fetches and parses history (last job) from the Moonraker API.
+ ///
+ private async Task FetchHistoryAsync(
+ string baseUrl,
+ string? apiKey,
+ CancellationToken cancellationToken)
+ {
+ var requestUrl = $"{baseUrl}/api/objects?history";
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
+ ApplyApiKey(request, apiKey);
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var doc = JsonDocument.Parse(json);
+
+ if (!doc.RootElement.TryGetProperty("result", out var result) ||
+ !result.TryGetProperty("history", out var history))
+ {
+ _logger.LogWarning("Moonraker response missing 'history' object");
+ return null;
+ }
+
+ // Try last_job first, then job
+ JsonElement jobEl;
+ if (!history.TryGetProperty("last_job", out jobEl) &&
+ !history.TryGetProperty("job", out jobEl))
+ {
+ _logger.LogDebug("Moonraker history has no 'last_job' or 'job' entry");
+ return null;
+ }
+
+ return new HistoryResult
+ {
+ StartTime = ParseDateTimeProperty(jobEl, "start_time"),
+ EndTime = ParseDateTimeProperty(jobEl, "end_time"),
+ PrintDuration = jobEl.TryGetProperty("print_duration", out var durEl) &&
+ durEl.ValueKind == JsonValueKind.Number
+ ? durEl.GetDouble() : (double?)null
+ };
+ }
+
+ ///
+ /// Parses a Moonraker timestamp property (Unix epoch seconds or ISO 8601 string).
+ ///
+ private static DateTime? ParseDateTimeProperty(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var prop))
+ return null;
+
+ // Moonraker returns Unix epoch seconds as a number
+ if (prop.ValueKind == JsonValueKind.Number)
+ {
+ var epochSeconds = prop.GetDouble();
+ return DateTime.UnixEpoch.AddSeconds(epochSeconds);
+ }
+
+ // Fallback: try parsing as ISO 8601 string
+ if (prop.ValueKind == JsonValueKind.String)
+ {
+ var str = prop.GetString();
+ if (str is not null &&
+ DateTime.TryParse(str, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal, out var dt))
+ {
+ return dt.ToUniversalTime();
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Builds the base URL for Moonraker API calls.
+ ///
+ private static string BuildBaseUrl(string hostnameOrIp, int port) =>
+ $"http://{hostnameOrIp}:{port}";
+
+ ///
+ /// Applies the Moonraker API key to the request header if provided.
+ ///
+ private static void ApplyApiKey(HttpRequestMessage request, string? apiKey)
+ {
+ if (!string.IsNullOrWhiteSpace(apiKey))
+ {
+ request.Headers.Add("X-Api-Key", apiKey);
+ }
+ }
+
+ ///
+ /// Parsed result from Moonraker's print_stats object.
+ /// Extracted immediately from the JSON response to avoid JsonDocument disposal issues.
+ ///
+ private sealed class PrintStatsResult
+ {
+ public string? State { get; set; }
+ public decimal? FilamentUsedMm { get; set; }
+ public string? FileName { get; set; }
+ }
+
+ ///
+ /// Parsed result from Moonraker's history/last_job object.
+ ///
+ private sealed class HistoryResult
+ {
+ public DateTime? StartTime { get; set; }
+ public DateTime? EndTime { get; set; }
+ public double? PrintDuration { get; set; }
+ }
+}
\ 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..e762358
--- /dev/null
+++ b/backend/Infrastructure/Services/MoonrakerUsagePoller.cs
@@ -0,0 +1,431 @@
+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 .
+/// - 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.");
+ }
+
+ ///
+ /// Polls all active Moonraker printers for filament usage data
+ /// and persists any completed print usage records.
+ ///
+ 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();
+
+ // Get all active Moonraker printers
+ 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);
+ }
+ }
+
+ ///
+ /// Polls a single Moonraker printer for filament usage data.
+ /// If a completed print job is detected with usage data, it is persisted.
+ ///
+ 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
+ {
+ // Update last-seen timestamp regardless of usage data
+ var usageData = await moonrakerClient.GetFilamentUsageAsync(
+ printer.HostnameOrIp,
+ printer.Port,
+ printer.ApiKey,
+ cancellationToken);
+
+ if (usageData is null)
+ {
+ _logger.LogDebug(
+ "No filament usage data from printer {PrinterName}.",
+ printer.Name);
+ return;
+ }
+
+ // Update printer last-seen timestamp
+ printer.LastSeenAt = DateTime.UtcNow;
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ _logger.LogDebug(
+ "Printer {PrinterName}: state={State}, mm={Mm}, file={File}",
+ printer.Name, usageData.PrintState, usageData.MmExtruded,
+ usageData.GcodeFileName);
+
+ // Only record usage for completed prints
+ if (usageData.MmExtruded <= 0)
+ {
+ _logger.LogDebug(
+ "Printer {PrinterName} has no filament usage to record.",
+ printer.Name);
+ return;
+ }
+
+ if (!IsCompleteState(usageData.PrintState))
+ {
+ _logger.LogDebug(
+ "Printer {PrinterName} print state '{State}' is not complete; skipping.",
+ printer.Name, usageData.PrintState);
+ return;
+ }
+
+ // Deduplicate: avoid recording the same completed job twice
+ var deduplicationKey = $"{printer.Id}:{usageData.GcodeFileName}";
+ if (_recordedJobs.Contains(deduplicationKey))
+ {
+ _logger.LogDebug(
+ "Printer {PrinterName} job '{File}' already recorded; skipping.",
+ printer.Name, usageData.GcodeFileName);
+ return;
+ }
+
+ // Find or create a PrintJob for this usage
+ var printJob = await FindOrCreatePrintJobAsync(
+ dbContext, printer, usageData, cancellationToken);
+
+ if (printJob is null)
+ {
+ _logger.LogWarning(
+ "Could not find or create print job for printer {PrinterName}. " +
+ "No active spool found.",
+ printer.Name);
+ return;
+ }
+
+ // Calculate grams from mm extruded using spool properties
+ var spool = await dbContext.Spools.FindAsync(
+ new object[] { printJob.SpoolId }, cancellationToken);
+
+ var gramsUsed = CalculateGramsUsed(usageData.MmExtruded, spool);
+
+ await usageService.RecordUsageAsync(
+ printJobId: printJob.Id,
+ spoolId: printJob.SpoolId,
+ printerId: printer.Id,
+ gramsUsed: gramsUsed,
+ mmExtruded: usageData.MmExtruded,
+ notes: $"Moonraker auto-recorded: {usageData.GcodeFileName}",
+ cancellationToken: cancellationToken);
+
+ // Mark job as recorded to prevent duplicates
+ _recordedJobs.Add(deduplicationKey);
+
+ _logger.LogInformation(
+ "Recorded Moonraker usage for printer {PrinterName}: " +
+ "{Mm}mm / {Grams}g, job '{File}'",
+ printer.Name, usageData.MmExtruded, gramsUsed,
+ usageData.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)
+ {
+ // Shutdown requested — rethrow to exit the poll loop
+ 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);
+ }
+ }
+
+ ///
+ /// Determines if a Moonraker print state indicates a completed job
+ /// that should have its usage recorded.
+ ///
+ private static bool IsCompleteState(string state) =>
+ state.Equals("complete", StringComparison.OrdinalIgnoreCase) ||
+ state.Equals("completed", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Finds an existing PrintJob for the current g-code file on this printer,
+ /// or creates a new one. Returns null if no spool is available.
+ ///
+ private async Task FindOrCreatePrintJobAsync(
+ ExtrudexDbContext dbContext,
+ Printer printer,
+ MoonrakerFilamentUsage usageData,
+ CancellationToken cancellationToken)
+ {
+ // Try to find an existing print job for this g-code file on this printer
+ if (!string.IsNullOrEmpty(usageData.GcodeFileName))
+ {
+ var existingJob = await dbContext.PrintJobs
+ .Where(j => j.PrinterId == printer.Id &&
+ j.GcodeFilePath == usageData.GcodeFileName &&
+ j.DataSource == DataSource.Moonraker &&
+ j.Status != JobStatus.Cancelled)
+ .OrderByDescending(j => j.CreatedAt)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (existingJob is not null)
+ {
+ // Update the existing job with completion data
+ existingJob.MmExtruded = usageData.MmExtruded;
+ existingJob.GramsDerived = CalculateGramsUsed(
+ usageData.MmExtruded,
+ await dbContext.Spools.FindAsync(
+ new object[] { existingJob.SpoolId }, cancellationToken));
+ existingJob.Status = JobStatus.Completed;
+ existingJob.CompletedAt = usageData.CompletedAt ?? DateTime.UtcNow;
+ existingJob.StartedAt ??= usageData.StartedAt;
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ return existingJob;
+ }
+ }
+
+ // No existing job — find the first active spool for this printer
+ // via AMS slots, or fall back to any active spool
+ var spool = await FindActiveSpoolForPrinterAsync(dbContext, printer, cancellationToken);
+
+ if (spool is null)
+ {
+ return null;
+ }
+
+ var gramsDerived = CalculateGramsUsed(usageData.MmExtruded, spool);
+
+ var newJob = new PrintJob
+ {
+ PrinterId = printer.Id,
+ SpoolId = spool.Id,
+ PrintName = usageData.GcodeFileName ?? "Moonraker Print",
+ GcodeFilePath = usageData.GcodeFileName,
+ MmExtruded = usageData.MmExtruded,
+ GramsDerived = gramsDerived,
+ FilamentDiameterAtPrintMm = spool.FilamentDiameterMm,
+ MaterialDensityAtPrint = GetMaterialDensity(spool),
+ DataSource = DataSource.Moonraker,
+ Status = JobStatus.Completed,
+ StartedAt = usageData.StartedAt ?? DateTime.UtcNow,
+ CompletedAt = usageData.CompletedAt ?? DateTime.UtcNow,
+ Notes = "Auto-created by Moonraker usage poller"
+ };
+
+ dbContext.PrintJobs.Add(newJob);
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ return newJob;
+ }
+
+ ///
+ /// Finds an active spool associated with the printer via AMS slots,
+ /// or falls back to any active spool in the system.
+ ///
+ private static async Task FindActiveSpoolForPrinterAsync(
+ ExtrudexDbContext dbContext,
+ Printer printer,
+ CancellationToken cancellationToken)
+ {
+ // Try to find a spool loaded in the printer's AMS
+ 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;
+
+ // Fallback: any active spool (for non-AMS printers)
+ return await dbContext.Spools
+ .Include(s => s.MaterialBase)
+ .Where(s => s.IsActive)
+ .OrderByDescending(s => s.WeightRemainingGrams)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ /// Calculates grams used from mm extruded using the spool's filament
+ /// diameter and the material density.
+ /// Formula: grams = mm × π × (diameter/2)² × density
+ /// Where density is in g/cm³, diameter in mm, giving grams.
+ ///
+ private static decimal CalculateGramsUsed(decimal mmExtruded, Spool? spool)
+ {
+ if (spool is null)
+ return 0m;
+
+ var diameterMm = spool.FilamentDiameterMm;
+ var densityGcm3 = GetMaterialDensity(spool);
+
+ // Cross-section area (mm²) = π × (diameter/2)²
+ var radiusMm = diameterMm / 2m;
+ var crossSectionArea = Math.PI * (double)radiusMm * (double)radiusMm;
+
+ // Volume (mm³) = mm_extruded × cross_section_area
+ // Convert mm³ to cm³: 1 cm³ = 1000 mm³
+ // Weight (g) = volume_cm³ × density (g/cm³)
+ var volumeMm3 = (double)mmExtruded * crossSectionArea;
+ var volumeCm3 = volumeMm3 / 1000.0;
+ var grams = volumeCm3 * (double)densityGcm3;
+
+ return Math.Round((decimal)grams, 2);
+ }
+
+ ///
+ /// Returns the material density for the spool's material base.
+ /// Falls back to 1.24 g/cm³ (typical PLA density) if not available.
+ ///
+ private static decimal GetMaterialDensity(Spool? spool)
+ {
+ // Standard material densities (g/cm³)
+ // These would ideally come from the MaterialBase entity,
+ // but we use sensible defaults for the initial integration.
+ 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 // Default to PLA density
+ };
+ }
+}
\ No newline at end of file
diff --git a/backend/Program.cs b/backend/Program.cs
index adcb0d6..992585e 100644
--- a/backend/Program.cs
+++ b/backend/Program.cs
@@ -1,4 +1,5 @@
using System.Reflection;
+using System.Net.Http.Headers;
using Extrudex.API.Filters;
using Extrudex.API.Hubs;
using Extrudex.Domain.Interfaces;
@@ -50,6 +51,23 @@ builder.Services.AddSwaggerGen(c =>
// ── QR Code Generation ──────────────────────────────────────
builder.Services.AddSingleton();
+// ── Filament Usage Service ──────────────────────────────────
+builder.Services.AddScoped();
+
+// ── Moonraker Client ───────────────────────────────────────
+// Named HttpClient for Moonraker API calls with configurable timeout.
+// Poller timeout is driven by MoonrakerPollerOptions.RequestTimeout.
+builder.Services.AddHttpClient(client =>
+{
+ client.DefaultRequestHeaders.Accept.Add(
+ new MediaTypeWithQualityHeaderValue("application/json"));
+});
+
+// ── 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 d924e27..4f8a225 100644
--- a/backend/appsettings.json
+++ b/backend/appsettings.json
@@ -9,5 +9,10 @@
"AllowedHosts": "*",
"ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme"
+ },
+ "MoonrakerPoller": {
+ "Enabled": true,
+ "PollInterval": "00:00:30",
+ "RequestTimeout": "00:00:10"
}
}
\ No newline at end of file