diff --git a/backend/API/Jobs/MoonrakerPrinterSyncJob.cs b/backend/API/Jobs/MoonrakerPrinterSyncJob.cs new file mode 100644 index 0000000..f227c36 --- /dev/null +++ b/backend/API/Jobs/MoonrakerPrinterSyncJob.cs @@ -0,0 +1,80 @@ +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 service that periodically syncs Moonraker printer status +/// and print job history into the Extrudex database. Runs as a hosted +/// service and polls all active Moonraker printers on a configurable +/// interval to update printer state and map completed print jobs +/// to PrintJob and FilamentUsage entities. +/// +/// Configuration is bound from the "MoonrakerPrinterSync" section in +/// appsettings.json. Set Enabled=false to disable without removing +/// the service registration. +/// +public class MoonrakerPrinterSyncJob : BackgroundService +{ + private readonly IMoonrakerPrinterSyncService _syncService; + private readonly MoonrakerPrinterSyncOptions _options; + private readonly ILogger _logger; + + /// + /// Creates a new MoonrakerPrinterSyncJob. + /// + /// The service that performs the actual sync logic. + /// Configuration options for polling interval and timeouts. + /// Logger for diagnostic output. + public MoonrakerPrinterSyncJob( + IMoonrakerPrinterSyncService syncService, + IOptions options, + ILogger logger) + { + _syncService = syncService; + _options = options.Value; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + { + _logger.LogInformation("Moonraker printer sync job is disabled via configuration — exiting"); + return; + } + + _logger.LogInformation( + "Moonraker printer 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(15), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var syncedCount = await _syncService.SyncAllAsync(stoppingToken); + + _logger.LogInformation( + "Moonraker printer 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 Moonraker printer sync cycle — will retry in {Interval}", + _options.PollingInterval); + } + + await Task.Delay(_options.PollingInterval, stoppingToken); + } + + _logger.LogInformation("Moonraker printer sync job shutting down"); + } +} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IMoonrakerPrinterSyncService.cs b/backend/Domain/Interfaces/IMoonrakerPrinterSyncService.cs new file mode 100644 index 0000000..bb89ff8 --- /dev/null +++ b/backend/Domain/Interfaces/IMoonrakerPrinterSyncService.cs @@ -0,0 +1,20 @@ +using Extrudex.Domain.DTOs.Moonraker; + +namespace Extrudex.Domain.Interfaces; + +/// +/// Service interface for syncing Moonraker printer data into the Extrudex database. +/// Handles periodic polling of printer status and mapping print job history +/// to PrintJob and FilamentUsage entities. +/// +public interface IMoonrakerPrinterSyncService +{ + /// + /// Performs a single sync cycle: queries all active Moonraker printers, + /// fetches their current status and print job history, 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/Infrastructure/Configuration/MoonrakerPrinterSyncOptions.cs b/backend/Infrastructure/Configuration/MoonrakerPrinterSyncOptions.cs new file mode 100644 index 0000000..442048b --- /dev/null +++ b/backend/Infrastructure/Configuration/MoonrakerPrinterSyncOptions.cs @@ -0,0 +1,41 @@ +namespace Extrudex.Infrastructure.Configuration; + +/// +/// Configuration options for the MoonrakerPrinterSync background service. +/// Bound from appsettings.json under the "MoonrakerPrinterSync" section. +/// Controls polling interval, timeouts, and feature toggles for the +/// printer status and print job mapping service. +/// +public class MoonrakerPrinterSyncOptions +{ + /// + /// The section name in appsettings.json where these options are bound. + /// + public const string SectionName = "MoonrakerPrinterSync"; + + /// + /// How often the background service polls Moonraker printers for status + /// and print job data. Default: 1 minute. + /// + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Timeout for individual HTTP requests to a Moonraker printer. + /// Default: 15 seconds. + /// + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// Whether the Moonraker printer sync service is enabled. + /// Set to false to disable without removing the service registration. + /// Default: true. + /// + public bool Enabled { get; set; } = true; + + /// + /// Maximum number of print history items to fetch per printer per sync cycle. + /// Controls the batch size when syncing print jobs from Moonraker. + /// Default: 25. + /// + public int HistoryBatchSize { get; set; } = 25; +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs b/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs new file mode 100644 index 0000000..48c20a2 --- /dev/null +++ b/backend/Infrastructure/Services/MoonrakerPrinterSyncService.cs @@ -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; + +/// +/// 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. +/// +public class MoonrakerPrinterSyncService : IMoonrakerPrinterSyncService +{ + private readonly ExtrudexDbContext _dbContext; + private readonly IMoonrakerClient _moonrakerClient; + private readonly ILogger _logger; + + /// + /// Creates a new MoonrakerPrinterSyncService. + /// + /// The EF Core database context for persisting updates. + /// The Moonraker HTTP client for fetching printer data. + /// Logger for diagnostic output. + public MoonrakerPrinterSyncService( + ExtrudexDbContext dbContext, + IMoonrakerClient moonrakerClient, + ILogger logger) + { + _dbContext = dbContext; + _moonrakerClient = moonrakerClient; + _logger = logger; + } + + /// + public async Task 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; + } + + /// + /// Syncs a single Moonraker printer: updates its status, fetches print history, + /// and maps new print jobs to database entities. + /// + 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); + } + } + + /// + /// Updates the printer's operational status based on Moonraker telemetry. + /// Maps Klipper/Moonraker state strings to the PrinterStatus enum. + /// + 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 + }; + } + } + + /// + /// 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). + /// + private async Task MapPrintJobsAsync( + Printer printer, + List 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(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; + } + + /// + /// Finds the default spool for a printer. Returns the first spool loaded + /// in an AMS slot, or null if no spool is available. + /// + 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; + } + + /// + /// Calculates derived grams from millimeters extruded using the standard formula: + /// grams = mm_extruded × cross_section_area × material_density + /// where cross_section_area = π × (diameter / 2)² + /// + 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; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index c2bdcc9..c3ac2a1 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -92,6 +92,12 @@ builder.Services.AddHttpClient(client => builder.Services.AddScoped(); builder.Services.AddHostedService(); +// ── Moonraker Printer Sync (Background Service) ────────── +builder.Services.Configure( + builder.Configuration.GetSection(MoonrakerPrinterSyncOptions.SectionName)); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + // ── Health Checks ─────────────────────────────────────────── builder.Services.AddHealthChecks() .AddNpgSql(connectionString); diff --git a/backend/appsettings.json b/backend/appsettings.json index e5c747f..d35bd81 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -14,5 +14,11 @@ "PollingInterval": "00:05:00", "RequestTimeout": "00:00:30", "Enabled": true + }, + "MoonrakerPrinterSync": { + "PollingInterval": "00:01:00", + "RequestTimeout": "00:00:15", + "Enabled": true, + "HistoryBatchSize": 25 } } \ No newline at end of file