From a8b5fd42c3dbf0c7452ee793c3bc8e624afe1e6c Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 17:28:24 -0400 Subject: [PATCH 1/2] 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 -- 2.53.0 From e209c3891ebd58341964503fc262ddcc77153486 Mon Sep 17 00:00:00 2001 From: rex-bot Date: Mon, 27 Apr 2026 18:16:47 -0400 Subject: [PATCH 2/2] merge(dev): Re-apply changes after conflict resolution --- .../API/Controllers/CostAnalysisController.cs | 108 ------ .../API/DTOs/PrintJobs/CostPerPrintDtos.cs | 99 ------ backend/API/Jobs/FilamentUsageSyncJob.cs | 79 ----- backend/Dockerfile | 3 - .../Domain/Interfaces/ICostPerPrintService.cs | 76 ----- .../Interfaces/IFilamentUsageSyncService.cs | 19 -- backend/Domain/Interfaces/IMoonrakerClient.cs | 75 +++-- .../Configuration/FilamentUsageSyncOptions.cs | 33 -- .../Services/CostPerPrintService.cs | 158 --------- .../Services/FilamentUsageSyncService.cs | 139 -------- .../Services/MoonrakerClient.cs | 307 ++++++++++++++---- backend/Program.cs | 31 +- backend/appsettings.Development.json | 5 - backend/appsettings.json | 8 +- deploy.sh | 5 +- docker-compose.dev.yml | 40 +-- frontend/package-lock.json | 16 + frontend/package.json | 1 + frontend/src/app/app.config.ts | 8 +- .../delete-filament-dialog.component.html | 78 +++++ .../delete-filament-dialog.component.scss | 150 +++++++++ .../delete-filament-dialog.component.ts | 68 ++++ .../filament-filter.component.html | 76 ----- .../filament-filter.component.scss | 134 -------- .../filament-filter.component.ts | 158 --------- .../filament-table.component.html | 44 ++- .../filament-table.component.scss | 14 + .../filament-table.component.ts | 120 +++---- frontend/src/app/services/filament.service.ts | 37 +++ 29 files changed, 784 insertions(+), 1305 deletions(-) delete mode 100644 backend/API/Controllers/CostAnalysisController.cs delete mode 100644 backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs delete mode 100644 backend/API/Jobs/FilamentUsageSyncJob.cs delete mode 100644 backend/Domain/Interfaces/ICostPerPrintService.cs delete mode 100644 backend/Domain/Interfaces/IFilamentUsageSyncService.cs delete mode 100644 backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs delete mode 100644 backend/Infrastructure/Services/CostPerPrintService.cs delete mode 100644 backend/Infrastructure/Services/FilamentUsageSyncService.cs create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts delete mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.html delete mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.scss delete mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.ts create mode 100644 frontend/src/app/services/filament.service.ts diff --git a/backend/API/Controllers/CostAnalysisController.cs b/backend/API/Controllers/CostAnalysisController.cs deleted file mode 100644 index 87e94b0..0000000 --- a/backend/API/Controllers/CostAnalysisController.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Extrudex.API.DTOs.PrintJobs; -using Extrudex.Domain.Interfaces; -using Microsoft.AspNetCore.Mvc; - -namespace Extrudex.API.Controllers; - -/// -/// Controller for cost analysis endpoints. Provides spool-level -/// cost breakdowns and aggregated COGS reporting. -/// -[ApiController] -[Route("api/cost-analysis")] -public class CostAnalysisController : ControllerBase -{ - private readonly ICostPerPrintService _costService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The cost-per-print calculation service. - /// The logger for diagnostic output. - public CostAnalysisController( - ICostPerPrintService costService, - ILogger logger) - { - _costService = costService; - _logger = logger; - } - - // ── POST /api/cost-analysis/spool ──────────────────────────── - - /// - /// Calculates cost breakdowns for all print jobs associated with a specific spool. - /// Returns per-job costs plus an aggregated total. Jobs with missing cost data - /// include warnings and null cost fields — the endpoint never throws for missing data. - /// - /// The request containing the spool identifier. - /// A spool-level cost summary with per-job breakdowns. - /// Returns the spool cost breakdown with per-job details. - /// If the spool has no print jobs. - [HttpPost("spool")] - [ProducesResponseType(typeof(SpoolCostResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> CalculateSpoolCost([FromBody] SpoolCostRequest request) - { - _logger.LogDebug("Calculating cost breakdown for spool {SpoolId}", request.SpoolId); - - var results = await _costService.CalculateBySpoolAsync(request.SpoolId); - - if (results.Count == 0) - { - return NotFound(new { error = $"No print jobs found for spool with ID '{request.SpoolId}'." }); - } - - // Build the spool-level summary - var firstResult = results[0]; - var jobResponses = results.Select(MapCostToResponse).ToList(); - - // Aggregate total cost and grams — only include jobs that have a valid cost - var calculableJobs = results.Where(r => r.CostPerPrint.HasValue).ToList(); - var totalCost = calculableJobs.Count == results.Count - ? Math.Round(calculableJobs.Sum(r => r.CostPerPrint!.Value), 4) - : (decimal?)null; - - var aggregateWarnings = new List(); - if (calculableJobs.Count < results.Count) - { - aggregateWarnings.Add( - $"{results.Count - calculableJobs.Count} of {results.Count} print jobs have missing cost data. " + - "Total cost reflects only jobs with complete data."); - } - - var response = new SpoolCostResponse - { - SpoolId = request.SpoolId, - SpoolSerial = firstResult.SpoolSerial, - PurchasePrice = firstResult.PurchasePrice, - WeightTotalGrams = firstResult.WeightTotalGrams, - CostPerGram = firstResult.CostPerGram, - TotalGramsConsumed = results.Sum(r => r.GramsDerived), - TotalCost = totalCost, - JobCount = results.Count, - Jobs = jobResponses, - Warnings = aggregateWarnings - }; - - return Ok(response); - } - - /// - /// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO. - /// - private static CostPerPrintResponse MapCostToResponse(CostPerPrintResult r) => new() - { - PrintJobId = r.PrintJobId, - PrintName = r.PrintName, - SpoolId = r.SpoolId, - SpoolSerial = r.SpoolSerial, - MmExtruded = r.MmExtruded, - GramsDerived = r.GramsDerived, - PurchasePrice = r.PurchasePrice, - WeightTotalGrams = r.WeightTotalGrams, - CostPerGram = r.CostPerGram, - CostPerPrint = r.CostPerPrint, - Warnings = r.Warnings - }; -} \ No newline at end of file diff --git a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs deleted file mode 100644 index e82e6b3..0000000 --- a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Extrudex.API.DTOs.PrintJobs; - -/// -/// Response DTO for cost-per-print calculation. Contains the full cost -/// breakdown and any warnings about missing or incomplete data. -/// -public class CostPerPrintResponse -{ - /// The print job identifier this result belongs to. - public Guid PrintJobId { get; set; } - - /// Human-readable name of the print job. - public string PrintName { get; set; } = string.Empty; - - /// The spool identifier that provided filament. - public Guid SpoolId { get; set; } - - /// Serial number of the spool. - public string SpoolSerial { get; set; } = string.Empty; - - /// Total millimeters of filament extruded. - public decimal MmExtruded { get; set; } - - /// Derived grams consumed for this print. - public decimal GramsDerived { get; set; } - - /// The spool's purchase price. Null if not recorded. - public decimal? PurchasePrice { get; set; } - - /// The spool's total weight in grams when full. - public decimal? WeightTotalGrams { get; set; } - - /// Cost per gram of filament. Null if purchase price or total weight is missing. - public decimal? CostPerGram { get; set; } - - /// Calculated cost of this print job. Null if cost data is incomplete. - public decimal? CostPerPrint { get; set; } - - /// - /// Warnings about missing or incomplete data. Empty when all data is available - /// and the calculation succeeded. - /// - public List Warnings { get; set; } = new(); -} - -/// -/// Request DTO for batch cost calculation by spool. Returns cost breakdowns -/// for all print jobs associated with the specified spool. -/// -public class SpoolCostRequest -{ - /// The unique identifier of the spool to calculate costs for. - [Required(ErrorMessage = "SpoolId is required.")] - public Guid SpoolId { get; set; } -} - -/// -/// Response DTO for spool-level cost calculation. Contains cost breakdowns -/// for all print jobs on the spool, plus a total cost summary. -/// -public class SpoolCostResponse -{ - /// The spool identifier. - public Guid SpoolId { get; set; } - - /// Serial number of the spool. - public string SpoolSerial { get; set; } = string.Empty; - - /// The spool's purchase price. Null if not recorded. - public decimal? PurchasePrice { get; set; } - - /// The spool's total weight in grams when full. - public decimal? WeightTotalGrams { get; set; } - - /// Cost per gram of filament. Null if cost data is incomplete. - public decimal? CostPerGram { get; set; } - - /// Total grams consumed across all print jobs on this spool. - public decimal TotalGramsConsumed { get; set; } - - /// Total calculated cost across all print jobs. Null if any job has missing data. - public decimal? TotalCost { get; set; } - - /// Number of print jobs included in this calculation. - public int JobCount { get; set; } - - /// - /// Individual cost breakdowns per print job. Jobs with missing data - /// will have null cost fields and populated warnings. - /// - public List Jobs { get; set; } = new(); - - /// - /// Aggregate warnings about missing data across all jobs. - /// - public List Warnings { get; set; } = new(); -} \ No newline at end of file diff --git a/backend/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs deleted file mode 100644 index 19c991d..0000000 --- a/backend/API/Jobs/FilamentUsageSyncJob.cs +++ /dev/null @@ -1,79 +0,0 @@ -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 job that periodically syncs filament usage data from -/// Moonraker printers. Runs as a hosted service and polls all active -/// Moonraker printers on a configurable interval to persist usage -/// data to the Extrudex database. -/// -/// Configuration is bound from the "FilamentUsageSync" section in -/// appsettings.json. Set Enabled=false to disable without removing -/// the service registration. -/// -public class FilamentUsageSyncJob : BackgroundService -{ - private readonly IFilamentUsageSyncService _syncService; - private readonly FilamentUsageSyncOptions _options; - private readonly ILogger _logger; - - /// - /// Creates a new FilamentUsageSyncJob. - /// - /// The service that performs the actual sync logic. - /// Configuration options for polling interval and timeouts. - /// Logger for diagnostic output. - public FilamentUsageSyncJob( - IFilamentUsageSyncService syncService, - IOptions options, - ILogger logger) - { - _syncService = syncService; - _options = options.Value; - _logger = logger; - } - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_options.Enabled) - { - _logger.LogInformation("Filament usage sync job is disabled via configuration — exiting"); - return; - } - - _logger.LogInformation( - "Filament usage 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(10), stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - var syncedCount = await _syncService.SyncAllAsync(stoppingToken); - - _logger.LogInformation( - "Filament usage 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 filament usage sync cycle — will retry in {Interval}", - _options.PollingInterval); - } - - await Task.Delay(_options.PollingInterval, stoppingToken); - } - - _logger.LogInformation("Filament usage sync job shutting down"); - } -} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 23aacef..b604978 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,9 +17,6 @@ RUN dotnet publish Extrudex.csproj \ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app -# Install curl for health check (not included in aspnet base image) -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* - # Non-root user for security RUN adduser --disabled-password --gecos "" appuser USER appuser diff --git a/backend/Domain/Interfaces/ICostPerPrintService.cs b/backend/Domain/Interfaces/ICostPerPrintService.cs deleted file mode 100644 index 041736a..0000000 --- a/backend/Domain/Interfaces/ICostPerPrintService.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace Extrudex.Domain.Interfaces; - -/// -/// Service interface for calculating the cost of goods sold (COGS) per print job. -/// Uses the spool's purchase price and the print job's derived grams consumed -/// to produce a cost breakdown. Handles missing cost data gracefully by returning -/// warnings rather than throwing exceptions. -/// -public interface ICostPerPrintService -{ - /// - /// Calculates the cost per print for a specific print job. - /// - /// The unique identifier of the print job. - /// Optional cancellation token. - /// - /// A containing the cost breakdown, - /// or warnings if cost data is missing or incomplete. - /// - Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default); - - /// - /// Calculates cost breakdowns for all print jobs associated with a specific spool. - /// Useful for spool-level COGS reporting. - /// - /// The unique identifier of the spool. - /// Optional cancellation token. - /// - /// A list of for each print job on the spool. - /// Jobs with missing cost data will include warnings. - /// - Task> CalculateBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default); -} - -/// -/// Result of a cost-per-print calculation. Contains the cost breakdown -/// and any warnings about missing or incomplete cost data. -/// -public class CostPerPrintResult -{ - /// The print job identifier this result belongs to. - public Guid PrintJobId { get; set; } - - /// Human-readable name of the print job. - public string PrintName { get; set; } = string.Empty; - - /// The spool identifier that provided filament. - public Guid SpoolId { get; set; } - - /// Serial number of the spool. - public string SpoolSerial { get; set; } = string.Empty; - - /// Total millimeters of filament extruded. - public decimal MmExtruded { get; set; } - - /// Derived grams consumed for this print. - public decimal GramsDerived { get; set; } - - /// The spool's purchase price. Null if not recorded. - public decimal? PurchasePrice { get; set; } - - /// The spool's total weight in grams when full. - public decimal? WeightTotalGrams { get; set; } - - /// Cost per gram of filament. Null if purchase price or total weight is missing. - public decimal? CostPerGram { get; set; } - - /// Calculated cost of this print job. Null if cost data is incomplete. - public decimal? CostPerPrint { get; set; } - - /// - /// Warnings about missing or incomplete data that prevented a full calculation. - /// Empty when all data is available and the calculation succeeded. - /// - public List Warnings { get; set; } = new(); -} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs deleted file mode 100644 index 951f80d..0000000 --- a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Extrudex.Domain.Interfaces; - -/// -/// Service interface for syncing filament usage data from printers -/// into the Extrudex database. Handles querying Moonraker printers, -/// computing derived usage metrics, and persisting updates to spools -/// and print job records. -/// -public interface IFilamentUsageSyncService -{ - /// - /// Performs a single sync cycle: queries all active Moonraker printers, - /// fetches their current filament usage data, 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/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs index d4599f2..6feff80 100644 --- a/backend/Domain/Interfaces/IMoonrakerClient.cs +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -1,39 +1,76 @@ namespace Extrudex.Domain.Interfaces; /// -/// Client interface for communicating with Moonraker REST API endpoints -/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). -/// Used to retrieve filament usage data, print job status, and -/// remaining spool weight from the printer. +/// 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 { /// - /// Fetches the current filament usage data from the Moonraker server. - /// Returns a dictionary of usage metrics reported by the printer. + /// Retrieves the current printer status from Moonraker. /// - /// The printer's hostname or IP address. - /// The Moonraker API port (default: 7125). + /// Printer hostname or IP address. + /// Moonraker port (default: 7125). /// Optional API key for authentication. - /// Cancellation token for the HTTP request. - /// A dictionary of usage metric names to their decimal values. - Task> GetFilamentUsageAsync( + /// Cancellation token. + /// The printer status string (e.g., "idle", "printing", "paused", "error"). + Task GetPrinterStatusAsync( string hostnameOrIp, int port, - string? apiKey, + string? apiKey = null, CancellationToken cancellationToken = default); /// - /// Checks whether the Moonraker server is reachable and responding. + /// 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. /// - /// The printer's hostname or IP address. - /// The Moonraker API port (default: 7125). + /// Printer hostname or IP address. + /// Moonraker port (default: 7125). /// Optional API key for authentication. - /// Cancellation token for the HTTP request. - /// true if the server responded successfully; otherwise false. - Task IsReachableAsync( + /// Cancellation token. + /// Filament usage data from Moonraker, or null if unavailable. + Task GetFilamentUsageAsync( string hostnameOrIp, int port, - string? apiKey, + 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/Configuration/FilamentUsageSyncOptions.cs b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs deleted file mode 100644 index 29f95b2..0000000 --- a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Extrudex.Infrastructure.Configuration; - -/// -/// Configuration options for the FilamentUsageSync background job. -/// Bound from appsettings.json under the "FilamentUsageSync" section. -/// Controls polling interval and per-printer timeout settings. -/// -public class FilamentUsageSyncOptions -{ - /// - /// The section name in appsettings.json where these options are bound. - /// - public const string SectionName = "FilamentUsageSync"; - - /// - /// How often the background job polls printers for usage data. - /// Default: 5 minutes. Minimum recommended: 1 minute. - /// - public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Timeout for individual HTTP requests to a Moonraker printer. - /// Default: 30 seconds. - /// - public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Whether the sync job is enabled. Set to false to disable - /// the background job without removing its registration. - /// Default: true. - /// - public bool Enabled { get; set; } = true; -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/CostPerPrintService.cs b/backend/Infrastructure/Services/CostPerPrintService.cs deleted file mode 100644 index e4b2593..0000000 --- a/backend/Infrastructure/Services/CostPerPrintService.cs +++ /dev/null @@ -1,158 +0,0 @@ -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Extrudex.Infrastructure.Services; - -/// -/// Calculates the cost of goods sold (COGS) per print job using the spool's -/// purchase price and the print job's derived grams consumed. -/// -/// Formula: -/// cost_per_gram = purchase_price / weight_total_grams -/// cost_per_print = grams_derived × cost_per_gram -/// -/// Handles missing data gracefully — if the spool has no purchase price or -/// weight recorded, the result includes warnings and null cost fields -/// instead of throwing exceptions. -/// -public class CostPerPrintService : ICostPerPrintService -{ - private readonly ExtrudexDbContext _dbContext; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The database context for data access. - /// The logger for diagnostic output. - public CostPerPrintService(ExtrudexDbContext dbContext, ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - /// - public async Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default) - { - _logger.LogDebug("Calculating cost per print for job {PrintJobId}", printJobId); - - var job = await _dbContext.PrintJobs - .Include(j => j.Spool) - .ThenInclude(s => s!.MaterialBase) - .FirstOrDefaultAsync(j => j.Id == printJobId, cancellationToken); - - if (job is null) - { - _logger.LogWarning("Print job {PrintJobId} not found for cost calculation", printJobId); - return new CostPerPrintResult - { - PrintJobId = printJobId, - Warnings = new List { $"Print job with ID '{printJobId}' not found." } - }; - } - - return BuildResult(job); - } - - /// - public async Task> CalculateBySpoolAsync( - Guid spoolId, CancellationToken cancellationToken = default) - { - _logger.LogDebug("Calculating cost per print for all jobs on spool {SpoolId}", spoolId); - - var jobs = await _dbContext.PrintJobs - .Include(j => j.Spool) - .ThenInclude(s => s!.MaterialBase) - .Where(j => j.SpoolId == spoolId) - .OrderByDescending(j => j.CreatedAt) - .ToListAsync(cancellationToken); - - if (jobs.Count == 0) - { - _logger.LogDebug("No print jobs found for spool {SpoolId}", spoolId); - return Array.Empty(); - } - - return jobs.Select(BuildResult).ToList(); - } - - /// - /// Builds a from a print job entity. - /// Computes cost_per_gram and cost_per_print when all required data is available. - /// Populates warnings when data is missing or incomplete. - /// - /// The print job entity with Spool navigation loaded. - /// A cost calculation result with breakdown and any warnings. - private CostPerPrintResult BuildResult(Domain.Entities.PrintJob job) - { - var warnings = new List(); - var spool = job.Spool; - - // Map what we always have - var result = new CostPerPrintResult - { - PrintJobId = job.Id, - PrintName = job.PrintName, - SpoolId = job.SpoolId, - SpoolSerial = spool?.SpoolSerial ?? string.Empty, - MmExtruded = job.MmExtruded, - GramsDerived = job.GramsDerived, - }; - - // Guard: spool must be loaded - if (spool is null) - { - warnings.Add("Spool data is not available for this print job."); - result.Warnings = warnings; - return result; - } - - // Capture purchase price - result.PurchasePrice = spool.PurchasePrice; - result.WeightTotalGrams = spool.WeightTotalGrams; - - // Check for missing purchase price - if (!spool.PurchasePrice.HasValue) - { - warnings.Add( - "Spool purchase price is not recorded. Cost calculation requires a purchase price on the spool."); - } - - // Check for zero or negative weight — prevents division by zero - if (spool.WeightTotalGrams <= 0) - { - warnings.Add( - "Spool total weight is zero or not recorded. Cost calculation requires a positive weight_total_grams on the spool."); - } - - // Check for zero grams derived - if (job.GramsDerived <= 0) - { - warnings.Add( - "Derived grams consumed is zero. Ensure mm_extruded, filament diameter, and material density are recorded for this print job."); - } - - // If all data is present and valid, compute the cost - if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0 && job.GramsDerived > 0) - { - var costPerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; - result.CostPerGram = Math.Round(costPerGram, 6); - result.CostPerPrint = Math.Round(job.GramsDerived * costPerGram, 4); - - _logger.LogDebug( - "Cost calculated for job {PrintJobId}: {GramsDerived}g × {CostPerGram:C}/g = {CostPerPrint:C}", - job.Id, job.GramsDerived, result.CostPerGram, result.CostPerPrint); - } - else - { - _logger.LogDebug( - "Cost calculation incomplete for job {PrintJobId}: missing data (warnings: {WarningCount})", - job.Id, warnings.Count); - } - - result.Warnings = warnings; - return result; - } -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs deleted file mode 100644 index c2e305b..0000000 --- a/backend/Infrastructure/Services/FilamentUsageSyncService.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Extrudex.Domain.Enums; -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Extrudex.Infrastructure.Configuration; - -/// -/// 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. -/// -public class FilamentUsageSyncService : IFilamentUsageSyncService -{ - private readonly ExtrudexDbContext _dbContext; - private readonly IMoonrakerClient _moonrakerClient; - private readonly ILogger _logger; - - /// - /// Creates a new FilamentUsageSyncService. - /// - /// The EF Core database context for persisting updates. - /// The Moonraker HTTP client for fetching printer data. - /// Logger for diagnostic output. - public FilamentUsageSyncService( - ExtrudexDbContext dbContext, - IMoonrakerClient moonrakerClient, - ILogger logger) - { - _dbContext = dbContext; - _moonrakerClient = moonrakerClient; - _logger = logger; - } - - /// - public async Task 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; - } - - /// - /// 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. - /// - private void UpdateSpoolWeights( - Domain.Entities.Printer printer, - Dictionary 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); - } - } -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs index 1666dcf..beb4ec3 100644 --- a/backend/Infrastructure/Services/MoonrakerClient.cs +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -1,15 +1,20 @@ -using System.Net.Http.Json; +using System.Globalization; +using System.Net.Http.Headers; using System.Text.Json; using Extrudex.Domain.Interfaces; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -namespace Extrudex.Infrastructure.Configuration; +namespace Extrudex.Infrastructure.Services; /// -/// 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. +/// 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 { @@ -17,9 +22,9 @@ public class MoonrakerClient : IMoonrakerClient private readonly ILogger _logger; /// - /// Creates a new MoonrakerClient with the configured HTTP client and logger. + /// Creates a new MoonrakerClient with the specified HTTP client and logger. /// - /// The HTTP client for making requests to Moonraker endpoints. + /// The HTTP client used for API calls. /// Logger for diagnostic output. public MoonrakerClient(HttpClient httpClient, ILogger logger) { @@ -28,103 +33,271 @@ public class MoonrakerClient : IMoonrakerClient } /// - public async Task> GetFilamentUsageAsync( + public async Task GetPrinterStatusAsync( string hostnameOrIp, int port, - string? apiKey, + string? apiKey = null, CancellationToken cancellationToken = default) { var baseUrl = BuildBaseUrl(hostnameOrIp, port); - var result = new Dictionary(); + var requestUrl = $"{baseUrl}/api/objects?print_stats"; + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + ApplyApiKey(request, apiKey); 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(cancellationToken: cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); - // 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 returns: { "result": { "print_stats": { "state": "idle", ... } } } + var state = doc.RootElement + .GetProperty("result") + .GetProperty("print_stats") + .GetProperty("state") + .GetString(); - // 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); + return state ?? "unknown"; } catch (HttpRequestException ex) { _logger.LogWarning(ex, - "Failed to retrieve filament usage from Moonraker at {Host}:{Port}", + "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, - "Failed to parse Moonraker response from {Host}:{Port}", + "Malformed Moonraker history response from {Host}:{Port}", hostnameOrIp, port); } - return result; + 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 + }; } - /// - public async Task IsReachableAsync( - string hostnameOrIp, - int port, + /// + /// Fetches and parses print_stats from the Moonraker API. + /// + private async Task FetchPrintStatsAsync( + string baseUrl, string? apiKey, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { - var baseUrl = BuildBaseUrl(hostnameOrIp, port); + var requestUrl = $"{baseUrl}/api/objects?print_stats"; - try + 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)) { - 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; + _logger.LogWarning("Moonraker response missing 'print_stats' object"); + return null; } - catch (HttpRequestException) + + return new PrintStatsResult { - _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port); - return false; + 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); } } /// - /// Builds the base URL for Moonraker API calls from hostname and port. + /// Parsed result from Moonraker's print_stats object. + /// Extracted immediately from the JSON response to avoid JsonDocument disposal issues. /// - private static string BuildBaseUrl(string hostnameOrIp, int port) + private sealed class PrintStatsResult { - return $"http://{hostnameOrIp}:{port}"; + 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/Program.cs b/backend/Program.cs index c2bdcc9..992585e 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,9 +1,8 @@ using System.Reflection; +using System.Net.Http.Headers; using Extrudex.API.Filters; using Extrudex.API.Hubs; -using Extrudex.API.Jobs; using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Configuration; using Extrudex.Infrastructure.Data; using Extrudex.Infrastructure.Services; using FluentValidation; @@ -52,8 +51,22 @@ builder.Services.AddSwaggerGen(c => // ── QR Code Generation ────────────────────────────────────── builder.Services.AddSingleton(); -// ── Cost Per Print Calculation ───────────────────────────── -builder.Services.AddScoped(); +// ── 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. @@ -82,16 +95,6 @@ builder.Services.AddCors(options => // ── SignalR (real-time printer updates) ──────────────────── builder.Services.AddSignalR(); -// ── Filament Usage Sync (Background Job) ────────────────── -builder.Services.Configure( - builder.Configuration.GetSection(FilamentUsageSyncOptions.SectionName)); -builder.Services.AddHttpClient(client => -{ - client.DefaultRequestHeaders.Add("User-Agent", "Extrudex/1.0"); -}); -builder.Services.AddScoped(); -builder.Services.AddHostedService(); - // ── Health Checks ─────────────────────────────────────────── builder.Services.AddHealthChecks() .AddNpgSql(connectionString); diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 06130e3..8edfdd9 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -8,10 +8,5 @@ }, "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme" - }, - "FilamentUsageSync": { - "PollingInterval": "00:01:00", - "RequestTimeout": "00:00:30", - "Enabled": true } } \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json index e5c747f..4f8a225 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -10,9 +10,9 @@ "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" }, - "FilamentUsageSync": { - "PollingInterval": "00:05:00", - "RequestTimeout": "00:00:30", - "Enabled": true + "MoonrakerPoller": { + "Enabled": true, + "PollInterval": "00:00:30", + "RequestTimeout": "00:00:10" } } \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index d00c1e6..d17960c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,14 +18,13 @@ echo "📦 Building and starting services..." $COMPOSE_CMD -f docker-compose.dev.yml up -d --build echo "⏳ Waiting for services to become healthy..." -sleep 15 +sleep 10 echo "✅ Deployment complete!" echo "" echo "Services running:" -echo " • PostgreSQL: localhost:5433" echo " • Extrudex API: http://localhost:5080" -echo " • Extrudex Web: http://localhost:5081" +echo " • Control Center Web: http://localhost:5081" echo "" echo "To view logs:" echo " $COMPOSE_CMD -f docker-compose.dev.yml logs -f" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a0a3d49..2859dff 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,25 +1,6 @@ -services: - extrudex-db: - image: postgres:16-alpine - container_name: extrudex-db - environment: - POSTGRES_USER: extrudex - POSTGRES_PASSWORD: changeme - POSTGRES_DB: extrudex - ports: - - "5433:5432" - volumes: - - extrudex-db-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U extrudex"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - restart: unless-stopped - networks: - - extrudex-network +version: '3.8' +services: extrudex-api: build: context: ./backend @@ -30,14 +11,6 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 - - EXTRUDEX_DB_HOST=extrudex-db - - EXTRUDEX_DB_PORT=5432 - - EXTRUDEX_DB_NAME=extrudex - - EXTRUDEX_DB_USER=extrudex - - EXTRUDEX_DB_PASSWORD=changeme - depends_on: - extrudex-db: - condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] @@ -48,11 +21,11 @@ services: networks: - extrudex-network - extrudex-web: + control-center-web: build: - context: ./frontend + context: ../Control-Center/frontend dockerfile: Dockerfile - container_name: extrudex-web + container_name: control-center-web ports: - "5081:80" depends_on: @@ -62,9 +35,6 @@ services: networks: - extrudex-network -volumes: - extrudex-db-data: - networks: extrudex-network: driver: bridge \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b31e340..d0614db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@angular/animations": "^21.2.10", "@angular/cdk": "^21.2.8", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -326,6 +327,21 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.10.tgz", + "integrity": "sha512-sIzAcxwtRCJ/fu0tK4mo1ooiEaDxJ+Nl6s9nK1D1NP1em12VX03Jx8CMixp/kVtgh4mZnm1x6psBB0FUz3U3Ug==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.10" + } + }, "node_modules/@angular/build": { "version": "21.2.8", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e2cedb..02eaba4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "private": true, "packageManager": "npm@11.11.0", "dependencies": { + "@angular/animations": "^21.2.10", "@angular/cdk": "^21.2.8", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..e66aad8 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,11 +1,15 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) + provideRouter(routes), + provideHttpClient(withFetch()), + provideAnimationsAsync(), ] -}; +}; \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html new file mode 100644 index 0000000..71fe1c6 --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html @@ -0,0 +1,78 @@ + +

+ + Delete Filament Spool? +

+ + +

+ You are about to permanently remove this filament spool from inventory. +

+ + +
+
+ Material + {{ filament.materialBaseName }}{{ filament.materialFinishName ? ' — ' + filament.materialFinishName : '' }}{{ filament.materialModifierName ? ' (' + filament.materialModifierName + ')' : '' }} +
+ +
+ Brand + {{ filament.brand }} +
+ +
+ Color + + + + {{ filament.colorName }} + +
+ +
+ Serial + {{ filament.spoolSerial }} +
+ +
+ Remaining + {{ formatWeight(filament.weightRemainingGrams) }} / {{ formatWeight(filament.weightTotalGrams) }} +
+ +
+ Status + + + {{ filament.isActive ? 'Active' : 'Inactive' }} + + +
+
+ +

+ + This action cannot be undone. +

+
+ + + + + \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss new file mode 100644 index 0000000..6789bf4 --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss @@ -0,0 +1,150 @@ +/** + * Delete Filament Dialog Styles + * Touch-optimized confirmation dialog for spool removal + */ + +$spacing-unit: 8px; +$color-critical: #ef4444; +$color-inactive: #94a3b8; +$color-active: #22c55e; + +// Dialog title +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: $spacing-unit; + color: $color-critical; + + mat-icon { + font-size: 24px !important; + width: 24px !important; + height: 24px !important; + } +} + +// Description text +.dialog-description { + margin: 0 0 $spacing-unit * 2; + font-size: 14px; + line-height: 1.5; + color: var(--mat-sys-on-surface); +} + +// Spool details card +.spool-details { + display: flex; + flex-direction: column; + gap: $spacing-unit; + padding: $spacing-unit * 1.5; + background-color: var(--mat-sys-surface-container); + border-radius: 8px; + margin-bottom: $spacing-unit * 2; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-unit * 0.5 0; + font-size: 14px; + + &:not(:last-child) { + border-bottom: 1px solid var(--mat-sys-outline-variant); + padding-bottom: $spacing-unit; + } +} + +.detail-label { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; +} + +.detail-value { + font-weight: 400; + color: var(--mat-sys-on-surface); + text-align: right; +} + +// Color swatch inline +.color-swatch-inline { + display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.5px solid rgba(0, 0, 0, 0.12); + vertical-align: middle; + margin-right: 4px; +} + +.color-value { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +// Serial value — monospace +.serial-value { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-size: 13px; + letter-spacing: 0.02em; +} + +// Status badge — matches filament table styling +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + + &.active { + background-color: rgba($color-active, 0.12); + color: $color-active; + } + + &.inactive { + background-color: rgba($color-inactive, 0.12); + color: $color-inactive; + } +} + +// Warning text +.dialog-warning { + display: flex; + align-items: center; + gap: $spacing-unit; + margin: 0; + font-size: 13px; + color: $color-critical; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + } +} + +// Dialog action buttons +mat-dialog-actions { + padding-top: $spacing-unit * 2; + + .cancel-button { + min-width: 80px; + } + + .confirm-button { + min-width: 120px; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + margin-right: 4px; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts new file mode 100644 index 0000000..d16a87a --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogModule, +} from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; + +import { Filament } from '../../models/filament.model'; + +/** + * Data passed into the delete confirmation dialog. + */ +export interface DeleteFilamentDialogData { + filament: Filament; +} + +/** + * Delete confirmation dialog for filament spool removal. + * + * Displays spool details (material, brand, color, serial, remaining weight) + * and requires the user to confirm before deletion proceeds. + * Cancel dismisses the dialog with no action. + */ +@Component({ + selector: 'app-delete-filament-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + ], + templateUrl: './delete-filament-dialog.component.html', + styleUrl: './delete-filament-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteFilamentDialogComponent { + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data: DeleteFilamentDialogData = inject(MAT_DIALOG_DATA); + + /** The filament spool being considered for deletion */ + readonly filament = this.data.filament; + + /** Format weight for display in dialog */ + formatWeight(grams: number): string { + if (grams >= 1000) { + return `${(grams / 1000).toFixed(1)}kg`; + } + return `${Math.round(grams)}g`; + } + + /** Cancel — close dialog with false (no deletion) */ + onCancel(): void { + this.dialogRef.close(false); + } + + /** Confirm — close dialog with true (proceed with deletion) */ + onConfirm(): void { + this.dialogRef.close(true); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.html b/frontend/src/app/components/filament-filter/filament-filter.component.html deleted file mode 100644 index fd93087..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.html +++ /dev/null @@ -1,76 +0,0 @@ - - \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.scss b/frontend/src/app/components/filament-filter/filament-filter.component.scss deleted file mode 100644 index 8ebbaaf..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.scss +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Filament Filter Bar Styles - * Responsive filter layout for kiosk and mobile - */ - -$spacing-unit: 8px; - -.filament-filter-bar { - display: flex; - align-items: center; - gap: $spacing-unit * 2; - flex-wrap: wrap; - padding: $spacing-unit * 2 0; - margin-bottom: $spacing-unit * 2; -} - -// Form field sizing -.filter-field { - flex: 0 1 auto; - min-width: 160px; - - &.material-filter { - min-width: 200px; - } - - &.color-filter { - min-width: 180px; - } - - // Reduce vertical spacing inside filter fields - .mat-mdc-form-field-subscript-wrapper { - display: none; // No hint/error text needed for filters - } -} - -// Selected material chips -.selected-chips { - flex-wrap: wrap; - gap: 4px; -} - -.filter-chip { - font-size: 12px !important; - min-height: 24px !important; - - mat-icon { - font-size: 14px !important; - width: 14px !important; - height: 14px !important; - } -} - -// Active filter icon -.filter-active-icon { - color: var(--mat-sys-primary); - font-size: 18px !important; - width: 18px !important; - height: 18px !important; -} - -// Checkbox styling -.filter-checkbox { - display: flex; - align-items: center; - gap: 4px; - white-space: nowrap; - user-select: none; - touch-action: manipulation; // Prevent zoom on double-tap - - .checkbox-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; - color: var(--mat-sys-on-surface-variant); - transition: color 0.2s ease; - - &.active { - color: var(--mat-sys-primary); - } - } -} - -// Clear filters button -.clear-filters-btn { - display: flex; - align-items: center; - gap: 4px; - font-size: 13px; - - mat-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; - } -} - -// Responsive: stack filters vertically on small screens -@media (max-width: 768px) { - .filament-filter-bar { - flex-direction: column; - align-items: stretch; - gap: $spacing-unit; - } - - .filter-field { - width: 100%; - min-width: unset; - - &.material-filter, - &.color-filter { - min-width: unset; - } - } - - .filter-checkbox { - padding: $spacing-unit 0; - } - - .clear-filters-btn { - align-self: flex-start; - } -} - -// Extra-small screens (phone portrait) -@media (max-width: 480px) { - .filament-filter-bar { - padding: $spacing-unit 0; - margin-bottom: $spacing-unit; - } - - .filter-checkbox { - font-size: 13px; - } -} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.ts b/frontend/src/app/components/filament-filter/filament-filter.component.ts deleted file mode 100644 index 7559afc..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - Output, - computed, - signal, -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatInputModule } from '@angular/material/input'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatIconModule } from '@angular/material/icon'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatButtonModule } from '@angular/material/button'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { - Filament, - StockLevel, - classifyStockLevel, -} from '../../models/filament.model'; - -/** Filter state emitted by the filament filter component */ -export interface FilamentFilterState { - /** Selected material base names — empty means all */ - materialBaseNames: string[]; - - /** Color search text — empty string means all */ - colorSearch: string; - - /** Whether to show only low/critical stock */ - lowStockOnly: boolean; - - /** Whether to show only active spools */ - activeOnly: boolean; -} - -/** - * FilamentFilterComponent — Filter bar for the filament inventory list. - * - * Provides: - * - Material type multi-select filter - * - Color name text search - * - Low stock toggle (shows only critical/low spools) - * - Active-only toggle - * - Clear all filters action - */ -@Component({ - selector: 'app-filament-filter', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatSelectModule, - MatInputModule, - MatCheckboxModule, - MatIconModule, - MatChipsModule, - MatButtonModule, - MatTooltipModule, - ], - templateUrl: './filament-filter.component.html', - styleUrl: './filament-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class FilamentFilterComponent { - /** Filament data input — used to derive material options */ - @Input() set filaments(value: Filament[]) { - this._filaments.set(value); - const materials = [...new Set(value.map((f) => f.materialBaseName))].sort(); - this.materialOptions.set(materials); - } - get filaments(): Filament[] { - return this._filaments(); - } - private readonly _filaments = signal([]); - - /** Available material base names derived from filament data */ - readonly materialOptions = signal([]); - - /** Selected material base names */ - readonly selectedMaterials = signal([]); - - /** Color search text */ - readonly colorSearch = signal(''); - - /** Low stock only toggle */ - readonly lowStockOnly = signal(false); - - /** Active only toggle */ - readonly activeOnly = signal(false); - - /** Computed: whether any filters are active */ - readonly hasActiveFilters = computed( - () => - this.selectedMaterials().length > 0 || - this.colorSearch().trim().length > 0 || - this.lowStockOnly() || - this.activeOnly() - ); - - /** Emits the current filter state whenever filters change */ - @Output() readonly filterChange = new EventEmitter(); - - /** Handle material selection change */ - onMaterialChange(selected: string[]): void { - this.selectedMaterials.set(selected); - this.emitFilterState(); - } - - /** Handle color search input */ - onColorSearchChange(value: string): void { - this.colorSearch.set(value); - this.emitFilterState(); - } - - /** Handle low stock toggle */ - onLowStockToggle(checked: boolean): void { - this.lowStockOnly.set(checked); - this.emitFilterState(); - } - - /** Handle active-only toggle */ - onActiveOnlyToggle(checked: boolean): void { - this.activeOnly.set(checked); - this.emitFilterState(); - } - - /** Remove a single material chip */ - removeMaterial(material: string): void { - const updated = this.selectedMaterials().filter((m) => m !== material); - this.selectedMaterials.set(updated); - this.emitFilterState(); - } - - /** Clear all filters */ - clearAll(): void { - this.selectedMaterials.set([]); - this.colorSearch.set(''); - this.lowStockOnly.set(false); - this.activeOnly.set(false); - this.emitFilterState(); - } - - /** Emit the current filter state */ - private emitFilterState(): void { - this.filterChange.emit({ - materialBaseNames: this.selectedMaterials(), - colorSearch: this.colorSearch().trim().toLowerCase(), - lowStockOnly: this.lowStockOnly(), - activeOnly: this.activeOnly(), - }); - } -} \ No newline at end of file diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html index 5beccd4..e0aa433 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -1,12 +1,6 @@ - +
- - - @if (criticalCount() > 0) {