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