From 51bfb6d1153832ae8247f98b9ca5d22e6ac2d56d Mon Sep 17 00:00:00 2001 From: rex-bot Date: Mon, 27 Apr 2026 18:42:47 -0400 Subject: [PATCH] CUB-10: Create IMoonrakerClient interface and DTOs - Expanded IMoonrakerClient interface with 6 strongly-typed methods: - GetServerInfoAsync (Moonraker /server/info) - IsReachableAsync (connectivity check) - GetPrinterInfoAsync (Moonraker /printer/info) - GetPrintHistoryAsync (Moonraker /server/history/items) - GetPrintStatsAsync (Moonraker /printer/objects/query?print_stats) - GetDisplayStatusAsync (Moonraker /printer/objects/query?display_status) - GetFilamentUsageAsync (retained for backward compatibility) - Created Domain/DTOs/Moonraker/ with 7 DTOs: - MoonrakerServerInfo, MoonrakerPrinterInfo, MoonrakerPrintJob - MoonrakerHistoryResponse, MoonrakerPrintStats - MoonrakerDisplayStatus, MoonrakerRequest - Updated MoonrakerClient implementation to support all new methods with proper JSON parsing and mapping helpers - Full XML doc comments on all public members --- .../DTOs/Moonraker/MoonrakerDisplayStatus.cs | 19 + .../Moonraker/MoonrakerHistoryResponse.cs | 20 + .../DTOs/Moonraker/MoonrakerPrintJob.cs | 56 +++ .../DTOs/Moonraker/MoonrakerPrintStats.cs | 36 ++ .../DTOs/Moonraker/MoonrakerPrinterInfo.cs | 26 ++ .../Domain/DTOs/Moonraker/MoonrakerRequest.cs | 25 ++ .../DTOs/Moonraker/MoonrakerServerInfo.cs | 44 ++ backend/Domain/Interfaces/IMoonrakerClient.cs | 104 ++++- .../Services/MoonrakerClient.cs | 405 ++++++++++++++++-- 9 files changed, 685 insertions(+), 50 deletions(-) create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerDisplayStatus.cs create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerHistoryResponse.cs create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerPrintJob.cs create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerPrintStats.cs create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerPrinterInfo.cs create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerRequest.cs create mode 100644 backend/Domain/DTOs/Moonraker/MoonrakerServerInfo.cs 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/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs index d4599f2..5aabb00 100644 --- a/backend/Domain/Interfaces/IMoonrakerClient.cs +++ b/backend/Domain/Interfaces/IMoonrakerClient.cs @@ -1,23 +1,26 @@ +using Extrudex.Domain.DTOs.Moonraker; + 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. +/// Provides strongly-typed methods for server discovery, printer status, +/// print job history, and real-time telemetry. /// public interface IMoonrakerClient { /// - /// Fetches the current filament usage data from the Moonraker server. - /// Returns a dictionary of usage metrics reported by the printer. + /// 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. /// /// 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( + /// Server info if reachable; null if unreachable. + Task GetServerInfoAsync( string hostnameOrIp, int port, string? apiKey, @@ -25,6 +28,8 @@ public interface IMoonrakerClient /// /// 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. /// /// The printer's hostname or IP address. /// The Moonraker API port (default: 7125). @@ -36,4 +41,91 @@ public interface IMoonrakerClient int port, string? apiKey, CancellationToken cancellationToken = default); + + /// + /// Fetches the current printer info from the /printer/info endpoint. + /// Returns the Klipper state and readiness status. + /// + /// 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); + + /// + /// 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. + /// + /// 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); + + /// + /// 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. + /// + /// 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); + + /// + /// 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. + /// + /// 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); + + /// + /// 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. + /// + /// + /// 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/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs index 1666dcf..1e8257f 100644 --- a/backend/Infrastructure/Services/MoonrakerClient.cs +++ b/backend/Infrastructure/Services/MoonrakerClient.cs @@ -1,15 +1,16 @@ using System.Net.Http.Json; using System.Text.Json; +using Extrudex.Domain.DTOs.Moonraker; 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. +/// Provides strongly-typed methods for server discovery, printer status, +/// print job history, and real-time telemetry. /// public class MoonrakerClient : IMoonrakerClient { @@ -28,69 +29,65 @@ public class MoonrakerClient : IMoonrakerClient } /// - public async Task> GetFilamentUsageAsync( + public async Task GetServerInfoAsync( 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 request = CreateRequest(HttpMethod.Get, $"{baseUrl}/server/info", 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)) + var serverInfo = new MoonrakerServerInfo(); + + if (json.TryGetProperty("result", out var result)) { - 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(); - } - } + 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 filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics", - hostnameOrIp, port, result.Count); + "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, - "Failed to retrieve filament usage from Moonraker at {Host}:{Port}", + "Failed to retrieve server info from Moonraker at {Host}:{Port}", hostnameOrIp, port); + return null; } catch (JsonException ex) { _logger.LogWarning(ex, - "Failed to parse Moonraker response from {Host}:{Port}", + "Failed to parse Moonraker server info response from {Host}:{Port}", hostnameOrIp, port); + return null; } - - return result; } /// @@ -99,25 +96,258 @@ public class MoonrakerClient : IMoonrakerClient int port, 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); try { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/info"); - if (!string.IsNullOrEmpty(apiKey)) + using var request = CreateRequest(HttpMethod.Get, $"{baseUrl}/printer/info", apiKey); + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + var printerInfo = new MoonrakerPrinterInfo(); + + if (json.TryGetProperty("result", out var result)) { - request.Headers.Add("X-Api-Key", apiKey); + 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(); } - using var response = await _httpClient.SendAsync(request, cancellationToken); - return response.IsSuccessStatusCode; + _logger.LogDebug( + "Retrieved printer info from Moonraker at {Host}:{Port} — state: {State}", + hostnameOrIp, port, printerInfo.State); + + return printerInfo; } - catch (HttpRequestException) + catch (HttpRequestException ex) { - _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port); - return false; + _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 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; } /// @@ -127,4 +357,91 @@ public class MoonrakerClient : IMoonrakerClient { return $"http://{hostnameOrIp}:{port}"; } + + /// + /// Creates an HttpRequestMessage with the optional API key header. + /// + private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string? apiKey) + { + var request = new HttpRequestMessage(method, url); + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("X-Api-Key", apiKey); + } + return request; + } + + /// + /// Maps a JSON element representing a Moonraker print job history item + /// to a DTO. + /// + private static MoonrakerPrintJob MapPrintJob(JsonElement item) + { + 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; + } + + /// + /// Maps a JSON element representing Moonraker print_stats + /// to a DTO. + /// + private static MoonrakerPrintStats MapPrintStats(JsonElement printStats) + { + 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