From a8b5fd42c3dbf0c7452ee793c3bc8e624afe1e6c Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 17:28:24 -0400 Subject: [PATCH] CUB-33: integrate Moonraker filament usage polling --- .../Interfaces/IFilamentUsageService.cs | 50 ++ backend/Domain/Interfaces/IMoonrakerClient.cs | 76 +++ .../Services/FilamentUsageService.cs | 79 ++++ .../Services/MoonrakerClient.cs | 303 ++++++++++++ .../Services/MoonrakerUsagePoller.cs | 431 ++++++++++++++++++ backend/Program.cs | 18 + backend/appsettings.json | 5 + 7 files changed, 962 insertions(+) create mode 100644 backend/Domain/Interfaces/IFilamentUsageService.cs create mode 100644 backend/Domain/Interfaces/IMoonrakerClient.cs create mode 100644 backend/Infrastructure/Services/FilamentUsageService.cs create mode 100644 backend/Infrastructure/Services/MoonrakerClient.cs create mode 100644 backend/Infrastructure/Services/MoonrakerUsagePoller.cs diff --git a/backend/Domain/Interfaces/IFilamentUsageService.cs b/backend/Domain/Interfaces/IFilamentUsageService.cs new file mode 100644 index 0000000..1b3a63a --- /dev/null +++ b/backend/Domain/Interfaces/IFilamentUsageService.cs @@ -0,0 +1,50 @@ +using Extrudex.Domain.Entities; + +namespace Extrudex.Domain.Interfaces; + +/// +/// Service for persisting and querying filament usage records. +/// Tracks consumption per print job and per spool for COGS and inventory tracking. +/// +public interface IFilamentUsageService +{ + /// + /// Records a new filament usage entry for a print job. + /// + /// The print job that consumed the filament. + /// The spool that provided the filament. + /// The printer that executed the print. + /// Grams of filament consumed. + /// Millimeters of filament extruded. + /// Optional notes about this usage record. + /// Cancellation token. + /// The created FilamentUsage entity. + Task RecordUsageAsync( + Guid printJobId, + Guid spoolId, + Guid printerId, + decimal gramsUsed, + decimal mmExtruded, + string? notes = null, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all filament usage records for a specific print job. + /// + /// The print job ID. + /// Cancellation token. + /// Collection of filament usage records for the print job. + Task> GetByPrintJobAsync( + Guid printJobId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all filament usage records for a specific spool. + /// + /// The spool ID. + /// Cancellation token. + /// Collection of filament usage records for the spool. + Task> GetBySpoolAsync( + Guid spoolId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs new file mode 100644 index 0000000..6feff80 --- /dev/null +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -0,0 +1,76 @@ +namespace Extrudex.Domain.Interfaces; + +/// +/// Client for communicating with Moonraker REST API on Klipper-based printers +/// (e.g., Elegoo Centauri Carbon). Retrieves print job metadata including +/// filament usage data. +/// +public interface IMoonrakerClient +{ + /// + /// Retrieves the current printer status from Moonraker. + /// + /// Printer hostname or IP address. + /// Moonraker port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token. + /// The printer status string (e.g., "idle", "printing", "paused", "error"). + Task GetPrinterStatusAsync( + string hostnameOrIp, + int port, + string? apiKey = null, + CancellationToken cancellationToken = default); + + /// + /// Retrieves filament usage data from the current or most recent print job. + /// Moonraker exposes this via the /api/objects endpoint querying + /// "history" and "print_stats" objects. + /// + /// Printer hostname or IP address. + /// Moonraker port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token. + /// Filament usage data from Moonraker, or null if unavailable. + Task GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey = null, + CancellationToken cancellationToken = default); +} + +/// +/// Represents filament usage data retrieved from a Moonraker-equipped printer. +/// Maps to Moonraker's print_stats and history objects. +/// +public class MoonrakerFilamentUsage +{ + /// + /// Millimeters of filament extruded during the print job. + /// + public decimal MmExtruded { get; set; } + + /// + /// The filename of the G-code file being or recently printed. + /// + public string? GcodeFileName { get; set; } + + /// + /// Current print state from Moonraker (e.g., "printing", "complete", "error"). + /// + public string PrintState { get; set; } = string.Empty; + + /// + /// Total print time in seconds, if available from Moonraker. + /// + public double? PrintDurationSeconds { get; set; } + + /// + /// Timestamp (UTC) when the print job was started, if available. + /// + public DateTime? StartedAt { get; set; } + + /// + /// Timestamp (UTC) when the print job completed, if available. + /// + public DateTime? CompletedAt { get; set; } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/FilamentUsageService.cs b/backend/Infrastructure/Services/FilamentUsageService.cs new file mode 100644 index 0000000..c4b95c8 --- /dev/null +++ b/backend/Infrastructure/Services/FilamentUsageService.cs @@ -0,0 +1,79 @@ +using Extrudex.Domain.Entities; +using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Services; + +/// +/// EF Core–backed implementation of the filament usage service. +/// Persists usage records to the database and provides query methods +/// for retrieving usage by print job or spool. +/// +public class FilamentUsageService : IFilamentUsageService +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + public FilamentUsageService( + ExtrudexDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + public async Task RecordUsageAsync( + Guid printJobId, + Guid spoolId, + Guid printerId, + decimal gramsUsed, + decimal mmExtruded, + string? notes = null, + CancellationToken cancellationToken = default) + { + var usage = new FilamentUsage + { + PrintJobId = printJobId, + SpoolId = spoolId, + PrinterId = printerId, + GramsUsed = gramsUsed, + MmExtruded = mmExtruded, + RecordedAt = DateTime.UtcNow, + Notes = notes + }; + + _dbContext.FilamentUsages.Add(usage); + await _dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Recorded filament usage: {Grams}g / {Mm}mm for print job {JobId} on spool {SpoolId}", + gramsUsed, mmExtruded, printJobId, spoolId); + + return usage; + } + + /// + public async Task> GetByPrintJobAsync( + Guid printJobId, + CancellationToken cancellationToken = default) + { + return await _dbContext.FilamentUsages + .Where(u => u.PrintJobId == printJobId) + .OrderByDescending(u => u.RecordedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetBySpoolAsync( + Guid spoolId, + CancellationToken cancellationToken = default) + { + return await _dbContext.FilamentUsages + .Where(u => u.SpoolId == spoolId) + .OrderByDescending(u => u.RecordedAt) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs new file mode 100644 index 0000000..beb4ec3 --- /dev/null +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Text.Json; +using Extrudex.Domain.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Services; + +/// +/// HTTP client for communicating with the Moonraker REST API on +/// Klipper-based printers (e.g., Elegoo Centauri Carbon). +/// +/// Moonraker endpoints used: +/// - GET /api/objects?print_stats — current print stats including filament used +/// - GET /api/objects?history — job history with filament usage per job +/// +/// Authentication: optional X-Api-Key header when API key is configured. +/// +public class MoonrakerClient : IMoonrakerClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Creates a new MoonrakerClient with the specified HTTP client and logger. + /// + /// The HTTP client used for API calls. + /// Logger for diagnostic output. + public MoonrakerClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task GetPrinterStatusAsync( + string hostnameOrIp, + int port, + string? apiKey = null, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + var requestUrl = $"{baseUrl}/api/objects?print_stats"; + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + + // Moonraker returns: { "result": { "print_stats": { "state": "idle", ... } } } + var state = doc.RootElement + .GetProperty("result") + .GetProperty("print_stats") + .GetProperty("state") + .GetString(); + + return state ?? "unknown"; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Moonraker printer status request failed for {Host}:{Port}", + hostnameOrIp, port); + return "offline"; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Malformed Moonraker response from {Host}:{Port}", + hostnameOrIp, port); + return "error"; + } + } + + /// + public async Task GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey = null, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + + // Fetch current print_stats (has live filament_used for active/recent job) + PrintStatsResult? printStats = null; + try + { + printStats = await FetchPrintStatsAsync(baseUrl, apiKey, cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Moonraker print_stats request failed for {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Malformed Moonraker print_stats response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + + if (printStats is null) + return null; + + // Attempt to enrich with history data for timing info + HistoryResult? history = null; + try + { + history = await FetchHistoryAsync(baseUrl, apiKey, cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Moonraker history request failed for {Host}:{Port}", + hostnameOrIp, port); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Malformed Moonraker history response from {Host}:{Port}", + hostnameOrIp, port); + } + + DateTime? startedAt = null; + DateTime? completedAt = null; + double? printDurationSeconds = null; + + if (history is not null) + { + startedAt = history.StartTime; + completedAt = history.EndTime; + printDurationSeconds = history.PrintDuration; + } + + return new MoonrakerFilamentUsage + { + MmExtruded = printStats.FilamentUsedMm ?? 0m, + GcodeFileName = printStats.FileName, + PrintState = printStats.State ?? "unknown", + PrintDurationSeconds = printDurationSeconds, + StartedAt = startedAt, + CompletedAt = completedAt + }; + } + + /// + /// Fetches and parses print_stats from the Moonraker API. + /// + private async Task FetchPrintStatsAsync( + string baseUrl, + string? apiKey, + CancellationToken cancellationToken) + { + var requestUrl = $"{baseUrl}/api/objects?print_stats"; + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("result", out var result) || + !result.TryGetProperty("print_stats", out var stats)) + { + _logger.LogWarning("Moonraker response missing 'print_stats' object"); + return null; + } + + return new PrintStatsResult + { + State = stats.TryGetProperty("state", out var stateEl) ? stateEl.GetString() : null, + FilamentUsedMm = stats.TryGetProperty("filament_used", out var filamentEl) && + filamentEl.ValueKind == JsonValueKind.Number + ? filamentEl.GetDecimal() : (decimal?)null, + FileName = stats.TryGetProperty("filename", out var fileEl) ? fileEl.GetString() : null + }; + } + + /// + /// Fetches and parses history (last job) from the Moonraker API. + /// + private async Task FetchHistoryAsync( + string baseUrl, + string? apiKey, + CancellationToken cancellationToken) + { + var requestUrl = $"{baseUrl}/api/objects?history"; + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("result", out var result) || + !result.TryGetProperty("history", out var history)) + { + _logger.LogWarning("Moonraker response missing 'history' object"); + return null; + } + + // Try last_job first, then job + JsonElement jobEl; + if (!history.TryGetProperty("last_job", out jobEl) && + !history.TryGetProperty("job", out jobEl)) + { + _logger.LogDebug("Moonraker history has no 'last_job' or 'job' entry"); + return null; + } + + return new HistoryResult + { + StartTime = ParseDateTimeProperty(jobEl, "start_time"), + EndTime = ParseDateTimeProperty(jobEl, "end_time"), + PrintDuration = jobEl.TryGetProperty("print_duration", out var durEl) && + durEl.ValueKind == JsonValueKind.Number + ? durEl.GetDouble() : (double?)null + }; + } + + /// + /// Parses a Moonraker timestamp property (Unix epoch seconds or ISO 8601 string). + /// + private static DateTime? ParseDateTimeProperty(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop)) + return null; + + // Moonraker returns Unix epoch seconds as a number + if (prop.ValueKind == JsonValueKind.Number) + { + var epochSeconds = prop.GetDouble(); + return DateTime.UnixEpoch.AddSeconds(epochSeconds); + } + + // Fallback: try parsing as ISO 8601 string + if (prop.ValueKind == JsonValueKind.String) + { + var str = prop.GetString(); + if (str is not null && + DateTime.TryParse(str, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out var dt)) + { + return dt.ToUniversalTime(); + } + } + + return null; + } + + /// + /// Builds the base URL for Moonraker API calls. + /// + private static string BuildBaseUrl(string hostnameOrIp, int port) => + $"http://{hostnameOrIp}:{port}"; + + /// + /// Applies the Moonraker API key to the request header if provided. + /// + private static void ApplyApiKey(HttpRequestMessage request, string? apiKey) + { + if (!string.IsNullOrWhiteSpace(apiKey)) + { + request.Headers.Add("X-Api-Key", apiKey); + } + } + + /// + /// Parsed result from Moonraker's print_stats object. + /// Extracted immediately from the JSON response to avoid JsonDocument disposal issues. + /// + private sealed class PrintStatsResult + { + public string? State { get; set; } + public decimal? FilamentUsedMm { get; set; } + public string? FileName { get; set; } + } + + /// + /// Parsed result from Moonraker's history/last_job object. + /// + private sealed class HistoryResult + { + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public double? PrintDuration { get; set; } + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerUsagePoller.cs b/backend/Infrastructure/Services/MoonrakerUsagePoller.cs new file mode 100644 index 0000000..e762358 --- /dev/null +++ b/backend/Infrastructure/Services/MoonrakerUsagePoller.cs @@ -0,0 +1,431 @@ +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 + }; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index adcb0d6..992585e 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Net.Http.Headers; using Extrudex.API.Filters; using Extrudex.API.Hubs; using Extrudex.Domain.Interfaces; @@ -50,6 +51,23 @@ builder.Services.AddSwaggerGen(c => // ── QR Code Generation ────────────────────────────────────── builder.Services.AddSingleton(); +// ── Filament Usage Service ────────────────────────────────── +builder.Services.AddScoped(); + +// ── Moonraker Client ─────────────────────────────────────── +// Named HttpClient for Moonraker API calls with configurable timeout. +// Poller timeout is driven by MoonrakerPollerOptions.RequestTimeout. +builder.Services.AddHttpClient(client => +{ + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); +}); + +// ── Moonraker Usage Poller (Background Service) ───────────── +builder.Services.Configure( + builder.Configuration.GetSection("MoonrakerPoller")); +builder.Services.AddHostedService(); + // ── FluentValidation ────────────────────────────────────── // Registers all validators from the API assembly into DI. builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); diff --git a/backend/appsettings.json b/backend/appsettings.json index d924e27..4f8a225 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -9,5 +9,10 @@ "AllowedHosts": "*", "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" + }, + "MoonrakerPoller": { + "Enabled": true, + "PollInterval": "00:00:30", + "RequestTimeout": "00:00:10" } } \ No newline at end of file