diff --git a/README.md b/README.md index e69de29..8eb0683 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,220 @@ +# Extrudex + +> Filament inventory and print tracking system for CubeCraft Creations. + +Extrudex replaces Spoolman with a fully custom solution built for Joshua's 7-printer fleet. It tracks spool stock, per-print material consumption, and cost-of-goods — with a touch-optimized kiosk interface on a Raspberry Pi 5. + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend | ASP.NET Core Web API (.NET 8) | +| Database | PostgreSQL (snake_case via EF Core) | +| ORM | Entity Framework Core | +| Real-time | SignalR (`PrinterHub`) | +| Printer integration | Moonraker REST/WebSocket (Elegoo) · MQTTnet + TLS (Bambu Lab) | +| Frontend | Angular 17+, Angular Material | +| Deployment | Docker · Docker Compose | + +--- + +## Project Structure + +``` +Extrudex/ +├── backend/ +│ ├── Domain/ +│ │ ├── Base/ # BaseEntity, AuditableEntity +│ │ ├── Entities/ # Spool, Printer, PrintJob, FilamentUsage, +│ │ │ # AmsUnit, AmsSlot, MaterialBase, +│ │ │ # MaterialFinish, MaterialModifier +│ │ ├── Enums/ # ConnectionType, DataSource, JobStatus, +│ │ │ # PrinterStatus, PrinterType, QrResourceType +│ │ └── Interfaces/ # ICostPerPrintService, IFilamentUsageSyncService, +│ │ # IMoonrakerClient, IQrCodeService +│ ├── Infrastructure/ +│ │ ├── Configuration/ # FilamentUsageSyncOptions +│ │ ├── Data/ +│ │ │ ├── Configurations/ # EF Core fluent configs (snake_case) +│ │ │ ├── Migrations/ # EF migrations +│ │ │ ├── Seed/ # SeedData.cs +│ │ │ └── ExtrudexDbContext.cs +│ │ └── Services/ # CostPerPrintService, FilamentUsageSyncService, +│ │ # MoonrakerClient, QrCodeService +│ └── API/ +│ ├── Controllers/ # Filaments, Spools, Printers, PrintJobs, +│ │ # MaterialBases, MaterialFinishes, +│ │ # MaterialModifiers, MaterialLookups, +│ │ # CostAnalysis, QR +│ ├── DTOs/ # Request/response shapes per domain +│ ├── Filters/ # FluentValidationFilter +│ ├── Hubs/ # PrinterHub, IPrinterClient +│ ├── Jobs/ # FilamentUsageSyncJob (background) +│ ├── Validators/ # FluentValidation validators +│ ├── Program.cs +│ └── appsettings.json +├── frontend/ +│ └── src/app/ +│ ├── components/ # DashboardSummary, FilamentFilter, FilamentTable +│ ├── models/ # Filament, Agent model types +│ └── app.routes.ts +├── design/ # UX specs and mockups (kiosk + mobile) +├── docker-compose.dev.yml +├── deploy.sh +└── README.md +``` + +--- + +## Domain Model + +### Materials (normalized taxonomy) + +| Entity | Description | +|---|---| +| `MaterialBase` | The base material type — PLA, PETG, ABS, ASA, TPU, etc. | +| `MaterialFinish` | Required. Surface finish — Basic (default), Matte, Silk, Sparkle, etc. | +| `MaterialModifier` | Optional. Composite fill — Carbon Fiber, Glass Fiber, Wood, etc. | + +**Rules:** +- `MaterialFinish` is required — every spool must have one. Default is `"Basic"`. +- `MaterialModifier` is optional — plain PLA has no modifier. + +### Consumption calculation + +``` +grams_used = mm_extruded × filament_cross_section_area × material_density +``` + +Grams are always derived, never assumed from printer telemetry directly. + +### Printers + +| Type | Integration | +|---|---| +| Bambu Lab (×5) | MQTTnet with TLS | +| Elegoo Centauri Carbon | Moonraker REST + WebSocket | +| Elegoo Saturn (resin ×2) | Manual / future | + +AMS units and slots are modelled as `AmsUnit` → `AmsSlot[]` → `Spool`. + +--- + +## Key Design Decisions + +1. **Spoolman rejected** — Full custom system for data model control and workflow flexibility. +2. **`"Basic"` not `"Standard"`** — Default `MaterialFinish` value is `Basic`. +3. **`MaterialFinish` is required** — No null/optional finish state allowed. +4. **`MaterialModifier` is optional** — Not every spool has a modifier. +5. **Derived consumption** — Grams calculated from mm × density, never assumed. +6. **Push over poll** — SignalR and MQTT preferred over periodic polling. +7. **Snake_case PostgreSQL** — All database identifiers follow this convention via EF Core. + +--- + +## Getting Started + +### Prerequisites + +- .NET 8 SDK +- Node.js 20+ +- Docker + Docker Compose +- PostgreSQL (or use the dev compose stack) + +### Backend + +```bash +cd backend + +# Restore and build +dotnet restore +dotnet build + +# Apply migrations +dotnet ef database update + +# Run API (dev) +dotnet run --project API +``` + +API runs at `http://localhost:5000` · Swagger at `http://localhost:5000/swagger` + +### Frontend + +```bash +cd frontend +npm install +ng serve +``` + +Frontend runs at `http://localhost:4200` + +### Docker (dev stack) + +```bash +docker-compose -f docker-compose.dev.yml up +``` + +--- + +## Configuration + +`backend/appsettings.json` — override in `appsettings.Development.json` or environment variables: + +| Key | Default | Description | +|---|---|---| +| `ConnectionStrings:ExtrudexDb` | `Host=localhost;...` | PostgreSQL connection string | +| `FilamentUsageSync:PollingInterval` | `00:05:00` | Sync job interval | +| `FilamentUsageSync:RequestTimeout` | `00:00:30` | Moonraker request timeout | +| `FilamentUsageSync:Enabled` | `true` | Enable/disable background sync | + +--- + +## Real-Time Events + +SignalR hub endpoint: `/hubs/printer` + +Clients receive `PrinterHub` events for live printer status, job progress, and spool consumption updates. + +--- + +## API Overview + +| Route prefix | Resource | +|---|---| +| `/api/filaments` | Filament catalog | +| `/api/spools` | Spool inventory | +| `/api/printers` | Printer registry | +| `/api/print-jobs` | Print job tracking | +| `/api/material-bases` | Material base types | +| `/api/material-finishes` | Material finishes | +| `/api/material-modifiers` | Material modifiers | +| `/api/material-lookups` | Combined material lookup | +| `/api/cost-analysis` | Cost-per-print and COGS | +| `/api/qr` | QR code generation | + +Full schema available at `/swagger` when running in dev. + +--- + +## CI + +Gitea Actions pipeline (`.gitea/workflows/dev.yml`) runs on every push to `dev`: + +- `dotnet build` +- Frontend `ng build` + +--- + +## Branch & PR Rules + +- All feature branches target `dev` — **never `main`** +- Branch naming: `agent//CUB-N-short-description` +- PR titles: `CUB-N: short description` +- PRs require Otto review before Joshua merges + +--- + +*Built by CubeCraft Creations · Orchestrated by Otto* diff --git a/backend/API/Controllers/FilamentsController.cs b/backend/API/Controllers/FilamentsController.cs index 5c62fec..f39c81d 100644 --- a/backend/API/Controllers/FilamentsController.cs +++ b/backend/API/Controllers/FilamentsController.cs @@ -40,15 +40,18 @@ public class FilamentsController : ControllerBase /// Returns the paginated list of filament spools. [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task>> GetFilaments( [FromQuery] FilamentQueryParameters query) { _logger.LogDebug( "Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " + "materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " + - "materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}", + "materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}, " + + "includeArchived={IncludeArchived}, storageLocation={StorageLocation}", query.PageNumber, query.PageSize, query.MaterialBaseId, - query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive); + query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive, + query.IncludeArchived, query.StorageLocation); // Clamp pagination values var pageNumber = Math.Max(1, query.PageNumber); @@ -77,6 +80,15 @@ public class FilamentsController : ControllerBase if (query.IsActive.HasValue) spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value); + // Exclude archived spools by default; include when explicitly requested + if (query.IncludeArchived != true) + spoolQuery = spoolQuery.Where(s => !s.IsArchived); + + if (!string.IsNullOrWhiteSpace(query.StorageLocation)) + spoolQuery = spoolQuery.Where(s => + s.StorageLocation != null && + s.StorageLocation.ToLower().Contains(query.StorageLocation.ToLower())); + var totalCount = await spoolQuery.CountAsync(); var items = await spoolQuery @@ -185,7 +197,9 @@ public class FilamentsController : ControllerBase SpoolSerial = request.SpoolSerial, PurchasePrice = request.PurchasePrice, PurchaseDate = request.PurchaseDate, - IsActive = request.IsActive + IsActive = request.IsActive, + IsArchived = request.IsArchived, + StorageLocation = request.StorageLocation }; _dbContext.Spools.Add(entity); @@ -267,6 +281,8 @@ public class FilamentsController : ControllerBase entity.PurchasePrice = request.PurchasePrice; entity.PurchaseDate = request.PurchaseDate; entity.IsActive = request.IsActive; + entity.IsArchived = request.IsArchived; + entity.StorageLocation = request.StorageLocation; await _dbContext.SaveChangesAsync(); @@ -307,6 +323,8 @@ public class FilamentsController : ControllerBase PurchasePrice = s.PurchasePrice, PurchaseDate = s.PurchaseDate, IsActive = s.IsActive, + IsArchived = s.IsArchived, + StorageLocation = s.StorageLocation, CreatedAt = s.CreatedAt, UpdatedAt = s.UpdatedAt, QrCodeUrl = $"/api/qr/spool/{s.Id}" diff --git a/backend/API/DTOs/Filaments/FilamentDtos.cs b/backend/API/DTOs/Filaments/FilamentDtos.cs index 3b1b91b..75f9247 100644 --- a/backend/API/DTOs/Filaments/FilamentDtos.cs +++ b/backend/API/DTOs/Filaments/FilamentDtos.cs @@ -59,6 +59,12 @@ public class FilamentResponse /// Whether the spool is currently active and available. public bool IsActive { get; set; } + /// Whether the spool has been archived (removed from active inventory). + public bool IsArchived { get; set; } + + /// Physical storage location (e.g., "Shelf A", "Drawer 3"). Null if unset. + public string? StorageLocation { get; set; } + /// Timestamp when this record was created (UTC). public DateTime CreatedAt { get; set; } @@ -133,6 +139,15 @@ public class CreateFilamentRequest /// Whether the spool is active. Defaults to true. public bool IsActive { get; set; } = true; + + /// Whether the spool is archived. Defaults to false. + /// + public bool IsArchived { get; set; } = false; + + /// Physical storage location (e.g., "Shelf A", "Drawer 3"). Optional. + /// + [StringLength(200, ErrorMessage = "StorageLocation must not exceed 200 characters.")] + public string? StorageLocation { get; set; } } /// @@ -196,4 +211,11 @@ public class UpdateFilamentRequest /// Whether the spool is active. public bool IsActive { get; set; } = true; + + /// Whether the spool is archived. Defaults to false. + public bool IsArchived { get; set; } = false; + + /// Physical storage location (e.g., "Shelf A", "Drawer 3"). Optional. + [StringLength(200, ErrorMessage = "StorageLocation must not exceed 200 characters.")] + public string? StorageLocation { get; set; } } \ No newline at end of file diff --git a/backend/API/DTOs/Filaments/FilamentQueryDtos.cs b/backend/API/DTOs/Filaments/FilamentQueryDtos.cs index 98c9bd6..43936e7 100644 --- a/backend/API/DTOs/Filaments/FilamentQueryDtos.cs +++ b/backend/API/DTOs/Filaments/FilamentQueryDtos.cs @@ -30,4 +30,11 @@ public class FilamentQueryParameters /// Optional filter by active status. True = active only, False = inactive only. public bool? IsActive { get; set; } + + /// Whether to include archived spools in results. Defaults to false (excludes archived). + /// + public bool? IncludeArchived { get; set; } + + /// Optional filter by storage location (case-insensitive partial match). + public string? StorageLocation { get; set; } } \ No newline at end of file diff --git a/backend/API/Validators/FilamentValidators.cs b/backend/API/Validators/FilamentValidators.cs index 8fe0f18..a36bd3d 100644 --- a/backend/API/Validators/FilamentValidators.cs +++ b/backend/API/Validators/FilamentValidators.cs @@ -10,6 +10,9 @@ namespace Extrudex.API.Validators; /// public class CreateFilamentRequestValidator : AbstractValidator { + /// + /// Initializes validation rules for . + /// public CreateFilamentRequestValidator() { RuleFor(x => x.MaterialBaseId) @@ -52,6 +55,12 @@ public class CreateFilamentRequestValidator : AbstractValidator x.PurchasePrice!.Value) .GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative."); }); + + When(x => x.StorageLocation != null, () => + { + RuleFor(x => x.StorageLocation!) + .MaximumLength(200).WithMessage("StorageLocation must not exceed 200 characters."); + }); } } @@ -62,6 +71,9 @@ public class CreateFilamentRequestValidator : AbstractValidator public class UpdateFilamentRequestValidator : AbstractValidator { + /// + /// Initializes validation rules for . + /// public UpdateFilamentRequestValidator() { RuleFor(x => x.MaterialBaseId) @@ -104,5 +116,11 @@ public class UpdateFilamentRequestValidator : AbstractValidator x.PurchasePrice!.Value) .GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative."); }); + + When(x => x.StorageLocation != null, () => + { + RuleFor(x => x.StorageLocation!) + .MaximumLength(200).WithMessage("StorageLocation must not exceed 200 characters."); + }); } } \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerDisplayStatus.cs b/backend/Domain/DTOs/Moonraker/MoonrakerDisplayStatus.cs new file mode 100644 index 0000000..1563309 --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerDisplayStatus.cs @@ -0,0 +1,19 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Response DTO for Moonraker /printer/objects/query?display_status endpoint. +/// Contains progress percentage and message for the current print job. +/// Used by the SignalR hub to push real-time progress to connected clients. +/// +public class MoonrakerDisplayStatus +{ + /// + /// Print progress as a decimal between 0 and 1 (0% to 100%). + /// + public decimal Progress { get; set; } + + /// + /// Status message displayed on the printer LCD (e.g., "Printing...", "Heating..."). + /// + public string Message { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerHistoryResponse.cs b/backend/Domain/DTOs/Moonraker/MoonrakerHistoryResponse.cs new file mode 100644 index 0000000..a7f557b --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerHistoryResponse.cs @@ -0,0 +1,20 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Response DTO for the Moonraker /server/history/items endpoint. +/// Wraps the paginated list of print job history items. +/// +public class MoonrakerHistoryResponse +{ + /// + /// The list of print job history items returned by Moonraker. + /// Most recent jobs appear first (descending by start time). + /// + public List Items { get; set; } = []; + + /// + /// Total number of print jobs available on the server + /// (for pagination; the Items list may be a subset). + /// + public int TotalCount { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerPrintJob.cs b/backend/Domain/DTOs/Moonraker/MoonrakerPrintJob.cs new file mode 100644 index 0000000..4e1783b --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerPrintJob.cs @@ -0,0 +1,56 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Response DTO for a single Moonraker print job history item. +/// Maps to the objects returned by /server/history/items. +/// Contains filament usage, duration, and status for a completed or active print. +/// +public class MoonrakerPrintJob +{ + /// + /// Unique Moonraker job identifier (e.g., "000001"). + /// + public string JobId { get; set; } = string.Empty; + + /// + /// Filename of the G-code file that was printed. + /// + public string Filename { get; set; } = string.Empty; + + /// + /// Current status of this print job: "completed", "cancelled", "error", "in_progress". + /// + public string Status { get; set; } = string.Empty; + + /// + /// Total filament used in millimeters for this print job. + /// This is the primary measurement; grams are derived from this value. + /// + public decimal FilamentUsedMm { get; set; } + + /// + /// Total print duration in seconds. + /// + public decimal PrintDurationSeconds { get; set; } + + /// + /// Total print duration including setup and warmup, in seconds. + /// + public decimal TotalDurationSeconds { get; set; } + + /// + /// Timestamp when the print job started (UTC). + /// + public DateTime? StartTime { get; set; } + + /// + /// Timestamp when the print job ended (UTC). Null if still in progress. + /// + public DateTime? EndTime { get; set; } + + /// + /// Metadata dictionary from Moonraker. May contain filament_type, + /// filament_name, nozzle_diameter, and other slicer-provided fields. + /// + public Dictionary Metadata { get; set; } = new(); +} \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerPrintStats.cs b/backend/Domain/DTOs/Moonraker/MoonrakerPrintStats.cs new file mode 100644 index 0000000..629b755 --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerPrintStats.cs @@ -0,0 +1,36 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Response DTO for Moonraker /printer/objects/query?print_stats endpoint. +/// Contains real-time print statistics including current job state, + /// filament consumed, and file being printed. +/// +public class MoonrakerPrintStats +{ + /// + /// Current print state: "standby", "printing", "paused", "complete", "error", "cancelled". + /// + public string State { get; set; } = string.Empty; + + /// + /// Total filament used in millimeters for the current print session. + /// + public decimal FilamentUsedMm { get; set; } + + /// + /// Total print duration in seconds for the current print session. + /// + public decimal PrintDurationSeconds { get; set; } + + /// + /// Filename of the G-code file currently being printed. + /// Null if no print is active. + /// + public string? Filename { get; set; } + + /// + /// Detailed message from Klipper about the current print state. + /// May contain error details when state is "error". + /// + public string? Message { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerPrinterInfo.cs b/backend/Domain/DTOs/Moonraker/MoonrakerPrinterInfo.cs new file mode 100644 index 0000000..b41fdce --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerPrinterInfo.cs @@ -0,0 +1,26 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Response DTO for the Moonraker /printer/info endpoint. +/// Contains the current operational state of the Klipper printer. +/// Used to determine whether the printer is idle, printing, paused, or in error. +/// +public class MoonrakerPrinterInfo +{ + /// + /// Current Klipper state: "ready", "startup", "shutdown", "error", "cancelled". + /// A "ready" state means the printer is connected and idle. + /// + public string State { get; set; } = string.Empty; + + /// + /// Detailed state message from Klipper. May contain error details + /// when the state is "error" or "shutdown". + /// + public string StateMessage { get; set; } = string.Empty; + + /// + /// Whether the Klipper firmware is currently connected and responsive. + /// + public bool KlippyReady { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerRequest.cs b/backend/Domain/DTOs/Moonraker/MoonrakerRequest.cs new file mode 100644 index 0000000..c940283 --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerRequest.cs @@ -0,0 +1,25 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Request DTO for querying the Moonraker API. +/// Encapsulates the connection parameters needed to reach a specific +/// Moonraker instance on a Klipper-based printer. +/// +public class MoonrakerRequest +{ + /// + /// Hostname or IP address of the Moonraker printer. + /// + public string HostnameOrIp { get; set; } = string.Empty; + + /// + /// Port number for the Moonraker API. Default: 7125. + /// + public int Port { get; set; } = 7125; + + /// + /// Optional API key for authenticating with Moonraker. + /// Required when the server has API key authentication enabled. + /// + public string? ApiKey { get; set; } +} \ No newline at end of file diff --git a/backend/Domain/DTOs/Moonraker/MoonrakerServerInfo.cs b/backend/Domain/DTOs/Moonraker/MoonrakerServerInfo.cs new file mode 100644 index 0000000..1d8d74c --- /dev/null +++ b/backend/Domain/DTOs/Moonraker/MoonrakerServerInfo.cs @@ -0,0 +1,44 @@ +namespace Extrudex.Domain.DTOs.Moonraker; + +/// +/// Response DTO for the Moonraker /server/info endpoint. +/// Contains server identification and operational state. +/// Used to verify connectivity and determine Moonraker version. +/// +public class MoonrakerServerInfo +{ + /// + /// The hostname of the Moonraker server (e.g., "mainsail"). + /// + public string Hostname { get; set; } = string.Empty; + + /// + /// Moonraker software version string (e.g., "0.8.0-89ee464"). + /// + public string SoftwareVersion { get; set; } = string.Empty; + + /// + /// CPU model string reported by the host system. + /// + public string CpuInfo { get; set; } = string.Empty; + + /// + /// Whether Klipper is currently connected to the MCU. + /// + public bool KlippyConnected { get; set; } + + /// + /// The current Klipper state (e.g., "ready", "startup", "error"). + /// + public string KlippyState { get; set; } = string.Empty; + + /// + /// Whether the Moonraker API requires an authentication token. + /// + public bool ApiKeyRequired { get; set; } + + /// + /// List of registered Moonraker plugin names. + /// + public List Plugins { get; set; } = []; +} \ No newline at end of file diff --git a/backend/Domain/Entities/Spool.cs b/backend/Domain/Entities/Spool.cs index 5084a7b..fd17d26 100644 --- a/backend/Domain/Entities/Spool.cs +++ b/backend/Domain/Entities/Spool.cs @@ -93,6 +93,20 @@ public class Spool : AuditableEntity /// public bool IsActive { get; set; } = true; + /// + /// Whether the spool has been archived (removed from active inventory). + /// Archived spools are retained for historical records but hidden from + /// default inventory views. Distinguishes long-term archival from + /// temporary inactivity (e.g., spool swapped out of AMS). + /// + public bool IsArchived { get; set; } = false; + + /// + /// Physical storage location of the spool (e.g., "Shelf A", "Drawer 3", "AMS Tray 2"). + /// Optional — not every spool has a designated storage location. + /// + public string? StorageLocation { get; set; } + /// /// Navigation collection of AMS slots where this spool is loaded. /// diff --git a/backend/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs index 6feff80..5aabb00 100644 --- a/backend/Domain/Interfaces/IMoonrakerClient.cs +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -1,76 +1,131 @@ +using Extrudex.Domain.DTOs.Moonraker; + 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. +/// Client interface for communicating with Moonraker REST API endpoints +/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). +/// Provides strongly-typed methods for server discovery, printer status, +/// print job history, and real-time telemetry. /// public interface IMoonrakerClient { /// - /// Retrieves the current printer status from Moonraker. + /// Checks whether the Moonraker server is reachable and responding. + /// Calls the /server/info endpoint and returns the server information + /// if successful, or null if the server is unreachable. /// - /// Printer hostname or IP address. - /// Moonraker port (default: 7125). + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). /// Optional API key for authentication. - /// Cancellation token. - /// The printer status string (e.g., "idle", "printing", "paused", "error"). - Task GetPrinterStatusAsync( + /// Cancellation token for the HTTP request. + /// Server info if reachable; null if unreachable. + Task GetServerInfoAsync( string hostnameOrIp, int port, - string? apiKey = null, + string? apiKey, 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. + /// Checks whether the Moonraker server is reachable and responding. + /// This is a convenience method equivalent to calling GetServerInfoAsync + /// and checking for a non-null result. /// - /// Printer hostname or IP address. - /// Moonraker port (default: 7125). + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). /// Optional API key for authentication. - /// Cancellation token. - /// Filament usage data from Moonraker, or null if unavailable. - Task GetFilamentUsageAsync( + /// Cancellation token for the HTTP request. + /// true if the server responded successfully; otherwise false. + Task IsReachableAsync( string hostnameOrIp, int port, - string? apiKey = null, + string? apiKey, 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. + /// Fetches the current printer info from the /printer/info endpoint. + /// Returns the Klipper state and readiness status. /// - public string? GcodeFileName { get; set; } + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token for the HTTP request. + /// Printer info if successful; null if the request failed. + Task GetPrinterInfoAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); /// - /// Current print state from Moonraker (e.g., "printing", "complete", "error"). + /// Fetches print job history from the /server/history/items endpoint. + /// Returns the most recent print jobs with filament usage data, + /// print duration, and completion status. /// - public string PrintState { get; set; } = string.Empty; + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Maximum number of history items to return. Default: 50. + /// Cancellation token for the HTTP request. + /// History response with print jobs; empty list if request failed. + Task GetPrintHistoryAsync( + string hostnameOrIp, + int port, + string? apiKey, + int limit = 50, + CancellationToken cancellationToken = default); /// - /// Total print time in seconds, if available from Moonraker. + /// Fetches the current print statistics from the /printer/objects/query endpoint. + /// Returns real-time data including filament used, print duration, + /// and current print state for the active or most recent print. /// - public double? PrintDurationSeconds { get; set; } + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token for the HTTP request. + /// Print stats if successful; null if the request failed. + Task GetPrintStatsAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); /// - /// Timestamp (UTC) when the print job was started, if available. + /// Fetches the current display status from the /printer/objects/query endpoint. + /// Returns progress percentage and status message for the active print. + /// Used by SignalR to push real-time progress updates to connected clients. /// - public DateTime? StartedAt { get; set; } + /// The printer's hostname or IP address. + /// The Moonraker API port (default: 7125). + /// Optional API key for authentication. + /// Cancellation token for the HTTP request. + /// Display status if successful; null if the request failed. + Task GetDisplayStatusAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default); /// - /// Timestamp (UTC) when the print job completed, if available. + /// Fetches the current filament usage data from the Moonraker server. + /// Returns a dictionary of usage metrics reported by the printer. + /// + /// + /// Prefer GetPrintHistoryAsync or GetPrintStatsAsync for new code. + /// This method is retained for backward compatibility with the + /// FilamentUsageSyncService and returns a dictionary of metric names + /// to their decimal values for callers that don't need typed DTOs. + /// /// - public DateTime? CompletedAt { get; set; } + /// 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); } \ No newline at end of file diff --git a/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs b/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs index a426906..19eff6d 100644 --- a/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs +++ b/backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs @@ -68,6 +68,15 @@ public class SpoolConfiguration : BaseEntityConfiguration .HasDefaultValue(true) .IsRequired(); + builder.Property(e => e.IsArchived) + .HasColumnName("is_archived") + .HasDefaultValue(false) + .IsRequired(); + + builder.Property(e => e.StorageLocation) + .HasColumnName("storage_location") + .HasMaxLength(200); + // Unique index on spool_serial — critical for barcode/QR scanning builder.HasIndex(e => e.SpoolSerial) .IsUnique() @@ -89,6 +98,14 @@ public class SpoolConfiguration : BaseEntityConfiguration builder.HasIndex(e => e.IsActive) .HasDatabaseName("ix_spools_is_active"); + // Index on is_archived for inventory filtering (exclude archived from default views) + builder.HasIndex(e => e.IsArchived) + .HasDatabaseName("ix_spools_is_archived"); + + // Composite index on is_active + is_archived for common inventory queries + builder.HasIndex(e => new { e.IsActive, e.IsArchived }) + .HasDatabaseName("ix_spools_active_archived"); + // Relationships builder.HasOne(e => e.MaterialBase) .WithMany(e => e.Spools) diff --git a/backend/Infrastructure/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs index beb4ec3..1e8257f 100644 --- a/backend/Infrastructure/Services/MoonrakerClient.cs +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -1,20 +1,16 @@ -using System.Globalization; -using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Text.Json; +using Extrudex.Domain.DTOs.Moonraker; using Extrudex.Domain.Interfaces; using Microsoft.Extensions.Logging; -namespace Extrudex.Infrastructure.Services; +namespace Extrudex.Infrastructure.Configuration; /// -/// 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. +/// HTTP client for communicating with Moonraker REST API endpoints +/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). +/// Provides strongly-typed methods for server discovery, printer status, +/// print job history, and real-time telemetry. /// public class MoonrakerClient : IMoonrakerClient { @@ -22,9 +18,9 @@ public class MoonrakerClient : IMoonrakerClient private readonly ILogger _logger; /// - /// Creates a new MoonrakerClient with the specified HTTP client and logger. + /// Creates a new MoonrakerClient with the configured HTTP client and logger. /// - /// The HTTP client used for API calls. + /// The HTTP client for making requests to Moonraker endpoints. /// Logger for diagnostic output. public MoonrakerClient(HttpClient httpClient, ILogger logger) { @@ -33,271 +29,419 @@ public class MoonrakerClient : IMoonrakerClient } /// - public async Task GetPrinterStatusAsync( + public async Task GetServerInfoAsync( string hostnameOrIp, int port, - string? apiKey = null, + string? apiKey, 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 request = CreateRequest(HttpMethod.Get, $"{baseUrl}/server/info", apiKey); using var response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(cancellationToken); - using var doc = JsonDocument.Parse(json); + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - // Moonraker returns: { "result": { "print_stats": { "state": "idle", ... } } } - var state = doc.RootElement - .GetProperty("result") - .GetProperty("print_stats") - .GetProperty("state") - .GetString(); + var serverInfo = new MoonrakerServerInfo(); - return state ?? "unknown"; + if (json.TryGetProperty("result", out var result)) + { + if (result.TryGetProperty("hostname", out var hostname)) + serverInfo.Hostname = hostname.GetString() ?? string.Empty; + if (result.TryGetProperty("software_version", out var version)) + serverInfo.SoftwareVersion = version.GetString() ?? string.Empty; + if (result.TryGetProperty("cpu_info", out var cpuInfo)) + serverInfo.CpuInfo = cpuInfo.GetString() ?? string.Empty; + if (result.TryGetProperty("klippy_connected", out var klippyConnected)) + serverInfo.KlippyConnected = klippyConnected.GetBoolean(); + if (result.TryGetProperty("klippy_state", out var klippyState)) + serverInfo.KlippyState = klippyState.GetString() ?? string.Empty; + if (result.TryGetProperty("api_key_required", out var apiKeyRequired)) + serverInfo.ApiKeyRequired = apiKeyRequired.GetBoolean(); + if (result.TryGetProperty("plugins", out var plugins)) + serverInfo.Plugins = plugins.EnumerateArray() + .Select(p => p.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + } + + _logger.LogDebug( + "Retrieved server info from Moonraker at {Host}:{Port} — version {Version}, klippy {State}", + hostnameOrIp, port, serverInfo.SoftwareVersion, serverInfo.KlippyState); + + return serverInfo; } catch (HttpRequestException ex) { _logger.LogWarning(ex, - "Moonraker printer status request failed for {Host}:{Port}", + "Failed to retrieve server info from Moonraker at {Host}:{Port}", hostnameOrIp, port); - return "offline"; + return null; } catch (JsonException ex) { _logger.LogWarning(ex, - "Malformed Moonraker response from {Host}:{Port}", + "Failed to parse Moonraker server info response from {Host}:{Port}", hostnameOrIp, port); - return "error"; + return null; } } /// - public async Task GetFilamentUsageAsync( + public async Task IsReachableAsync( string hostnameOrIp, int port, - string? apiKey = null, + string? apiKey, + CancellationToken cancellationToken = default) + { + var serverInfo = await GetServerInfoAsync(hostnameOrIp, port, apiKey, cancellationToken); + return serverInfo is not null; + } + + /// + public async Task GetPrinterInfoAsync( + string hostnameOrIp, + int port, + string? apiKey, 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; - } + using var request = CreateRequest(HttpMethod.Get, $"{baseUrl}/printer/info", apiKey); + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); - if (printStats is null) - return null; + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - // 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); - } + var printerInfo = new MoonrakerPrinterInfo(); - 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)) + if (json.TryGetProperty("result", out var result)) { - return dt.ToUniversalTime(); + if (result.TryGetProperty("state", out var state)) + printerInfo.State = state.GetString() ?? string.Empty; + if (result.TryGetProperty("state_message", out var stateMessage)) + printerInfo.StateMessage = stateMessage.GetString() ?? string.Empty; + if (result.TryGetProperty("klippy_ready", out var klippyReady)) + printerInfo.KlippyReady = klippyReady.GetBoolean(); } + + _logger.LogDebug( + "Retrieved printer info from Moonraker at {Host}:{Port} — state: {State}", + hostnameOrIp, port, printerInfo.State); + + return printerInfo; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Failed to retrieve printer info from Moonraker at {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Failed to parse Moonraker printer info response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + } + + /// + public async Task GetPrintHistoryAsync( + string hostnameOrIp, + int port, + string? apiKey, + int limit = 50, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + var historyResponse = new MoonrakerHistoryResponse(); + + try + { + using var request = CreateRequest( + HttpMethod.Get, + $"{baseUrl}/server/history/items?limit={limit}", + apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (json.TryGetProperty("result", out var result)) + { + if (result.TryGetProperty("count", out var count)) + historyResponse.TotalCount = count.GetInt32(); + + if (result.TryGetProperty("items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var job = MapPrintJob(item); + historyResponse.Items.Add(job); + } + } + } + + _logger.LogDebug( + "Retrieved {JobCount} print history items from Moonraker at {Host}:{Port}", + historyResponse.Items.Count, hostnameOrIp, port); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Failed to retrieve print history from Moonraker at {Host}:{Port}", + hostnameOrIp, port); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Failed to parse Moonraker history response from {Host}:{Port}", + hostnameOrIp, port); } - return null; + return historyResponse; + } + + /// + public async Task GetPrintStatsAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + + try + { + using var request = CreateRequest( + HttpMethod.Get, + $"{baseUrl}/printer/objects/query?print_stats", + apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (json.TryGetProperty("result", out var result) + && result.TryGetProperty("status", out var status) + && status.TryGetProperty("print_stats", out var printStats)) + { + var stats = MapPrintStats(printStats); + + _logger.LogDebug( + "Retrieved print stats from Moonraker at {Host}:{Port} — state: {State}, filament: {FilamentMm}mm", + hostnameOrIp, port, stats.State, stats.FilamentUsedMm); + + return stats; + } + + _logger.LogWarning( + "Moonraker print_stats not found in response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Failed to retrieve print stats from Moonraker at {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Failed to parse Moonraker print stats response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + } + + /// + public async Task GetDisplayStatusAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + var baseUrl = BuildBaseUrl(hostnameOrIp, port); + + try + { + using var request = CreateRequest( + HttpMethod.Get, + $"{baseUrl}/printer/objects/query?display_status", + apiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (json.TryGetProperty("result", out var result) + && result.TryGetProperty("status", out var status) + && status.TryGetProperty("display_status", out var displayStatus)) + { + var ds = new MoonrakerDisplayStatus(); + + if (displayStatus.TryGetProperty("progress", out var progress)) + ds.Progress = progress.GetDecimal(); + if (displayStatus.TryGetProperty("message", out var message)) + ds.Message = message.GetString() ?? string.Empty; + + _logger.LogDebug( + "Retrieved display status from Moonraker at {Host}:{Port} — progress: {Progress:P0}", + hostnameOrIp, port, ds.Progress); + + return ds; + } + + _logger.LogWarning( + "Moonraker display_status not found in response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, + "Failed to retrieve display status from Moonraker at {Host}:{Port}", + hostnameOrIp, port); + return null; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, + "Failed to parse Moonraker display status response from {Host}:{Port}", + hostnameOrIp, port); + return null; + } + } + + /// + public async Task> GetFilamentUsageAsync( + string hostnameOrIp, + int port, + string? apiKey, + CancellationToken cancellationToken = default) + { + // Delegate to the typed GetPrintHistoryAsync and extract metrics + var history = await GetPrintHistoryAsync(hostnameOrIp, port, apiKey, limit: 1, cancellationToken); + var result = new Dictionary(); + + if (history.Items.Count > 0) + { + var latestJob = history.Items[0]; + result["mm_extruded"] = latestJob.FilamentUsedMm; + result["print_duration_seconds"] = latestJob.PrintDurationSeconds; + } + + _logger.LogDebug( + "Retrieved filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics", + hostnameOrIp, port, result.Count); + + return result; } /// - /// Builds the base URL for Moonraker API calls. + /// Builds the base URL for Moonraker API calls from hostname and port. /// - private static string BuildBaseUrl(string hostnameOrIp, int port) => - $"http://{hostnameOrIp}:{port}"; + private static string BuildBaseUrl(string hostnameOrIp, int port) + { + return $"http://{hostnameOrIp}:{port}"; + } /// - /// Applies the Moonraker API key to the request header if provided. + /// Creates an HttpRequestMessage with the optional API key header. /// - private static void ApplyApiKey(HttpRequestMessage request, string? apiKey) + private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string? apiKey) { - if (!string.IsNullOrWhiteSpace(apiKey)) + var request = new HttpRequestMessage(method, url); + if (!string.IsNullOrEmpty(apiKey)) { request.Headers.Add("X-Api-Key", apiKey); } + return request; } /// - /// Parsed result from Moonraker's print_stats object. - /// Extracted immediately from the JSON response to avoid JsonDocument disposal issues. + /// Maps a JSON element representing a Moonraker print job history item + /// to a DTO. /// - private sealed class PrintStatsResult + private static MoonrakerPrintJob MapPrintJob(JsonElement item) { - public string? State { get; set; } - public decimal? FilamentUsedMm { get; set; } - public string? FileName { get; set; } + var job = new MoonrakerPrintJob(); + + if (item.TryGetProperty("job_id", out var jobId)) + job.JobId = jobId.GetString() ?? string.Empty; + if (item.TryGetProperty("filename", out var filename)) + job.Filename = filename.GetString() ?? string.Empty; + if (item.TryGetProperty("status", out var status)) + job.Status = status.GetString() ?? string.Empty; + if (item.TryGetProperty("filament_used", out var filamentUsed)) + job.FilamentUsedMm = filamentUsed.GetDecimal(); + if (item.TryGetProperty("print_duration", out var printDuration)) + job.PrintDurationSeconds = printDuration.GetDecimal(); + if (item.TryGetProperty("total_duration", out var totalDuration)) + job.TotalDurationSeconds = totalDuration.GetDecimal(); + + if (item.TryGetProperty("start_time", out var startTime) && startTime.ValueKind != JsonValueKind.Null) + { + if (startTime.TryGetInt64(out var startTimeSeconds)) + job.StartTime = DateTimeOffset.FromUnixTimeSeconds(startTimeSeconds).UtcDateTime; + } + + if (item.TryGetProperty("end_time", out var endTime) && endTime.ValueKind != JsonValueKind.Null) + { + if (endTime.TryGetInt64(out var endTimeSeconds)) + job.EndTime = DateTimeOffset.FromUnixTimeSeconds(endTimeSeconds).UtcDateTime; + } + + if (item.TryGetProperty("metadata", out var metadata) && metadata.ValueKind == JsonValueKind.Object) + { + foreach (var prop in metadata.EnumerateObject()) + { + object value = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString() ?? string.Empty, + JsonValueKind.Number => prop.Value.GetDecimal(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => prop.Value.ToString() ?? string.Empty + }; + job.Metadata[prop.Name] = value; + } + } + + return job; } /// - /// Parsed result from Moonraker's history/last_job object. + /// Maps a JSON element representing Moonraker print_stats + /// to a DTO. /// - private sealed class HistoryResult + private static MoonrakerPrintStats MapPrintStats(JsonElement printStats) { - public DateTime? StartTime { get; set; } - public DateTime? EndTime { get; set; } - public double? PrintDuration { get; set; } + var stats = new MoonrakerPrintStats(); + + if (printStats.TryGetProperty("state", out var state)) + stats.State = state.GetString() ?? string.Empty; + if (printStats.TryGetProperty("filament_used", out var filamentUsed)) + stats.FilamentUsedMm = filamentUsed.GetDecimal(); + if (printStats.TryGetProperty("print_duration", out var printDuration)) + stats.PrintDurationSeconds = printDuration.GetDecimal(); + if (printStats.TryGetProperty("filename", out var filename) && filename.ValueKind != JsonValueKind.Null) + stats.Filename = filename.GetString(); + if (printStats.TryGetProperty("message", out var message) && message.ValueKind != JsonValueKind.Null) + stats.Message = message.GetString(); + + return stats; } } \ No newline at end of file