diff --git a/backend/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs new file mode 100644 index 0000000..19c991d --- /dev/null +++ b/backend/API/Jobs/FilamentUsageSyncJob.cs @@ -0,0 +1,79 @@ +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/Domain/Interfaces/IFilamentUsageSyncService.cs b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs new file mode 100644 index 0000000..951f80d --- /dev/null +++ b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..d4599f2 --- /dev/null +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -0,0 +1,39 @@ +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. +/// +public interface IMoonrakerClient +{ + /// + /// Fetches the current filament usage data from the Moonraker server. + /// Returns a dictionary of usage metrics reported by the printer. + /// + /// The printer's hostname or IP address. + /// The Moonraker API 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( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); + + /// + /// Checks whether the Moonraker server is reachable and responding. + /// + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token for the HTTP request. + /// true if the server responded successfully; otherwise false. + Task IsReachableAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs new file mode 100644 index 0000000..29f95b2 --- /dev/null +++ b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs @@ -0,0 +1,33 @@ +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/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs new file mode 100644 index 0000000..c2e305b --- /dev/null +++ b/backend/Infrastructure/Services/FilamentUsageSyncService.cs @@ -0,0 +1,139 @@ +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 new file mode 100644 index 0000000..1666dcf --- /dev/null +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -0,0 +1,130 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Extrudex.Domain.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Extrudex.Infrastructure.Configuration; + +/// +/// 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. +/// +public class MoonrakerClient : IMoonrakerClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Creates a new MoonrakerClient with the configured HTTP client and logger. + /// + /// The HTTP client for making requests to Moonraker endpoints. + /// Logger for diagnostic output. + public MoonrakerClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task> GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + var result = new Dictionary(); + + 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); + + // 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 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); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Failed to retrieve filament usage from Moonraker at {Host}:{Port}", + hostnameOrIp, port); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Failed to parse Moonraker response from {Host}:{Port}", + hostnameOrIp, port); + } + + return result; + } + + /// + public async Task IsReachableAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + + try + { + 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; + } + catch (HttpRequestException) + { + _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port); + return false; + } + } + + /// + /// Builds the base URL for Moonraker API calls from hostname and port. + /// + private static string BuildBaseUrl(string hostnameOrIp, int port) + { + return $"http://{hostnameOrIp}:{port}"; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index adcb0d6..c166231 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,7 +1,9 @@ using System.Reflection; 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; @@ -77,6 +79,16 @@ 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 8edfdd9..06130e3 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -8,5 +8,10 @@ }, "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 d924e27..e5c747f 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" + }, + "FilamentUsageSync": { + "PollingInterval": "00:05:00", + "RequestTimeout": "00:00:30", + "Enabled": true } } \ No newline at end of file