merge(dev): Re-apply changes after conflict resolution
This commit is contained in:
@@ -1,158 +0,0 @@
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Extrudex.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the cost of goods sold (COGS) per print job using the spool's
|
||||
/// purchase price and the print job's derived grams consumed.
|
||||
///
|
||||
/// Formula:
|
||||
/// cost_per_gram = purchase_price / weight_total_grams
|
||||
/// cost_per_print = grams_derived × cost_per_gram
|
||||
///
|
||||
/// Handles missing data gracefully — if the spool has no purchase price or
|
||||
/// weight recorded, the result includes warnings and null cost fields
|
||||
/// instead of throwing exceptions.
|
||||
/// </summary>
|
||||
public class CostPerPrintService : ICostPerPrintService
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<CostPerPrintService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CostPerPrintService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public CostPerPrintService(ExtrudexDbContext dbContext, ILogger<CostPerPrintService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CostPerPrintResult> CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Calculating cost per print for job {PrintJobId}", printJobId);
|
||||
|
||||
var job = await _dbContext.PrintJobs
|
||||
.Include(j => j.Spool)
|
||||
.ThenInclude(s => s!.MaterialBase)
|
||||
.FirstOrDefaultAsync(j => j.Id == printJobId, cancellationToken);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
_logger.LogWarning("Print job {PrintJobId} not found for cost calculation", printJobId);
|
||||
return new CostPerPrintResult
|
||||
{
|
||||
PrintJobId = printJobId,
|
||||
Warnings = new List<string> { $"Print job with ID '{printJobId}' not found." }
|
||||
};
|
||||
}
|
||||
|
||||
return BuildResult(job);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CostPerPrintResult>> CalculateBySpoolAsync(
|
||||
Guid spoolId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Calculating cost per print for all jobs on spool {SpoolId}", spoolId);
|
||||
|
||||
var jobs = await _dbContext.PrintJobs
|
||||
.Include(j => j.Spool)
|
||||
.ThenInclude(s => s!.MaterialBase)
|
||||
.Where(j => j.SpoolId == spoolId)
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No print jobs found for spool {SpoolId}", spoolId);
|
||||
return Array.Empty<CostPerPrintResult>();
|
||||
}
|
||||
|
||||
return jobs.Select(BuildResult).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="CostPerPrintResult"/> from a print job entity.
|
||||
/// Computes cost_per_gram and cost_per_print when all required data is available.
|
||||
/// Populates warnings when data is missing or incomplete.
|
||||
/// </summary>
|
||||
/// <param name="job">The print job entity with Spool navigation loaded.</param>
|
||||
/// <returns>A cost calculation result with breakdown and any warnings.</returns>
|
||||
private CostPerPrintResult BuildResult(Domain.Entities.PrintJob job)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var spool = job.Spool;
|
||||
|
||||
// Map what we always have
|
||||
var result = new CostPerPrintResult
|
||||
{
|
||||
PrintJobId = job.Id,
|
||||
PrintName = job.PrintName,
|
||||
SpoolId = job.SpoolId,
|
||||
SpoolSerial = spool?.SpoolSerial ?? string.Empty,
|
||||
MmExtruded = job.MmExtruded,
|
||||
GramsDerived = job.GramsDerived,
|
||||
};
|
||||
|
||||
// Guard: spool must be loaded
|
||||
if (spool is null)
|
||||
{
|
||||
warnings.Add("Spool data is not available for this print job.");
|
||||
result.Warnings = warnings;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Capture purchase price
|
||||
result.PurchasePrice = spool.PurchasePrice;
|
||||
result.WeightTotalGrams = spool.WeightTotalGrams;
|
||||
|
||||
// Check for missing purchase price
|
||||
if (!spool.PurchasePrice.HasValue)
|
||||
{
|
||||
warnings.Add(
|
||||
"Spool purchase price is not recorded. Cost calculation requires a purchase price on the spool.");
|
||||
}
|
||||
|
||||
// Check for zero or negative weight — prevents division by zero
|
||||
if (spool.WeightTotalGrams <= 0)
|
||||
{
|
||||
warnings.Add(
|
||||
"Spool total weight is zero or not recorded. Cost calculation requires a positive weight_total_grams on the spool.");
|
||||
}
|
||||
|
||||
// Check for zero grams derived
|
||||
if (job.GramsDerived <= 0)
|
||||
{
|
||||
warnings.Add(
|
||||
"Derived grams consumed is zero. Ensure mm_extruded, filament diameter, and material density are recorded for this print job.");
|
||||
}
|
||||
|
||||
// If all data is present and valid, compute the cost
|
||||
if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0 && job.GramsDerived > 0)
|
||||
{
|
||||
var costPerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams;
|
||||
result.CostPerGram = Math.Round(costPerGram, 6);
|
||||
result.CostPerPrint = Math.Round(job.GramsDerived * costPerGram, 4);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Cost calculated for job {PrintJobId}: {GramsDerived}g × {CostPerGram:C}/g = {CostPerPrint:C}",
|
||||
job.Id, job.GramsDerived, result.CostPerGram, result.CostPerPrint);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Cost calculation incomplete for job {PrintJobId}: missing data (warnings: {WarningCount})",
|
||||
job.Id, warnings.Count);
|
||||
}
|
||||
|
||||
result.Warnings = warnings;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
using Extrudex.Domain.Enums;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Extrudex.Infrastructure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Service that syncs filament usage data from Moonraker printers into the
|
||||
/// Extrudex database. Queries all active Moonraker printers, fetches their
|
||||
/// current filament usage metrics, and updates spool remaining weights and
|
||||
/// print job records.
|
||||
/// </summary>
|
||||
public class FilamentUsageSyncService : IFilamentUsageSyncService
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly IMoonrakerClient _moonrakerClient;
|
||||
private readonly ILogger<FilamentUsageSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new FilamentUsageSyncService.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The EF Core database context for persisting updates.</param>
|
||||
/// <param name="moonrakerClient">The Moonraker HTTP client for fetching printer data.</param>
|
||||
/// <param name="logger">Logger for diagnostic output.</param>
|
||||
public FilamentUsageSyncService(
|
||||
ExtrudexDbContext dbContext,
|
||||
IMoonrakerClient moonrakerClient,
|
||||
ILogger<FilamentUsageSyncService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_moonrakerClient = moonrakerClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SyncAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Starting filament usage sync cycle");
|
||||
|
||||
var printers = await _dbContext.Printers
|
||||
.Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker)
|
||||
.Include(p => p.AmsUnits)
|
||||
.ThenInclude(u => u.Slots)
|
||||
.ThenInclude(s => s.Spool)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (printers.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No active Moonraker printers found — skipping sync");
|
||||
return 0;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count);
|
||||
|
||||
var syncedCount = 0;
|
||||
|
||||
foreach (var printer in printers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var usageData = await _moonrakerClient.GetFilamentUsageAsync(
|
||||
printer.HostnameOrIp,
|
||||
printer.Port,
|
||||
printer.ApiKey,
|
||||
cancellationToken);
|
||||
|
||||
if (usageData.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No usage data returned from printer {PrinterName} ({Host}:{Port})",
|
||||
printer.Name, printer.HostnameOrIp, printer.Port);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update spool remaining weights from AMS data
|
||||
UpdateSpoolWeights(printer, usageData);
|
||||
|
||||
// Mark printer as seen and idle (reachable = idle, not printing)
|
||||
printer.LastSeenAt = DateTime.UtcNow;
|
||||
printer.Status = PrinterStatus.Idle;
|
||||
|
||||
syncedCount++;
|
||||
_logger.LogInformation(
|
||||
"Successfully synced filament usage from printer {PrinterName}",
|
||||
printer.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error syncing filament usage from printer {PrinterName} ({Host}:{Port})",
|
||||
printer.Name, printer.HostnameOrIp, printer.Port);
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Filament usage sync cycle complete — {SyncedCount}/{TotalCount} printers synced",
|
||||
syncedCount, printers.Count);
|
||||
|
||||
return syncedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates spool remaining weights based on usage data received from Moonraker.
|
||||
/// For printers with AMS units, updates the remaining weight on each slot's spool.
|
||||
/// </summary>
|
||||
private void UpdateSpoolWeights(
|
||||
Domain.Entities.Printer printer,
|
||||
Dictionary<string, decimal> usageData)
|
||||
{
|
||||
// Update AMS slot remaining weights if available
|
||||
foreach (var amsUnit in printer.AmsUnits)
|
||||
{
|
||||
foreach (var slot in amsUnit.Slots)
|
||||
{
|
||||
if (slot.Spool != null && slot.RemainingWeightG.HasValue)
|
||||
{
|
||||
// Sync the AMS-reported remaining weight to the spool
|
||||
slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updated spool {SpoolSerial} remaining weight to {Weight}g",
|
||||
slot.Spool.SpoolSerial, slot.RemainingWeightG.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If usage data contains extruded mm, log it for observability
|
||||
if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job",
|
||||
printer.Name, mmExtruded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Extrudex.Infrastructure.Configuration;
|
||||
namespace Extrudex.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class MoonrakerClient : IMoonrakerClient
|
||||
{
|
||||
@@ -17,9 +22,9 @@ public class MoonrakerClient : IMoonrakerClient
|
||||
private readonly ILogger<MoonrakerClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MoonrakerClient with the configured HTTP client and logger.
|
||||
/// Creates a new MoonrakerClient with the specified HTTP client and logger.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client for making requests to Moonraker endpoints.</param>
|
||||
/// <param name="httpClient">The HTTP client used for API calls.</param>
|
||||
/// <param name="logger">Logger for diagnostic output.</param>
|
||||
public MoonrakerClient(HttpClient httpClient, ILogger<MoonrakerClient> logger)
|
||||
{
|
||||
@@ -28,103 +33,271 @@ public class MoonrakerClient : IMoonrakerClient
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<string, decimal>> GetFilamentUsageAsync(
|
||||
public async Task<string> GetPrinterStatusAsync(
|
||||
string hostnameOrIp,
|
||||
int port,
|
||||
string? apiKey,
|
||||
string? apiKey = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var baseUrl = BuildBaseUrl(hostnameOrIp, port);
|
||||
var result = new Dictionary<string, decimal>();
|
||||
var requestUrl = $"{baseUrl}/api/objects?print_stats";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
ApplyApiKey(request, apiKey);
|
||||
|
||||
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 response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Extract filament usage from the response
|
||||
// Moonraker returns job history with filament_used and similar fields
|
||||
if (json.TryGetProperty("result", out var resultElement))
|
||||
{
|
||||
if (resultElement.TryGetProperty("items", out var items) && items.GetArrayLength() > 0)
|
||||
{
|
||||
var job = items[0];
|
||||
// Moonraker returns: { "result": { "print_stats": { "state": "idle", ... } } }
|
||||
var state = doc.RootElement
|
||||
.GetProperty("result")
|
||||
.GetProperty("print_stats")
|
||||
.GetProperty("state")
|
||||
.GetString();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Retrieved filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics",
|
||||
hostnameOrIp, port, result.Count);
|
||||
return state ?? "unknown";
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to retrieve filament usage from Moonraker at {Host}:{Port}",
|
||||
"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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MoonrakerFilamentUsage?> 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,
|
||||
"Failed to parse Moonraker response from {Host}:{Port}",
|
||||
"Malformed Moonraker history response from {Host}:{Port}",
|
||||
hostnameOrIp, port);
|
||||
}
|
||||
|
||||
return result;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsReachableAsync(
|
||||
string hostnameOrIp,
|
||||
int port,
|
||||
/// <summary>
|
||||
/// Fetches and parses print_stats from the Moonraker API.
|
||||
/// </summary>
|
||||
private async Task<PrintStatsResult?> FetchPrintStatsAsync(
|
||||
string baseUrl,
|
||||
string? apiKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = BuildBaseUrl(hostnameOrIp, port);
|
||||
var requestUrl = $"{baseUrl}/api/objects?print_stats";
|
||||
|
||||
try
|
||||
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))
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/info");
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
request.Headers.Add("X-Api-Key", apiKey);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
_logger.LogWarning("Moonraker response missing 'print_stats' object");
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
|
||||
return new PrintStatsResult
|
||||
{
|
||||
_logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port);
|
||||
return false;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and parses history (last job) from the Moonraker API.
|
||||
/// </summary>
|
||||
private async Task<HistoryResult?> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Moonraker timestamp property (Unix epoch seconds or ISO 8601 string).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the base URL for Moonraker API calls.
|
||||
/// </summary>
|
||||
private static string BuildBaseUrl(string hostnameOrIp, int port) =>
|
||||
$"http://{hostnameOrIp}:{port}";
|
||||
|
||||
/// <summary>
|
||||
/// Applies the Moonraker API key to the request header if provided.
|
||||
/// </summary>
|
||||
private static void ApplyApiKey(HttpRequestMessage request, string? apiKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
request.Headers.Add("X-Api-Key", apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the base URL for Moonraker API calls from hostname and port.
|
||||
/// Parsed result from Moonraker's print_stats object.
|
||||
/// Extracted immediately from the JSON response to avoid JsonDocument disposal issues.
|
||||
/// </summary>
|
||||
private static string BuildBaseUrl(string hostnameOrIp, int port)
|
||||
private sealed class PrintStatsResult
|
||||
{
|
||||
return $"http://{hostnameOrIp}:{port}";
|
||||
public string? State { get; set; }
|
||||
public decimal? FilamentUsedMm { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed result from Moonraker's history/last_job object.
|
||||
/// </summary>
|
||||
private sealed class HistoryResult
|
||||
{
|
||||
public DateTime? StartTime { get; set; }
|
||||
public DateTime? EndTime { get; set; }
|
||||
public double? PrintDuration { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user