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