diff --git a/MakerPrompt.sln b/MakerPrompt.sln index 210fac7..c7b3e4c 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -31,6 +31,30 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Wasm", "Mak EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Maui", "MakerPrompt.E2E.Maui\MakerPrompt.E2E.Maui.csproj", "{A03CF971-E571-4E00-849E-48675DAB24D7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B4F2D4E1-0000-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Core", "src\MakerPrompt.Core\MakerPrompt.Core.csproj", "{B4F2D4E1-0001-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Application", "src\MakerPrompt.Application\MakerPrompt.Application.csproj", "{B4F2D4E1-0002-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure", "src\MakerPrompt.Infrastructure\MakerPrompt.Infrastructure.csproj", "{B4F2D4E1-0003-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Cloud", "src\MakerPrompt.Cloud\MakerPrompt.Cloud.csproj", "{B4F2D4E1-0004-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.EdgeAgent", "src\MakerPrompt.EdgeAgent\MakerPrompt.EdgeAgent.csproj", "{B4F2D4E1-0005-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests.Unit", "src\MakerPrompt.Tests.Unit\MakerPrompt.Tests.Unit.csproj", "{B4F2D4E1-0006-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Components", "src\MakerPrompt.UI.Components\MakerPrompt.UI.Components.csproj", "{B4F2D4E1-0007-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Blazor", "src\MakerPrompt.UI.Blazor\MakerPrompt.UI.Blazor.csproj", "{B4F2D4E1-0008-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.MAUI", "src\MakerPrompt.UI.MAUI\MakerPrompt.UI.MAUI.csproj", "{B4F2D4E1-0009-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.Sqlite", "src\MakerPrompt.Infrastructure.Sqlite\MakerPrompt.Infrastructure.Sqlite.csproj", "{6D881570-CC49-44BC-84D6-15F602342899}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.InfluxDb", "src\MakerPrompt.Infrastructure.InfluxDb\MakerPrompt.Infrastructure.InfluxDb.csproj", "{61DEF2BC-BA90-405D-B32B-CD9EC5928E63}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -114,12 +138,156 @@ Global {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x64.Build.0 = Release|Any CPU {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x86.ActiveCfg = Release|Any CPU {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0001-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0002-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0003-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0004-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0005-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0006-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0007-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0008-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4F2D4E1-0009-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x64.ActiveCfg = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x64.Build.0 = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x86.ActiveCfg = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Debug|x86.Build.0 = Debug|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|Any CPU.Build.0 = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x64.ActiveCfg = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x64.Build.0 = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x86.ActiveCfg = Release|Any CPU + {6D881570-CC49-44BC-84D6-15F602342899}.Release|x86.Build.0 = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x64.ActiveCfg = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x64.Build.0 = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x86.ActiveCfg = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Debug|x86.Build.0 = Debug|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|Any CPU.Build.0 = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x64.ActiveCfg = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x64.Build.0 = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x86.ActiveCfg = Release|Any CPU + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} + {B4F2D4E1-0001-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0002-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0003-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0004-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0005-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0006-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0007-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0008-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0009-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {6D881570-CC49-44BC-84D6-15F602342899} = {B4F2D4E1-0000-0000-0000-000000000001} + {61DEF2BC-BA90-405D-B32B-CD9EC5928E63} = {B4F2D4E1-0000-0000-0000-000000000001} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} diff --git a/src/MakerPrompt.Application/MakerPrompt.Application.csproj b/src/MakerPrompt.Application/MakerPrompt.Application.csproj new file mode 100644 index 0000000..03a0896 --- /dev/null +++ b/src/MakerPrompt.Application/MakerPrompt.Application.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/MakerPrompt.Application/Services/AnalyticsService.cs b/src/MakerPrompt.Application/Services/AnalyticsService.cs new file mode 100644 index 0000000..0b62bfc --- /dev/null +++ b/src/MakerPrompt.Application/Services/AnalyticsService.cs @@ -0,0 +1,69 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for print-job analytics. +/// +/// Provides aggregation helpers on top of : +/// - Total print hours +/// - Total filament consumed (all printers / by printer / by spool) +/// +public sealed class AnalyticsService +{ + private readonly IPrintJobAnalyticsStore _store; + private readonly ILogger _logger; + + /// Raised whenever a new usage record is added. + public event EventHandler? AnalyticsUpdated; + + public AnalyticsService(IPrintJobAnalyticsStore store, ILogger logger) + { + _store = store; + _logger = logger; + } + + public Task> GetRecordsAsync(CancellationToken cancellationToken = default) + => _store.GetAllAsync(cancellationToken); + + public async Task RecordUsageAsync(PrintJobUsageRecord record, CancellationToken cancellationToken = default) + { + try + { + await _store.SaveAsync(record, cancellationToken); + AnalyticsUpdated?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record print job usage"); + } + } + + // ── Aggregations ───────────────────────────────────────────────────────── + + public async Task GetTotalPrintTimeAsync(CancellationToken cancellationToken = default) + { + var records = await _store.GetAllAsync(cancellationToken); + return TimeSpan.FromTicks(records.Sum(r => r.Duration.Ticks)); + } + + public async Task GetTotalFilamentConsumedGramsAsync(CancellationToken cancellationToken = default) + { + var records = await _store.GetAllAsync(cancellationToken); + return records.Sum(r => r.EffectiveFilamentGrams); + } + + public async Task GetFilamentConsumedByPrinterAsync(Guid printerId, CancellationToken cancellationToken = default) + { + var records = await _store.GetByPrinterAsync(printerId, cancellationToken); + return records.Sum(r => r.EffectiveFilamentGrams); + } + + public async Task GetFilamentConsumedBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default) + { + var records = await _store.GetBySpoolAsync(spoolId, cancellationToken); + return records.Sum(r => r.EffectiveFilamentGrams); + } +} diff --git a/src/MakerPrompt.Application/Services/FarmService.cs b/src/MakerPrompt.Application/Services/FarmService.cs new file mode 100644 index 0000000..19cfb9c --- /dev/null +++ b/src/MakerPrompt.Application/Services/FarmService.cs @@ -0,0 +1,107 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for managing farm configurations. +/// +/// Responsibilities: +/// - CRUD for farm profiles. +/// - Switching the active farm (saving current printer state, loading the new one). +/// - Import / export as JSON. +/// +public sealed class FarmService +{ + private readonly IFarmRepository _repo; + private readonly ILogger _logger; + + /// Raised whenever the list of farms changes. + public event EventHandler? FarmsChanged; + + public FarmService(IFarmRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public Task> GetFarmsAsync(CancellationToken cancellationToken = default) + => _repo.GetAllAsync(cancellationToken); + + public Task GetFarmAsync(Guid farmId, CancellationToken cancellationToken = default) + => _repo.GetByIdAsync(farmId, cancellationToken); + + /// Creates a new farm profile with the given name. + public async Task CreateFarmAsync(string name, CancellationToken cancellationToken = default) + { + var farm = new FarmConfiguration { Name = name.Trim() }; + await _repo.SaveAsync(farm, cancellationToken); + OnFarmsChanged(); + return farm; + } + + /// Updates the display name of a farm. + public async Task RenameFarmAsync(Guid farmId, string newName, CancellationToken cancellationToken = default) + { + var farm = await _repo.GetByIdAsync(farmId, cancellationToken); + if (farm is null) + { + _logger.LogWarning("RenameFarm: farm {FarmId} not found", farmId); + return; + } + + farm.Name = newName.Trim(); + await _repo.SaveAsync(farm, cancellationToken); + OnFarmsChanged(); + } + + /// + /// Saves a snapshot of the given printer definitions into the farm, then persists. + /// Used when switching away from this farm to preserve its current printer list. + /// + public async Task SnapshotPrintersAsync(Guid farmId, + IEnumerable currentPrinters, + CancellationToken cancellationToken = default) + { + var farm = await _repo.GetByIdAsync(farmId, cancellationToken); + if (farm is null) return; + + farm.Printers = currentPrinters.ToList(); + await _repo.SaveAsync(farm, cancellationToken); + } + + /// Deletes a farm profile. + public async Task DeleteFarmAsync(Guid farmId, CancellationToken cancellationToken = default) + { + await _repo.DeleteAsync(farmId, cancellationToken); + OnFarmsChanged(); + } + + /// + /// Exports a farm as a JSON string suitable for file download / transfer. + /// + public async Task ExportFarmAsync(Guid farmId, CancellationToken cancellationToken = default) + { + var farm = await _repo.GetByIdAsync(farmId, cancellationToken); + if (farm is null) return "{}"; + return System.Text.Json.JsonSerializer.Serialize(farm, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + + /// + /// Imports a farm from a JSON string. Assigns a fresh ID to prevent collisions. + /// + public async Task ImportFarmAsync(string json, CancellationToken cancellationToken = default) + { + var farm = System.Text.Json.JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Invalid farm configuration JSON."); + + farm.Id = Guid.NewGuid(); + farm.CreatedAt = DateTime.UtcNow; + await _repo.SaveAsync(farm, cancellationToken); + OnFarmsChanged(); + return farm; + } + + private void OnFarmsChanged() => FarmsChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/MakerPrompt.Application/Services/FilamentInventoryService.cs b/src/MakerPrompt.Application/Services/FilamentInventoryService.cs new file mode 100644 index 0000000..f551c98 --- /dev/null +++ b/src/MakerPrompt.Application/Services/FilamentInventoryService.cs @@ -0,0 +1,71 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for filament spool inventory management. +/// +/// Wraps with business rules: +/// - Deduction clamping (never negative) +/// - Event publication on any change +/// - Aggregation helpers (total filament consumed per spool) +/// +public sealed class FilamentInventoryService +{ + private readonly IFilamentInventoryStore _store; + private readonly ILogger _logger; + + /// Raised whenever the inventory is modified. + public event EventHandler? InventoryChanged; + + public FilamentInventoryService(IFilamentInventoryStore store, ILogger logger) + { + _store = store; + _logger = logger; + } + + public Task> GetSpoolsAsync(CancellationToken cancellationToken = default) + => _store.GetAllAsync(cancellationToken); + + public Task GetSpoolAsync(Guid id, CancellationToken cancellationToken = default) + => _store.GetByIdAsync(id, cancellationToken); + + public async Task AddSpoolAsync(FilamentSpool spool, CancellationToken cancellationToken = default) + { + await _store.SaveAsync(spool, cancellationToken); + OnInventoryChanged(); + } + + public async Task UpdateSpoolAsync(FilamentSpool spool, CancellationToken cancellationToken = default) + { + await _store.SaveAsync(spool, cancellationToken); + OnInventoryChanged(); + } + + public async Task DeleteSpoolAsync(Guid id, CancellationToken cancellationToken = default) + { + await _store.DeleteAsync(id, cancellationToken); + OnInventoryChanged(); + } + + /// + /// Deducts consumed filament from the spool. + /// Logs a warning if the spool is not found. + /// + public async Task DeductFilamentAsync(Guid spoolId, double grams, CancellationToken cancellationToken = default) + { + var spool = await _store.GetByIdAsync(spoolId, cancellationToken); + if (spool is null) + { + _logger.LogWarning("DeductFilament: spool {SpoolId} not found", spoolId); + return; + } + + await _store.DeductFilamentAsync(spoolId, grams, cancellationToken); + OnInventoryChanged(); + } + + private void OnInventoryChanged() => InventoryChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/MakerPrompt.Application/Services/PrintProjectService.cs b/src/MakerPrompt.Application/Services/PrintProjectService.cs new file mode 100644 index 0000000..1eb68f7 --- /dev/null +++ b/src/MakerPrompt.Application/Services/PrintProjectService.cs @@ -0,0 +1,163 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service for print project management. +/// +/// Business rules enforced here: +/// - Project names are trimmed before persistence. +/// - Deleting a project also deletes all stored G-code files. +/// - Job status transitions are validated. +/// +public sealed class PrintProjectService +{ + private readonly IPrintProjectRepository _repo; + private readonly ILogger _logger; + + /// Raised when any project or job is created, updated, or deleted. + public event EventHandler? ProjectsChanged; + + public PrintProjectService(IPrintProjectRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public Task> GetProjectsAsync(CancellationToken cancellationToken = default) + => _repo.GetAllAsync(cancellationToken); + + public Task GetProjectAsync(Guid projectId, CancellationToken cancellationToken = default) + => _repo.GetByIdAsync(projectId, cancellationToken); + + // ── Project CRUD ────────────────────────────────────────────────────────── + + public async Task CreateProjectAsync(string name, string? notes = null, + CancellationToken cancellationToken = default) + { + var project = new PrintProject { Name = name.Trim(), Notes = notes }; + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + return project; + } + + public async Task RenameProjectAsync(Guid projectId, string newName, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken) + ?? throw new InvalidOperationException($"Project {projectId} not found."); + + project.Name = newName.Trim(); + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + } + + public async Task DeleteProjectAsync(Guid projectId, CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + if (project is null) return; + + foreach (var job in project.Jobs) + { + try + { + await _repo.DeleteJobFileAsync(job.StoragePath, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete G-code file {Path}", job.StoragePath); + } + } + + await _repo.DeleteAsync(projectId, cancellationToken); + OnProjectsChanged(); + } + + // ── Job management ──────────────────────────────────────────────────────── + + /// + /// Uploads a G-code file into the project and registers a new job. + /// + public async Task AddJobAsync(Guid projectId, string fileName, Stream fileContent, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken) + ?? throw new InvalidOperationException($"Project {projectId} not found."); + + var storagePath = await _repo.SaveJobFileAsync(projectId, fileName, fileContent, cancellationToken); + + var job = new PrintJob + { + FileName = fileName, + StoragePath = storagePath, + Size = fileContent.CanSeek ? fileContent.Length : 0, + }; + + project.Jobs.Add(job); + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + return job; + } + + /// + /// Removes a job and deletes its stored G-code file. + /// + public async Task RemoveJobAsync(Guid projectId, Guid jobId, CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (project is null || job is null) return; + + try { await _repo.DeleteJobFileAsync(job.StoragePath, cancellationToken); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete file {Path}", job.StoragePath); } + + project.Jobs.Remove(job); + await _repo.SaveAsync(project, cancellationToken); + OnProjectsChanged(); + } + + /// + /// Assigns a job to a printer and sets it to . + /// + public async Task AssignJobAsync(Guid projectId, Guid jobId, Guid printerId, string printerName, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job is null) return; + + job.AssignedPrinterId = printerId; + job.AssignedPrinterName = printerName; + job.Status = PrintJobStatus.Printing; + await _repo.SaveAsync(project!, cancellationToken); + OnProjectsChanged(); + } + + /// + /// Updates the status of a job (typically to Completed or Failed). + /// + public async Task UpdateJobStatusAsync(Guid projectId, Guid jobId, PrintJobStatus status, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + if (job is null) return; + + job.Status = status; + await _repo.SaveAsync(project!, cancellationToken); + OnProjectsChanged(); + } + + /// Returns the G-code file stream for a job (null if not found). + public async Task OpenJobFileAsync(Guid projectId, Guid jobId, + CancellationToken cancellationToken = default) + { + var project = await _repo.GetByIdAsync(projectId, cancellationToken); + var job = project?.Jobs.FirstOrDefault(j => j.Id == jobId); + return job is null ? null : await _repo.OpenJobFileAsync(job.StoragePath, cancellationToken); + } + + private void OnProjectsChanged() => ProjectsChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/MakerPrompt.Application/Services/PrinterFleetService.cs b/src/MakerPrompt.Application/Services/PrinterFleetService.cs new file mode 100644 index 0000000..c34315f --- /dev/null +++ b/src/MakerPrompt.Application/Services/PrinterFleetService.cs @@ -0,0 +1,162 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Application service that manages a fleet of printers. +/// +/// Responsibilities +/// ---------------- +/// • Maintains a registry of active instances, +/// keyed by a stable printer identifier. +/// • Surfaces aggregated fleet status and per-printer telemetry. +/// • Coordinates with implementations to discover +/// printers exposed by cloud/farm accounts, then creates the appropriate +/// backend connection service for each one. +/// +/// Design note +/// ----------- +/// This service lives in the Application layer and has no dependency on Blazor, +/// making it equally usable from the EdgeAgent, the Cloud backend, and any future +/// CLI or native UI host. +/// +public sealed class PrinterFleetService : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly Dictionary _connections = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Raised when any printer in the fleet changes state. + public event EventHandler? FleetChanged; + + public PrinterFleetService(ILogger logger) + { + _logger = logger; + } + + /// + /// Returns a read-only snapshot of all registered printer identifiers. + /// + public IReadOnlyCollection PrinterIds + { + get + { + lock (_connections) + return _connections.Keys.ToArray(); + } + } + + /// + /// Returns the communication service for the given printer, or null if not registered. + /// + public IPrinterCommunicationService? GetConnection(string printerId) + { + lock (_connections) + return _connections.GetValueOrDefault(printerId); + } + + /// + /// Registers a printer and immediately attempts to connect using the supplied settings. + /// If a connection for already exists it is disconnected + /// and replaced. + /// + public async Task AddAndConnectAsync( + string printerId, + IPrinterCommunicationService service, + PrinterConnectionSettings settings, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_connections.TryGetValue(printerId, out var existing)) + { + await existing.DisconnectAsync(cancellationToken); + await existing.DisposeAsync(); + } + + service.ConnectionStateChanged += (_, connected) => OnFleetChanged(); + service.TelemetryUpdated += (_, _) => OnFleetChanged(); + + var connected = await service.ConnectAsync(settings, cancellationToken); + _connections[printerId] = service; + OnFleetChanged(); + return connected; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect printer {PrinterId}", printerId); + return false; + } + finally + { + _lock.Release(); + } + } + + /// + /// Disconnects and removes the printer with the given identifier from the fleet. + /// + public async Task RemoveAsync(string printerId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_connections.Remove(printerId, out var service)) return; + await service.DisconnectAsync(cancellationToken); + await service.DisposeAsync(); + OnFleetChanged(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing printer {PrinterId}", printerId); + } + finally + { + _lock.Release(); + } + } + + /// + /// Returns the latest telemetry for all connected printers, keyed by printer identifier. + /// + public IReadOnlyDictionary GetFleetTelemetry() + { + lock (_connections) + { + return _connections + .Where(kv => kv.Value.IsConnected) + .ToDictionary(kv => kv.Key, kv => kv.Value.LastTelemetry); + } + } + + private void OnFleetChanged() => FleetChanged?.Invoke(this, EventArgs.Empty); + + public async ValueTask DisposeAsync() + { + await _lock.WaitAsync(); + try + { + foreach (var service in _connections.Values) + { + try + { + await service.DisconnectAsync(); + await service.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing printer connection"); + } + } + _connections.Clear(); + } + finally + { + _lock.Release(); + _lock.Dispose(); + } + } +} diff --git a/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs b/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs new file mode 100644 index 0000000..bb08629 --- /dev/null +++ b/src/MakerPrompt.Application/Services/TelemetryAggregationService.cs @@ -0,0 +1,67 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Application.Services; + +/// +/// Aggregates telemetry from multiple printer connections and forwards snapshots +/// to a for persistence and cloud forwarding. +/// +/// This service is the Application-layer glue between the live +/// and the storage / cloud pipeline: +/// +/// [PrinterFleetService] --(telemetry events)--> [TelemetryAggregationService] +/// | +/// [ITelemetryStore] +/// | +/// (Cloud / SQLite / etc.) +/// +public sealed class TelemetryAggregationService : IDisposable +{ + private readonly PrinterFleetService _fleet; + private readonly ITelemetryStore _store; + private readonly ILogger _logger; + private bool _disposed; + + public TelemetryAggregationService( + PrinterFleetService fleet, + ITelemetryStore store, + ILogger logger) + { + _fleet = fleet; + _store = store; + _logger = logger; + + _fleet.FleetChanged += OnFleetChanged; + } + + private void OnFleetChanged(object? sender, EventArgs e) + { + var snapshot = _fleet.GetFleetTelemetry(); + Task.Run(() => PersistSnapshotAsync(snapshot)); + } + + private async Task PersistSnapshotAsync(IReadOnlyDictionary snapshot) + { + foreach (var (printerId, telemetry) in snapshot) + { + try + { + await _store.SaveAsync(printerId, telemetry); + } + catch (Exception ex) + { + // Telemetry persistence errors are swallowed — never spam logs. + _logger.LogDebug(ex, "Failed to persist telemetry for {PrinterId}", printerId); + } + } + } + + public void Dispose() + { + if (_disposed) return; + _fleet.FleetChanged -= OnFleetChanged; + _disposed = true; + } +} diff --git a/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj new file mode 100644 index 0000000..bfdd308 --- /dev/null +++ b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.Cloud/Program.cs b/src/MakerPrompt.Cloud/Program.cs new file mode 100644 index 0000000..eaae7fc --- /dev/null +++ b/src/MakerPrompt.Cloud/Program.cs @@ -0,0 +1,228 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Camera; +using MakerPrompt.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// ── Constants ───────────────────────────────────────────────────────────────── + +const int JwtClockSkewMinutes = 5; + +// ── Authentication — JWT Bearer / OIDC ─────────────────────────────────────── +// +// The Cloud API validates JWTs issued by any OIDC-compliant provider +// (Azure AD, Auth0, Keycloak, etc.). Configure the authority and audience +// via environment variables or appsettings.json: +// +// MakerPrompt:Auth:Authority – OIDC issuer URL (e.g. https://tenant.auth0.com/) +// MakerPrompt:Auth:Audience – API identifier (e.g. https://api.makerprompt.io) +// MakerPrompt:Auth:RequireHttpsMetadata – true in production, false in local dev +// +// Edge Agents send a machine-to-machine token; member clients send user tokens. + +var authSection = builder.Configuration.GetSection("MakerPrompt:Auth"); +var authority = authSection["Authority"]; +var audience = authSection["Audience"]; +var requireHttps = authSection.GetValue("RequireHttpsMetadata", true); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + // If no Authority is configured, we run in open/dev mode. + if (!string.IsNullOrWhiteSpace(authority)) + { + options.Authority = authority; + options.Audience = audience; + options.RequireHttpsMetadata = requireHttps; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = !string.IsNullOrWhiteSpace(audience), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(JwtClockSkewMinutes), + }; + } + else + { + // Development fallback: accept any well-formed token but skip signature. + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = false, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + SignatureValidator = (token, _) => + { + // Only allow the bypass in Development. + if (!builder.Environment.IsDevelopment()) + throw new SecurityTokenValidationException( + "Auth:Authority must be configured in non-Development environments."); + + return new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(token); + } + }; + } + + // Swallow token validation exceptions — return 401 instead of 500. + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = ctx => + { + ctx.Response.Headers.Append("WWW-Authenticate", + $"Bearer error=\"invalid_token\", " + + $"error_description=\"{Uri.EscapeDataString(ctx.Exception.Message)}\""); + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + // Default policy: require any authenticated user / machine. + options.DefaultPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + + // "EdgeAgent" policy: must have the edge-agent scope claim. + options.AddPolicy("EdgeAgent", policy => + policy.RequireAuthenticatedUser() + .RequireClaim("scope", "makerprompt:ingest")); + + // "Member" read policy: authenticated users may read telemetry. + options.AddPolicy("MemberRead", policy => + policy.RequireAuthenticatedUser()); +}); + +// ── Services ───────────────────────────────────────────────────────────────── + +// Local in-memory telemetry store (swap for SqliteTelemetryStore / InfluxDbTelemetryStore in production). +builder.Services.AddSingleton(); + +// In-memory camera snapshot store (swap for SqliteCameraSnapshotStore in production). +builder.Services.AddSingleton(); + +// API Explorer for potential future Swagger integration. +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +// ── Middleware ──────────────────────────────────────────────────────────────── + +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + +// ── Endpoints ───────────────────────────────────────────────────────────────── + +// Health check — public, no auth required. +app.MapGet("/health", () => Results.Ok(new { status = "healthy", utc = DateTimeOffset.UtcNow })) + .WithName("HealthCheck") + .WithTags("System") + .AllowAnonymous(); + +// Ingest telemetry from an EdgeAgent. +// Requires the "makerprompt:ingest" scope (machine-to-machine token from EdgeAgent). +app.MapPost("/api/telemetry/{printerId}", async ( + string printerId, + [FromBody] PrinterTelemetry telemetry, + ITelemetryStore store, + CancellationToken ct) => +{ + await store.SaveAsync(printerId, telemetry, ct); + return Results.Accepted(); +}) +.WithName("IngestTelemetry") +.WithTags("Telemetry") +.RequireAuthorization("EdgeAgent"); + +// Retrieve the latest telemetry for a printer. +// Requires any authenticated user (member read access). +app.MapGet("/api/telemetry/{printerId}/latest", async ( + string printerId, + ITelemetryStore store, + CancellationToken ct) => +{ + var latest = await store.GetLatestAsync(printerId, ct); + return latest is null ? Results.NotFound() : Results.Ok(latest); +}) +.WithName("GetLatestTelemetry") +.WithTags("Telemetry") +.RequireAuthorization("MemberRead"); + +// Retrieve telemetry history for a printer. +// Requires any authenticated user (member read access). +app.MapGet("/api/telemetry/{printerId}/history", async ( + string printerId, + ITelemetryStore store, + [FromQuery] int count = 100, + CancellationToken ct = default) => +{ + var history = await store.GetHistoryAsync(printerId, count, ct); + return Results.Ok(history); +}) +.WithName("GetTelemetryHistory") +.WithTags("Telemetry") +.RequireAuthorization("MemberRead"); + +// ── Camera endpoints ────────────────────────────────────────────────────────── + +// Ingest a camera snapshot from an EdgeAgent. +// Requires the "makerprompt:ingest" scope (same as telemetry ingest). +app.MapPost("/api/camera/{cameraId}/snapshot", async ( + string cameraId, + [FromBody] CameraSnapshot snapshot, + ICameraSnapshotStore cameraStore, + CancellationToken ct) => +{ + snapshot.CameraId = cameraId; + await cameraStore.SaveAsync(snapshot, ct); + return Results.Accepted(); +}) +.WithName("IngestCameraSnapshot") +.WithTags("Camera") +.RequireAuthorization("EdgeAgent"); + +// Retrieve the latest JPEG snapshot for a camera (returns raw JPEG bytes). +app.MapGet("/api/camera/{cameraId}/latest", async ( + string cameraId, + ICameraSnapshotStore cameraStore, + CancellationToken ct) => +{ + var snapshot = await cameraStore.GetLatestAsync(cameraId, ct); + if (snapshot is null) return Results.NotFound(); + + return snapshot.JpegData.Length > 0 + ? Results.File(snapshot.JpegData, "image/jpeg") + : Results.NotFound(); +}) +.WithName("GetLatestCameraSnapshot") +.WithTags("Camera") +.RequireAuthorization("MemberRead"); + +// Retrieve snapshot metadata history for a camera (no image data). +app.MapGet("/api/camera/{cameraId}/history", async ( + string cameraId, + ICameraSnapshotStore cameraStore, + [FromQuery] int count = 20, + CancellationToken ct = default) => +{ + var history = await cameraStore.GetHistoryAsync(cameraId, count, ct); + return Results.Ok(history); +}) +.WithName("GetCameraSnapshotHistory") +.WithTags("Camera") +.RequireAuthorization("MemberRead"); + +// ── Run ─────────────────────────────────────────────────────────────────────── + +app.Run(); + +// Make the Program class visible for integration tests +public partial class Program { } diff --git a/src/MakerPrompt.Cloud/appsettings.Development.json b/src/MakerPrompt.Cloud/appsettings.Development.json new file mode 100644 index 0000000..5def1c3 --- /dev/null +++ b/src/MakerPrompt.Cloud/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "MakerPrompt": { + "Auth": { + "Authority": "", + "Audience": "", + "RequireHttpsMetadata": false + } + } +} diff --git a/src/MakerPrompt.Cloud/appsettings.json b/src/MakerPrompt.Cloud/appsettings.json new file mode 100644 index 0000000..98bd1a2 --- /dev/null +++ b/src/MakerPrompt.Cloud/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "MakerPrompt": { + "Auth": { + "Authority": "", + "Audience": "", + "RequireHttpsMetadata": false + } + } +} diff --git a/src/MakerPrompt.Core/Abstractions/ICameraProvider.cs b/src/MakerPrompt.Core/Abstractions/ICameraProvider.cs new file mode 100644 index 0000000..ac8552a --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/ICameraProvider.cs @@ -0,0 +1,37 @@ +namespace MakerPrompt.Core.Abstractions; + +/// +/// Abstraction for a camera feed associated with a printer or a hackerspace bay. +/// +/// Implementations may read from: +/// - an MJPEG HTTP stream (most webcams, OctoPrint, Mainsail) +/// - an RTSP stream (IP cameras) +/// - a local V4L2 device (Linux EdgeAgent) +/// +/// The camera is identified by its which correlates snapshots +/// with the printer they are mounted next to (same ID as the printer it monitors). +/// +public interface ICameraProvider : IAsyncDisposable +{ + /// Unique identifier for this camera, typically matching a printer ID. + string CameraId { get; } + + /// Human-readable label (e.g. "Ender-3 Webcam"). + string Label { get; } + + /// Whether the camera stream is currently reachable. + bool IsAvailable { get; } + + /// + /// Captures a single JPEG snapshot from the camera stream. + /// + /// Cancellation token. + /// Raw JPEG bytes, or an empty array if the camera is unavailable. + Task CaptureSnapshotAsync(CancellationToken cancellationToken = default); + + /// + /// Verifies the camera is reachable and updates . + /// Called during EdgeAgent startup and periodically during health checks. + /// + Task CheckAvailabilityAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs b/src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs new file mode 100644 index 0000000..e5943cc --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/ICameraSnapshotStore.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.Core.Abstractions; + +/// +/// Persists and retrieves camera snapshots. +/// Implementations: in-memory (tests), SQLite (EdgeAgent), Cloud REST projection. +/// +public interface ICameraSnapshotStore +{ + /// + /// Persists a snapshot. The store associates it with the + /// and the + /// capture timestamp already embedded in the model. + /// + Task SaveAsync(Core.Models.CameraSnapshot snapshot, CancellationToken cancellationToken = default); + + /// + /// Returns the most recent snapshot for , + /// or null if none has been stored. + /// + Task GetLatestAsync(string cameraId, CancellationToken cancellationToken = default); + + /// + /// Returns up to snapshots for , + /// ordered from most-recent to oldest. Only metadata is returned by default; + /// implementations may omit the JpegData blob to reduce memory pressure. + /// + Task> GetHistoryAsync( + string cameraId, int count = 20, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs b/src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs new file mode 100644 index 0000000..52c9c77 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IEdgeAgentClient.cs @@ -0,0 +1,28 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Contract for the local EdgeAgent component that runs in the hackerspace / farm +/// and bridges printers to the cloud backend. +/// +/// The EdgeAgent is responsible for: +/// • Connecting to configured printers via . +/// • Polling telemetry on a regular interval. +/// • Forwarding telemetry snapshots to the cloud via . +/// • Optionally capturing webcam snapshots. +/// +public interface IEdgeAgentClient +{ + /// + /// Submits a telemetry snapshot to the cloud backend. + /// Implementations should retry on transient failures and swallow permanent errors silently. + /// + Task SendTelemetryAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default); + + /// + /// Checks connectivity to the cloud backend. + /// + Task IsReachableAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IFarmRepository.cs b/src/MakerPrompt.Core/Abstractions/IFarmRepository.cs new file mode 100644 index 0000000..fad64bb --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IFarmRepository.cs @@ -0,0 +1,18 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Manages farm configurations — named groups of printer connections that +/// can be saved, switched, imported, and exported. +/// +public interface IFarmRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid farmId, CancellationToken cancellationToken = default); + + Task SaveAsync(FarmConfiguration farm, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid farmId, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs b/src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs new file mode 100644 index 0000000..3a6ebde --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IFilamentInventoryStore.cs @@ -0,0 +1,24 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Persists and retrieves filament spools for inventory tracking. +/// Implementations may use local JSON storage, a database, or a cloud API. +/// +public interface IFilamentInventoryStore +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task SaveAsync(FilamentSpool spool, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Deducts from the spool's remaining weight. + /// Clamps to zero — never goes negative. + /// + Task DeductFilamentAsync(Guid spoolId, double grams, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs b/src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs new file mode 100644 index 0000000..28c82af --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrintJobAnalyticsStore.cs @@ -0,0 +1,21 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Stores and queries print-job analytics records. +/// +public interface IPrintJobAnalyticsStore +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(PrintJobUsageRecord record, CancellationToken cancellationToken = default); + + /// Returns records for a specific printer, ordered newest-first. + Task> GetByPrinterAsync( + Guid printerId, CancellationToken cancellationToken = default); + + /// Returns records for a specific spool, ordered newest-first. + Task> GetBySpoolAsync( + Guid spoolId, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs b/src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs new file mode 100644 index 0000000..9e464a0 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrintProjectRepository.cs @@ -0,0 +1,34 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Manages print projects and their associated G-code jobs. +/// Business logic lives in the Application layer; this interface +/// describes the persistence contract for both storage and application use. +/// +public interface IPrintProjectRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid projectId, CancellationToken cancellationToken = default); + + Task SaveAsync(PrintProject project, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid projectId, CancellationToken cancellationToken = default); + + /// + /// Opens the binary content of a job's G-code file from storage. + /// Returns null if the file does not exist. + /// + Task OpenJobFileAsync(string storagePath, CancellationToken cancellationToken = default); + + /// + /// Stores the binary content of a G-code file and returns the storage path assigned to it. + /// + Task SaveJobFileAsync(Guid projectId, string fileName, Stream content, + CancellationToken cancellationToken = default); + + /// Deletes the physical G-code file for a job. + Task DeleteJobFileAsync(string storagePath, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs b/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs new file mode 100644 index 0000000..18e016a --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs @@ -0,0 +1,88 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Controls a single printer connection. +/// +/// Architecture note +/// ----------------- +/// This interface is intentionally scoped to ONE printer. Fleet / multi-printer +/// scenarios are handled at the Application layer via +/// and the fleet orchestration services that compose multiple +/// instances. +/// +public interface IPrinterCommunicationService : IAsyncDisposable +{ + // ── Events ────────────────────────────────────────────────────────────── + + /// Raised when the connection state changes (true = connected, false = disconnected). + event EventHandler ConnectionStateChanged; + + /// Raised whenever fresh telemetry is available from the printer. + event EventHandler TelemetryUpdated; + + // ── State ─────────────────────────────────────────────────────────────── + + /// Backend protocol in use. + PrinterConnectionType ConnectionType { get; } + + /// Most recently received telemetry snapshot. + PrinterTelemetry LastTelemetry { get; } + + /// Human-readable name for this connection (e.g. "Workshop Prusa MK4"). + string ConnectionName { get; } + + /// true when a live connection is established. + bool IsConnected { get; } + + /// true when a print job is actively running on this printer. + bool IsPrinting { get; } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + + /// Establishes the connection using the supplied settings. + /// true on success, false on failure. + Task ConnectAsync(PrinterConnectionSettings settings, CancellationToken cancellationToken = default); + + /// Gracefully closes the connection and releases backend resources. + Task DisconnectAsync(CancellationToken cancellationToken = default); + + // ── Data transfer ─────────────────────────────────────────────────────── + + /// Sends a raw G-code command string to the printer. + Task WriteDataAsync(string command, CancellationToken cancellationToken = default); + + /// Fetches an up-to-date telemetry snapshot from the printer. + Task GetTelemetryAsync(CancellationToken cancellationToken = default); + + /// Returns the list of files available on the printer's storage. + Task> GetFilesAsync(CancellationToken cancellationToken = default); + + // ── Print control ─────────────────────────────────────────────────────── + + /// Sets the hotend target temperature. Pass 0 to turn off. + Task SetHotendTempAsync(int targetCelsius, CancellationToken cancellationToken = default); + + /// Sets the heated bed target temperature. Pass 0 to turn off. + Task SetBedTempAsync(int targetCelsius, CancellationToken cancellationToken = default); + + /// Homes the specified axes. + Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default); + + /// Performs a relative move on the given axes at mm/min. + Task RelativeMoveAsync(int feedRate, float x = 0f, float y = 0f, float z = 0f, float e = 0f, + CancellationToken cancellationToken = default); + + /// Sets the part-cooling fan speed (0–100 %). + Task SetFanSpeedAsync(int speedPercent, CancellationToken cancellationToken = default); + + /// Sets the print feed-rate override (typically 10–200 %). + Task SetPrintSpeedAsync(int speedPercent, CancellationToken cancellationToken = default); + + /// Sets the extrusion flow-rate override (typically 10–200 %). + Task SetPrintFlowAsync(int flowPercent, CancellationToken cancellationToken = default); + + /// Starts printing the specified file from printer storage. + Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs b/src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs new file mode 100644 index 0000000..52ae3e2 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrinterProvider.cs @@ -0,0 +1,44 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Abstracts a service that can enumerate printers from a remote provider account +/// (e.g. PrusaConnect, OctoPrint farm, future cloud providers). +/// +/// Architecture note — provider vs. connection +/// -------------------------------------------- +/// An answers the question: +/// "Which printers does this account know about?" +/// +/// An answers the question: +/// "How do I talk to this specific printer?" +/// +/// These concerns are intentionally separate so that one provider can expose +/// many printers, each controlled by its own communication service instance. +/// +/// Usage pattern +/// ------------- +/// 1. Call with a bearer/API token. +/// 2. Call to enumerate available printers. +/// 3. Pass a to the Application layer to create +/// the matching for that printer. +/// +public interface IPrinterProvider +{ + /// The provider type this implementation represents. + PrinterConnectionType ProviderType { get; } + + /// + /// Configures the provider with the credentials needed to reach the upstream API. + /// Must be called before . + /// + Task ConfigureAsync(string bearerToken, CancellationToken cancellationToken = default); + + /// + /// Returns the list of printers available under the configured account. + /// Returns an empty list on authentication failure or transient network errors + /// (callers should not treat an empty result as fatal). + /// + Task> GetPrintersAsync(CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs b/src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs new file mode 100644 index 0000000..98ce8df --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/ITelemetryStore.cs @@ -0,0 +1,30 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Core.Abstractions; + +/// +/// Persists and retrieves telemetry snapshots for reporting, analytics, and the +/// cloud backend. Implementations may write to a local SQLite database (EdgeAgent), +/// an in-memory store (tests), or a remote REST API (Cloud-side projections). +/// +public interface ITelemetryStore +{ + /// + /// Persists a telemetry snapshot. The store is responsible for tagging the + /// record with and the current UTC timestamp. + /// + Task SaveAsync(string printerId, PrinterTelemetry telemetry, CancellationToken cancellationToken = default); + + /// + /// Returns the most recent telemetry snapshot for the given printer, + /// or null if no snapshot has been stored yet. + /// + Task GetLatestAsync(string printerId, CancellationToken cancellationToken = default); + + /// + /// Returns up to telemetry snapshots for the given printer, + /// ordered from most-recent to oldest. + /// + Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default); +} diff --git a/src/MakerPrompt.Core/MakerPrompt.Core.csproj b/src/MakerPrompt.Core/MakerPrompt.Core.csproj new file mode 100644 index 0000000..93f5ab4 --- /dev/null +++ b/src/MakerPrompt.Core/MakerPrompt.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/MakerPrompt.Core/Models/CameraSnapshot.cs b/src/MakerPrompt.Core/Models/CameraSnapshot.cs new file mode 100644 index 0000000..6469157 --- /dev/null +++ b/src/MakerPrompt.Core/Models/CameraSnapshot.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.Core.Models; + +/// +/// A single camera frame snapshot — JPEG bytes plus metadata. +/// Forwarded from an EdgeAgent to the Cloud API alongside telemetry. +/// +public sealed class CameraSnapshot +{ + /// + /// Unique identifier of the camera (typically matches a printer ID so snapshots + /// can be correlated with telemetry). + /// + public string CameraId { get; set; } = string.Empty; + + /// Human-readable camera label. + public string Label { get; set; } = string.Empty; + + /// Raw JPEG image data. + public byte[] JpegData { get; set; } = []; + + /// UTC timestamp when the frame was captured. + public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; + + /// Image width in pixels (0 if unknown). + public int Width { get; set; } + + /// Image height in pixels (0 if unknown). + public int Height { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/FarmConfiguration.cs b/src/MakerPrompt.Core/Models/FarmConfiguration.cs new file mode 100644 index 0000000..a950690 --- /dev/null +++ b/src/MakerPrompt.Core/Models/FarmConfiguration.cs @@ -0,0 +1,47 @@ +namespace MakerPrompt.Core.Models; + +/// +/// A saved farm profile that bundles a named group of printer connections. +/// Users can switch between farms (e.g. "Workshop A" vs "Hackerspace B"). +/// +public sealed class FarmConfiguration +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Snapshot of printer connection definitions belonging to this farm. + /// Populated when the farm is saved or exported. + /// + public List Printers { get; set; } = []; +} + +/// +/// Persistent connection profile for a single printer. +/// +public sealed class PrinterConnectionDefinition +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// User-friendly display name (e.g. "Workshop Prusa MK4"). + public string Name { get; set; } = string.Empty; + + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + + /// Connection settings (URL, credentials, or serial port). + public PrinterConnectionSettings Settings { get; set; } = new(); + + /// Auto-connect this printer on app startup. + public bool AutoConnect { get; set; } + + /// Optional hex color for the Fleet card UI. + public string? Color { get; set; } + + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastConnectedAt { get; set; } + + /// The filament spool currently loaded in this printer. + public Guid? AssignedFilamentSpoolId { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/FilamentSpool.cs b/src/MakerPrompt.Core/Models/FilamentSpool.cs new file mode 100644 index 0000000..2178c59 --- /dev/null +++ b/src/MakerPrompt.Core/Models/FilamentSpool.cs @@ -0,0 +1,30 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Represents a spool of filament tracked in the inventory. +/// +public sealed class FilamentSpool +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Material { get; set; } = string.Empty; + public string Brand { get; set; } = string.Empty; + public string Color { get; set; } = string.Empty; + + /// Filament diameter in mm (typically 1.75 or 2.85). + public double Diameter { get; set; } = 1.75; + + /// Total spool weight in grams as purchased. + public double TotalWeightGrams { get; set; } = 1000; + + /// Estimated remaining weight in grams (decremented as jobs complete). + public double RemainingWeightGrams { get; set; } = 1000; + + /// Cost paid for this spool. + public decimal Cost { get; set; } + + public DateTime PurchaseDate { get; set; } = DateTime.UtcNow; + + /// Archived spools are hidden from active selections but kept for history. + public bool IsArchived { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/NotificationRecord.cs b/src/MakerPrompt.Core/Models/NotificationRecord.cs new file mode 100644 index 0000000..da7b2f3 --- /dev/null +++ b/src/MakerPrompt.Core/Models/NotificationRecord.cs @@ -0,0 +1,29 @@ +namespace MakerPrompt.Core.Models; + +public enum NotificationLevel +{ + Info, + Warning, + Error, + Critical, +} + +/// +/// A persisted notification event (print completion, error, low filament alert, etc.). +/// +public sealed class NotificationRecord +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public NotificationLevel Level { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// Associated printer (if notification relates to a specific printer). + public Guid? PrinterId { get; set; } + + /// Associated filament spool (e.g. low-filament warnings). + public Guid? FilamentSpoolId { get; set; } + + public bool IsRead { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs b/src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs new file mode 100644 index 0000000..cfe5989 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrintJobUsageRecord.cs @@ -0,0 +1,35 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Audit record for a completed (or in-progress) print job. +/// Used for analytics — print hours, filament consumption per printer/spool. +/// +public sealed class PrintJobUsageRecord +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// ID of the printer that ran this job. + public Guid PrinterId { get; set; } + + /// ID of the filament spool consumed by this job (empty = unknown). + public Guid FilamentSpoolId { get; set; } + + public string JobName { get; set; } = string.Empty; + + /// Total elapsed print time. + public TimeSpan Duration { get; set; } + + /// Pre-slice estimated filament consumption in grams. + public double EstimatedFilamentUsedGrams { get; set; } + + /// Actual filament consumed in grams (0 = not measured). + public double ActualFilamentUsedGrams { get; set; } + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// + /// Returns the best available filament figure: actual if measured, otherwise estimated. + /// + public double EffectiveFilamentGrams => + ActualFilamentUsedGrams > 0 ? ActualFilamentUsedGrams : EstimatedFilamentUsedGrams; +} diff --git a/src/MakerPrompt.Core/Models/PrintProject.cs b/src/MakerPrompt.Core/Models/PrintProject.cs new file mode 100644 index 0000000..d0bc2fd --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrintProject.cs @@ -0,0 +1,47 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Groups a set of G-code files under a single project name. +/// Files are dispatched to printers; status is tracked per job. +/// +public sealed class PrintProject +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public List Jobs { get; set; } = []; +} + +/// +/// A single G-code file within a . +/// +public sealed class PrintJob +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Original filename (e.g. "benchy.gcode"). + public string FileName { get; set; } = string.Empty; + + /// Storage path within IStorageProvider (e.g. "PrintProjects/{projectId}/{filename}"). + public string StoragePath { get; set; } = string.Empty; + + /// File size in bytes (0 if not measurable at upload time). + public long Size { get; set; } + + public PrintJobStatus Status { get; set; } = PrintJobStatus.Queued; + + /// The printer this job is assigned to (null = unassigned). + public Guid? AssignedPrinterId { get; set; } + + /// Friendly printer name kept for display when the printer is offline. + public string? AssignedPrinterName { get; set; } +} + +public enum PrintJobStatus +{ + Queued, + Printing, + Completed, + Failed, +} diff --git a/src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs b/src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs new file mode 100644 index 0000000..8d1fc17 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterConnectionSettings.cs @@ -0,0 +1,37 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Settings required to establish a connection to a single printer backend. +/// +public sealed class PrinterConnectionSettings +{ + /// Backend protocol to use. + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + + // ── HTTP / WebSocket backends ─────────────────────────────────────────── + + /// Base URL of the printer's HTTP API (e.g. "http://192.168.1.10"). + public string? ApiUrl { get; set; } + + /// Username for API authentication (if required). + public string? UserName { get; set; } + + /// Password or API key for authentication. + public string? Password { get; set; } + + // ── Serial / USB backends ─────────────────────────────────────────────── + + /// Serial port name (e.g. "COM3" on Windows, "/dev/ttyUSB0" on Linux). + public string? PortName { get; set; } + + /// Serial baud rate (default 115 200). + public int BaudRate { get; set; } = 115_200; + + // ── Provider-backed backends ──────────────────────────────────────────── + + /// + /// Provider printer identifier returned by . + /// Required when connecting to a specific printer within a fleet provider. + /// + public string? ProviderId { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/PrinterConnectionType.cs b/src/MakerPrompt.Core/Models/PrinterConnectionType.cs new file mode 100644 index 0000000..dc229f4 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterConnectionType.cs @@ -0,0 +1,37 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Identifies the backend protocol used to communicate with a printer. +/// Single-printer backends connect directly to one machine; provider-backed +/// types (e.g. PrusaConnect, OctoPrint) expose multiple printers through a +/// single account and are resolved via . +/// +public enum PrinterConnectionType +{ + /// In-memory demo backend — no real hardware required. + Demo, + + /// Direct USB/serial connection (Marlin, RepRap firmware, etc.). + Serial, + + /// Moonraker HTTP + WebSocket backend (Klipper firmware). + Moonraker, + + /// PrusaLink single-printer HTTP/JSON API (MK4, XL, etc.). + PrusaLink, + + /// + /// PrusaConnect cloud account — a provider that may expose multiple printers. + /// Resolved via . + /// + PrusaConnect, + + /// BambuLab proprietary MQTT + HTTP backend. + BambuLab, + + /// + /// OctoPrint server — may act as a farm hub exposing multiple printers. + /// Resolved via . + /// + OctoPrint, +} diff --git a/src/MakerPrompt.Core/Models/PrinterInfo.cs b/src/MakerPrompt.Core/Models/PrinterInfo.cs new file mode 100644 index 0000000..4930c86 --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterInfo.cs @@ -0,0 +1,28 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Describes a printer discovered from a provider account (e.g. PrusaConnect, OctoPrint farm). +/// This model is intentionally minimal — the provider fills in whatever the upstream API exposes. +/// +public sealed class PrinterInfo +{ + /// + /// Unique identifier assigned by the provider (e.g. the PrusaConnect UUID, OctoPrint printer key). + /// + public string Id { get; set; } = string.Empty; + + /// User-visible printer name as reported by the provider account. + public string Name { get; set; } = string.Empty; + + /// Hardware model string (e.g. "MK4", "XL", "X1C"). May be empty. + public string Model { get; set; } = string.Empty; + + /// Raw status string returned by the provider. Use for typed access. + public string RawStatus { get; set; } = string.Empty; + + /// Typed printer status derived from . + public PrinterStatus Status { get; set; } = PrinterStatus.Disconnected; + + /// The provider type that surfaced this printer. + public PrinterConnectionType ProviderType { get; set; } +} diff --git a/src/MakerPrompt.Core/Models/PrinterStatus.cs b/src/MakerPrompt.Core/Models/PrinterStatus.cs new file mode 100644 index 0000000..1073fca --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterStatus.cs @@ -0,0 +1,22 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Operational status of a managed printer. +/// +public enum PrinterStatus +{ + /// No connection has been established. + Disconnected, + + /// Connected and idle — ready to accept commands. + Connected, + + /// A print job is actively running. + Printing, + + /// Print job is paused (awaiting user action or filament change). + Paused, + + /// The printer has reported a fault condition. + Error, +} diff --git a/src/MakerPrompt.Core/Models/PrinterTelemetry.cs b/src/MakerPrompt.Core/Models/PrinterTelemetry.cs new file mode 100644 index 0000000..47f6e6a --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterTelemetry.cs @@ -0,0 +1,55 @@ +namespace MakerPrompt.Core.Models; + +/// +/// Snapshot of live telemetry data received from a connected printer. +/// +public sealed class PrinterTelemetry +{ + /// Display name of the printer (populated by the backend). + public string PrinterName { get; set; } = string.Empty; + + /// Current hotend temperature in °C. + public double HotendTemp { get; set; } + + /// Hotend target temperature in °C (0 = heater off). + public double HotendTarget { get; set; } + + /// Current heated-bed temperature in °C. + public double BedTemp { get; set; } + + /// Heated-bed target temperature in °C (0 = off). + public double BedTarget { get; set; } + + /// Chamber temperature in °C (0 if not supported). + public double ChamberTemp { get; set; } + + /// Chamber target temperature in °C (0 = off or not supported). + public double ChamberTarget { get; set; } + + /// Current operational status. + public PrinterStatus Status { get; set; } = PrinterStatus.Disconnected; + + /// Feed-rate override percentage (100 = nominal speed). + public int FeedRate { get; set; } = 100; + + /// Flow-rate override percentage (100 = nominal extrusion). + public int FlowRate { get; set; } = 100; + + /// Part cooling fan speed (0–100 %). + public int FanSpeed { get; set; } + + /// Name of the currently active print job (empty when idle). + public string PrintJobName { get; set; } = string.Empty; + + /// Elapsed time since the print job started. + public TimeSpan PrintDuration { get; set; } + + /// Filament consumed in the current job (mm). + public double FilamentUsed { get; set; } + + /// Print progress (0–100 %). 0 when not printing. + public double PrintProgress { get; set; } + + /// UTC timestamp when this snapshot was captured. + public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj b/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj new file mode 100644 index 0000000..433f298 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + MakerPrompt.EdgeAgent + + + + + + + + + + + + + diff --git a/src/MakerPrompt.EdgeAgent/Program.cs b/src/MakerPrompt.EdgeAgent/Program.cs new file mode 100644 index 0000000..d74f277 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Program.cs @@ -0,0 +1,31 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.EdgeAgent.Workers; +using MakerPrompt.Infrastructure.Camera; +using MakerPrompt.Infrastructure.Telemetry; + +var builder = Host.CreateApplicationBuilder(args); +var configuration = builder.Configuration; + +// ── Telemetry store ─────────────────────────────────────────────────────────── +// Default: in-memory ring buffer. +// Swap to SqliteTelemetryStore or InfluxDbTelemetryStore via DI registration below. +builder.Services.AddSingleton(); + +// ── Camera snapshot store ───────────────────────────────────────────────────── +builder.Services.AddSingleton(); + +// ── Application-layer fleet manager ────────────────────────────────────────── +builder.Services.AddSingleton(); + +// ── Background workers ──────────────────────────────────────────────────────── +// Telemetry polling — polls each connected printer, saves to ITelemetryStore. +builder.Services.AddHostedService(); + +// Camera polling — captures MJPEG snapshots, saves to ICameraSnapshotStore. +builder.Services.AddHostedService(); + +// ── Build & Run ─────────────────────────────────────────────────────────────── + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs b/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs new file mode 100644 index 0000000..9b14360 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs @@ -0,0 +1,142 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Camera; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; + +namespace MakerPrompt.EdgeAgent.Workers; + +/// +/// Background worker that captures periodic snapshots from all configured camera +/// feeds and persists them via . +/// +/// Cameras are configured in appsettings.json (or environment variables) +/// under the EdgeAgent:Cameras array: +/// +/// +/// "EdgeAgent": { +/// "CameraIntervalSeconds": 10, +/// "Cameras": [ +/// { "CameraId": "printer-1", "Label": "Ender-3 Cam", "MjpegUrl": "http://192.168.1.10:8080/?action=snapshot" }, +/// { "CameraId": "printer-2", "Label": "Voron Cam", "MjpegUrl": "http://192.168.1.11/webcam/?action=snapshot" } +/// ] +/// } +/// +/// +/// Each camera entry must supply a CameraId (matching the printer it monitors), +/// a Label, and an MJPEG snapshot or stream MjpegUrl. +/// +public sealed class CameraPollingWorker : BackgroundService +{ + private readonly ICameraSnapshotStore _store; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly TimeSpan _captureInterval; + private readonly List _cameras = []; + + public CameraPollingWorker( + ICameraSnapshotStore store, + ILoggerFactory loggerFactory, + IConfiguration configuration) + { + _store = store; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + + var intervalSeconds = configuration.GetValue("EdgeAgent:CameraIntervalSeconds", 10); + _captureInterval = TimeSpan.FromSeconds(Math.Max(1, intervalSeconds)); + + // Build providers from configuration. + var camerasSection = configuration.GetSection("EdgeAgent:Cameras"); + foreach (var cam in camerasSection.GetChildren()) + { + var cameraId = cam["CameraId"] ?? string.Empty; + var label = cam["Label"] ?? cameraId; + var url = cam["MjpegUrl"] ?? string.Empty; + + if (string.IsNullOrWhiteSpace(cameraId) || string.IsNullOrWhiteSpace(url)) + { + _logger.LogWarning( + "Camera entry is missing CameraId or MjpegUrl — skipping"); + continue; + } + + _cameras.Add(new MjpegCameraProvider( + cameraId, label, url, + loggerFactory.CreateLogger())); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (_cameras.Count == 0) + { + _logger.LogInformation( + "CameraPollingWorker: no cameras configured — worker idle"); + return; + } + + _logger.LogInformation( + "CameraPollingWorker started — {Count} camera(s), interval {Interval}", + _cameras.Count, _captureInterval); + + // Verify availability before first capture cycle. + foreach (var cam in _cameras) + await cam.CheckAvailabilityAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + await CaptureAllAsync(stoppingToken); + await Task.Delay(_captureInterval, stoppingToken).ConfigureAwait(false); + } + + _logger.LogInformation("CameraPollingWorker stopped"); + } + + private async Task CaptureAllAsync(CancellationToken ct) + { + foreach (var cam in _cameras) + { + try + { + var jpeg = await cam.CaptureSnapshotAsync(ct); + if (jpeg.Length == 0) continue; + + var snapshot = new CameraSnapshot + { + CameraId = cam.CameraId, + Label = cam.Label, + JpegData = jpeg, + CapturedAt = DateTimeOffset.UtcNow + }; + + await _store.SaveAsync(snapshot, ct); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + // Swallow per-camera errors — log at Debug to avoid spam. + _logger.LogDebug(ex, + "Capture error for camera {CameraId}", cam.CameraId); + } + } + } + + public override void Dispose() + { + // DisposeAsync() is not available as an override on BackgroundService in .NET 10. + // Use synchronous dispose here and rely on the host's graceful shutdown for cleanup. + foreach (var cam in _cameras) + { + var disposeTask = cam.DisposeAsync(); + if (!disposeTask.IsCompleted) + disposeTask.AsTask().GetAwaiter().GetResult(); + } + + base.Dispose(); + } +} diff --git a/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs b/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs new file mode 100644 index 0000000..40fc732 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs @@ -0,0 +1,77 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; + +namespace MakerPrompt.EdgeAgent.Workers; + +/// +/// Background worker that polls registered printers on a fixed interval, +/// collects telemetry snapshots, and forwards them to the configured +/// (and optionally to the cloud backend via +/// ). +/// +/// Polling errors are swallowed silently to avoid log spam — only unexpected +/// exceptions that indicate a fatal misconfiguration are propagated. +/// +public sealed class PrinterPollingWorker : BackgroundService +{ + private readonly PrinterFleetService _fleet; + private readonly ITelemetryStore _store; + private readonly ILogger _logger; + private readonly TimeSpan _pollInterval; + + public PrinterPollingWorker( + PrinterFleetService fleet, + ITelemetryStore store, + ILogger logger, + IConfiguration configuration) + { + _fleet = fleet; + _store = store; + _logger = logger; + + var intervalSeconds = configuration.GetValue("EdgeAgent:PollIntervalSeconds", 5); + _pollInterval = TimeSpan.FromSeconds(Math.Max(1, intervalSeconds)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "PrinterPollingWorker started — poll interval {Interval}", _pollInterval); + + while (!stoppingToken.IsCancellationRequested) + { + await PollAllPrintersAsync(stoppingToken); + await Task.Delay(_pollInterval, stoppingToken).ConfigureAwait(false); + } + + _logger.LogInformation("PrinterPollingWorker stopped"); + } + + private async Task PollAllPrintersAsync(CancellationToken cancellationToken) + { + var printerIds = _fleet.PrinterIds; + if (printerIds.Count == 0) return; + + foreach (var printerId in printerIds) + { + var service = _fleet.GetConnection(printerId); + if (service is null || !service.IsConnected) continue; + + try + { + var telemetry = await service.GetTelemetryAsync(cancellationToken); + await _store.SaveAsync(printerId, telemetry, cancellationToken); + } + catch (OperationCanceledException) + { + // Shutdown requested — exit cleanly. + return; + } + catch (Exception ex) + { + // Swallow per-printer polling errors — log at Debug to avoid spam. + _logger.LogDebug(ex, "Polling error for printer {PrinterId}", printerId); + } + } + } +} diff --git a/src/MakerPrompt.EdgeAgent/appsettings.json b/src/MakerPrompt.EdgeAgent/appsettings.json new file mode 100644 index 0000000..749970f --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "EdgeAgent": { + "PollIntervalSeconds": 5, + "CameraIntervalSeconds": 10, + "Cameras": [ + { + "CameraId": "printer-1", + "Label": "Ender-3 Webcam", + "MjpegUrl": "http://192.168.1.10:8080/?action=snapshot" + } + ] + }, + "CloudApi": { + "BaseUrl": "https://makerprompt.example.com", + "ApiToken": "" + } +} diff --git a/src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs b/src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs new file mode 100644 index 0000000..66e2f8f --- /dev/null +++ b/src/MakerPrompt.Infrastructure.InfluxDb/InfluxDbTelemetryStore.cs @@ -0,0 +1,224 @@ +using InfluxDB.Client; +using InfluxDB.Client.Api.Domain; +using InfluxDB.Client.Writes; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.InfluxDb; + +/// +/// InfluxDB 2.x / 3.x (compatibility API) implementation of . +/// +/// Data model — Line Protocol +/// -------------------------- +/// Each telemetry snapshot is stored as a single data point in the +/// printer_telemetry measurement with these fields and tags: +/// +/// Tags (indexed, low-cardinality): +/// printer_id — unique printer identifier +/// printer_name — display name +/// status — PrinterStatus enum value +/// +/// Fields (numeric/string values): +/// hotend_temp, hotend_target, bed_temp, bed_target, chamber_temp, chamber_target +/// feed_rate, flow_rate, fan_speed +/// print_progress, filament_used, print_duration_secs +/// print_job_name +/// +/// Configuration +/// ------------- +/// Configure via DI: +/// +/// builder.Services.AddSingleton<ITelemetryStore>(sp => +/// new InfluxDbTelemetryStore( +/// url: "http://influxdb:8086", +/// token: "my-api-token", +/// org: "makerprompt", +/// bucket: "telemetry", +/// sp.GetRequiredService<ILogger<InfluxDbTelemetryStore>>())); +/// +/// +/// Or use environment variables: +/// INFLUXDB_URL, INFLUXDB_TOKEN, INFLUXDB_ORG, INFLUXDB_BUCKET +/// +public sealed class InfluxDbTelemetryStore : ITelemetryStore, IAsyncDisposable +{ + private const string Measurement = "printer_telemetry"; + + private readonly InfluxDBClient _client; + private readonly string _org; + private readonly string _bucket; + private readonly ILogger _logger; + + /// InfluxDB base URL (e.g. "http://influxdb:8086"). + /// InfluxDB API token. + /// InfluxDB organisation name. + /// InfluxDB bucket name. + /// Logger. + public InfluxDbTelemetryStore( + string url, + string token, + string org, + string bucket, + ILogger logger) + { + _org = org; + _bucket = bucket; + _logger = logger; + _client = new InfluxDBClient(url, token); + } + + // ── ITelemetryStore ─────────────────────────────────────────────────────── + + public async Task SaveAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + try + { + var point = BuildPoint(printerId, telemetry); + var writeApi = _client.GetWriteApiAsync(); + await writeApi.WritePointAsync(point, _bucket, _org, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, + "[InfluxDbTelemetryStore] Failed to write point for printer {PrinterId}", printerId); + } + } + + public async Task GetLatestAsync(string printerId, + CancellationToken cancellationToken = default) + { + var flux = $""" + from(bucket: "{EscapeFlux(_bucket)}") + |> range(start: -30d) + |> filter(fn: (r) => r["_measurement"] == "{EscapeFlux(Measurement)}") + |> filter(fn: (r) => r["printer_id"] == "{EscapeFlux(printerId)}") + |> last() + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + """; + + var tables = await QueryAsync(flux, cancellationToken); + return tables.SelectMany(t => t.Records) + .OrderByDescending(r => r.GetTime()) + .Select(MapRecord) + .FirstOrDefault(); + } + + public async Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default) + { + var flux = $""" + from(bucket: "{EscapeFlux(_bucket)}") + |> range(start: -30d) + |> filter(fn: (r) => r["_measurement"] == "{EscapeFlux(Measurement)}") + |> filter(fn: (r) => r["printer_id"] == "{EscapeFlux(printerId)}") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + |> sort(columns: ["_time"], desc: true) + |> limit(n: {count}) + """; + + var tables = await QueryAsync(flux, cancellationToken); + return tables + .SelectMany(t => t.Records) + .Select(MapRecord) + .Where(t => t is not null) + .Select(t => t!) + .ToList() + .AsReadOnly(); + } + + // ── Line Protocol helpers ───────────────────────────────────────────────── + + private static PointData BuildPoint(string printerId, PrinterTelemetry t) + { + return PointData + .Measurement(Measurement) + .Tag("printer_id", printerId) + .Tag("printer_name", t.PrinterName) + .Tag("status", t.Status.ToString()) + .Field("hotend_temp", t.HotendTemp) + .Field("hotend_target", t.HotendTarget) + .Field("bed_temp", t.BedTemp) + .Field("bed_target", t.BedTarget) + .Field("chamber_temp", t.ChamberTemp) + .Field("chamber_target", t.ChamberTarget) + .Field("feed_rate", (long)t.FeedRate) + .Field("flow_rate", (long)t.FlowRate) + .Field("fan_speed", (long)t.FanSpeed) + .Field("print_progress", t.PrintProgress) + .Field("filament_used", t.FilamentUsed) + .Field("print_duration_secs", (long)t.PrintDuration.TotalSeconds) + .Field("print_job_name", t.PrintJobName) + .Timestamp(t.CapturedAt.UtcDateTime, WritePrecision.Ns); + } + + // ── Flux query helpers ──────────────────────────────────────────────────── + + private async Task> QueryAsync( + string flux, CancellationToken cancellationToken) + { + try + { + var queryApi = _client.GetQueryApi(); + return await queryApi.QueryAsync(flux, _org, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "[InfluxDbTelemetryStore] Flux query failed"); + return []; + } + } + + private static PrinterTelemetry? MapRecord(InfluxDB.Client.Core.Flux.Domain.FluxRecord r) + { + try + { + return new PrinterTelemetry + { + PrinterName = GetString(r, "printer_name"), + Status = Enum.TryParse(GetString(r, "status"), out var s) ? s : PrinterStatus.Disconnected, + HotendTemp = GetDouble(r, "hotend_temp"), + HotendTarget = GetDouble(r, "hotend_target"), + BedTemp = GetDouble(r, "bed_temp"), + BedTarget = GetDouble(r, "bed_target"), + ChamberTemp = GetDouble(r, "chamber_temp"), + ChamberTarget = GetDouble(r, "chamber_target"), + FeedRate = (int)GetLong(r, "feed_rate", 100), + FlowRate = (int)GetLong(r, "flow_rate", 100), + FanSpeed = (int)GetLong(r, "fan_speed"), + PrintProgress = GetDouble(r, "print_progress"), + FilamentUsed = GetDouble(r, "filament_used"), + PrintDuration = TimeSpan.FromSeconds(GetLong(r, "print_duration_secs")), + PrintJobName = GetString(r, "print_job_name"), + CapturedAt = r.GetTime() is { } t + ? new DateTimeOffset(t.ToDateTimeUtc(), TimeSpan.Zero) + : DateTimeOffset.UtcNow + }; + } + catch + { + return null; + } + } + + private static string GetString(InfluxDB.Client.Core.Flux.Domain.FluxRecord r, string key) + => r.GetValueByKey(key) is string v ? v : string.Empty; + + private static double GetDouble(InfluxDB.Client.Core.Flux.Domain.FluxRecord r, string key) + => r.GetValueByKey(key) is double v ? v : 0.0; + + private static long GetLong(InfluxDB.Client.Core.Flux.Domain.FluxRecord r, string key, long fallback = 0) + => r.GetValueByKey(key) is long v ? v : fallback; + + /// Escapes special characters in Flux string literals. + private static string EscapeFlux(string value) + => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + public ValueTask DisposeAsync() + { + _client.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj b/src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj new file mode 100644 index 0000000..b38d51a --- /dev/null +++ b/src/MakerPrompt.Infrastructure.InfluxDb/MakerPrompt.Infrastructure.InfluxDb.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + MakerPrompt.Infrastructure.InfluxDb + + + + + + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj b/src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj new file mode 100644 index 0000000..2ecb19e --- /dev/null +++ b/src/MakerPrompt.Infrastructure.Sqlite/MakerPrompt.Infrastructure.Sqlite.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + MakerPrompt.Infrastructure.Sqlite + + + + + + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs b/src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs new file mode 100644 index 0000000..08652f9 --- /dev/null +++ b/src/MakerPrompt.Infrastructure.Sqlite/SqliteCameraSnapshotStore.cs @@ -0,0 +1,178 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.Sqlite; + +/// +/// SQLite-backed implementation of . +/// +/// Schema +/// ------ +/// +/// CREATE TABLE camera_snapshots ( +/// id INTEGER PRIMARY KEY AUTOINCREMENT, +/// camera_id TEXT NOT NULL, +/// label TEXT NOT NULL, +/// captured_at TEXT NOT NULL, -- ISO-8601 UTC +/// width INTEGER NOT NULL DEFAULT 0, +/// height INTEGER NOT NULL DEFAULT 0, +/// jpeg_data BLOB NOT NULL +/// ); +/// CREATE INDEX ix_camera_captured ON camera_snapshots (camera_id, captured_at DESC); +/// +/// +/// Usage +/// ----- +/// +/// builder.Services.AddSingleton<ICameraSnapshotStore>(sp => +/// new SqliteCameraSnapshotStore("Data Source=cameras.db", sp.GetRequiredService<ILogger<SqliteCameraSnapshotStore>>())); +/// +/// +public sealed class SqliteCameraSnapshotStore : ICameraSnapshotStore, IAsyncDisposable +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + /// SQLite connection string, e.g. "Data Source=cameras.db". + /// Logger. + public SqliteCameraSnapshotStore(string connectionString, ILogger logger) + { + _connectionString = connectionString; + _logger = logger; + InitialiseSchema(); + } + + // ── ICameraSnapshotStore ────────────────────────────────────────────────── + + public async Task SaveAsync(CameraSnapshot snapshot, CancellationToken cancellationToken = default) + { + await _writeLock.WaitAsync(cancellationToken); + try + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO camera_snapshots (camera_id, label, captured_at, width, height, jpeg_data) + VALUES ($cameraId, $label, $capturedAt, $width, $height, $jpegData); + """; + cmd.Parameters.AddWithValue("$cameraId", snapshot.CameraId); + cmd.Parameters.AddWithValue("$label", snapshot.Label); + cmd.Parameters.AddWithValue("$capturedAt", snapshot.CapturedAt.ToString("O")); + cmd.Parameters.AddWithValue("$width", snapshot.Width); + cmd.Parameters.AddWithValue("$height", snapshot.Height); + cmd.Parameters.AddWithValue("$jpegData", snapshot.JpegData); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task GetLatestAsync(string cameraId, + CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT camera_id, label, captured_at, width, height, jpeg_data + FROM camera_snapshots + WHERE camera_id = $cameraId + ORDER BY captured_at DESC + LIMIT 1; + """; + cmd.Parameters.AddWithValue("$cameraId", cameraId); + + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) return null; + + return ReadSnapshot(reader, includeBlob: true); + } + + public async Task> GetHistoryAsync( + string cameraId, int count = 20, CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + // History returns metadata only — JPEG blob is excluded to save memory. + cmd.CommandText = """ + SELECT camera_id, label, captured_at, width, height, NULL as jpeg_data + FROM camera_snapshots + WHERE camera_id = $cameraId + ORDER BY captured_at DESC + LIMIT $count; + """; + cmd.Parameters.AddWithValue("$cameraId", cameraId); + cmd.Parameters.AddWithValue("$count", count); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + results.Add(ReadSnapshot(reader, includeBlob: false)); + + return results.AsReadOnly(); + } + + // ── Schema initialisation ───────────────────────────────────────────────── + + private void InitialiseSchema() + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS camera_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + camera_id TEXT NOT NULL, + label TEXT NOT NULL, + captured_at TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + jpeg_data BLOB NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_camera_captured + ON camera_snapshots (camera_id, captured_at DESC); + """; + cmd.ExecuteNonQuery(); + _logger.LogDebug("[SqliteCameraSnapshotStore] Schema initialised ({Connection})", _connectionString); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(cancellationToken); + return conn; + } + + private SqliteConnection OpenConnection() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + return conn; + } + + private static CameraSnapshot ReadSnapshot(SqliteDataReader reader, bool includeBlob) + { + return new CameraSnapshot + { + CameraId = reader.GetString(0), + Label = reader.GetString(1), + CapturedAt = DateTimeOffset.Parse(reader.GetString(2)), + Width = reader.GetInt32(3), + Height = reader.GetInt32(4), + JpegData = includeBlob && !reader.IsDBNull(5) + ? (byte[])reader.GetValue(5) + : [] + }; + } + + public ValueTask DisposeAsync() + { + _writeLock.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs b/src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs new file mode 100644 index 0000000..cbecbb6 --- /dev/null +++ b/src/MakerPrompt.Infrastructure.Sqlite/SqliteTelemetryStore.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.Sqlite; + +/// +/// SQLite-backed implementation of . +/// +/// Schema +/// ------ +/// +/// CREATE TABLE telemetry_snapshots ( +/// id INTEGER PRIMARY KEY AUTOINCREMENT, +/// printer_id TEXT NOT NULL, +/// captured_at TEXT NOT NULL, -- ISO-8601 UTC +/// payload TEXT NOT NULL -- JSON-serialised PrinterTelemetry +/// ); +/// CREATE INDEX ix_telemetry_printer_captured ON telemetry_snapshots (printer_id, captured_at DESC); +/// +/// +/// The full model is stored as a JSON payload so the +/// schema never needs to be migrated when new fields are added. +/// +/// Usage +/// ----- +/// Register via DI in the EdgeAgent or Cloud host: +/// +/// builder.Services.AddSingleton<ITelemetryStore>(sp => +/// new SqliteTelemetryStore("Data Source=telemetry.db", sp.GetRequiredService<ILogger<SqliteTelemetryStore>>())); +/// +/// +public sealed class SqliteTelemetryStore : ITelemetryStore, IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOpts = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly string _connectionString; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + /// SQLite connection string, e.g. "Data Source=telemetry.db". + /// Logger. + public SqliteTelemetryStore(string connectionString, ILogger logger) + { + _connectionString = connectionString; + _logger = logger; + InitialiseSchema(); + } + + // ── ITelemetryStore ─────────────────────────────────────────────────────── + + public async Task SaveAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(telemetry, JsonOpts); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO telemetry_snapshots (printer_id, captured_at, payload) + VALUES ($printerId, $capturedAt, $payload); + """; + cmd.Parameters.AddWithValue("$printerId", printerId); + cmd.Parameters.AddWithValue("$capturedAt", telemetry.CapturedAt.ToString("O")); + cmd.Parameters.AddWithValue("$payload", json); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task GetLatestAsync(string printerId, + CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT payload FROM telemetry_snapshots + WHERE printer_id = $printerId + ORDER BY captured_at DESC + LIMIT 1; + """; + cmd.Parameters.AddWithValue("$printerId", printerId); + + var json = (string?)await cmd.ExecuteScalarAsync(cancellationToken); + return json is null ? null : Deserialise(json); + } + + public async Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default) + { + await using var conn = await OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT payload FROM telemetry_snapshots + WHERE printer_id = $printerId + ORDER BY captured_at DESC + LIMIT $count; + """; + cmd.Parameters.AddWithValue("$printerId", printerId); + cmd.Parameters.AddWithValue("$count", count); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var item = Deserialise(reader.GetString(0)); + if (item is not null) results.Add(item); + } + + return results.AsReadOnly(); + } + + // ── Schema initialisation ───────────────────────────────────────────────── + + private void InitialiseSchema() + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS telemetry_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + printer_id TEXT NOT NULL, + captured_at TEXT NOT NULL, + payload TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_telemetry_printer_captured + ON telemetry_snapshots (printer_id, captured_at DESC); + """; + cmd.ExecuteNonQuery(); + _logger.LogDebug("[SqliteTelemetryStore] Schema initialised ({Connection})", _connectionString); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(cancellationToken); + return conn; + } + + private SqliteConnection OpenConnection() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + return conn; + } + + private PrinterTelemetry? Deserialise(string json) + { + try + { + return JsonSerializer.Deserialize(json, JsonOpts); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "[SqliteTelemetryStore] Failed to deserialise telemetry row"); + return null; + } + } + + public ValueTask DisposeAsync() + { + _writeLock.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs b/src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs new file mode 100644 index 0000000..d73d852 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Analytics/InMemoryPrintJobAnalyticsStore.cs @@ -0,0 +1,63 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Analytics; + +/// +/// Thread-safe, in-memory implementation of . +/// +public sealed class InMemoryPrintJobAnalyticsStore : IPrintJobAnalyticsStore +{ + private readonly List _records = []; + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _records.AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(PrintJobUsageRecord record, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + // Replace if already present, otherwise append. + var idx = _records.FindIndex(r => r.Id == record.Id); + if (idx >= 0) _records[idx] = record; + else _records.Add(record); + } + finally { _lock.Release(); } + } + + public async Task> GetByPrinterAsync( + Guid printerId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _records + .Where(r => r.PrinterId == printerId) + .OrderByDescending(r => r.Timestamp) + .ToList() + .AsReadOnly(); + } + finally { _lock.Release(); } + } + + public async Task> GetBySpoolAsync( + Guid spoolId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _records + .Where(r => r.FilamentSpoolId == spoolId) + .OrderByDescending(r => r.Timestamp) + .ToList() + .AsReadOnly(); + } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs b/src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs new file mode 100644 index 0000000..1a7fc44 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Camera/InMemoryCameraSnapshotStore.cs @@ -0,0 +1,84 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Camera; + +/// +/// In-memory implementation of . +/// Retains the last N snapshots per camera in a bounded ring buffer. +/// Suitable for tests and EdgeAgent scenarios where the process is long-running. +/// +public sealed class InMemoryCameraSnapshotStore : ICameraSnapshotStore +{ + private readonly int _maxPerCamera; + private readonly Dictionary> _data = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Maximum snapshots retained per camera (default 50). + public InMemoryCameraSnapshotStore(int maxPerCamera = 50) + { + _maxPerCamera = maxPerCamera; + } + + public async Task SaveAsync(CameraSnapshot snapshot, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(snapshot.CameraId, out var list)) + { + list = new LinkedList(); + _data[snapshot.CameraId] = list; + } + + list.AddFirst(snapshot); + while (list.Count > _maxPerCamera) + list.RemoveLast(); + } + finally + { + _lock.Release(); + } + } + + public async Task GetLatestAsync(string cameraId, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _data.TryGetValue(cameraId, out var list) ? list.First?.Value : null; + } + finally + { + _lock.Release(); + } + } + + public async Task> GetHistoryAsync( + string cameraId, int count = 20, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(cameraId, out var list)) + return []; + + // Return metadata only (no JPEG data) to reduce memory pressure. + return list.Take(count).Select(s => new CameraSnapshot + { + CameraId = s.CameraId, + Label = s.Label, + CapturedAt = s.CapturedAt, + Width = s.Width, + Height = s.Height, + JpegData = [] + }).ToList().AsReadOnly(); + } + finally + { + _lock.Release(); + } + } +} diff --git a/src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs b/src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs new file mode 100644 index 0000000..0ba491f --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Camera/MjpegCameraProvider.cs @@ -0,0 +1,181 @@ +using MakerPrompt.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace MakerPrompt.Infrastructure.Camera; + +/// +/// Camera provider that reads a single JPEG frame from an MJPEG HTTP stream. +/// +/// Compatibility +/// ------------- +/// Works with any device that exposes a standard MJPEG endpoint, including: +/// - OctoPrint (e.g. http://printer:8080/?action=snapshot) +/// - Mainsail / Fluidd webcam streams +/// - Generic USB webcams served via mjpg-streamer +/// - Any IP camera that exposes an MJPEG endpoint +/// +/// The provider captures a single frame per call +/// by reading only the first JPEG segment of the multipart MJPEG stream. +/// +public sealed class MjpegCameraProvider : ICameraProvider +{ + private static readonly HttpClient SharedClient = new() + { + Timeout = TimeSpan.FromSeconds(10) + }; + + private readonly string _streamUrl; + private readonly ILogger _logger; + + /// + public string CameraId { get; } + + /// + public string Label { get; } + + /// + public bool IsAvailable { get; private set; } + + /// Printer/camera ID this feed belongs to. + /// Human-readable label for the camera. + /// + /// MJPEG snapshot URL (e.g. http://printer:8080/?action=snapshot). + /// May be a snapshot endpoint (returns a single JPEG) or a live MJPEG + /// stream (the provider will extract the first frame automatically). + /// + /// Logger. + public MjpegCameraProvider( + string cameraId, + string label, + string streamUrl, + ILogger logger) + { + CameraId = cameraId; + Label = label; + _streamUrl = streamUrl; + _logger = logger; + } + + /// + public async Task CaptureSnapshotAsync(CancellationToken cancellationToken = default) + { + if (!IsAvailable) return []; + + try + { + using var response = await SharedClient.GetAsync( + _streamUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + response.EnsureSuccessStatusCode(); + + var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + // Snapshot endpoint — response is already a single JPEG. + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + else if (contentType.StartsWith("multipart/x-mixed-replace", StringComparison.OrdinalIgnoreCase)) + { + // MJPEG stream — extract the first JPEG frame. + return await ExtractFirstMjpegFrameAsync(response, cancellationToken); + } + else + { + _logger.LogWarning( + "[CameraProvider:{CameraId}] Unexpected content-type: {ContentType}", + CameraId, contentType); + return []; + } + } + catch (OperationCanceledException) + { + return []; + } + catch (Exception ex) + { + // Swallow capture errors — camera may be temporarily unavailable. + _logger.LogDebug(ex, "[CameraProvider:{CameraId}] Snapshot capture failed", CameraId); + IsAvailable = false; + return []; + } + } + + /// + public async Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, _streamUrl); + using var response = await SharedClient.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + IsAvailable = response.IsSuccessStatusCode; + } + catch + { + IsAvailable = false; + } + + return IsAvailable; + } + + // ── MJPEG frame extraction ──────────────────────────────────────────────── + + private static async Task ExtractFirstMjpegFrameAsync( + HttpResponseMessage response, CancellationToken ct) + { + // MJPEG multipart boundary is declared in the Content-Type header. + // e.g. multipart/x-mixed-replace;boundary=--myboundary + // We scan for the JPEG SOI marker (0xFF 0xD8) and EOF marker (0xFF 0xD9). + await using var stream = await response.Content.ReadAsStreamAsync(ct); + + using var ms = new MemoryStream(); + var buf = new byte[8192]; + bool inJpeg = false; + int soi0 = -1; + + while (true) + { + ct.ThrowIfCancellationRequested(); + int read = await stream.ReadAsync(buf, ct); + if (read == 0) break; + + if (!inJpeg) + { + for (int i = 0; i < read - 1; i++) + { + if (buf[i] == 0xFF && buf[i + 1] == 0xD8) + { + soi0 = i; + inJpeg = true; + ms.Write(buf, i, read - i); + break; + } + } + } + else + { + ms.Write(buf, 0, read); + + // Check for EOI marker (0xFF 0xD9). + var data = ms.GetBuffer(); + var len = (int)ms.Length; + for (int i = len - 2; i >= Math.Max(0, len - read - 2); i--) + { + if (data[i] == 0xFF && data[i + 1] == 0xD9) + return ms.ToArray()[..(i + 2)]; + } + } + + // Safety valve: don't buffer more than 5 MB. + if (ms.Length > 5 * 1024 * 1024) + break; + } + + return ms.Length > 0 ? ms.ToArray() : []; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs b/src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs new file mode 100644 index 0000000..090cc5e --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Farm/InMemoryFarmRepository.cs @@ -0,0 +1,41 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Farm; + +/// +/// Thread-safe, in-memory implementation of . +/// +public sealed class InMemoryFarmRepository : IFarmRepository +{ + private readonly Dictionary _farms = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _farms.Values.OrderBy(f => f.CreatedAt).ToList().AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task GetByIdAsync(Guid farmId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _farms.GetValueOrDefault(farmId); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(FarmConfiguration farm, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _farms[farm.Id] = farm; } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(Guid farmId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _farms.Remove(farmId); } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs b/src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs new file mode 100644 index 0000000..441bb3b --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Inventory/InMemoryFilamentInventoryStore.cs @@ -0,0 +1,53 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Inventory; + +/// +/// Thread-safe, in-memory implementation of . +/// Suitable for unit tests and development; does not survive process restart. +/// +public sealed class InMemoryFilamentInventoryStore : IFilamentInventoryStore +{ + private readonly Dictionary _spools = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _spools.Values.ToList().AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _spools.GetValueOrDefault(id); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(FilamentSpool spool, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _spools[spool.Id] = spool; } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _spools.Remove(id); } + finally { _lock.Release(); } + } + + public async Task DeductFilamentAsync(Guid spoolId, double grams, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_spools.TryGetValue(spoolId, out var spool)) + spool.RemainingWeightGrams = Math.Max(0, spool.RemainingWeightGrams - grams); + } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj new file mode 100644 index 0000000..03a0896 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs b/src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs new file mode 100644 index 0000000..06fff67 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Projects/InMemoryPrintProjectRepository.cs @@ -0,0 +1,76 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Projects; + +/// +/// Thread-safe, in-memory implementation of . +/// G-code file "storage" is kept in-memory as byte arrays. +/// +public sealed class InMemoryPrintProjectRepository : IPrintProjectRepository +{ + private readonly Dictionary _projects = new(); + private readonly Dictionary _files = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _projects.Values.ToList().AsReadOnly(); } + finally { _lock.Release(); } + } + + public async Task GetByIdAsync(Guid projectId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { return _projects.GetValueOrDefault(projectId); } + finally { _lock.Release(); } + } + + public async Task SaveAsync(PrintProject project, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _projects[project.Id] = project; } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(Guid projectId, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _projects.Remove(projectId); } + finally { _lock.Release(); } + } + + public async Task OpenJobFileAsync(string storagePath, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _files.TryGetValue(storagePath, out var data) + ? new MemoryStream(data, writable: false) + : null; + } + finally { _lock.Release(); } + } + + public async Task SaveJobFileAsync(Guid projectId, string fileName, Stream content, + CancellationToken cancellationToken = default) + { + var storagePath = $"PrintProjects/{projectId}/{fileName}"; + using var ms = new MemoryStream(); + await content.CopyToAsync(ms, cancellationToken); + + await _lock.WaitAsync(cancellationToken); + try { _files[storagePath] = ms.ToArray(); } + finally { _lock.Release(); } + + return storagePath; + } + + public async Task DeleteJobFileAsync(string storagePath, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try { _files.Remove(storagePath); } + finally { _lock.Release(); } + } +} diff --git a/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs b/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs new file mode 100644 index 0000000..39d49d5 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Serial/SerialCommunicationServiceBase.cs @@ -0,0 +1,345 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Timers; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Serial; + +/// +/// Base class for serial/USB printer communication services (Marlin / RepRap firmware). +/// +/// Architecture +/// ------------ +/// This class implements all methods that +/// are protocol-level (G-code command building, Marlin response parsing, telemetry +/// polling timer). Platform-specific transport (opening the port, writing/reading +/// bytes) is left to the concrete subclass via and +/// the lifecycle hooks / . +/// +/// Dependency +/// ---------- +/// Only references MakerPrompt.Core — no Blazor, no MAUI, no platform APIs. +/// Platform subclasses live in the host projects (MakerPrompt.UI.MAUI). +/// +public abstract class SerialCommunicationServiceBase : IPrinterCommunicationService +{ + // ── Regex patterns for Marlin response parsing ─────────────────────────── + private static readonly Regex TempRegex = + new(@"T:([\d.]+)\s*/\s*([\d.]+)\s+B:([\d.]+)\s*/\s*([\d.]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex PrintProgressRegex = + new(@"SD printing byte (\d+)/(\d+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + // ── Events ─────────────────────────────────────────────────────────────── + public event EventHandler? ConnectionStateChanged; + public event EventHandler? TelemetryUpdated; + + // ── State ──────────────────────────────────────────────────────────────── + public PrinterConnectionType ConnectionType => PrinterConnectionType.Serial; + public PrinterTelemetry LastTelemetry { get; private set; } = new(); + public string ConnectionName { get; protected set; } = string.Empty; + public bool IsConnected { get; protected set; } + public bool IsPrinting { get; protected set; } + + // ── Private fields ─────────────────────────────────────────────────────── + /// Default baud rate for Marlin/RepRap firmware. 250 000 bps is standard. + protected const int DefaultBaudRate = 250_000; + private static readonly TimeSpan TelemetryPollInterval = TimeSpan.FromSeconds(3); + + private readonly System.Timers.Timer _telemetryTimer = new(TelemetryPollInterval); + private readonly StringBuilder _receiveBuffer = new(); + + protected SerialCommunicationServiceBase() + { + _telemetryTimer.Elapsed += (_, _) => SafePollTelemetry(); + _telemetryTimer.AutoReset = true; + } + + // Non-async timer callback that fires-and-forgets with exception guarding. + private void SafePollTelemetry() + { + _ = PollTelemetryAsync().ContinueWith( + t => Console.WriteLine($"[SerialCommunicationServiceBase] Telemetry poll error: {t.Exception?.GetBaseException().Message}"), + System.Threading.Tasks.TaskContinuationOptions.OnlyOnFaulted); + } + + // ── Abstract transport hooks ───────────────────────────────────────────── + + /// + /// Opens the underlying transport (serial port, USB driver, etc.) using the + /// supplied connection settings. Called by . + /// + protected abstract Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken); + + /// + /// Closes the underlying transport. Called by + /// and . + /// + protected abstract Task CloseTransportAsync(CancellationToken cancellationToken); + + /// + /// Writes (a single G-code command line) to the transport. + /// The base class appends a newline; implementations should send the bytes as-is + /// or append their own framing. + /// + protected abstract Task WriteTransportAsync(string data, CancellationToken cancellationToken); + + // ── IPrinterCommunicationService: Lifecycle ────────────────────────────── + + public async Task ConnectAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken = default) + { + if (IsConnected) return true; + + try + { + await OpenTransportAsync(settings, cancellationToken); + IsConnected = true; + ConnectionName = settings.PortName ?? settings.ConnectionType.ToString(); + LastTelemetry = new PrinterTelemetry { Status = PrinterStatus.Connected }; + _telemetryTimer.Start(); + RaiseConnectionChanged(); + return true; + } + catch + { + IsConnected = false; + return false; + } + } + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) return; + + _telemetryTimer.Stop(); + IsConnected = false; + IsPrinting = false; + + try + { + await CloseTransportAsync(cancellationToken); + } + catch + { + // Swallow close errors — we are already marking as disconnected. + } + + RaiseConnectionChanged(); + } + + // ── IPrinterCommunicationService: Data transfer ────────────────────────── + + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) + => IsConnected ? WriteTransportAsync(command, cancellationToken) : Task.CompletedTask; + + public async Task GetTelemetryAsync( + CancellationToken cancellationToken = default) + { + await WriteTransportAsync("M105", cancellationToken); // temperatures + await WriteTransportAsync("M27", cancellationToken); // SD print progress + await Task.Delay(200, cancellationToken); + return LastTelemetry; + } + + public Task> GetFilesAsync(CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + // ── IPrinterCommunicationService: Print control ────────────────────────── + + public Task SetHotendTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M104 S{targetCelsius}", cancellationToken); + } + + public Task SetBedTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M140 S{targetCelsius}", cancellationToken); + } + + public async Task HomeAsync(bool x = true, bool y = true, bool z = true, + CancellationToken cancellationToken = default) + { + if (!IsConnected) return; + + var axes = string.Concat( + x ? "X" : "", + y ? "Y" : "", + z ? "Z" : ""); + + await WriteTransportAsync(axes.Length > 0 ? $"G28 {axes}" : "G28", cancellationToken); + } + + public async Task RelativeMoveAsync(int feedRate, + float x = 0f, float y = 0f, float z = 0f, float e = 0f, + CancellationToken cancellationToken = default) + { + if (!IsConnected) return; + + var sb = new StringBuilder("G1"); + if (x != 0f) sb.Append($" X{x:0.0}"); + if (y != 0f) sb.Append($" Y{y:0.0}"); + if (z != 0f) sb.Append($" Z{z:0.0}"); + if (e != 0f) sb.Append($" E{e:0.0}"); + sb.Append($" F{feedRate}"); + + await WriteTransportAsync("G91", cancellationToken); + await WriteTransportAsync(sb.ToString(), cancellationToken); + await WriteTransportAsync("G90", cancellationToken); + } + + public Task SetFanSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + if (speedPercent <= 0) + return WriteTransportAsync("M107", cancellationToken); + + var value = (int)Math.Clamp(speedPercent * 2.55, 0, 255); + return WriteTransportAsync($"M106 S{value}", cancellationToken); + } + + public Task SetPrintSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M220 S{speedPercent}", cancellationToken); + } + + public Task SetPrintFlowAsync(int flowPercent, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + return WriteTransportAsync($"M221 S{flowPercent}", cancellationToken); + } + + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + IsPrinting = true; + return WriteTransportAsync($"M23 {fileName}", cancellationToken); + } + + // ── Response parsing (called by platform subclasses) ───────────────────── + + /// + /// Appends to the receive buffer and processes any + /// complete lines. Platform subclasses call this from their read loops. + /// + protected void ProcessReceivedData(string data) + { + _receiveBuffer.Append(data); + + while (true) + { + var bufferStr = _receiveBuffer.ToString(); + var newlineIndex = bufferStr.IndexOf('\n'); + if (newlineIndex < 0) break; + + var line = bufferStr[..(newlineIndex + 1)].Trim('\r', '\n', ' '); + if (!string.IsNullOrEmpty(line)) + ParseLine(line); + + _receiveBuffer.Remove(0, newlineIndex + 1); + } + } + + private void ParseLine(string line) + { + try + { + // Temperature response: ok T:200.00 /200.00 B:60.00 /60.00 + if (line.StartsWith("ok T:", StringComparison.Ordinal) || + line.StartsWith("T:", StringComparison.Ordinal)) + { + var m = TempRegex.Match(line); + if (m.Success) + { + LastTelemetry.HotendTemp = double.Parse(m.Groups[1].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.HotendTarget = double.Parse(m.Groups[2].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.BedTemp = double.Parse(m.Groups[3].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.BedTarget = double.Parse(m.Groups[4].Value, + System.Globalization.CultureInfo.InvariantCulture); + LastTelemetry.CapturedAt = DateTimeOffset.UtcNow; + LastTelemetry.Status = IsConnected ? PrinterStatus.Connected : PrinterStatus.Disconnected; + } + } + // SD progress: SD printing byte 12345/67890 + else if (line.Contains("SD printing byte", StringComparison.Ordinal)) + { + var m = PrintProgressRegex.Match(line); + if (m.Success) + { + var done = double.Parse(m.Groups[1].Value, + System.Globalization.CultureInfo.InvariantCulture); + var total = double.Parse(m.Groups[2].Value, + System.Globalization.CultureInfo.InvariantCulture); + if (total > 0) + { + LastTelemetry.PrintProgress = done / total * 100.0; + LastTelemetry.Status = PrinterStatus.Printing; + IsPrinting = true; + } + } + } + // Print complete + else if (line.Equals("Done printing file", StringComparison.OrdinalIgnoreCase)) + { + LastTelemetry.PrintProgress = 100; + LastTelemetry.Status = PrinterStatus.Connected; + IsPrinting = false; + } + + RaiseTelemetryUpdated(); + } + catch + { + // Swallow parse errors — never crash the receive loop. + } + } + + // ── Telemetry polling ──────────────────────────────────────────────────── + + private async Task PollTelemetryAsync() + { + if (!IsConnected) return; + try + { + await WriteTransportAsync("M105", CancellationToken.None); + await WriteTransportAsync("M27", CancellationToken.None); + } + catch + { + // Telemetry polling errors are swallowed silently — no log spam. + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + protected void RaiseConnectionChanged() => + ConnectionStateChanged?.Invoke(this, IsConnected); + + protected void RaiseTelemetryUpdated() => + TelemetryUpdated?.Invoke(this, LastTelemetry); + + // ── IAsyncDisposable ───────────────────────────────────────────────────── + + public async ValueTask DisposeAsync() + { + _telemetryTimer.Stop(); + _telemetryTimer.Dispose(); + + if (IsConnected) + { + await DisconnectAsync(); + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs b/src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs new file mode 100644 index 0000000..229f951 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Telemetry/InMemoryTelemetryStore.cs @@ -0,0 +1,76 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Infrastructure.Telemetry; + +/// +/// In-memory implementation of . +/// Retains the last N snapshots per printer in a bounded ring buffer. +/// Suitable for tests and development; does not survive process restart. +/// +public sealed class InMemoryTelemetryStore : ITelemetryStore +{ + private readonly int _maxPerPrinter; + private readonly Dictionary> _data = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Maximum snapshots retained per printer ID (default 500). + public InMemoryTelemetryStore(int maxPerPrinter = 500) + { + _maxPerPrinter = maxPerPrinter; + } + + public async Task SaveAsync(string printerId, PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(printerId, out var list)) + { + list = new LinkedList(); + _data[printerId] = list; + } + + list.AddFirst(telemetry); + + while (list.Count > _maxPerPrinter) + list.RemoveLast(); + } + finally + { + _lock.Release(); + } + } + + public async Task GetLatestAsync(string printerId, + CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + return _data.TryGetValue(printerId, out var list) ? list.First?.Value : null; + } + finally + { + _lock.Release(); + } + } + + public async Task> GetHistoryAsync( + string printerId, int count = 100, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (!_data.TryGetValue(printerId, out var list)) + return []; + + return list.Take(count).ToList().AsReadOnly(); + } + finally + { + _lock.Release(); + } + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs new file mode 100644 index 0000000..180a469 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/AnalyticsServiceTests.cs @@ -0,0 +1,123 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Analytics; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class AnalyticsServiceTests +{ + private static AnalyticsService CreateSut() => + new(new InMemoryPrintJobAnalyticsStore(), NullLogger.Instance); + + private static readonly Guid PrinterA = Guid.NewGuid(); + private static readonly Guid PrinterB = Guid.NewGuid(); + private static readonly Guid SpoolX = Guid.NewGuid(); + + [Fact] + public async Task GetRecords_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var records = await sut.GetRecordsAsync(); + Assert.Empty(records); + } + + [Fact] + public async Task RecordUsage_AppearsInGetRecords() + { + var sut = CreateSut(); + var record = new PrintJobUsageRecord + { + PrinterId = PrinterA, + JobName = "Benchy", + Duration = TimeSpan.FromHours(2), + EstimatedFilamentUsedGrams = 30, + }; + + await sut.RecordUsageAsync(record); + + var records = await sut.GetRecordsAsync(); + Assert.Single(records); + Assert.Equal("Benchy", records[0].JobName); + } + + [Fact] + public async Task GetTotalPrintTime_SumsAllDurations() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord { Duration = TimeSpan.FromHours(1) }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { Duration = TimeSpan.FromHours(2.5) }); + + var total = await sut.GetTotalPrintTimeAsync(); + + Assert.Equal(TimeSpan.FromHours(3.5), total); + } + + [Fact] + public async Task GetTotalFilamentConsumed_UsesActualWhenPresent() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord + { + EstimatedFilamentUsedGrams = 50, + ActualFilamentUsedGrams = 48, // actual overrides estimated + }); + await sut.RecordUsageAsync(new PrintJobUsageRecord + { + EstimatedFilamentUsedGrams = 30, + ActualFilamentUsedGrams = 0, // use estimated when actual is 0 + }); + + var total = await sut.GetTotalFilamentConsumedGramsAsync(); + + Assert.Equal(78, total); // 48 + 30 + } + + [Fact] + public async Task GetFilamentConsumedByPrinter_FiltersCorrectly() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord { PrinterId = PrinterA, EstimatedFilamentUsedGrams = 40 }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { PrinterId = PrinterB, EstimatedFilamentUsedGrams = 60 }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { PrinterId = PrinterA, EstimatedFilamentUsedGrams = 20 }); + + var consumed = await sut.GetFilamentConsumedByPrinterAsync(PrinterA); + + Assert.Equal(60, consumed); // 40 + 20 + } + + [Fact] + public async Task GetFilamentConsumedBySpool_FiltersCorrectly() + { + var sut = CreateSut(); + await sut.RecordUsageAsync(new PrintJobUsageRecord { FilamentSpoolId = SpoolX, EstimatedFilamentUsedGrams = 35 }); + await sut.RecordUsageAsync(new PrintJobUsageRecord { FilamentSpoolId = Guid.NewGuid(), EstimatedFilamentUsedGrams = 100 }); + + var consumed = await sut.GetFilamentConsumedBySpoolAsync(SpoolX); + + Assert.Equal(35, consumed); + } + + [Fact] + public async Task RecordUsage_RaisesAnalyticsUpdatedEvent() + { + var sut = CreateSut(); + var raised = false; + sut.AnalyticsUpdated += (_, _) => raised = true; + + await sut.RecordUsageAsync(new PrintJobUsageRecord()); + + Assert.True(raised); + } + + [Fact] + public async Task GetTotalPrintTime_WhenNoRecords_ReturnsZero() + { + var sut = CreateSut(); + var total = await sut.GetTotalPrintTimeAsync(); + Assert.Equal(TimeSpan.Zero, total); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs new file mode 100644 index 0000000..c0ba0a3 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/FarmServiceTests.cs @@ -0,0 +1,143 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Farm; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class FarmServiceTests +{ + private static FarmService CreateSut() => + new(new InMemoryFarmRepository(), NullLogger.Instance); + + [Fact] + public async Task GetFarms_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var farms = await sut.GetFarmsAsync(); + Assert.Empty(farms); + } + + [Fact] + public async Task CreateFarm_AppearsInList() + { + var sut = CreateSut(); + + var farm = await sut.CreateFarmAsync("Hackerspace A"); + + var farms = await sut.GetFarmsAsync(); + Assert.Single(farms); + Assert.Equal("Hackerspace A", farms[0].Name); + } + + [Fact] + public async Task CreateFarm_TrimsName() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync(" Workshop "); + Assert.Equal("Workshop", farm.Name); + } + + [Fact] + public async Task RenameFarm_UpdatesName() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("Old"); + + await sut.RenameFarmAsync(farm.Id, "New"); + + var retrieved = await sut.GetFarmAsync(farm.Id); + Assert.Equal("New", retrieved?.Name); + } + + [Fact] + public async Task RenameFarm_UnknownId_DoesNotThrow() + { + var sut = CreateSut(); + await sut.RenameFarmAsync(Guid.NewGuid(), "Whatever"); + // No exception expected + } + + [Fact] + public async Task DeleteFarm_RemovesFromList() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("To Delete"); + + await sut.DeleteFarmAsync(farm.Id); + + var farms = await sut.GetFarmsAsync(); + Assert.Empty(farms); + } + + [Fact] + public async Task SnapshotPrinters_StoresPrinterListInFarm() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("F"); + var printers = new[] + { + new PrinterConnectionDefinition { Name = "MK4 Alpha" }, + new PrinterConnectionDefinition { Name = "MK4 Beta" }, + }; + + await sut.SnapshotPrintersAsync(farm.Id, printers); + + var retrieved = await sut.GetFarmAsync(farm.Id); + Assert.Equal(2, retrieved!.Printers.Count); + Assert.Contains(retrieved.Printers, p => p.Name == "MK4 Alpha"); + } + + [Fact] + public async Task ExportFarm_ReturnsValidJson() + { + var sut = CreateSut(); + var farm = await sut.CreateFarmAsync("Export Test"); + + var json = await sut.ExportFarmAsync(farm.Id); + + Assert.Contains("Export Test", json); + Assert.Contains("\"Name\"", json); + } + + [Fact] + public async Task ImportFarm_AddsNewFarmWithFreshId() + { + var sut = CreateSut(); + var original = await sut.CreateFarmAsync("Original Farm"); + var json = await sut.ExportFarmAsync(original.Id); + + var imported = await sut.ImportFarmAsync(json); + + // Imported farm should get a new ID + Assert.NotEqual(original.Id, imported.Id); + // But same name + Assert.Equal("Original Farm", imported.Name); + + var farms = await sut.GetFarmsAsync(); + Assert.Equal(2, farms.Count); + } + + [Fact] + public async Task FarmsChanged_RaisedOnCreate() + { + var sut = CreateSut(); + var raised = false; + sut.FarmsChanged += (_, _) => raised = true; + + await sut.CreateFarmAsync("Event Farm"); + + Assert.True(raised); + } + + [Fact] + public async Task ExportFarm_UnknownId_ReturnsEmptyObject() + { + var sut = CreateSut(); + var result = await sut.ExportFarmAsync(Guid.NewGuid()); + Assert.Equal("{}", result); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs new file mode 100644 index 0000000..ee9416d --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/FilamentInventoryServiceTests.cs @@ -0,0 +1,124 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Inventory; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class FilamentInventoryServiceTests +{ + private static FilamentInventoryService CreateSut() => + new(new InMemoryFilamentInventoryStore(), NullLogger.Instance); + + [Fact] + public async Task GetSpools_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var spools = await sut.GetSpoolsAsync(); + Assert.Empty(spools); + } + + [Fact] + public async Task AddSpool_SpoolAppearsInList() + { + var sut = CreateSut(); + var spool = new FilamentSpool { Name = "PETG Black", Material = "PETG" }; + + await sut.AddSpoolAsync(spool); + + var spools = await sut.GetSpoolsAsync(); + Assert.Single(spools); + Assert.Equal("PETG Black", spools[0].Name); + } + + [Fact] + public async Task UpdateSpool_ReplacesExistingEntry() + { + var sut = CreateSut(); + var spool = new FilamentSpool { Name = "PLA White" }; + await sut.AddSpoolAsync(spool); + + spool.Name = "PLA White (renamed)"; + await sut.UpdateSpoolAsync(spool); + + var retrieved = await sut.GetSpoolAsync(spool.Id); + Assert.Equal("PLA White (renamed)", retrieved?.Name); + } + + [Fact] + public async Task DeleteSpool_SpoolNoLongerInList() + { + var sut = CreateSut(); + var spool = new FilamentSpool { Name = "ABS Red" }; + await sut.AddSpoolAsync(spool); + + await sut.DeleteSpoolAsync(spool.Id); + + var spools = await sut.GetSpoolsAsync(); + Assert.Empty(spools); + } + + [Fact] + public async Task DeductFilament_ReducesRemainingWeight() + { + var sut = CreateSut(); + var spool = new FilamentSpool { RemainingWeightGrams = 500 }; + await sut.AddSpoolAsync(spool); + + await sut.DeductFilamentAsync(spool.Id, 100); + + var retrieved = await sut.GetSpoolAsync(spool.Id); + Assert.Equal(400, retrieved!.RemainingWeightGrams); + } + + [Fact] + public async Task DeductFilament_ClampsAtZero_NeverGoesNegative() + { + var sut = CreateSut(); + var spool = new FilamentSpool { RemainingWeightGrams = 50 }; + await sut.AddSpoolAsync(spool); + + await sut.DeductFilamentAsync(spool.Id, 999); + + var retrieved = await sut.GetSpoolAsync(spool.Id); + Assert.Equal(0, retrieved!.RemainingWeightGrams); + } + + [Fact] + public async Task DeductFilament_UnknownSpool_DoesNotThrow() + { + var sut = CreateSut(); + // Should log a warning and return gracefully + await sut.DeductFilamentAsync(Guid.NewGuid(), 100); + } + + [Fact] + public async Task AddSpool_RaisesInventoryChangedEvent() + { + var sut = CreateSut(); + var raised = false; + sut.InventoryChanged += (_, _) => raised = true; + + await sut.AddSpoolAsync(new FilamentSpool { Name = "TPU" }); + + Assert.True(raised); + } + + [Fact] + public async Task DeleteSpool_RaisesInventoryChangedEvent() + { + var sut = CreateSut(); + var spool = new FilamentSpool(); + await sut.AddSpoolAsync(spool); + + var raised = false; + sut.InventoryChanged += (_, _) => raised = true; + + await sut.DeleteSpoolAsync(spool.Id); + + Assert.True(raised); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs new file mode 100644 index 0000000..810a448 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/PrintProjectServiceTests.cs @@ -0,0 +1,168 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Projects; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class PrintProjectServiceTests +{ + private static PrintProjectService CreateSut() => + new(new InMemoryPrintProjectRepository(), NullLogger.Instance); + + [Fact] + public async Task GetProjects_Initially_ReturnsEmpty() + { + var sut = CreateSut(); + var projects = await sut.GetProjectsAsync(); + Assert.Empty(projects); + } + + [Fact] + public async Task CreateProject_AppearsInList() + { + var sut = CreateSut(); + + var project = await sut.CreateProjectAsync("Test Print", "Some notes"); + + var projects = await sut.GetProjectsAsync(); + Assert.Single(projects); + Assert.Equal("Test Print", projects[0].Name); + Assert.Equal("Some notes", projects[0].Notes); + } + + [Fact] + public async Task CreateProject_TrimsName() + { + var sut = CreateSut(); + + var project = await sut.CreateProjectAsync(" Trimmed "); + + Assert.Equal("Trimmed", project.Name); + } + + [Fact] + public async Task RenameProject_UpdatesName() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("Old Name"); + + await sut.RenameProjectAsync(project.Id, "New Name"); + + var retrieved = await sut.GetProjectAsync(project.Id); + Assert.Equal("New Name", retrieved?.Name); + } + + [Fact] + public async Task DeleteProject_RemovesFromList() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("To Delete"); + + await sut.DeleteProjectAsync(project.Id); + + var projects = await sut.GetProjectsAsync(); + Assert.Empty(projects); + } + + [Fact] + public async Task AddJob_JobAppearsInProject() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("Batch"); + var content = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("G28\nG0 X10\n")); + + await sut.AddJobAsync(project.Id, "test.gcode", content); + + var retrieved = await sut.GetProjectAsync(project.Id); + Assert.Single(retrieved!.Jobs); + Assert.Equal("test.gcode", retrieved.Jobs[0].FileName); + } + + [Fact] + public async Task RemoveJob_JobRemovedFromProject() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + var content = new MemoryStream(new byte[] { 1, 2, 3 }); + await sut.AddJobAsync(project.Id, "file.gcode", content); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + await sut.RemoveJobAsync(project.Id, jobId); + + retrieved = await sut.GetProjectAsync(project.Id); + Assert.Empty(retrieved!.Jobs); + } + + [Fact] + public async Task AssignJob_SetsPrinterAndStatusToPrinting() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + var content = new MemoryStream(new byte[] { 1 }); + await sut.AddJobAsync(project.Id, "job.gcode", content); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + var printerId = Guid.NewGuid(); + + await sut.AssignJobAsync(project.Id, jobId, printerId, "MK4 Alpha"); + + retrieved = await sut.GetProjectAsync(project.Id); + var job = retrieved!.Jobs[0]; + Assert.Equal(PrintJobStatus.Printing, job.Status); + Assert.Equal(printerId, job.AssignedPrinterId); + Assert.Equal("MK4 Alpha", job.AssignedPrinterName); + } + + [Fact] + public async Task UpdateJobStatus_ChangesStatus() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + await sut.AddJobAsync(project.Id, "j.gcode", new MemoryStream(new byte[] { 1 })); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + + await sut.UpdateJobStatusAsync(project.Id, jobId, PrintJobStatus.Completed); + + retrieved = await sut.GetProjectAsync(project.Id); + Assert.Equal(PrintJobStatus.Completed, retrieved!.Jobs[0].Status); + } + + [Fact] + public async Task OpenJobFile_ReturnsFileContent() + { + var sut = CreateSut(); + var project = await sut.CreateProjectAsync("P"); + var originalBytes = System.Text.Encoding.UTF8.GetBytes("G28\nG1 X100\n"); + await sut.AddJobAsync(project.Id, "file.gcode", new MemoryStream(originalBytes)); + + var retrieved = await sut.GetProjectAsync(project.Id); + var jobId = retrieved!.Jobs[0].Id; + + await using var stream = await sut.OpenJobFileAsync(project.Id, jobId); + + Assert.NotNull(stream); + using var reader = new System.IO.StreamReader(stream!); + var content = await reader.ReadToEndAsync(); + Assert.Equal("G28\nG1 X100\n", content); + } + + [Fact] + public async Task ProjectsChanged_RaisedOnCreate() + { + var sut = CreateSut(); + var raised = false; + sut.ProjectsChanged += (_, _) => raised = true; + + await sut.CreateProjectAsync("Event Test"); + + Assert.True(raised); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs b/src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs new file mode 100644 index 0000000..968d548 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Application/PrinterFleetServiceTests.cs @@ -0,0 +1,142 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.Tests.Unit.Helpers; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Application; + +/// +/// Unit tests for . +/// +public sealed class PrinterFleetServiceTests : IAsyncDisposable +{ + private readonly PrinterFleetService _sut = new(NullLogger.Instance); + + // ── AddAndConnect ──────────────────────────────────────────────────────── + + [Fact] + public async Task AddAndConnect_SuccessfulConnect_ReturnsTrueAndPrinterIsTracked() + { + var fake = new FakePrinterService { ConnectShouldSucceed = true }; + + var result = await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + Assert.True(result); + Assert.Contains("p1", _sut.PrinterIds); + } + + [Fact] + public async Task AddAndConnect_FailedConnect_ReturnsFalseButPrinterStillRegistered() + { + var fake = new FakePrinterService { ConnectShouldSucceed = false }; + + var result = await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + Assert.False(result); + // Service is registered even on connection failure so the caller can retry. + Assert.Contains("p1", _sut.PrinterIds); + } + + [Fact] + public async Task AddAndConnect_ReplacesExistingConnection() + { + var first = new FakePrinterService(); + var second = new FakePrinterService(); + + await _sut.AddAndConnectAsync("p1", first, new PrinterConnectionSettings()); + await _sut.AddAndConnectAsync("p1", second, new PrinterConnectionSettings()); + + // first should have been disconnected when replaced + Assert.False(first.IsConnected); + Assert.True(second.IsConnected); + } + + // ── Remove ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Remove_DisconnectsAndUnregisters() + { + var fake = new FakePrinterService(); + await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + await _sut.RemoveAsync("p1"); + + Assert.DoesNotContain("p1", _sut.PrinterIds); + Assert.False(fake.IsConnected); + } + + [Fact] + public async Task Remove_UnknownId_DoesNotThrow() + { + await _sut.RemoveAsync("nonexistent"); + // No exception expected + } + + // ── GetConnection ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetConnection_ReturnsServiceForKnownId() + { + var fake = new FakePrinterService(); + await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + var retrieved = _sut.GetConnection("p1"); + + Assert.Same(fake, retrieved); + } + + [Fact] + public void GetConnection_ReturnsNullForUnknownId() + { + Assert.Null(_sut.GetConnection("unknown")); + } + + // ── GetFleetTelemetry ──────────────────────────────────────────────────── + + [Fact] + public async Task GetFleetTelemetry_IncludesOnlyConnectedPrinters() + { + var connected = new FakePrinterService { ConnectShouldSucceed = true }; + var disconnected = new FakePrinterService { ConnectShouldSucceed = false }; + + await _sut.AddAndConnectAsync("p-connected", connected, new PrinterConnectionSettings()); + await _sut.AddAndConnectAsync("p-disconnected", disconnected, new PrinterConnectionSettings()); + + var telemetry = _sut.GetFleetTelemetry(); + + Assert.Contains("p-connected", telemetry.Keys); + Assert.DoesNotContain("p-disconnected", telemetry.Keys); + } + + // ── FleetChanged event ─────────────────────────────────────────────────── + + [Fact] + public async Task AddAndConnect_RaisesFleetChangedEvent() + { + var fake = new FakePrinterService(); + var raised = false; + _sut.FleetChanged += (_, _) => raised = true; + + await _sut.AddAndConnectAsync("p1", fake, new PrinterConnectionSettings()); + + Assert.True(raised); + } + + // ── Disposal ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Dispose_DisconnectsAllPrinters() + { + var p1 = new FakePrinterService(); + var p2 = new FakePrinterService(); + await _sut.AddAndConnectAsync("p1", p1, new PrinterConnectionSettings()); + await _sut.AddAndConnectAsync("p2", p2, new PrinterConnectionSettings()); + + await _sut.DisposeAsync(); + + Assert.False(p1.IsConnected); + Assert.False(p2.IsConnected); + } + + public async ValueTask DisposeAsync() => await _sut.DisposeAsync(); +} diff --git a/src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs b/src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs new file mode 100644 index 0000000..1dd5aaf --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Core/InMemoryTelemetryStoreTests.cs @@ -0,0 +1,94 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Telemetry; + +namespace MakerPrompt.Tests.Unit.Core; + +/// +/// Unit tests for . +/// +public sealed class InMemoryTelemetryStoreTests +{ + private readonly InMemoryTelemetryStore _sut = new(); + + [Fact] + public async Task GetLatest_WhenNoDataSaved_ReturnsNull() + { + var result = await _sut.GetLatestAsync("unknown"); + Assert.Null(result); + } + + [Fact] + public async Task SaveAndGetLatest_ReturnsMostRecentSnapshot() + { + var first = new PrinterTelemetry { HotendTemp = 200 }; + var second = new PrinterTelemetry { HotendTemp = 210 }; + + await _sut.SaveAsync("p1", first); + await _sut.SaveAsync("p1", second); + + var latest = await _sut.GetLatestAsync("p1"); + + Assert.NotNull(latest); + Assert.Equal(210, latest.HotendTemp); + } + + [Fact] + public async Task GetHistory_ReturnsSnapshotsInDescendingOrder() + { + for (var i = 1; i <= 5; i++) + await _sut.SaveAsync("p1", new PrinterTelemetry { HotendTemp = i * 10 }); + + var history = await _sut.GetHistoryAsync("p1", count: 5); + + Assert.Equal(5, history.Count); + // Most-recent first (50 °C was saved last) + Assert.Equal(50, history[0].HotendTemp); + } + + [Fact] + public async Task GetHistory_CountParameter_LimitsResults() + { + for (var i = 0; i < 20; i++) + await _sut.SaveAsync("p1", new PrinterTelemetry()); + + var history = await _sut.GetHistoryAsync("p1", count: 5); + + Assert.Equal(5, history.Count); + } + + [Fact] + public async Task GetHistory_UnknownPrinterId_ReturnsEmpty() + { + var result = await _sut.GetHistoryAsync("nonexistent"); + Assert.Empty(result); + } + + [Fact] + public async Task BoundedBuffer_OldestSnapshotEvicted() + { + var bounded = new InMemoryTelemetryStore(maxPerPrinter: 3); + + for (var i = 1; i <= 5; i++) + await bounded.SaveAsync("p1", new PrinterTelemetry { HotendTemp = i * 10 }); + + var history = await bounded.GetHistoryAsync("p1", count: 10); + + Assert.Equal(3, history.Count); + // Should retain the 3 newest: 50, 40, 30 + Assert.DoesNotContain(history, t => t.HotendTemp == 10); + Assert.DoesNotContain(history, t => t.HotendTemp == 20); + } + + [Fact] + public async Task MultiPrinter_DataIsIsolated() + { + await _sut.SaveAsync("printer-a", new PrinterTelemetry { HotendTemp = 190 }); + await _sut.SaveAsync("printer-b", new PrinterTelemetry { HotendTemp = 240 }); + + var a = await _sut.GetLatestAsync("printer-a"); + var b = await _sut.GetLatestAsync("printer-b"); + + Assert.Equal(190, a?.HotendTemp); + Assert.Equal(240, b?.HotendTemp); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs b/src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs new file mode 100644 index 0000000..87b5581 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Helpers/FakePrinterService.cs @@ -0,0 +1,80 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.Tests.Unit.Helpers; + +/// +/// A minimal, fully-controllable in-memory implementation of +/// for use in unit tests. +/// +public sealed class FakePrinterService : IPrinterCommunicationService +{ + public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; + public PrinterTelemetry LastTelemetry { get; set; } = new(); + public string ConnectionName { get; set; } = "FakePrinter"; + public bool IsConnected { get; private set; } + public bool IsPrinting { get; private set; } + + public bool ConnectShouldSucceed { get; set; } = true; + public PrinterTelemetry? TelemetryToReturn { get; set; } + + public event EventHandler? ConnectionStateChanged; + public event EventHandler? TelemetryUpdated; + + public Task ConnectAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken = default) + { + IsConnected = ConnectShouldSucceed; + ConnectionStateChanged?.Invoke(this, IsConnected); + return Task.FromResult(IsConnected); + } + + public Task DisconnectAsync(CancellationToken cancellationToken = default) + { + IsConnected = false; + IsPrinting = false; + ConnectionStateChanged?.Invoke(this, false); + return Task.CompletedTask; + } + + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task GetTelemetryAsync(CancellationToken cancellationToken = default) + { + var t = TelemetryToReturn ?? LastTelemetry; + TelemetryUpdated?.Invoke(this, t); + return Task.FromResult(t); + } + + public Task> GetFilesAsync(CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task SetHotendTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetBedTempAsync(int targetCelsius, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task HomeAsync(bool x = true, bool y = true, bool z = true, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task RelativeMoveAsync(int feedRate, float x = 0, float y = 0, float z = 0, float e = 0, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetFanSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetPrintSpeedAsync(int speedPercent, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SetPrintFlowAsync(int flowPercent, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs b/src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs new file mode 100644 index 0000000..195e8c5 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Infrastructure/CameraStoreTests.cs @@ -0,0 +1,102 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Camera; + +namespace MakerPrompt.Tests.Unit.Infrastructure; + +/// +/// Tests for the in-memory camera snapshot store and related types. +/// +public sealed class CameraStoreTests +{ + [Fact] + public async Task InMemoryCameraStore_Save_And_GetLatest_RoundTrip() + { + var store = new InMemoryCameraSnapshotStore(); + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; + var snapshot = new CameraSnapshot + { + CameraId = "cam-a", + Label = "Test Camera", + JpegData = jpeg, + Width = 1280, + Height = 720, + CapturedAt = DateTimeOffset.UtcNow + }; + + await store.SaveAsync(snapshot); + var latest = await store.GetLatestAsync("cam-a"); + + Assert.NotNull(latest); + Assert.Equal("Test Camera", latest.Label); + Assert.Equal(jpeg, latest.JpegData); + Assert.Equal(1280, latest.Width); + Assert.Equal(720, latest.Height); + } + + [Fact] + public async Task InMemoryCameraStore_GetLatest_ReturnsNull_WhenEmpty() + { + var store = new InMemoryCameraSnapshotStore(); + Assert.Null(await store.GetLatestAsync("cam-nonexistent")); + } + + [Fact] + public async Task InMemoryCameraStore_GetHistory_ExcludesJpegBlob() + { + var store = new InMemoryCameraSnapshotStore(); + for (int i = 0; i < 3; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-b", + JpegData = new byte[500], + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("cam-b", count: 10); + + Assert.Equal(3, history.Count); + Assert.All(history, s => Assert.Empty(s.JpegData)); + } + + [Fact] + public async Task InMemoryCameraStore_RingBuffer_DropsOldestWhenFull() + { + const int max = 3; + var store = new InMemoryCameraSnapshotStore(maxPerCamera: max); + + for (int i = 1; i <= max + 2; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-c", + Label = $"Snap-{i}", + JpegData = new byte[] { (byte)i }, + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + // Latest should be the newest (Snap-5). + var latest = await store.GetLatestAsync("cam-c"); + Assert.Equal("Snap-5", latest!.Label); + + // History should only contain max entries. + var history = await store.GetHistoryAsync("cam-c", count: 100); + Assert.Equal(max, history.Count); + } + + [Fact] + public async Task InMemoryCameraStore_IsolatesCameras() + { + var store = new InMemoryCameraSnapshotStore(); + await store.SaveAsync(new CameraSnapshot { CameraId = "camX", Label = "X", JpegData = new byte[] { 1 } }); + await store.SaveAsync(new CameraSnapshot { CameraId = "camY", Label = "Y", JpegData = new byte[] { 2 } }); + + var latestX = await store.GetLatestAsync("camX"); + var latestY = await store.GetLatestAsync("camY"); + + Assert.Equal("X", latestX!.Label); + Assert.Equal("Y", latestY!.Label); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs b/src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs new file mode 100644 index 0000000..b4e9343 --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Infrastructure/InMemoryStoreTests.cs @@ -0,0 +1,168 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Inventory; +using MakerPrompt.Infrastructure.Analytics; +using MakerPrompt.Infrastructure.Farm; +using MakerPrompt.Infrastructure.Projects; + +namespace MakerPrompt.Tests.Unit.Infrastructure; + +/// +/// Verifies the thread-safety guarantees and contract compliance of +/// the in-memory infrastructure store implementations. +/// +public sealed class InMemoryStoreTests +{ + // ── FilamentInventoryStore ──────────────────────────────────────────────── + + [Fact] + public async Task FilamentStore_Save_And_GetById_RoundTrip() + { + var store = new InMemoryFilamentInventoryStore(); + var spool = new FilamentSpool { Name = "PLA Blue", RemainingWeightGrams = 800 }; + + await store.SaveAsync(spool); + var result = await store.GetByIdAsync(spool.Id); + + Assert.NotNull(result); + Assert.Equal("PLA Blue", result.Name); + } + + [Fact] + public async Task FilamentStore_Delete_RemovesEntry() + { + var store = new InMemoryFilamentInventoryStore(); + var spool = new FilamentSpool(); + await store.SaveAsync(spool); + + await store.DeleteAsync(spool.Id); + + Assert.Null(await store.GetByIdAsync(spool.Id)); + } + + [Fact] + public async Task FilamentStore_Deduct_ClampsAtZero() + { + var store = new InMemoryFilamentInventoryStore(); + var spool = new FilamentSpool { RemainingWeightGrams = 10 }; + await store.SaveAsync(spool); + + await store.DeductFilamentAsync(spool.Id, 1000); + + var result = await store.GetByIdAsync(spool.Id); + Assert.Equal(0, result!.RemainingWeightGrams); + } + + // ── AnalyticsStore ──────────────────────────────────────────────────────── + + [Fact] + public async Task AnalyticsStore_Save_AppearsInGetAll() + { + var store = new InMemoryPrintJobAnalyticsStore(); + var record = new PrintJobUsageRecord { JobName = "Test Job" }; + + await store.SaveAsync(record); + var all = await store.GetAllAsync(); + + Assert.Single(all); + Assert.Equal("Test Job", all[0].JobName); + } + + [Fact] + public async Task AnalyticsStore_GetByPrinter_FiltersCorrectly() + { + var store = new InMemoryPrintJobAnalyticsStore(); + var printerId = Guid.NewGuid(); + await store.SaveAsync(new PrintJobUsageRecord { PrinterId = printerId }); + await store.SaveAsync(new PrintJobUsageRecord { PrinterId = Guid.NewGuid() }); + + var result = await store.GetByPrinterAsync(printerId); + + Assert.Single(result); + Assert.Equal(printerId, result[0].PrinterId); + } + + // ── FarmRepository ──────────────────────────────────────────────────────── + + [Fact] + public async Task FarmRepo_Save_And_GetById_RoundTrip() + { + var repo = new InMemoryFarmRepository(); + var farm = new FarmConfiguration { Name = "Test Farm" }; + + await repo.SaveAsync(farm); + var result = await repo.GetByIdAsync(farm.Id); + + Assert.NotNull(result); + Assert.Equal("Test Farm", result.Name); + } + + [Fact] + public async Task FarmRepo_Delete_RemovesEntry() + { + var repo = new InMemoryFarmRepository(); + var farm = new FarmConfiguration { Name = "Delete Me" }; + await repo.SaveAsync(farm); + + await repo.DeleteAsync(farm.Id); + + Assert.Null(await repo.GetByIdAsync(farm.Id)); + } + + [Fact] + public async Task FarmRepo_GetAll_OrderedByCreationDate() + { + var repo = new InMemoryFarmRepository(); + var older = new FarmConfiguration { Name = "Older", CreatedAt = DateTime.UtcNow.AddDays(-1) }; + var newer = new FarmConfiguration { Name = "Newer", CreatedAt = DateTime.UtcNow }; + await repo.SaveAsync(newer); + await repo.SaveAsync(older); + + var all = await repo.GetAllAsync(); + + Assert.Equal("Older", all[0].Name); + Assert.Equal("Newer", all[1].Name); + } + + // ── PrintProjectRepository ──────────────────────────────────────────────── + + [Fact] + public async Task ProjectRepo_Save_And_GetAll_RoundTrip() + { + var repo = new InMemoryPrintProjectRepository(); + var project = new PrintProject { Name = "Test Project" }; + + await repo.SaveAsync(project); + var all = await repo.GetAllAsync(); + + Assert.Single(all); + Assert.Equal("Test Project", all[0].Name); + } + + [Fact] + public async Task ProjectRepo_SaveJobFile_And_OpenJobFile_RoundTrip() + { + var repo = new InMemoryPrintProjectRepository(); + var projectId = Guid.NewGuid(); + var gcode = System.Text.Encoding.UTF8.GetBytes("G28\nG0 X10\n"); + + var storagePath = await repo.SaveJobFileAsync(projectId, "test.gcode", new MemoryStream(gcode)); + + await using var stream = await repo.OpenJobFileAsync(storagePath); + Assert.NotNull(stream); + using var reader = new System.IO.StreamReader(stream!); + var content = await reader.ReadToEndAsync(); + Assert.Equal("G28\nG0 X10\n", content); + } + + [Fact] + public async Task ProjectRepo_DeleteJobFile_FileNoLongerAccessible() + { + var repo = new InMemoryPrintProjectRepository(); + var storagePath = await repo.SaveJobFileAsync(Guid.NewGuid(), "delete.gcode", + new MemoryStream(new byte[] { 1 })); + + await repo.DeleteJobFileAsync(storagePath); + + Assert.Null(await repo.OpenJobFileAsync(storagePath)); + } +} diff --git a/src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs b/src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs new file mode 100644 index 0000000..8dc281f --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/Infrastructure/SqliteStoreTests.cs @@ -0,0 +1,252 @@ +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MakerPrompt.Tests.Unit.Infrastructure; + +/// +/// Integration-style tests for the SQLite-backed infrastructure stores. +/// Each test creates a unique temp-file SQLite database and cleans it up on dispose. +/// +public sealed class SqliteStoreTests +{ + // ── Helper: unique temp-file SQLite database per test ──────────────────── + + // SQLite in-memory databases disappear when the last connection closes. + // Using a temp file per test ensures the schema (created in the constructor) + // persists across multiple connection open/close cycles in the same test. + // The returned TempDb is IDisposable and deletes the file on dispose. + private static TempDb CreateTempDb() + { + var path = Path.Combine(Path.GetTempPath(), $"makerprompt_test_{Guid.NewGuid():N}.db"); + return new TempDb(path); + } + + private sealed class TempDb : IDisposable + { + public string ConnectionString { get; } + private readonly string _path; + + public TempDb(string path) + { + _path = path; + ConnectionString = $"Data Source={path}"; + } + + public void Dispose() + { + try { if (File.Exists(_path)) File.Delete(_path); } + catch { /* best-effort cleanup */ } + } + } + + // ── SqliteTelemetryStore ────────────────────────────────────────────────── + + [Fact] + public async Task TelemetryStore_Save_And_GetLatest_RoundTrip() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var telemetry = new PrinterTelemetry + { + PrinterName = "Ender-3", + HotendTemp = 215.0, + HotendTarget = 215.0, + BedTemp = 60.0, + Status = PrinterStatus.Printing, + PrintProgress = 45.0, + CapturedAt = DateTimeOffset.UtcNow + }; + + await store.SaveAsync("printer-1", telemetry); + var latest = await store.GetLatestAsync("printer-1"); + + Assert.NotNull(latest); + Assert.Equal("Ender-3", latest.PrinterName); + Assert.Equal(215.0, latest.HotendTemp); + Assert.Equal(PrinterStatus.Printing, latest.Status); + Assert.Equal(45.0, latest.PrintProgress); + } + + [Fact] + public async Task TelemetryStore_GetLatest_ReturnsNull_WhenEmpty() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var result = await store.GetLatestAsync("printer-x"); + Assert.Null(result); + } + + [Fact] + public async Task TelemetryStore_GetHistory_ReturnsMultipleSnapshots_OrderedNewestFirst() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var baseTime = DateTimeOffset.UtcNow; + for (int i = 0; i < 5; i++) + { + await store.SaveAsync("p1", new PrinterTelemetry + { + PrinterName = $"Snap-{i}", + HotendTemp = 200 + i, + CapturedAt = baseTime.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("p1", count: 5); + + Assert.Equal(5, history.Count); + // Newest (highest HotendTemp) should be first. + Assert.Equal(204.0, history[0].HotendTemp); + } + + [Fact] + public async Task TelemetryStore_GetHistory_RespectsCountLimit() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + for (int i = 0; i < 10; i++) + await store.SaveAsync("p2", new PrinterTelemetry { CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) }); + + var history = await store.GetHistoryAsync("p2", count: 3); + + Assert.Equal(3, history.Count); + } + + [Fact] + public async Task TelemetryStore_IsolatePrinters_DifferentPrinterIdsDontMix() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + await store.SaveAsync("printerA", new PrinterTelemetry { HotendTemp = 190 }); + await store.SaveAsync("printerB", new PrinterTelemetry { HotendTemp = 230 }); + + var latestA = await store.GetLatestAsync("printerA"); + var latestB = await store.GetLatestAsync("printerB"); + + Assert.Equal(190.0, latestA!.HotendTemp); + Assert.Equal(230.0, latestB!.HotendTemp); + } + + [Fact] + public async Task TelemetryStore_GetLatest_ReturnsNewestSnapshot() + { + using var db = CreateTempDb(); + await using var store = new SqliteTelemetryStore( + db.ConnectionString, + NullLogger.Instance); + + var older = new PrinterTelemetry { HotendTemp = 100, CapturedAt = DateTimeOffset.UtcNow.AddSeconds(-10) }; + var newer = new PrinterTelemetry { HotendTemp = 210, CapturedAt = DateTimeOffset.UtcNow }; + + await store.SaveAsync("p3", older); + await store.SaveAsync("p3", newer); + + var latest = await store.GetLatestAsync("p3"); + Assert.Equal(210.0, latest!.HotendTemp); + } + + // ── SqliteCameraSnapshotStore ───────────────────────────────────────────── + + [Fact] + public async Task CameraStore_Save_And_GetLatest_RoundTrip() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + var jpeg = new byte[] { 0xFF, 0xD8, 0x00, 0x01, 0xFF, 0xD9 }; // minimal JPEG + var snapshot = new CameraSnapshot + { + CameraId = "cam-1", + Label = "Ender-3 Cam", + JpegData = jpeg, + Width = 640, + Height = 480, + CapturedAt = DateTimeOffset.UtcNow + }; + + await store.SaveAsync(snapshot); + var latest = await store.GetLatestAsync("cam-1"); + + Assert.NotNull(latest); + Assert.Equal("Ender-3 Cam", latest.Label); + Assert.Equal(640, latest.Width); + Assert.Equal(480, latest.Height); + Assert.Equal(jpeg, latest.JpegData); + } + + [Fact] + public async Task CameraStore_GetLatest_ReturnsNull_WhenEmpty() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + Assert.Null(await store.GetLatestAsync("cam-x")); + } + + [Fact] + public async Task CameraStore_GetHistory_ExcludesJpegBlob() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + for (int i = 0; i < 3; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-2", + Label = "Test", + JpegData = new byte[1000], + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("cam-2", count: 10); + + Assert.Equal(3, history.Count); + // History should strip JPEG data (returns metadata only). + Assert.All(history, s => Assert.Empty(s.JpegData)); + } + + [Fact] + public async Task CameraStore_GetHistory_RespectsCountLimit() + { + using var db = CreateTempDb(); + await using var store = new SqliteCameraSnapshotStore( + db.ConnectionString, + NullLogger.Instance); + + for (int i = 0; i < 5; i++) + { + await store.SaveAsync(new CameraSnapshot + { + CameraId = "cam-3", + CapturedAt = DateTimeOffset.UtcNow.AddSeconds(i) + }); + } + + var history = await store.GetHistoryAsync("cam-3", count: 2); + Assert.Equal(2, history.Count); + } +} diff --git a/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj b/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj new file mode 100644 index 0000000..fe3541b --- /dev/null +++ b/src/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj new file mode 100644 index 0000000..f55995b --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + 2 + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.Blazor/Program.cs b/src/MakerPrompt.UI.Blazor/Program.cs new file mode 100644 index 0000000..ee322b0 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Program.cs @@ -0,0 +1,34 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Infrastructure.Analytics; +using MakerPrompt.Infrastructure.Farm; +using MakerPrompt.Infrastructure.Inventory; +using MakerPrompt.Infrastructure.Projects; +using MakerPrompt.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// ── Infrastructure stores (in-memory; swap for SQLite/cloud in production) ── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Application services ───────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Logging ────────────────────────────────────────────────────────────────── +builder.Logging.SetMinimumLevel(LogLevel.Warning); + +await builder.Build().RunAsync(); diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/css/app.css b/src/MakerPrompt.UI.Blazor/wwwroot/css/app.css new file mode 100644 index 0000000..7be0b49 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/css/app.css @@ -0,0 +1,9 @@ +/* MakerPrompt.UI.Blazor – minimal shell styles */ +html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; height: 100%; } +.page { display: flex; min-height: 100vh; } +.sidebar { width: 220px; background-color: #1a1a2e; color: #eee; padding-top: 1rem; flex-shrink: 0; } +.sidebar .nav-link { color: #ccc; font-size: .9rem; padding: .35rem 1rem; } +.sidebar .nav-link.active, .sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.1); border-radius: .25rem; } +main { flex: 1; display: flex; flex-direction: column; overflow: auto; } +.top-row { padding: .5rem 1rem; background: #f8f9fa; border-bottom: 1px solid #dee2e6; text-align: right; } +.content { padding: 1.5rem; flex: 1; } diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/index.html b/src/MakerPrompt.UI.Blazor/wwwroot/index.html new file mode 100644 index 0000000..1fc2c3a --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/index.html @@ -0,0 +1,31 @@ + + + + + + + MakerPrompt + + + + + + + +
+ + + + + +
+
+ Loading… +
+
+
+ + + + + diff --git a/src/MakerPrompt.UI.Components/App.razor b/src/MakerPrompt.UI.Components/App.razor new file mode 100644 index 0000000..71ea9e3 --- /dev/null +++ b/src/MakerPrompt.UI.Components/App.razor @@ -0,0 +1,13 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/MakerPrompt.UI.Components/Layout/MainLayout.razor b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor new file mode 100644 index 0000000..7e2bd8c --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/src/MakerPrompt.UI.Components/Layout/NavMenu.razor b/src/MakerPrompt.UI.Components/Layout/NavMenu.razor new file mode 100644 index 0000000..873c22a --- /dev/null +++ b/src/MakerPrompt.UI.Components/Layout/NavMenu.razor @@ -0,0 +1,32 @@ + diff --git a/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj b/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj new file mode 100644 index 0000000..d7b32b4 --- /dev/null +++ b/src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor new file mode 100644 index 0000000..a682580 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/AnalyticsPage.razor @@ -0,0 +1,159 @@ +@page "/analytics" +@inject AnalyticsService Analytics +@inject ILogger Logger +@implements IDisposable + +

Analytics

+ +@if (_records is null) +{ +

Loading…

+} +else +{ + +
+
+
+
+

Total Jobs

+

@_records.Count

+
+
+
+
+
+
+

Total Print Time

+

@FormatDuration(_totalPrintTime)

+
+
+
+
+
+
+

Total Filament (g)

+

@_totalFilament.ToString("F0")

+
+
+
+
+ +
+ +
+
+
Print Time by Printer
+
+ @if (!_byPrinter.Any()) + { +

No data recorded yet.

+ } + else + { + + + + + + @foreach (var (id, time, grams) in _byPrinter) + { + + + + + + } + +
Printer IDTimeFilament (g)
@TruncateId(id)@FormatDuration(time)@grams.ToString("F1")
+ } +
+
+
+ + +
+
+
Recent Jobs
+
+ @if (!_records.Any()) + { +

No jobs recorded yet.

+ } + else + { + + + + + + @foreach (var r in _records.OrderByDescending(r => r.Timestamp).Take(20)) + { + + + + + + } + +
JobDurationDate
@r.JobName@FormatDuration(r.Duration)@r.Timestamp.ToString("MM/dd HH:mm")
+ } +
+
+
+
+} + +@code { + private IReadOnlyList? _records; + private TimeSpan _totalPrintTime; + private double _totalFilament; + private List<(Guid Id, TimeSpan Time, double Grams)> _byPrinter = []; + + protected override async Task OnInitializedAsync() + { + Analytics.AnalyticsUpdated += OnAnalyticsUpdated; + await LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + _records = await Analytics.GetRecordsAsync(); + + _totalPrintTime = TimeSpan.FromTicks(_records.Sum(r => r.Duration.Ticks)); + _totalFilament = _records.Sum(r => r.EffectiveFilamentGrams); + + _byPrinter = _records + .GroupBy(r => r.PrinterId) + .Select(g => ( + Id: g.Key, + Time: TimeSpan.FromTicks(g.Sum(r => r.Duration.Ticks)), + Grams: g.Sum(r => r.EffectiveFilamentGrams))) + .OrderByDescending(x => x.Time) + .ToList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load analytics"); + _records = []; + } + } + + private void OnAnalyticsUpdated(object? sender, EventArgs e) => + InvokeAsync(async () => { await LoadAsync(); StateHasChanged(); }); + + private static string FormatDuration(TimeSpan ts) + => ts.TotalHours >= 1 + ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" + : $"{ts.Minutes}m {ts.Seconds:D2}s"; + + private static string TruncateId(Guid id) + { + var s = id.ToString(); + return s.Length > 8 ? s[..8] + "…" : s; + } + + public void Dispose() => Analytics.AnalyticsUpdated -= OnAnalyticsUpdated; +} diff --git a/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor b/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor new file mode 100644 index 0000000..b51bd93 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/DashboardPage.razor @@ -0,0 +1,192 @@ +@page "/dashboard" +@inject PrinterFleetService FleetService +@inject ILogger Logger +@implements IDisposable + +

Dashboard

+ +@if (!_printerIds.Any()) +{ +
+ No printers connected. Visit the Fleet page to add one. +
+} +else +{ +
+ + +
+ + @if (_selected is not null) + { + var t = _selected.LastTelemetry; +
+ +
+
+
+
Status
+

@t.Status

+ @if (t.Status == PrinterStatus.Printing && !string.IsNullOrEmpty(t.PrintJobName)) + { +

@t.PrintJobName

+ } +
+
+
+ + +
+
+
+
Temperature
+ + + + + + + + + + + @if (t.ChamberTemp > 0) + { + + + + + } + +
Hotend@t.HotendTemp.ToString("F1") / @t.HotendTarget.ToString("F0") °C
Bed@t.BedTemp.ToString("F1") / @t.BedTarget.ToString("F0") °C
Chamber@t.ChamberTemp.ToString("F1") / @t.ChamberTarget.ToString("F0") °C
+
+
+
+ + + @if (t.Status == PrinterStatus.Printing) + { +
+
+
+
Print Progress
+
+
+
+
+

@t.PrintProgress.ToString("F1") %

+ + Duration: @FormatDuration(t.PrintDuration) +  |  Filament: @t.FilamentUsed.ToString("F1") mm + +
+
+
+ } + + +
+
+
+
Controls
+
+ + + +
+
+
+
+
+ } +} + +@code { + private IReadOnlyCollection _printerIds = []; + private string? _selectedId; + private IPrinterCommunicationService? _selected; + + protected override void OnInitialized() + { + FleetService.FleetChanged += OnFleetChanged; + Refresh(); + } + + private void Refresh() + { + _printerIds = FleetService.PrinterIds; + if (_selectedId is null || !_printerIds.Contains(_selectedId)) + _selectedId = _printerIds.FirstOrDefault(); + + _selected = _selectedId is not null + ? FleetService.GetConnection(_selectedId) : null; + } + + private void OnFleetChanged(object? sender, EventArgs e) => + InvokeAsync(() => { Refresh(); StateHasChanged(); }); + + private void OnPrinterSelected(ChangeEventArgs e) + { + _selectedId = e.Value?.ToString(); + _selected = _selectedId is not null + ? FleetService.GetConnection(_selectedId) : null; + } + + private async Task HomeAllAxes() + { + if (_selected is null) return; + try { await _selected.HomeAsync(); } + catch (Exception ex) { Logger.LogError(ex, "Home failed"); } + } + + private async Task TurnOffHeaters() + { + if (_selected is null) return; + try + { + await _selected.SetHotendTempAsync(0); + await _selected.SetBedTempAsync(0); + } + catch (Exception ex) { Logger.LogError(ex, "Heaters off failed"); } + } + + private async Task FanOff() + { + if (_selected is null) return; + try { await _selected.SetFanSpeedAsync(0); } + catch (Exception ex) { Logger.LogError(ex, "Fan off failed"); } + } + + private static string FormatDuration(TimeSpan ts) + => ts.TotalHours >= 1 + ? $"{(int)ts.TotalHours}h {ts.Minutes:D2}m" + : $"{ts.Minutes}m {ts.Seconds:D2}s"; + + public void Dispose() => FleetService.FleetChanged -= OnFleetChanged; +} diff --git a/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor b/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor new file mode 100644 index 0000000..3bffb56 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/FilamentInventoryPage.razor @@ -0,0 +1,209 @@ +@page "/filament" +@inject FilamentInventoryService InventoryService +@inject ILogger Logger +@implements IDisposable + +

Filament Inventory

+ +@if (_spools is null) +{ +

Loading…

+} +else +{ +
+ +
+ + @if (_showForm) + { +
+
@(_editingSpool is null ? "Add Spool" : "Edit Spool")
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(_formError)) + { +
@_formError
+ } +
+
+ } + + @if (!_spools.Any()) + { +
No spools tracked yet. Add one above.
+ } + else + { + + + + + + + + + + + + + @foreach (var spool in _spools) + { + var pct = spool.TotalWeightGrams > 0 + ? (int)(spool.RemainingWeightGrams / spool.TotalWeightGrams * 100) + : 0; + + + + + + + + + } + +
MaterialBrandColorRemainingTotal
@spool.Material@spool.Brand@spool.Color +
+
+
+
+
+ @spool.RemainingWeightGrams.ToString("F0") g +
+
@spool.TotalWeightGrams.ToString("F0") g + + +
+ } +} + +@code { + private IReadOnlyList? _spools; + private bool _showForm; + private FilamentSpool? _editingSpool; + private string _formMaterial = ""; + private string _formBrand = ""; + private string _formColor = ""; + private double _formTotalWeight = 1000; + private double _formRemainingWeight = 1000; + private string _formError = ""; + + protected override async Task OnInitializedAsync() + { + InventoryService.InventoryChanged += OnInventoryChanged; + await LoadAsync(); + } + + private async Task LoadAsync() + { + try { _spools = await InventoryService.GetSpoolsAsync(); } + catch (Exception ex) { Logger.LogError(ex, "Failed to load spools"); } + } + + private void OnInventoryChanged(object? sender, EventArgs e) => + InvokeAsync(async () => { await LoadAsync(); StateHasChanged(); }); + + private void ShowAddForm() + { + _editingSpool = null; + _formMaterial = ""; + _formBrand = ""; + _formColor = ""; + _formTotalWeight = 1000; + _formRemainingWeight = 1000; + _formError = ""; + _showForm = true; + } + + private void EditSpool(FilamentSpool spool) + { + _editingSpool = spool; + _formMaterial = spool.Material; + _formBrand = spool.Brand; + _formColor = spool.Color; + _formTotalWeight = spool.TotalWeightGrams; + _formRemainingWeight = spool.RemainingWeightGrams; + _formError = ""; + _showForm = true; + } + + private void CancelForm() => _showForm = false; + + private async Task SaveSpoolAsync() + { + _formError = ""; + if (string.IsNullOrWhiteSpace(_formMaterial)) + { + _formError = "Material is required."; + return; + } + + try + { + var spool = _editingSpool ?? new FilamentSpool { Id = Guid.NewGuid() }; + spool.Material = _formMaterial; + spool.Brand = _formBrand; + spool.Color = _formColor; + spool.TotalWeightGrams = _formTotalWeight; + spool.RemainingWeightGrams = _formRemainingWeight; + + if (_editingSpool is null) + await InventoryService.AddSpoolAsync(spool); + else + await InventoryService.UpdateSpoolAsync(spool); + + _showForm = false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to save spool"); + _formError = "Save failed. Please try again."; + } + } + + private async Task DeleteSpoolAsync(Guid id) + { + try { await InventoryService.DeleteSpoolAsync(id); } + catch (Exception ex) { Logger.LogError(ex, "Failed to delete spool"); } + } + + public void Dispose() => InventoryService.InventoryChanged -= OnInventoryChanged; +} diff --git a/src/MakerPrompt.UI.Components/Pages/FleetPage.razor b/src/MakerPrompt.UI.Components/Pages/FleetPage.razor new file mode 100644 index 0000000..c4ff866 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/FleetPage.razor @@ -0,0 +1,125 @@ +@page "/fleet" +@inject PrinterFleetService FleetService +@inject ILogger Logger +@implements IDisposable + +

Printer Fleet

+ +@if (!_printerIds.Any()) +{ +
+ No printers connected. Add a printer to get started. +
+} +else +{ +
+ @foreach (var id in _printerIds) + { + var conn = FleetService.GetConnection(id); + if (conn is null) continue; +
+
+
+ + + @(string.IsNullOrEmpty(conn.ConnectionName) ? id : conn.ConnectionName) + + @conn.ConnectionType +
+
+ @if (conn.IsConnected) + { + var t = conn.LastTelemetry; +
+
Status
+
@t.Status
+
Hotend
+
@t.HotendTemp.ToString("F1") °C / @t.HotendTarget.ToString("F0") °C
+
Bed
+
@t.BedTemp.ToString("F1") °C / @t.BedTarget.ToString("F0") °C
+ @if (t.Status == PrinterStatus.Printing) + { +
Progress
+
+
+
+
+
+ @t.PrintProgress.ToString("F1") % +
+ } +
+ } + else + { +

Not connected

+ } +
+ +
+
+ } +
+} + +@code { + private IReadOnlyCollection _printerIds = []; + + protected override void OnInitialized() + { + FleetService.FleetChanged += OnFleetChanged; + Refresh(); + } + + private void Refresh() => + _printerIds = FleetService.PrinterIds; + + private void OnFleetChanged(object? sender, EventArgs e) => + InvokeAsync(() => { Refresh(); StateHasChanged(); }); + + private async Task DisconnectAsync(string printerId) + { + var conn = FleetService.GetConnection(printerId); + if (conn is null) return; + try + { + await conn.DisconnectAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error disconnecting printer {PrinterId}", printerId); + } + } + + private async Task RemoveAsync(string printerId) + { + try + { + await FleetService.RemoveAsync(printerId); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error removing printer {PrinterId}", printerId); + } + } + + public void Dispose() => FleetService.FleetChanged -= OnFleetChanged; +} diff --git a/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor new file mode 100644 index 0000000..45d2df4 --- /dev/null +++ b/src/MakerPrompt.UI.Components/Pages/SettingsPage.razor @@ -0,0 +1,72 @@ +@page "/settings" + +

Settings

+ +
+
+
+
About MakerPrompt
+
+
+
Version
+
0.5.0
+
Architecture
+
Core / Application / Infrastructure
+
Source
+
+ + GitHub + +
+
+
+
+
+ +
+
+
Cloud Connection
+
+
+ + +
+
+ + +
+ + @if (_saved) + { + Saved! + } +
+
+
+
+ +@code { + private string _cloudUrl = ""; + private string _apiKey = ""; + private bool _saved; + + private void SaveSettings() + { + // Persist via IAppLocalStorageProvider in future phases. + _saved = true; + // Fire-and-forget to clear the saved flag after 2 s; exceptions are logged to console. + Task.Delay(2000) + .ContinueWith( + _ => InvokeAsync(() => { _saved = false; StateHasChanged(); }), + TaskContinuationOptions.OnlyOnRanToCompletion) + .ContinueWith( + t => Console.WriteLine($"[SettingsPage] Error clearing saved flag: {t.Exception?.GetBaseException().Message}"), + TaskContinuationOptions.OnlyOnFaulted); + } +} diff --git a/src/MakerPrompt.UI.Components/_Imports.razor b/src/MakerPrompt.UI.Components/_Imports.razor new file mode 100644 index 0000000..643a970 --- /dev/null +++ b/src/MakerPrompt.UI.Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.Logging +@using MakerPrompt.Core.Abstractions +@using MakerPrompt.Core.Models +@using MakerPrompt.Application.Services +@using MakerPrompt.UI.Components.Layout +@using MakerPrompt.UI.Components.Pages diff --git a/src/MakerPrompt.UI.MAUI/App.cs b/src/MakerPrompt.UI.MAUI/App.cs new file mode 100644 index 0000000..c832cb0 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/App.cs @@ -0,0 +1,9 @@ +namespace MakerPrompt.UI.MAUI; + +public class App : Application +{ + public App() + { + MainPage = new MainPage(); + } +} diff --git a/src/MakerPrompt.UI.MAUI/Components/Routes.razor b/src/MakerPrompt.UI.MAUI/Components/Routes.razor new file mode 100644 index 0000000..267f9a1 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Components/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/MakerPrompt.UI.MAUI/MainPage.cs b/src/MakerPrompt.UI.MAUI/MainPage.cs new file mode 100644 index 0000000..cd1c57f --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/MainPage.cs @@ -0,0 +1,20 @@ +namespace MakerPrompt.UI.MAUI; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + Content = new BlazorWebView + { + HostPage = "wwwroot/index.html", + RootComponents = + { + new RootComponent + { + Selector = "#app", + ComponentType = typeof(MakerPrompt.UI.Components.App) + } + } + }; + } +} diff --git a/src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj b/src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj new file mode 100644 index 0000000..1052411 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj @@ -0,0 +1,77 @@ + + + + net10.0-android;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + android-arm64;android-x64 + maccatalyst-x64;maccatalyst-arm64 + + Exe + MakerPrompt.UI.MAUI + true + true + enable + false + enable + + MakerPrompt + com.makerprompt.maui.v2 + 0.5.0 + 0.5.0 + + None + + 15.0 + 24.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.MAUI/MauiProgram.cs b/src/MakerPrompt.UI.MAUI/MauiProgram.cs new file mode 100644 index 0000000..eb2335d --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/MauiProgram.cs @@ -0,0 +1,57 @@ +using MakerPrompt.Application.Services; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Infrastructure.Analytics; +using MakerPrompt.Infrastructure.Farm; +using MakerPrompt.Infrastructure.Inventory; +using MakerPrompt.Infrastructure.Projects; +using MakerPrompt.Infrastructure.Telemetry; + +namespace MakerPrompt.UI.MAUI; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + + builder.Services.AddMauiBlazorWebView(); + + // Enable GPU rasterisation in the embedded WebView2 on Windows. + var webViewArgs = "--ignore-gpu-blocklist --enable-gpu-rasterization"; +#if DEBUG + webViewArgs += " --remote-debugging-port=9223"; + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", webViewArgs); + + // ── Infrastructure stores (in-memory) ──────────────────────────────── + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ── Application services ───────────────────────────────────────────── + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ── Platform-specific serial service ───────────────────────────────── + // SerialCommunicationService is a partial class with platform-specific + // transport implementations compiled conditionally per-platform. + // It implements IPrinterCommunicationService via SerialCommunicationServiceBase. + builder.Services.AddTransient(); + + return builder.Build(); + } +} diff --git a/src/MakerPrompt.UI.MAUI/Resources/AppIcon/appicon.png b/src/MakerPrompt.UI.MAUI/Resources/AppIcon/appicon.png new file mode 100644 index 0000000..94381b4 Binary files /dev/null and b/src/MakerPrompt.UI.MAUI/Resources/AppIcon/appicon.png differ diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs new file mode 100644 index 0000000..8085e0a --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Android.cs @@ -0,0 +1,111 @@ +using System.Text; +using MakerPrompt.Core.Models; +using UsbSerialForAndroid.Net; +using UsbSerialForAndroid.Net.Drivers; +using UsbSerialForAndroid.Net.Helper; + +namespace MakerPrompt.UI.MAUI.Services; + +public partial class SerialCommunicationService +{ + // ── Android state ──────────────────────────────────────────────────────── + private UsbDriverBase? _usbDriver; + private CancellationTokenSource? _androidCts; + private Task? _androidReceiveTask; + + // ── Transport hooks ────────────────────────────────────────────────────── + + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + { + var deviceName = settings.PortName + ?? throw new ArgumentException("PortName (device name) is required for Android serial connections"); + var baudRate = settings.BaudRate == 0 ? DefaultBaudRate : settings.BaudRate; + + // Locate the USB device by name. + var usbDevice = UsbManagerHelper.GetAllUsbDevices() + .FirstOrDefault(d => d.DeviceName == deviceName) + ?? throw new InvalidOperationException($"USB device '{deviceName}' not found."); + + // Request permission if not yet granted. + if (!UsbManagerHelper.HasPermission(usbDevice)) + UsbManagerHelper.RequestPermission(usbDevice); + + _usbDriver = UsbDriverFactory.CreateUsbDriver(usbDevice.DeviceId); + _usbDriver.Open(baudRate, + dataBits: 8, + stopBits: UsbSerialForAndroid.Net.Enums.StopBits.One, + parity: UsbSerialForAndroid.Net.Enums.Parity.None); + + _androidCts?.Dispose(); + _androidCts = new CancellationTokenSource(); + _androidReceiveTask = Task.Run(() => ReceiveLoopAsync(_androidCts.Token)); + + return Task.CompletedTask; + } + + protected override async Task CloseTransportAsync(CancellationToken cancellationToken) + { + _androidCts?.Cancel(); + + if (_androidReceiveTask is not null) + await _androidReceiveTask.ContinueWith(_ => { }, TaskContinuationOptions.None); + + try { _usbDriver?.Close(); } + catch { /* Ignore close errors */ } + + _usbDriver = null; + _androidCts?.Dispose(); + _androidCts = null; + } + + protected override Task WriteTransportAsync(string data, CancellationToken cancellationToken) + { + if (_usbDriver is null) return Task.CompletedTask; + + // Android USB driver uses synchronous write — run on thread pool. + return Task.Run(() => + { + var bytes = Encoding.ASCII.GetBytes(data + "\n"); + _usbDriver.Write(bytes); + }, cancellationToken); + } + + // ── Receive loop (Android) ─────────────────────────────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && IsConnected) + { + try + { + if (_usbDriver is null) break; + + // Android driver read is synchronous; offload to thread pool. + var bytes = await Task.Run(() => _usbDriver.Read(4096), ct); + if (bytes.Length > 0) + ProcessReceivedData(Encoding.ASCII.GetString(bytes)); + + await Task.Delay(10, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.Android] Receive error: {ex.Message}"); + await DisconnectAsync(); + break; + } + } + } + + // ── Available ports (Android — USB device names) ───────────────────────── + + public static partial Task> GetAvailablePortsAsync() + => Task.FromResult>( + UsbManagerHelper.GetAllUsbDevices() + .Select(d => d.DeviceName) + .ToArray()); +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs new file mode 100644 index 0000000..e1baa23 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.MacOS.cs @@ -0,0 +1,126 @@ +using System.Text; +using System.Threading.Tasks.Dataflow; +using MakerPrompt.Core.Models; +using UsbSerialForMacOS; + +namespace MakerPrompt.UI.MAUI.Services; + +public partial class SerialCommunicationService +{ + // ── macOS state ────────────────────────────────────────────────────────── + private UsbSerialManager? _manager; + private readonly BufferBlock _macCommandQueue = new(); + private CancellationTokenSource? _macCts; + private Task? _macSendTask; + private Task? _macReceiveTask; + + // ── Transport hooks ────────────────────────────────────────────────────── + + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + { + var portName = settings.PortName + ?? throw new ArgumentException("PortName is required for macOS serial connections"); + var baudRate = settings.BaudRate == 0 ? DefaultBaudRate : settings.BaudRate; + + _manager?.Close(); + _manager = new UsbSerialManager(); + + var opened = _manager.Open(portName, baudRate); + if (!opened) + throw new InvalidOperationException($"Failed to open serial port '{portName}'"); + + _macCts?.Dispose(); + _macCts = new CancellationTokenSource(); + + _macSendTask = Task.Run(() => SendLoopAsync(_macCts.Token)); + _macReceiveTask = Task.Run(() => ReceiveLoopAsync(_macCts.Token)); + + return Task.CompletedTask; + } + + protected override async Task CloseTransportAsync(CancellationToken cancellationToken) + { + _macCts?.Cancel(); + + if (_macSendTask is not null) + await _macSendTask.ContinueWith(_ => { }, TaskContinuationOptions.None); + if (_macReceiveTask is not null) + await _macReceiveTask.ContinueWith(_ => { }, TaskContinuationOptions.None); + + try { _manager?.Close(); } + catch { /* Swallow close errors */ } + + _manager = null; + _macCts?.Dispose(); + _macCts = null; + } + + protected override Task WriteTransportAsync(string data, CancellationToken cancellationToken) + { + if (_manager is null) return Task.CompletedTask; + return _macCommandQueue.SendAsync(data, cancellationToken).AsTask(); + } + + // ── Send loop (macOS) ──────────────────────────────────────────────────── + + private async Task SendLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && IsConnected) + { + var command = await _macCommandQueue.ReceiveAsync(ct); + var mgr = _manager; + if (mgr is null || ct.IsCancellationRequested) break; + + mgr.Write(command + "\n"); + await Task.Delay(10, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.MacOS] Send loop error: {ex.Message}"); + } + } + + // ── Receive loop (macOS) ───────────────────────────────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && IsConnected) + { + try + { + var mgr = _manager; + if (mgr is null) break; + + var bytes = mgr.Read(4096); + if (bytes.Length > 0) + ProcessReceivedData(Encoding.UTF8.GetString(bytes.ToArray())); + + await Task.Delay(10, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.MacOS] Receive error: {ex.Message}"); + await DisconnectAsync(); + break; + } + } + } + + // ── Available ports (macOS) ────────────────────────────────────────────── + + public static partial Task> GetAvailablePortsAsync() + { + var mgr = new UsbSerialManager(); + return Task.FromResult>( + mgr.AvailablePorts().OrderBy(p => p).ToArray()); + } +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs new file mode 100644 index 0000000..e989e25 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.Windows.cs @@ -0,0 +1,150 @@ +using System.IO.Ports; +using System.Text; +using System.Threading.Tasks.Dataflow; +using MakerPrompt.Core.Models; + +namespace MakerPrompt.UI.MAUI.Services; + +public partial class SerialCommunicationService +{ + // ── Windows state ──────────────────────────────────────────────────────── + private SerialPort? _serialPort; + private readonly BufferBlock _commandQueue = new(); + private CancellationTokenSource? _cts; + private Task? _sendTask; + private Task? _receiveTask; + + // ── Transport hooks ────────────────────────────────────────────────────── + + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + { + _serialPort = new SerialPort + { + PortName = settings.PortName + ?? throw new ArgumentException("PortName is required for Serial connections"), + BaudRate = settings.BaudRate == 0 ? DefaultBaudRate : settings.BaudRate, + DataBits = 8, + Parity = Parity.None, + StopBits = StopBits.One, + Handshake = Handshake.None, + DtrEnable = true, + RtsEnable = true, + ReadTimeout = 2000, + WriteTimeout = 5000, + NewLine = "\n", + Encoding = Encoding.ASCII + }; + + _serialPort.Open(); + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + + _sendTask = Task.Run(() => SendLoopAsync(_cts.Token)); + _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); + + return Task.CompletedTask; + } + + protected override async Task CloseTransportAsync(CancellationToken cancellationToken) + { + if (_cts is { } cts) + { + cts.Cancel(); + + if (_sendTask is not null) + // Suppress faults from the shutting-down send loop — intentional. + await _sendTask.ContinueWith(_ => { }, TaskContinuationOptions.None); + if (_receiveTask is not null) + await _receiveTask.ContinueWith(_ => { }, TaskContinuationOptions.None); + } + + if (_serialPort is { IsOpen: true }) + { + await Task.Run(() => + { + try + { + _serialPort.DiscardInBuffer(); + _serialPort.DiscardOutBuffer(); + _serialPort.Close(); + } + catch + { + // Ignore close errors during shutdown. + } + }); + } + + _serialPort?.Dispose(); + _serialPort = null; + _cts?.Dispose(); + _cts = null; + } + + protected override async Task WriteTransportAsync(string data, CancellationToken cancellationToken) + { + if (_serialPort is not { IsOpen: true }) return; + await _commandQueue.SendAsync(data, cancellationToken); + } + + // ── Send loop (Windows) ────────────────────────────────────────────────── + + private async Task SendLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && IsConnected) + { + var command = await _commandQueue.ReceiveAsync(ct); + if (_serialPort is not { IsOpen: true }) break; + + var payload = Encoding.ASCII.GetBytes(command + "\n"); + await _serialPort.BaseStream.WriteAsync(payload, ct); + await _serialPort.BaseStream.FlushAsync(ct); + await Task.Delay(10, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Console.WriteLine($"[SerialService.Windows] Send loop error: {ex.Message}"); + } + } + + // ── Receive loop (Windows) ─────────────────────────────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var buffer = new byte[4096]; + while (!ct.IsCancellationRequested && IsConnected) + { + try + { + if (_serialPort is not { IsOpen: true }) break; + + var bytesRead = await _serialPort.BaseStream.ReadAsync(buffer, ct); + if (bytesRead > 0) + ProcessReceivedData(Encoding.ASCII.GetString(buffer, 0, bytesRead)); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + // Swallow receive errors — surface only as disconnection. + Console.WriteLine($"[SerialService.Windows] Receive loop error: {ex.Message}"); + await DisconnectAsync(); + break; + } + } + } + + // ── Available ports (Windows) ──────────────────────────────────────────── + + public static partial Task> GetAvailablePortsAsync() + => Task.FromResult>( + SerialPort.GetPortNames().OrderBy(p => p).ToArray()); +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs new file mode 100644 index 0000000..15e0d22 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.cs @@ -0,0 +1,28 @@ +using MakerPrompt.Infrastructure.Serial; + +namespace MakerPrompt.UI.MAUI.Services; + +/// +/// Platform-specific serial communication service for the new /src architecture. +/// +/// This is a partial class split across per-platform files: +/// SerialCommunicationService.Windows.cs – System.IO.Ports.SerialPort +/// SerialCommunicationService.Android.cs – UsbSerialForAndroid.Net +/// SerialCommunicationService.MacOS.cs – UsbSerialForMacOS +/// SerialCommunicationService.iOS.cs – stub (not supported) +/// +/// The base class () handles all G-code +/// command building and Marlin response parsing. Each platform implementation +/// overrides only the three transport hooks: +/// • OpenTransportAsync – open the hardware port +/// • CloseTransportAsync – close and dispose hardware resources +/// • WriteTransportAsync – write a single G-code line to the port +/// +/// The receive loop is also started per-platform in +/// and cancelled in . +/// +public partial class SerialCommunicationService : SerialCommunicationServiceBase +{ + // Enumerates available ports / device identifiers on the current platform. + public static partial Task> GetAvailablePortsAsync(); +} diff --git a/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs new file mode 100644 index 0000000..e89d930 --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/Services/SerialCommunicationService.iOS.cs @@ -0,0 +1,27 @@ +using MakerPrompt.Core.Models; + +namespace MakerPrompt.UI.MAUI.Services; + +/// +/// iOS serial service stub. +/// Direct USB/serial connections are not supported on iOS (sandboxing restrictions). +/// The class satisfies the partial class requirement but throws on any attempt to connect. +/// +public partial class SerialCommunicationService +{ + protected override Task OpenTransportAsync(PrinterConnectionSettings settings, + CancellationToken cancellationToken) + => throw new PlatformNotSupportedException( + "Direct USB/serial connections are not supported on iOS. " + + "Use a network-based backend (Moonraker, PrusaLink) instead."); + + protected override Task CloseTransportAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + protected override Task WriteTransportAsync(string data, CancellationToken cancellationToken) + => throw new PlatformNotSupportedException( + "Direct USB/serial connections are not supported on iOS."); + + public static partial Task> GetAvailablePortsAsync() + => Task.FromResult>([]); +} diff --git a/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css b/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css new file mode 100644 index 0000000..416f91d --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/wwwroot/css/app.css @@ -0,0 +1,9 @@ +/* MakerPrompt.UI.MAUI – minimal shell styles */ +html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; height: 100%; } +.page { display: flex; min-height: 100vh; } +.sidebar { width: 220px; background-color: #1a1a2e; color: #eee; padding-top: 1rem; flex-shrink: 0; } +.sidebar .nav-link { color: #ccc; font-size: .9rem; padding: .35rem 1rem; } +.sidebar .nav-link.active, .sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.1); border-radius: .25rem; } +main { flex: 1; display: flex; flex-direction: column; overflow: auto; } +.top-row { padding: .5rem 1rem; background: #f8f9fa; border-bottom: 1px solid #dee2e6; text-align: right; } +.content { padding: 1.5rem; flex: 1; } diff --git a/src/MakerPrompt.UI.MAUI/wwwroot/index.html b/src/MakerPrompt.UI.MAUI/wwwroot/index.html new file mode 100644 index 0000000..04d90cb --- /dev/null +++ b/src/MakerPrompt.UI.MAUI/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + + MakerPrompt + + + + + + +
Loading…
+ + +