feat(CUB-8): Create background service for Moonraker mapping
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m7s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Has been skipped

This commit is contained in:
2026-04-27 20:40:23 -04:00
parent 90a89eecf3
commit 8b2a29881d
6 changed files with 473 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
using Extrudex.Domain.DTOs.Moonraker;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Configuration;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// Service that syncs Moonraker printer status and print job history into the
/// Extrudex database. Queries all active Moonraker printers, fetches their
/// current operational state, and maps completed print jobs to PrintJob and
/// FilamentUsage entities with derived gram calculations.
/// </summary>
public class MoonrakerPrinterSyncService : IMoonrakerPrinterSyncService
{
private readonly ExtrudexDbContext _dbContext;
private readonly IMoonrakerClient _moonrakerClient;
private readonly ILogger<MoonrakerPrinterSyncService> _logger;
/// <summary>
/// Creates a new MoonrakerPrinterSyncService.
/// </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 MoonrakerPrinterSyncService(
ExtrudexDbContext dbContext,
IMoonrakerClient moonrakerClient,
ILogger<MoonrakerPrinterSyncService> logger)
{
_dbContext = dbContext;
_moonrakerClient = moonrakerClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<int> SyncAllAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting Moonraker printer 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)
.ThenInclude(s => s.MaterialBase)
.Include(p => p.PrintJobs)
.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
{
await SyncPrinterAsync(printer, cancellationToken);
syncedCount++;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex,
"Error syncing printer {PrinterName} ({Host}:{Port})",
printer.Name, printer.HostnameOrIp, printer.Port);
// Mark printer as offline if we can't reach it
printer.Status = PrinterStatus.Offline;
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Moonraker printer sync cycle complete — {SyncedCount}/{TotalCount} printers synced",
syncedCount, printers.Count);
return syncedCount;
}
/// <summary>
/// Syncs a single Moonraker printer: updates its status, fetches print history,
/// and maps new print jobs to database entities.
/// </summary>
private async Task SyncPrinterAsync(Printer printer, CancellationToken cancellationToken)
{
// Step 1: Fetch printer status
var printerInfo = await _moonrakerClient.GetPrinterInfoAsync(
printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken);
var printStats = await _moonrakerClient.GetPrintStatsAsync(
printer.HostnameOrIp, printer.Port, printer.ApiKey, cancellationToken);
// Step 2: Update printer status
UpdatePrinterStatus(printer, printerInfo, printStats);
printer.LastSeenAt = DateTime.UtcNow;
_logger.LogDebug(
"Printer {PrinterName} status updated to {Status}",
printer.Name, printer.Status);
// Step 3: Fetch and map print job history
var history = await _moonrakerClient.GetPrintHistoryAsync(
printer.HostnameOrIp, printer.Port, printer.ApiKey,
limit: 25,
cancellationToken);
if (history.Items.Count == 0)
{
_logger.LogDebug("No print history returned for printer {PrinterName}", printer.Name);
return;
}
var newJobsCount = await MapPrintJobsAsync(printer, history.Items, cancellationToken);
if (newJobsCount > 0)
{
_logger.LogInformation(
"Mapped {NewJobsCount} new print job(s) from printer {PrinterName}",
newJobsCount, printer.Name);
}
}
/// <summary>
/// Updates the printer's operational status based on Moonraker telemetry.
/// Maps Klipper/Moonraker state strings to the PrinterStatus enum.
/// </summary>
private void UpdatePrinterStatus(
Printer printer,
MoonrakerPrinterInfo? printerInfo,
MoonrakerPrintStats? printStats)
{
// Prefer print_stats state — it's the most authoritative
if (printStats != null)
{
printer.Status = printStats.State.ToLowerInvariant() switch
{
"printing" => PrinterStatus.Printing,
"paused" => PrinterStatus.Paused,
"complete" => PrinterStatus.Idle,
"standby" => PrinterStatus.Idle,
"cancelled" => PrinterStatus.Idle,
"error" => PrinterStatus.Error,
_ => PrinterStatus.Idle
};
return;
}
// Fall back to printer_info state
if (printerInfo != null)
{
printer.Status = printerInfo.State.ToLowerInvariant() switch
{
"ready" => PrinterStatus.Idle,
"startup" => PrinterStatus.Idle,
"shutdown" => PrinterStatus.Offline,
"error" => PrinterStatus.Error,
"cancelled" => PrinterStatus.Idle,
_ => printer.Status // Preserve existing status if unknown
};
}
}
/// <summary>
/// Maps Moonraker print job history items to Extrudex PrintJob and FilamentUsage entities.
/// Only creates records for jobs not already tracked (by Moonraker JobId stored in GcodeFilePath).
/// </summary>
private async Task<int> MapPrintJobsAsync(
Printer printer,
List<MoonrakerPrintJob> historyItems,
CancellationToken cancellationToken)
{
// Build a set of already-tracked Moonraker JobIds for this printer
// We store the Moonraker JobId in the GcodeFilePath field with a "moonraker:" prefix
var trackedJobIds = await _dbContext.PrintJobs
.Where(pj => pj.PrinterId == printer.Id && pj.GcodeFilePath != null && pj.GcodeFilePath.StartsWith("moonraker:"))
.Select(pj => pj.GcodeFilePath!)
.ToListAsync(cancellationToken);
var trackedIdSet = new HashSet<string>(trackedJobIds);
var newJobsCount = 0;
// Find the default spool for this printer (first active spool in AMS, or first active spool overall)
var defaultSpool = FindDefaultSpool(printer);
foreach (var moonrakerJob in historyItems)
{
var jobIdKey = $"moonraker:{moonrakerJob.JobId}";
if (trackedIdSet.Contains(jobIdKey))
{
continue; // Already tracked — skip
}
// Only map completed, cancelled, or errored jobs (not in_progress)
// In-progress jobs will be captured on the next cycle once they finish
if (moonrakerJob.Status == "in_progress")
{
continue;
}
// Map Moonraker job status to JobStatus enum
var jobStatus = moonrakerJob.Status.ToLowerInvariant() switch
{
"completed" => JobStatus.Completed,
"cancelled" => JobStatus.Cancelled,
"error" => JobStatus.Failed,
_ => JobStatus.Completed
};
// Calculate derived grams if we have a spool and filament data
decimal gramsDerived = 0m;
decimal filamentDiameterMm = 1.75m;
decimal materialDensity = 1.24m; // PLA default
if (defaultSpool != null)
{
filamentDiameterMm = defaultSpool.FilamentDiameterMm;
materialDensity = defaultSpool.MaterialBase.DensityGperCm3;
gramsDerived = CalculateGrams(moonrakerJob.FilamentUsedMm, filamentDiameterMm, materialDensity);
}
else if (moonrakerJob.FilamentUsedMm > 0)
{
gramsDerived = CalculateGrams(moonrakerJob.FilamentUsedMm, 1.75m, 1.24m);
_logger.LogWarning(
"No default spool found for printer {PrinterName} — using PLA defaults for grams derivation on job {JobId}",
printer.Name, moonrakerJob.JobId);
}
var printJob = new PrintJob
{
PrinterId = printer.Id,
SpoolId = defaultSpool?.Id ?? Guid.Empty,
PrintName = moonrakerJob.Filename,
GcodeFilePath = jobIdKey,
MmExtruded = moonrakerJob.FilamentUsedMm,
GramsDerived = gramsDerived,
StartedAt = moonrakerJob.StartTime,
CompletedAt = moonrakerJob.EndTime,
Status = jobStatus,
DataSource = DataSource.Moonraker,
FilamentDiameterAtPrintMm = filamentDiameterMm,
MaterialDensityAtPrint = materialDensity,
Notes = $"Auto-imported from Moonraker (JobId: {moonrakerJob.JobId})"
};
_dbContext.PrintJobs.Add(printJob);
// Create a FilamentUsage record if filament was consumed
if (moonrakerJob.FilamentUsedMm > 0 && defaultSpool != null)
{
var usage = new FilamentUsage
{
PrintJob = printJob,
SpoolId = defaultSpool.Id,
PrinterId = printer.Id,
GramsUsed = gramsDerived,
MmExtruded = moonrakerJob.FilamentUsedMm,
RecordedAt = DateTime.UtcNow,
Notes = $"Auto-imported from Moonraker history (JobId: {moonrakerJob.JobId})"
};
_dbContext.FilamentUsages.Add(usage);
}
newJobsCount++;
trackedIdSet.Add(jobIdKey); // Prevent duplicates within this batch
}
return newJobsCount;
}
/// <summary>
/// Finds the default spool for a printer. Returns the first spool loaded
/// in an AMS slot, or null if no spool is available.
/// </summary>
private static Spool? FindDefaultSpool(Printer printer)
{
// Prefer the first active spool in an AMS slot
foreach (var amsUnit in printer.AmsUnits)
{
foreach (var slot in amsUnit.Slots)
{
if (slot.Spool != null && slot.Spool.IsActive && !slot.Spool.IsArchived)
{
return slot.Spool;
}
}
}
return null;
}
/// <summary>
/// Calculates derived grams from millimeters extruded using the standard formula:
/// grams = mm_extruded × cross_section_area × material_density
/// where cross_section_area = π × (diameter / 2)²
/// </summary>
private static decimal CalculateGrams(decimal mmExtruded, decimal diameterMm, decimal densityGperCm3)
{
if (mmExtruded <= 0) return 0m;
var radiusCm = (double)diameterMm / 2.0 / 10.0; // mm to cm
var crossSectionAreaCm2 = Math.PI * radiusCm * radiusCm;
var mmToCm = (double)mmExtruded / 10.0;
var grams = mmToCm * crossSectionAreaCm2 * (double)densityGperCm3;
return (decimal)grams;
}
}