using System.Globalization; using System.Net.Http.Headers; using System.Text.Json; using Extrudex.Domain.Interfaces; using Microsoft.Extensions.Logging; namespace Extrudex.Infrastructure.Services; /// /// 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. /// public class MoonrakerClient : IMoonrakerClient { private readonly HttpClient _httpClient; private readonly ILogger _logger; /// /// Creates a new MoonrakerClient with the specified HTTP client and logger. /// /// The HTTP client used for API calls. /// Logger for diagnostic output. public MoonrakerClient(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; _logger = logger; } /// public async Task GetPrinterStatusAsync( string hostnameOrIp, int port, string? apiKey = null, CancellationToken cancellationToken = default) { var baseUrl = BuildBaseUrl(hostnameOrIp, port); var requestUrl = $"{baseUrl}/api/objects?print_stats"; using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); ApplyApiKey(request, apiKey); try { using var response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); // Moonraker returns: { "result": { "print_stats": { "state": "idle", ... } } } var state = doc.RootElement .GetProperty("result") .GetProperty("print_stats") .GetProperty("state") .GetString(); return state ?? "unknown"; } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Moonraker printer status request failed for {Host}:{Port}", hostnameOrIp, port); return "offline"; } catch (JsonException ex) { _logger.LogWarning(ex, "Malformed Moonraker response from {Host}:{Port}", hostnameOrIp, port); return "error"; } } /// public async Task GetFilamentUsageAsync( string hostnameOrIp, int port, string? apiKey = null, CancellationToken cancellationToken = default) { var baseUrl = BuildBaseUrl(hostnameOrIp, port); // Fetch current print_stats (has live filament_used for active/recent job) PrintStatsResult? printStats = null; try { printStats = await FetchPrintStatsAsync(baseUrl, apiKey, cancellationToken); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Moonraker print_stats request failed for {Host}:{Port}", hostnameOrIp, port); return null; } catch (JsonException ex) { _logger.LogWarning(ex, "Malformed Moonraker print_stats response from {Host}:{Port}", hostnameOrIp, port); return null; } if (printStats is null) return null; // Attempt to enrich with history data for timing info HistoryResult? history = null; try { history = await FetchHistoryAsync(baseUrl, apiKey, cancellationToken); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Moonraker history request failed for {Host}:{Port}", hostnameOrIp, port); } catch (JsonException ex) { _logger.LogWarning(ex, "Malformed Moonraker history response from {Host}:{Port}", hostnameOrIp, port); } DateTime? startedAt = null; DateTime? completedAt = null; double? printDurationSeconds = null; if (history is not null) { startedAt = history.StartTime; completedAt = history.EndTime; printDurationSeconds = history.PrintDuration; } return new MoonrakerFilamentUsage { MmExtruded = printStats.FilamentUsedMm ?? 0m, GcodeFileName = printStats.FileName, PrintState = printStats.State ?? "unknown", PrintDurationSeconds = printDurationSeconds, StartedAt = startedAt, CompletedAt = completedAt }; } /// /// 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)) { return dt.ToUniversalTime(); } } return null; } /// /// Builds the base URL for Moonraker API calls. /// private static string BuildBaseUrl(string hostnameOrIp, int port) => $"http://{hostnameOrIp}:{port}"; /// /// Applies the Moonraker API key to the request header if provided. /// private static void ApplyApiKey(HttpRequestMessage request, string? apiKey) { if (!string.IsNullOrWhiteSpace(apiKey)) { request.Headers.Add("X-Api-Key", apiKey); } } /// /// Parsed result from Moonraker's print_stats object. /// Extracted immediately from the JSON response to avoid JsonDocument disposal issues. /// private sealed class PrintStatsResult { public string? State { get; set; } public decimal? FilamentUsedMm { get; set; } public string? FileName { get; set; } } /// /// Parsed result from Moonraker's history/last_job object. /// private sealed class HistoryResult { public DateTime? StartTime { get; set; } public DateTime? EndTime { get; set; } public double? PrintDuration { get; set; } } }