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.Configuration; /// /// 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 { private readonly HttpClient _httpClient; private readonly ILogger _logger; /// /// Creates a new MoonrakerClient with the configured HTTP client and logger. /// /// The HTTP client for making requests to Moonraker endpoints. /// Logger for diagnostic output. public MoonrakerClient(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; _logger = logger; } /// public async Task GetServerInfoAsync( string hostnameOrIp, int port, string? apiKey, CancellationToken cancellationToken = default) { var baseUrl = BuildBaseUrl(hostnameOrIp, port); 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.ReadFromJsonAsync(cancellationToken: cancellationToken); var serverInfo = new MoonrakerServerInfo(); 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, "Failed to retrieve server info from Moonraker at {Host}:{Port}", hostnameOrIp, port); return null; } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to parse Moonraker server info response from {Host}:{Port}", hostnameOrIp, port); return null; } } /// public async Task IsReachableAsync( string hostnameOrIp, 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 = 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)) { 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 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 from hostname and port. /// private static string BuildBaseUrl(string hostnameOrIp, int port) { 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; } }