Compare commits

..

20 Commits

Author SHA1 Message Date
e209c3891e merge(dev): Re-apply changes after conflict resolution
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m9s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 4s
2026-04-27 18:16:47 -04:00
d3c1b929c5 Merge remote-tracking branch 'origin/dev' into fix-pr-22
# Conflicts:
#	backend/Domain/Interfaces/IMoonrakerClient.cs
#	backend/Infrastructure/Services/MoonrakerClient.cs
#	backend/Program.cs
#	backend/appsettings.json
2026-04-27 18:16:47 -04:00
c3a0f210a1 Merge pull request 'CUB-64: Docker Runtime Setup for Development & Deployment' (#14) from agent/dex/CUB-64-docker-runtime-setup into dev
Some checks failed
Dev Build / build-test (push) Failing after 59s
Dev Build / deploy-dev (push) Has been skipped
Dev Build / notify-success (push) Has been skipped
Dev Build / notify-failure (push) Successful in 3s
Reviewed-on: #14
2026-04-27 17:34:40 -04:00
2017843dc1 Merge branch 'dev' into agent/dex/CUB-64-docker-runtime-setup
Some checks failed
Dev Build / build-test (pull_request) Failing after 58s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
2026-04-27 17:34:26 -04:00
c150f54c64 Merge pull request 'feat(CUB-39): Create background job for filament usage sync' (#16) from agent/dex/CUB-39-filament-usage-sync into dev
Some checks failed
Dev Build / deploy-dev (push) Has been cancelled
Dev Build / notify-success (push) Has been cancelled
Dev Build / notify-failure (push) Has been cancelled
Dev Build / build-test (push) Has been cancelled
Reviewed-on: #16
2026-04-27 17:33:59 -04:00
a8b5fd42c3 CUB-33: integrate Moonraker filament usage polling
Some checks failed
Dev Build / build-test (pull_request) Failing after 57s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
2026-04-27 17:28:24 -04:00
73363206ec Merge branch 'dev' into agent/dex/CUB-39-filament-usage-sync
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m3s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 4s
2026-04-27 17:25:58 -04:00
174dd294e9 Merge pull request 'CUB-37: Implement cost-per-print calculation service' (#18) from agent/dex/CUB-37-cost-per-print into dev
Some checks failed
Dev Build / deploy-dev (push) Has been skipped
Dev Build / notify-success (push) Has been skipped
Dev Build / build-test (push) Failing after 1m3s
Dev Build / notify-failure (push) Successful in 3s
Reviewed-on: #18
2026-04-27 17:25:37 -04:00
0378aee43e Merge branch 'dev' into agent/dex/CUB-37-cost-per-print
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m0s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 5s
2026-04-27 17:25:22 -04:00
72a39ec766 Merge pull request 'CUB-34: Add filament filter bar with material type, color, and low stock filters' (#21) from agent/rex/CUB-34-filament-list-ui into dev
Some checks failed
Dev Build / build-test (push) Failing after 51s
Dev Build / deploy-dev (push) Has been skipped
Dev Build / notify-success (push) Has been skipped
Dev Build / notify-failure (push) Successful in 3s
Reviewed-on: #21
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-04-27 17:14:55 -04:00
c05b9dd87d merge(dev): Re-apply CUB-34 changes after merge conflict resolution
Some checks failed
Dev Build / build-test (pull_request) Failing after 54s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
2026-04-27 17:02:25 -04:00
5a577e1871 Merge remote-tracking branch 'origin/dev' into fix-pr-21
# Conflicts:
#	frontend/src/app/components/filament-table/filament-table.component.html
#	frontend/src/app/components/filament-table/filament-table.component.ts
2026-04-27 17:02:25 -04:00
2e8227c3f9 Merge pull request 'CUB-36: Add delete confirmation dialog for filament spool removal' (#19) from agent/rex/CUB-36-delete-confirmation into dev
Some checks failed
Dev Build / build-test (push) Failing after 55s
Dev Build / deploy-dev (push) Has been skipped
Dev Build / notify-success (push) Has been skipped
Dev Build / notify-failure (push) Successful in 4s
Reviewed-on: #19
2026-04-27 15:24:55 -04:00
d207c49ffd CUB-34: add filament filter bar with material type, color, and low stock filters
Some checks failed
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / build-test (pull_request) Failing after 54s
Dev Build / notify-failure (pull_request) Successful in 6s
2026-04-27 15:08:31 -04:00
5b9dde13fe Merge remote-tracking branch 'origin/dev' into fix-pr-18
Some checks failed
Dev Build / build-test (pull_request) Failing after 54s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 4s
# Conflicts:
#	backend/API/Controllers/PrintJobsController.cs
2026-04-27 14:30:05 -04:00
fd9fcd47ab Merge remote-tracking branch 'origin/dev' into fix-pr-14
Some checks failed
Dev Build / build-test (pull_request) Failing after 58s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
# Conflicts:
#	frontend/.dockerignore
#	frontend/Dockerfile
#	frontend/nginx.conf
2026-04-27 14:30:03 -04:00
d43985cad9 Merge branch 'dev' into agent/dex/CUB-39-filament-usage-sync
Some checks failed
Dev Build / build-test (pull_request) Failing after 52s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
2026-04-27 14:15:16 -04:00
6aa31f4be3 CUB-37: implement cost-per-print calculation service
Some checks failed
Dev Build / build-test (pull_request) Failing after 48s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
2026-04-27 17:57:57 +00:00
4ba98966eb feat(CUB-39): create background job for filament usage sync
Some checks failed
Dev Build / build-test (pull_request) Failing after 48s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
2026-04-27 17:23:24 +00:00
61178ebb7b feat(CUB-64): Docker runtime setup for development & deployment
Some checks failed
Dev Build / build-test (pull_request) Failing after 47s
Dev Build / deploy-dev (pull_request) Has been skipped
Dev Build / notify-success (pull_request) Has been skipped
Dev Build / notify-failure (pull_request) Successful in 3s
- Backend Dockerfile: added curl install for health check (not in aspnet base image)
- Frontend Dockerfile: multi-stage Angular build with nginx serving
- Frontend nginx.conf: SPA routing, API proxy, SignalR WebSocket support, health endpoint
- Frontend .dockerignore: excludes node_modules, dist, .angular, etc.
- docker-compose.dev.yml: added PostgreSQL service, fixed frontend context path,
  renamed web service from control-center-web to extrudex-web, added DB env vars,
  proper service dependencies with health checks
- deploy.sh: updated service list to include PostgreSQL port
2026-04-27 08:33:18 +00:00
7 changed files with 962 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
using Extrudex.Domain.Entities;
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Service for persisting and querying filament usage records.
/// Tracks consumption per print job and per spool for COGS and inventory tracking.
/// </summary>
public interface IFilamentUsageService
{
/// <summary>
/// Records a new filament usage entry for a print job.
/// </summary>
/// <param name="printJobId">The print job that consumed the filament.</param>
/// <param name="spoolId">The spool that provided the filament.</param>
/// <param name="printerId">The printer that executed the print.</param>
/// <param name="gramsUsed">Grams of filament consumed.</param>
/// <param name="mmExtruded">Millimeters of filament extruded.</param>
/// <param name="notes">Optional notes about this usage record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created FilamentUsage entity.</returns>
Task<FilamentUsage> RecordUsageAsync(
Guid printJobId,
Guid spoolId,
Guid printerId,
decimal gramsUsed,
decimal mmExtruded,
string? notes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all filament usage records for a specific print job.
/// </summary>
/// <param name="printJobId">The print job ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of filament usage records for the print job.</returns>
Task<IReadOnlyList<FilamentUsage>> GetByPrintJobAsync(
Guid printJobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all filament usage records for a specific spool.
/// </summary>
/// <param name="spoolId">The spool ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of filament usage records for the spool.</returns>
Task<IReadOnlyList<FilamentUsage>> GetBySpoolAsync(
Guid spoolId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,76 @@
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Client for communicating with Moonraker REST API on Klipper-based printers
/// (e.g., Elegoo Centauri Carbon). Retrieves print job metadata including
/// filament usage data.
/// </summary>
public interface IMoonrakerClient
{
/// <summary>
/// Retrieves the current printer status from Moonraker.
/// </summary>
/// <param name="hostnameOrIp">Printer hostname or IP address.</param>
/// <param name="port">Moonraker port (default: 7125).</param>
/// <param name="apiKey">Optional API key for authentication.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The printer status string (e.g., "idle", "printing", "paused", "error").</returns>
Task<string> GetPrinterStatusAsync(
string hostnameOrIp,
int port,
string? apiKey = null,
CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
/// <param name="hostnameOrIp">Printer hostname or IP address.</param>
/// <param name="port">Moonraker port (default: 7125).</param>
/// <param name="apiKey">Optional API key for authentication.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns> Filament usage data from Moonraker, or null if unavailable.</returns>
Task<MoonrakerFilamentUsage?> GetFilamentUsageAsync(
string hostnameOrIp,
int port,
string? apiKey = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Represents filament usage data retrieved from a Moonraker-equipped printer.
/// Maps to Moonraker's print_stats and history objects.
/// </summary>
public class MoonrakerFilamentUsage
{
/// <summary>
/// Millimeters of filament extruded during the print job.
/// </summary>
public decimal MmExtruded { get; set; }
/// <summary>
/// The filename of the G-code file being or recently printed.
/// </summary>
public string? GcodeFileName { get; set; }
/// <summary>
/// Current print state from Moonraker (e.g., "printing", "complete", "error").
/// </summary>
public string PrintState { get; set; } = string.Empty;
/// <summary>
/// Total print time in seconds, if available from Moonraker.
/// </summary>
public double? PrintDurationSeconds { get; set; }
/// <summary>
/// Timestamp (UTC) when the print job was started, if available.
/// </summary>
public DateTime? StartedAt { get; set; }
/// <summary>
/// Timestamp (UTC) when the print job completed, if available.
/// </summary>
public DateTime? CompletedAt { get; set; }
}

View File

@@ -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;
/// <summary>
/// EF Corebacked implementation of the filament usage service.
/// Persists usage records to the database and provides query methods
/// for retrieving usage by print job or spool.
/// </summary>
public class FilamentUsageService : IFilamentUsageService
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<FilamentUsageService> _logger;
public FilamentUsageService(
ExtrudexDbContext dbContext,
ILogger<FilamentUsageService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<FilamentUsage> 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;
}
/// <inheritdoc />
public async Task<IReadOnlyList<FilamentUsage>> GetByPrintJobAsync(
Guid printJobId,
CancellationToken cancellationToken = default)
{
return await _dbContext.FilamentUsages
.Where(u => u.PrintJobId == printJobId)
.OrderByDescending(u => u.RecordedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FilamentUsage>> GetBySpoolAsync(
Guid spoolId,
CancellationToken cancellationToken = default)
{
return await _dbContext.FilamentUsages
.Where(u => u.SpoolId == spoolId)
.OrderByDescending(u => u.RecordedAt)
.ToListAsync(cancellationToken);
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class MoonrakerClient : IMoonrakerClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<MoonrakerClient> _logger;
/// <summary>
/// Creates a new MoonrakerClient with the specified HTTP client and logger.
/// </summary>
/// <param name="httpClient">The HTTP client used for API calls.</param>
/// <param name="logger">Logger for diagnostic output.</param>
public MoonrakerClient(HttpClient httpClient, ILogger<MoonrakerClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<string> 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";
}
}
/// <inheritdoc />
public async Task<MoonrakerFilamentUsage?> 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
};
}
/// <summary>
/// Fetches and parses print_stats from the Moonraker API.
/// </summary>
private async Task<PrintStatsResult?> 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
};
}
/// <summary>
/// Fetches and parses history (last job) from the Moonraker API.
/// </summary>
private async Task<HistoryResult?> 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
};
}
/// <summary>
/// Parses a Moonraker timestamp property (Unix epoch seconds or ISO 8601 string).
/// </summary>
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;
}
/// <summary>
/// Builds the base URL for Moonraker API calls.
/// </summary>
private static string BuildBaseUrl(string hostnameOrIp, int port) =>
$"http://{hostnameOrIp}:{port}";
/// <summary>
/// Applies the Moonraker API key to the request header if provided.
/// </summary>
private static void ApplyApiKey(HttpRequestMessage request, string? apiKey)
{
if (!string.IsNullOrWhiteSpace(apiKey))
{
request.Headers.Add("X-Api-Key", apiKey);
}
}
/// <summary>
/// Parsed result from Moonraker's print_stats object.
/// Extracted immediately from the JSON response to avoid JsonDocument disposal issues.
/// </summary>
private sealed class PrintStatsResult
{
public string? State { get; set; }
public decimal? FilamentUsedMm { get; set; }
public string? FileName { get; set; }
}
/// <summary>
/// Parsed result from Moonraker's history/last_job object.
/// </summary>
private sealed class HistoryResult
{
public DateTime? StartTime { get; set; }
public DateTime? EndTime { get; set; }
public double? PrintDuration { get; set; }
}
}

View File

@@ -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;
/// <summary>
/// Configuration options for the Moonraker usage polling service.
/// </summary>
public class MoonrakerPollerOptions
{
/// <summary>
/// How often to poll each Moonraker printer for filament usage data.
/// Default: 30 seconds.
/// </summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Timeout for individual Moonraker HTTP requests.
/// Default: 10 seconds.
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Whether the polling service is enabled. Default: true.
/// Set to false to disable polling (e.g., in development or testing).
/// </summary>
public bool Enabled { get; set; } = true;
}
/// <summary>
/// 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
/// <see cref="IFilamentUsageService"/>.
///
/// <para>Polling logic:</para>
/// <list type="number">
/// <item>Query the database for all active printers with ConnectionType == Moonraker.</item>
/// <item>For each printer, call <see cref="IMoonrakerClient.GetFilamentUsageAsync"/>.</item>
/// <item>If usage data is available and the print state is "complete",
/// create or update a FilamentUsage record.</item>
/// <item>If the printer is unreachable or returns malformed data, log a warning
/// and continue to the next printer (no crash).</item>
/// </list>
///
/// <para>Error handling:</para>
/// <list type="bullet">
/// <item>API unreachable: logged as warning, poller continues for other printers.</item>
/// <item>Malformed response: logged as warning, poller continues.</item>
/// <item>Database errors: logged as error, poller continues.</item>
/// </list>
/// </summary>
public class MoonrakerUsagePoller : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MoonrakerUsagePoller> _logger;
private readonly MoonrakerPollerOptions _options;
/// <summary>
/// Tracks which Moonraker print jobs have already been recorded,
/// keyed by "printerId:gcodeFileName" to avoid duplicate recording.
/// </summary>
private readonly HashSet<string> _recordedJobs = new();
public MoonrakerUsagePoller(
IServiceScopeFactory scopeFactory,
ILogger<MoonrakerUsagePoller> logger,
IOptions<MoonrakerPollerOptions> 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.");
}
/// <summary>
/// Polls all active Moonraker printers for filament usage data
/// and persists any completed print usage records.
/// </summary>
private async Task PollAllPrintersAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ExtrudexDbContext>();
var moonrakerClient = scope.ServiceProvider.GetRequiredService<IMoonrakerClient>();
var usageService = scope.ServiceProvider.GetRequiredService<IFilamentUsageService>();
// 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);
}
}
/// <summary>
/// Polls a single Moonraker printer for filament usage data.
/// If a completed print job is detected with usage data, it is persisted.
/// </summary>
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);
}
}
/// <summary>
/// Determines if a Moonraker print state indicates a completed job
/// that should have its usage recorded.
/// </summary>
private static bool IsCompleteState(string state) =>
state.Equals("complete", StringComparison.OrdinalIgnoreCase) ||
state.Equals("completed", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 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.
/// </summary>
private async Task<PrintJob?> 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;
}
/// <summary>
/// Finds an active spool associated with the printer via AMS slots,
/// or falls back to any active spool in the system.
/// </summary>
private static async Task<Spool?> 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Returns the material density for the spool's material base.
/// Falls back to 1.24 g/cm³ (typical PLA density) if not available.
/// </summary>
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
};
}
}

View File

@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Net.Http.Headers;
using Extrudex.API.Filters; using Extrudex.API.Filters;
using Extrudex.API.Hubs; using Extrudex.API.Hubs;
using Extrudex.Domain.Interfaces; using Extrudex.Domain.Interfaces;
@@ -50,6 +51,23 @@ builder.Services.AddSwaggerGen(c =>
// ── QR Code Generation ────────────────────────────────────── // ── QR Code Generation ──────────────────────────────────────
builder.Services.AddSingleton<IQrCodeService, QrCodeService>(); builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
// ── Filament Usage Service ──────────────────────────────────
builder.Services.AddScoped<IFilamentUsageService, FilamentUsageService>();
// ── Moonraker Client ───────────────────────────────────────
// Named HttpClient for Moonraker API calls with configurable timeout.
// Poller timeout is driven by MoonrakerPollerOptions.RequestTimeout.
builder.Services.AddHttpClient<IMoonrakerClient, MoonrakerClient>(client =>
{
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
});
// ── Moonraker Usage Poller (Background Service) ─────────────
builder.Services.Configure<MoonrakerPollerOptions>(
builder.Configuration.GetSection("MoonrakerPoller"));
builder.Services.AddHostedService<MoonrakerUsagePoller>();
// ── FluentValidation ────────────────────────────────────── // ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI. // Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

View File

@@ -9,5 +9,10 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme"
},
"MoonrakerPoller": {
"Enabled": true,
"PollInterval": "00:00:30",
"RequestTimeout": "00:00:10"
} }
} }