diff --git a/backend/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs
new file mode 100644
index 0000000..19c991d
--- /dev/null
+++ b/backend/API/Jobs/FilamentUsageSyncJob.cs
@@ -0,0 +1,79 @@
+using Extrudex.Domain.Interfaces;
+using Extrudex.Infrastructure.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Extrudex.API.Jobs;
+
+///
+/// Background job that periodically syncs filament usage data from
+/// Moonraker printers. Runs as a hosted service and polls all active
+/// Moonraker printers on a configurable interval to persist usage
+/// data to the Extrudex database.
+///
+/// Configuration is bound from the "FilamentUsageSync" section in
+/// appsettings.json. Set Enabled=false to disable without removing
+/// the service registration.
+///
+public class FilamentUsageSyncJob : BackgroundService
+{
+ private readonly IFilamentUsageSyncService _syncService;
+ private readonly FilamentUsageSyncOptions _options;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new FilamentUsageSyncJob.
+ ///
+ /// The service that performs the actual sync logic.
+ /// Configuration options for polling interval and timeouts.
+ /// Logger for diagnostic output.
+ public FilamentUsageSyncJob(
+ IFilamentUsageSyncService syncService,
+ IOptions options,
+ ILogger logger)
+ {
+ _syncService = syncService;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!_options.Enabled)
+ {
+ _logger.LogInformation("Filament usage sync job is disabled via configuration — exiting");
+ return;
+ }
+
+ _logger.LogInformation(
+ "Filament usage sync job starting — polling every {Interval}",
+ _options.PollingInterval);
+
+ // Delay briefly on startup to allow the web host to fully initialize
+ await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var syncedCount = await _syncService.SyncAllAsync(stoppingToken);
+
+ _logger.LogInformation(
+ "Filament usage sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}",
+ syncedCount, _options.PollingInterval);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex,
+ "Error during filament usage sync cycle — will retry in {Interval}",
+ _options.PollingInterval);
+ }
+
+ await Task.Delay(_options.PollingInterval, stoppingToken);
+ }
+
+ _logger.LogInformation("Filament usage sync job shutting down");
+ }
+}
\ No newline at end of file
diff --git a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs
new file mode 100644
index 0000000..951f80d
--- /dev/null
+++ b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs
@@ -0,0 +1,19 @@
+namespace Extrudex.Domain.Interfaces;
+
+///
+/// Service interface for syncing filament usage data from printers
+/// into the Extrudex database. Handles querying Moonraker printers,
+/// computing derived usage metrics, and persisting updates to spools
+/// and print job records.
+///
+public interface IFilamentUsageSyncService
+{
+ ///
+ /// Performs a single sync cycle: queries all active Moonraker printers,
+ /// fetches their current filament usage data, and persists updates to
+ /// the database.
+ ///
+ /// Cancellation token for graceful shutdown.
+ /// The number of printers successfully synced.
+ Task SyncAllAsync(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..d4599f2
--- /dev/null
+++ b/backend/Domain/Interfaces/IMoonrakerClient.cs
@@ -0,0 +1,39 @@
+namespace Extrudex.Domain.Interfaces;
+
+///
+/// Client interface for communicating with Moonraker REST API endpoints
+/// on Klipper-based printers (e.g., Elegoo Centauri Carbon).
+/// Used to retrieve filament usage data, print job status, and
+/// remaining spool weight from the printer.
+///
+public interface IMoonrakerClient
+{
+ ///
+ /// Fetches the current filament usage data from the Moonraker server.
+ /// Returns a dictionary of usage metrics reported by the printer.
+ ///
+ /// The printer's hostname or IP address.
+ /// The Moonraker API port (default: 7125).
+ /// Optional API key for authentication.
+ /// Cancellation token for the HTTP request.
+ /// A dictionary of usage metric names to their decimal values.
+ Task> GetFilamentUsageAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks whether the Moonraker server is reachable and responding.
+ ///
+ /// The printer's hostname or IP address.
+ /// The Moonraker API port (default: 7125).
+ /// Optional API key for authentication.
+ /// Cancellation token for the HTTP request.
+ /// true if the server responded successfully; otherwise false.
+ Task IsReachableAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey,
+ CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs
new file mode 100644
index 0000000..29f95b2
--- /dev/null
+++ b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs
@@ -0,0 +1,33 @@
+namespace Extrudex.Infrastructure.Configuration;
+
+///
+/// Configuration options for the FilamentUsageSync background job.
+/// Bound from appsettings.json under the "FilamentUsageSync" section.
+/// Controls polling interval and per-printer timeout settings.
+///
+public class FilamentUsageSyncOptions
+{
+ ///
+ /// The section name in appsettings.json where these options are bound.
+ ///
+ public const string SectionName = "FilamentUsageSync";
+
+ ///
+ /// How often the background job polls printers for usage data.
+ /// Default: 5 minutes. Minimum recommended: 1 minute.
+ ///
+ public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Timeout for individual HTTP requests to a Moonraker printer.
+ /// Default: 30 seconds.
+ ///
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Whether the sync job is enabled. Set to false to disable
+ /// the background job without removing its registration.
+ /// Default: true.
+ ///
+ public bool Enabled { get; set; } = true;
+}
\ No newline at end of file
diff --git a/backend/Infrastructure/Services/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs
new file mode 100644
index 0000000..c2e305b
--- /dev/null
+++ b/backend/Infrastructure/Services/FilamentUsageSyncService.cs
@@ -0,0 +1,139 @@
+using Extrudex.Domain.Enums;
+using Extrudex.Domain.Interfaces;
+using Extrudex.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Extrudex.Infrastructure.Configuration;
+
+///
+/// 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.
+///
+public class FilamentUsageSyncService : IFilamentUsageSyncService
+{
+ private readonly ExtrudexDbContext _dbContext;
+ private readonly IMoonrakerClient _moonrakerClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new FilamentUsageSyncService.
+ ///
+ /// The EF Core database context for persisting updates.
+ /// The Moonraker HTTP client for fetching printer data.
+ /// Logger for diagnostic output.
+ public FilamentUsageSyncService(
+ ExtrudexDbContext dbContext,
+ IMoonrakerClient moonrakerClient,
+ ILogger logger)
+ {
+ _dbContext = dbContext;
+ _moonrakerClient = moonrakerClient;
+ _logger = logger;
+ }
+
+ ///
+ public async Task SyncAllAsync(CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Starting filament usage sync cycle");
+
+ var printers = await _dbContext.Printers
+ .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker)
+ .Include(p => p.AmsUnits)
+ .ThenInclude(u => u.Slots)
+ .ThenInclude(s => s.Spool)
+ .ToListAsync(cancellationToken);
+
+ if (printers.Count == 0)
+ {
+ _logger.LogInformation("No active Moonraker printers found — skipping sync");
+ return 0;
+ }
+
+ _logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count);
+
+ var syncedCount = 0;
+
+ foreach (var printer in printers)
+ {
+ 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;
+
+ syncedCount++;
+ _logger.LogInformation(
+ "Successfully synced filament usage from printer {PrinterName}",
+ printer.Name);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Error syncing filament usage from printer {PrinterName} ({Host}:{Port})",
+ printer.Name, printer.HostnameOrIp, printer.Port);
+ }
+ }
+
+ await _dbContext.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Filament usage sync cycle complete — {SyncedCount}/{TotalCount} printers synced",
+ syncedCount, printers.Count);
+
+ return syncedCount;
+ }
+
+ ///
+ /// 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,
+ Dictionary usageData)
+ {
+ // Update AMS slot remaining weights if available
+ foreach (var amsUnit in printer.AmsUnits)
+ {
+ foreach (var slot in amsUnit.Slots)
+ {
+ if (slot.Spool != null && slot.RemainingWeightG.HasValue)
+ {
+ // Sync the AMS-reported remaining weight to the spool
+ slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value;
+
+ _logger.LogDebug(
+ "Updated spool {SpoolSerial} remaining weight to {Weight}g",
+ slot.Spool.SpoolSerial, slot.RemainingWeightG.Value);
+ }
+ }
+ }
+
+ // If usage data contains extruded mm, log it for observability
+ if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0)
+ {
+ _logger.LogInformation(
+ "Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job",
+ printer.Name, mmExtruded);
+ }
+ }
+}
\ 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..1666dcf
--- /dev/null
+++ b/backend/Infrastructure/Services/MoonrakerClient.cs
@@ -0,0 +1,130 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Extrudex.Domain.Interfaces;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Extrudex.Infrastructure.Configuration;
+
+///
+/// HTTP client for communicating with Moonraker REST API endpoints
+/// on Klipper-based printers (e.g., Elegoo Centauri Carbon).
+/// Retrieves filament usage data and printer status information.
+///
+public class MoonrakerClient : IMoonrakerClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new MoonrakerClient with the configured HTTP client and logger.
+ ///
+ /// The HTTP client for making requests to Moonraker endpoints.
+ /// Logger for diagnostic output.
+ public MoonrakerClient(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ ///
+ public async Task> GetFilamentUsageAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey,
+ CancellationToken cancellationToken = default)
+ {
+ var baseUrl = BuildBaseUrl(hostnameOrIp, port);
+ var result = new Dictionary();
+
+ try
+ {
+ // Query Moonraker server info endpoint for filament usage data
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/history/items?limit=1");
+ if (!string.IsNullOrEmpty(apiKey))
+ {
+ request.Headers.Add("X-Api-Key", apiKey);
+ }
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+
+ // Extract filament usage from the response
+ // Moonraker returns job history with filament_used and similar fields
+ if (json.TryGetProperty("result", out var resultElement))
+ {
+ if (resultElement.TryGetProperty("items", out var items) && items.GetArrayLength() > 0)
+ {
+ var job = items[0];
+
+ // Moonraker tracks filament_used in millimeters
+ if (job.TryGetProperty("filament_used", out var filamentUsed))
+ {
+ result["mm_extruded"] = filamentUsed.GetDecimal();
+ }
+
+ // Total duration in seconds
+ if (job.TryGetProperty("print_duration", out var duration))
+ {
+ result["print_duration_seconds"] = duration.GetDecimal();
+ }
+ }
+ }
+
+ _logger.LogDebug(
+ "Retrieved filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics",
+ hostnameOrIp, port, result.Count);
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to retrieve filament usage from Moonraker at {Host}:{Port}",
+ hostnameOrIp, port);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to parse Moonraker response from {Host}:{Port}",
+ hostnameOrIp, port);
+ }
+
+ return result;
+ }
+
+ ///
+ public async Task IsReachableAsync(
+ string hostnameOrIp,
+ int port,
+ string? apiKey,
+ CancellationToken cancellationToken = default)
+ {
+ var baseUrl = BuildBaseUrl(hostnameOrIp, port);
+
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/info");
+ if (!string.IsNullOrEmpty(apiKey))
+ {
+ request.Headers.Add("X-Api-Key", apiKey);
+ }
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ return response.IsSuccessStatusCode;
+ }
+ catch (HttpRequestException)
+ {
+ _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port);
+ return false;
+ }
+ }
+
+ ///
+ /// Builds the base URL for Moonraker API calls from hostname and port.
+ ///
+ private static string BuildBaseUrl(string hostnameOrIp, int port)
+ {
+ return $"http://{hostnameOrIp}:{port}";
+ }
+}
\ No newline at end of file
diff --git a/backend/Program.cs b/backend/Program.cs
index adcb0d6..c166231 100644
--- a/backend/Program.cs
+++ b/backend/Program.cs
@@ -1,7 +1,9 @@
using System.Reflection;
using Extrudex.API.Filters;
using Extrudex.API.Hubs;
+using Extrudex.API.Jobs;
using Extrudex.Domain.Interfaces;
+using Extrudex.Infrastructure.Configuration;
using Extrudex.Infrastructure.Data;
using Extrudex.Infrastructure.Services;
using FluentValidation;
@@ -77,6 +79,16 @@ builder.Services.AddCors(options =>
// ── SignalR (real-time printer updates) ────────────────────
builder.Services.AddSignalR();
+// ── Filament Usage Sync (Background Job) ──────────────────
+builder.Services.Configure(
+ builder.Configuration.GetSection(FilamentUsageSyncOptions.SectionName));
+builder.Services.AddHttpClient(client =>
+{
+ client.DefaultRequestHeaders.Add("User-Agent", "Extrudex/1.0");
+});
+builder.Services.AddScoped();
+builder.Services.AddHostedService();
+
// ── Health Checks ───────────────────────────────────────────
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString);
diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json
index 8edfdd9..06130e3 100644
--- a/backend/appsettings.Development.json
+++ b/backend/appsettings.Development.json
@@ -8,5 +8,10 @@
},
"ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme"
+ },
+ "FilamentUsageSync": {
+ "PollingInterval": "00:01:00",
+ "RequestTimeout": "00:00:30",
+ "Enabled": true
}
}
\ No newline at end of file
diff --git a/backend/appsettings.json b/backend/appsettings.json
index d924e27..e5c747f 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"
+ },
+ "FilamentUsageSync": {
+ "PollingInterval": "00:05:00",
+ "RequestTimeout": "00:00:30",
+ "Enabled": true
}
}
\ No newline at end of file