feat(CUB-39): create background job for filament usage sync
Some checks failed
Dev Build / build-test (pull_request) Failing after 48s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s

This commit is contained in:
2026-04-27 17:23:24 +00:00
parent 8168d25bdf
commit 4ba98966eb
9 changed files with 461 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class FilamentUsageSyncJob : BackgroundService
{
private readonly IFilamentUsageSyncService _syncService;
private readonly FilamentUsageSyncOptions _options;
private readonly ILogger<FilamentUsageSyncJob> _logger;
/// <summary>
/// Creates a new FilamentUsageSyncJob.
/// </summary>
/// <param name="syncService">The service that performs the actual sync logic.</param>
/// <param name="options">Configuration options for polling interval and timeouts.</param>
/// <param name="logger">Logger for diagnostic output.</param>
public FilamentUsageSyncJob(
IFilamentUsageSyncService syncService,
IOptions<FilamentUsageSyncOptions> options,
ILogger<FilamentUsageSyncJob> logger)
{
_syncService = syncService;
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
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");
}
}

View File

@@ -0,0 +1,19 @@
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IFilamentUsageSyncService
{
/// <summary>
/// Performs a single sync cycle: queries all active Moonraker printers,
/// fetches their current filament usage data, and persists updates to
/// the database.
/// </summary>
/// <param name="cancellationToken">Cancellation token for graceful shutdown.</param>
/// <returns>The number of printers successfully synced.</returns>
Task<int> SyncAllAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,39 @@
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IMoonrakerClient
{
/// <summary>
/// Fetches the current filament usage data from the Moonraker server.
/// Returns a dictionary of usage metrics reported by the printer.
/// </summary>
/// <param name="hostnameOrIp">The printer's hostname or IP address.</param>
/// <param name="port">The Moonraker API port (default: 7125).</param>
/// <param name="apiKey">Optional API key for authentication.</param>
/// <param name="cancellationToken">Cancellation token for the HTTP request.</param>
/// <returns>A dictionary of usage metric names to their decimal values.</returns>
Task<Dictionary<string, decimal>> GetFilamentUsageAsync(
string hostnameOrIp,
int port,
string? apiKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether the Moonraker server is reachable and responding.
/// </summary>
/// <param name="hostnameOrIp">The printer's hostname or IP address.</param>
/// <param name="port">The Moonraker API port (default: 7125).</param>
/// <param name="apiKey">Optional API key for authentication.</param>
/// <param name="cancellationToken">Cancellation token for the HTTP request.</param>
/// <returns><c>true</c> if the server responded successfully; otherwise <c>false</c>.</returns>
Task<bool> IsReachableAsync(
string hostnameOrIp,
int port,
string? apiKey,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,33 @@
namespace Extrudex.Infrastructure.Configuration;
/// <summary>
/// Configuration options for the FilamentUsageSync background job.
/// Bound from appsettings.json under the "FilamentUsageSync" section.
/// Controls polling interval and per-printer timeout settings.
/// </summary>
public class FilamentUsageSyncOptions
{
/// <summary>
/// The section name in appsettings.json where these options are bound.
/// </summary>
public const string SectionName = "FilamentUsageSync";
/// <summary>
/// How often the background job polls printers for usage data.
/// Default: 5 minutes. Minimum recommended: 1 minute.
/// </summary>
public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Timeout for individual HTTP requests to a Moonraker printer.
/// Default: 30 seconds.
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether the sync job is enabled. Set to false to disable
/// the background job without removing its registration.
/// Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class FilamentUsageSyncService : IFilamentUsageSyncService
{
private readonly ExtrudexDbContext _dbContext;
private readonly IMoonrakerClient _moonrakerClient;
private readonly ILogger<FilamentUsageSyncService> _logger;
/// <summary>
/// Creates a new FilamentUsageSyncService.
/// </summary>
/// <param name="dbContext">The EF Core database context for persisting updates.</param>
/// <param name="moonrakerClient">The Moonraker HTTP client for fetching printer data.</param>
/// <param name="logger">Logger for diagnostic output.</param>
public FilamentUsageSyncService(
ExtrudexDbContext dbContext,
IMoonrakerClient moonrakerClient,
ILogger<FilamentUsageSyncService> logger)
{
_dbContext = dbContext;
_moonrakerClient = moonrakerClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<int> 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;
}
/// <summary>
/// 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.
/// </summary>
private void UpdateSpoolWeights(
Domain.Entities.Printer printer,
Dictionary<string, decimal> 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);
}
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class MoonrakerClient : IMoonrakerClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<MoonrakerClient> _logger;
/// <summary>
/// Creates a new MoonrakerClient with the configured HTTP client and logger.
/// </summary>
/// <param name="httpClient">The HTTP client for making requests to Moonraker endpoints.</param>
/// <param name="logger">Logger for diagnostic output.</param>
public MoonrakerClient(HttpClient httpClient, ILogger<MoonrakerClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<Dictionary<string, decimal>> GetFilamentUsageAsync(
string hostnameOrIp,
int port,
string? apiKey,
CancellationToken cancellationToken = default)
{
var baseUrl = BuildBaseUrl(hostnameOrIp, port);
var result = new Dictionary<string, decimal>();
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<JsonElement>(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;
}
/// <inheritdoc />
public async Task<bool> 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;
}
}
/// <summary>
/// Builds the base URL for Moonraker API calls from hostname and port.
/// </summary>
private static string BuildBaseUrl(string hostnameOrIp, int port)
{
return $"http://{hostnameOrIp}:{port}";
}
}

View File

@@ -1,7 +1,9 @@
using System.Reflection; using System.Reflection;
using Extrudex.API.Filters; using Extrudex.API.Filters;
using Extrudex.API.Hubs; using Extrudex.API.Hubs;
using Extrudex.API.Jobs;
using Extrudex.Domain.Interfaces; using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Configuration;
using Extrudex.Infrastructure.Data; using Extrudex.Infrastructure.Data;
using Extrudex.Infrastructure.Services; using Extrudex.Infrastructure.Services;
using FluentValidation; using FluentValidation;
@@ -77,6 +79,16 @@ builder.Services.AddCors(options =>
// ── SignalR (real-time printer updates) ──────────────────── // ── SignalR (real-time printer updates) ────────────────────
builder.Services.AddSignalR(); builder.Services.AddSignalR();
// ── Filament Usage Sync (Background Job) ──────────────────
builder.Services.Configure<FilamentUsageSyncOptions>(
builder.Configuration.GetSection(FilamentUsageSyncOptions.SectionName));
builder.Services.AddHttpClient<IMoonrakerClient, MoonrakerClient>(client =>
{
client.DefaultRequestHeaders.Add("User-Agent", "Extrudex/1.0");
});
builder.Services.AddScoped<IFilamentUsageSyncService, FilamentUsageSyncService>();
builder.Services.AddHostedService<FilamentUsageSyncJob>();
// ── Health Checks ─────────────────────────────────────────── // ── Health Checks ───────────────────────────────────────────
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddNpgSql(connectionString); .AddNpgSql(connectionString);

View File

@@ -8,5 +8,10 @@
}, },
"ConnectionStrings": { "ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme" "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme"
},
"FilamentUsageSync": {
"PollingInterval": "00:01:00",
"RequestTimeout": "00:00:30",
"Enabled": true
} }
} }

View File

@@ -9,5 +9,10 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme"
},
"FilamentUsageSync": {
"PollingInterval": "00:05:00",
"RequestTimeout": "00:00:30",
"Enabled": true
} }
} }