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,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}";
}
}