diff --git a/.github/workflows/azure-static-web-apps-yellow-sea-04668c503.yml b/.github/workflows/azure-static-web-apps-yellow-sea-04668c503.yml index 56556db..5b22a3e 100644 --- a/.github/workflows/azure-static-web-apps-yellow-sea-04668c503.yml +++ b/.github/workflows/azure-static-web-apps-yellow-sea-04668c503.yml @@ -30,6 +30,6 @@ jobs: azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_YELLOW_SEA_04668C503 }} repo_token: ${{ secrets.GITHUB_TOKEN }} action: "upload" - app_location: "./MakerPrompt.Blazor" + app_location: "./src/MakerPrompt.UI.Blazor" api_location: "" output_location: "wwwroot" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2afa8b3..6463320 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,13 +28,13 @@ jobs: dotnet workload install maui-windows - name: Restore dependencies (Blazor) - run: dotnet restore MakerPrompt.Blazor/MakerPrompt.Blazor.csproj + run: dotnet restore src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj - name: Build (Windows) - run: dotnet build MakerPrompt.MAUI/MakerPrompt.MAUI.csproj -c Release -f net10.0-windows10.0.19041.0 -p:RuntimeIdentifierOverride=win-x64 + run: dotnet build src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj -c Release -f net10.0-windows10.0.19041.0 -p:RuntimeIdentifierOverride=win-x64 - name: Build (Blazor WebAssembly) - run: dotnet build MakerPrompt.Blazor/MakerPrompt.Blazor.csproj -c Release --no-restore + run: dotnet build src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj -c Release --no-restore - name: Test (Unit) - run: dotnet test MakerPrompt.Tests/MakerPrompt.Tests.csproj -c Release + run: dotnet test tests/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj -c Release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27299ed..8a93f67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,14 +21,14 @@ jobs: dotnet-version: '10.0.x' - name: Restore - run: dotnet restore MakerPrompt.Tests/MakerPrompt.Tests.csproj + run: dotnet restore tests/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj - name: Build - run: dotnet build MakerPrompt.Tests/MakerPrompt.Tests.csproj --no-restore -c Release + run: dotnet build tests/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj --no-restore -c Release - name: Test with coverage run: | - dotnet test MakerPrompt.Tests/MakerPrompt.Tests.csproj \ + dotnet test tests/MakerPrompt.Tests.Unit/MakerPrompt.Tests.Unit.csproj \ --no-build \ --configuration Release \ --collect:"XPlat Code Coverage" \ diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bdfc3b8..c8dafb7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,19 +25,19 @@ jobs: run: dotnet workload install wasm-tools - name: Restore - run: dotnet restore MakerPrompt.E2E.Wasm/MakerPrompt.E2E.Wasm.csproj + run: dotnet restore tests/MakerPrompt.Tests.E2E.Wasm/MakerPrompt.Tests.E2E.Wasm.csproj - name: Build E2E project - run: dotnet build MakerPrompt.E2E.Wasm/MakerPrompt.E2E.Wasm.csproj --no-restore -c Release + run: dotnet build tests/MakerPrompt.Tests.E2E.Wasm/MakerPrompt.Tests.E2E.Wasm.csproj --no-restore -c Release - name: Install Playwright browsers - run: pwsh MakerPrompt.E2E.Wasm/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + run: pwsh tests/MakerPrompt.Tests.E2E.Wasm/bin/Release/net10.0/playwright.ps1 install --with-deps chromium - name: Run Playwright E2E tests env: E2E_HEADLESS: "true" run: | - dotnet test MakerPrompt.E2E.Wasm/MakerPrompt.E2E.Wasm.csproj \ + dotnet test tests/MakerPrompt.Tests.E2E.Wasm/MakerPrompt.Tests.E2E.Wasm.csproj \ --no-build \ --configuration Release \ --logger "trx;LogFileName=wasm-e2e-results.trx" \ @@ -74,25 +74,25 @@ jobs: - name: Build MAUI app (Debug — enables CDP debugging port) run: > - dotnet build MakerPrompt.MAUI/MakerPrompt.MAUI.csproj + dotnet build src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj -c Debug -f net10.0-windows10.0.19041.0 -p:RuntimeIdentifierOverride=win-x64 - name: Restore E2E.Maui - run: dotnet restore MakerPrompt.E2E.Maui/MakerPrompt.E2E.Maui.csproj + run: dotnet restore tests/MakerPrompt.Tests.E2E.Maui/MakerPrompt.Tests.E2E.Maui.csproj - name: Build E2E.Maui project - run: dotnet build MakerPrompt.E2E.Maui/MakerPrompt.E2E.Maui.csproj --no-restore -c Release + run: dotnet build tests/MakerPrompt.Tests.E2E.Maui/MakerPrompt.Tests.E2E.Maui.csproj --no-restore -c Release - name: Install Playwright browsers - run: pwsh MakerPrompt.E2E.Maui/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + run: pwsh tests/MakerPrompt.Tests.E2E.Maui/bin/Release/net10.0/playwright.ps1 install --with-deps chromium - name: Run MAUI E2E tests env: - MAUI_APP_PATH: ${{ github.workspace }}\MakerPrompt.MAUI\bin\Debug\net10.0-windows10.0.19041.0\win-x64\MakerPrompt.MAUI.exe + MAUI_APP_PATH: ${{ github.workspace }}\src\MakerPrompt.UI.MAUI\bin\Debug\net10.0-windows10.0.19041.0\win-x64\MakerPrompt.UI.MAUI.exe run: | - dotnet test MakerPrompt.E2E.Maui/MakerPrompt.E2E.Maui.csproj ` + dotnet test tests/MakerPrompt.Tests.E2E.Maui/MakerPrompt.Tests.E2E.Maui.csproj ` --no-build ` --configuration Release ` --logger "trx;LogFileName=maui-e2e-results.trx" ` diff --git a/.github/workflows/publish-github-pages.yml b/.github/workflows/publish-github-pages.yml index 8d2a62e..a381858 100644 --- a/.github/workflows/publish-github-pages.yml +++ b/.github/workflows/publish-github-pages.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - WASM_PROJECT: 'MakerPrompt.Blazor/MakerPrompt.Blazor.csproj' + WASM_PROJECT: 'src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj' OUTPUT_DIR: 'publish_wasm' concurrency: diff --git a/.github/workflows/publish-maui.yml b/.github/workflows/publish-maui.yml index a849b16..47058a6 100644 --- a/.github/workflows/publish-maui.yml +++ b/.github/workflows/publish-maui.yml @@ -6,8 +6,8 @@ on: - 'v*' env: - MAUI_PROJECT_DIR: 'MakerPrompt.MAUI/' - MAUI_PROJECT: 'MakerPrompt.MAUI/MakerPrompt.MAUI.csproj' + MAUI_PROJECT_DIR: 'src/MakerPrompt.UI.MAUI/' + MAUI_PROJECT: 'src/MakerPrompt.UI.MAUI/MakerPrompt.UI.MAUI.csproj' OUTPUT_DIR: 'publish' jobs: diff --git a/MakerPrompt.Blazor/.gitattributes b/MakerPrompt.Blazor/.gitattributes deleted file mode 100644 index bc0a213..0000000 --- a/MakerPrompt.Blazor/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.js binary diff --git a/MakerPrompt.Blazor/Program.cs b/MakerPrompt.Blazor/Program.cs deleted file mode 100644 index 26c1fa6..0000000 --- a/MakerPrompt.Blazor/Program.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Globalization; -using MakerPrompt.Blazor; -using MakerPrompt.Blazor.Services; -using MakerPrompt.Blazor.Storage; -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Services; -using MakerPrompt.Shared.Utils; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.JSInterop; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); - -builder.Services.RegisterMakerPromptSharedServices(); -builder.Services.AddScoped(); -// WASM: AES-GCM not supported in browser — use Base64 encoding fallback -builder.Services.AddSingleton(); - -var host = builder.Build(); -const string defaultCulture = "en-US"; - -// Initialize configuration from localStorage before the app renders so that -// Index.razor and other components see the persisted FarmModeEnabled / FarmName -// values on the very first render rather than always defaulting. -var configService = host.Services.GetRequiredService(); -await configService.InitializeAsync(); - -var js = host.Services.GetRequiredService(); -var result = await js.InvokeAsync("blazorCulture.get"); -var culture = CultureInfo.GetCultureInfo(result ?? defaultCulture); - -if (result == null) -{ - await js.InvokeVoidAsync("blazorCulture.set", defaultCulture); -} - -CultureInfo.DefaultThreadCurrentCulture = culture; -CultureInfo.DefaultThreadCurrentUICulture = culture; - -await host.RunAsync(); \ No newline at end of file diff --git a/MakerPrompt.Blazor/Services/AppConfigurationService.cs b/MakerPrompt.Blazor/Services/AppConfigurationService.cs deleted file mode 100644 index aa2471d..0000000 --- a/MakerPrompt.Blazor/Services/AppConfigurationService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Utils; -using Microsoft.JSInterop; -using System.Text.Json; - -namespace MakerPrompt.Blazor.Services -{ - public class AppConfigurationService : IAppConfigurationService, IAsyncDisposable - { - private const string StorageKey = "AppConfig"; - private readonly IJSRuntime _jsRuntime; - private AppConfiguration _config = new(); - - public AppConfiguration Configuration => _config; - - public AppConfigurationService(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - - public async Task InitializeAsync() - { - var json = await _jsRuntime.InvokeAsync("localStorage.getItem", StorageKey); - _config = json != null - ? JsonSerializer.Deserialize(json) ?? new AppConfiguration() - : new AppConfiguration(); - } - - public async Task SaveConfigurationAsync() - { - await _jsRuntime.InvokeVoidAsync("localStorage.setItem", StorageKey, - JsonSerializer.Serialize(_config)); - } - - public async Task ResetToDefaultsAsync() - { - _config = new AppConfiguration(); - await SaveConfigurationAsync(); - } - - public async ValueTask DisposeAsync() => await SaveConfigurationAsync(); - } -} diff --git a/MakerPrompt.E2E.Maui/Tests/FleetWorkflowTests.cs b/MakerPrompt.E2E.Maui/Tests/FleetWorkflowTests.cs deleted file mode 100644 index c5ea228..0000000 --- a/MakerPrompt.E2E.Maui/Tests/FleetWorkflowTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.Playwright; -using MakerPrompt.E2E.Maui.Fixtures; - -namespace MakerPrompt.E2E.Maui.Tests; - -/// -/// Fleet workflow tests for the MAUI app via Playwright + CDP. -/// Covers: add printer → connect demo → telemetry → disconnect. -/// Mirrors the WASM FleetWorkflowTests but runs inside the MAUI WebView2. -/// -/// Each test run uses a unique name suffix so printers from previous runs -/// don't cause strict-mode violations. MAUI stores printer data on the -/// filesystem (not browser localStorage), and the PrinterConnectionManager -/// singleton keeps state in memory — JS cleanup can't clear either. Unique -/// names sidestep this entirely. -/// -[Collection("Appium")] -[Trait("Category", "E2E-Maui")] -[TestCaseOrderer("MakerPrompt.E2E.Maui.Fixtures.AlphabeticalOrderer", "MakerPrompt.E2E.Maui")] -public class FleetWorkflowTests -{ - private static IPage Page => AppiumSetup.Page; - - // Unique suffix per test run so locators never match old printers - private static readonly string S = DateTime.UtcNow.Ticks.ToString()[^6..]; - - [Fact] - public async Task Fleet_AddPrinter_Demo_Mode() - { - await NavigateToFleetAsync(); - - var name = $"Add {S}"; - await Page.Locator("[data-testid='fleet-add-btn']").ClickAsync(); - - var nameInput = Page.Locator("#printerName"); - await nameInput.WaitForAsync(new LocatorWaitForOptions { Timeout = 5_000 }); - await nameInput.FillAsync(name); - - // Demo is the default connection type — click Save - await Page.Locator("[data-testid='fleet-save-printer-btn']").ClickAsync(); - - var card = Page.Locator($".card strong:has-text('{name}')").First; - await card.WaitForAsync(new LocatorWaitForOptions { Timeout = 5_000 }); - Assert.True(await card.IsVisibleAsync()); - } - - [Fact] - public async Task Fleet_ConnectDemoPrinter() - { - await NavigateToFleetAsync(); - var name = $"Conn {S}"; - await AddDemoPrinterAsync(name); - await SelectAndConnectAsync(name); - - var badge = Page.Locator(".badge.bg-success"); - await badge.WaitForAsync(new LocatorWaitForOptions { Timeout = 10_000 }); - Assert.True(await badge.IsVisibleAsync()); - } - - [Fact] - public async Task Fleet_TelemetryUpdates() - { - await NavigateToFleetAsync(); - var name = $"Tele {S}"; - await AddDemoPrinterAsync(name); - await SelectAndConnectAsync(name); - - var heatingCard = Page.Locator(".card-header", new PageLocatorOptions - { - HasTextRegex = new System.Text.RegularExpressions.Regex( - "Heating|Temperature", - System.Text.RegularExpressions.RegexOptions.IgnoreCase) - }); - await heatingCard.WaitForAsync(new LocatorWaitForOptions { Timeout = 10_000 }); - Assert.True(await heatingCard.IsVisibleAsync()); - - var tempValue = Page.Locator(".input-group-text:has-text('C:')"); - await tempValue.First.WaitForAsync(new LocatorWaitForOptions { Timeout = 5_000 }); - Assert.True(await tempValue.First.IsVisibleAsync()); - } - - [Fact] - public async Task Fleet_DisconnectPrinter() - { - await NavigateToFleetAsync(); - var name = $"Disc {S}"; - await AddDemoPrinterAsync(name); - await SelectAndConnectAsync(name); - - var disconnectBtn = Page.Locator("button.btn-outline-danger:has(.bi-x-circle)"); - await disconnectBtn.WaitForAsync(new LocatorWaitForOptions { Timeout = 5_000 }); - await disconnectBtn.ClickAsync(); - - var disconnectedIcon = Page.Locator($".card:has-text('{name}') .bi-plug.text-muted").First; - await disconnectedIcon.WaitForAsync(new LocatorWaitForOptions { Timeout = 10_000 }); - Assert.True(await disconnectedIcon.IsVisibleAsync()); - } - - // ── Helpers ── - - /// - /// Navigates to the Fleet page with _selectedPrinter reset. - /// Goes to /settings first to unmount Fleet, then back to / to remount fresh. - /// This ensures the card grid (not ControlPanel) is shown even if a previous - /// test left a printer connected. - /// - private static async Task NavigateToFleetAsync() - { - await AppiumSetup.NavigateAsync("/settings"); - await Page.WaitForTimeoutAsync(300); - await AppiumSetup.NavigateAsync("/fleet"); - await Page.Locator("[data-testid='fleet-add-btn']").WaitForAsync( - new LocatorWaitForOptions { Timeout = 30_000 }); - } - - private static async Task AddDemoPrinterAsync(string name) - { - await Page.Locator("[data-testid='fleet-add-btn']").ClickAsync(); - var nameInput = Page.Locator("#printerName"); - await nameInput.WaitForAsync(new LocatorWaitForOptions { Timeout = 5_000 }); - await nameInput.FillAsync(name); - await Page.Locator("[data-testid='fleet-save-printer-btn']").ClickAsync(); - await Page.Locator($".card strong:has-text('{name}')").First.WaitForAsync( - new LocatorWaitForOptions { Timeout = 5_000 }); - } - - private static async Task SelectAndConnectAsync(string name) - { - await Page.Locator($".card:has-text('{name}')").First.ClickAsync(); - - var connectBtn = Page.Locator($".card:has-text('{name}') button.btn-outline-success").First; - await connectBtn.WaitForAsync(new LocatorWaitForOptions { Timeout = 5_000 }); - await connectBtn.ClickAsync(); - - await Page.Locator(".badge.bg-success").WaitForAsync( - new LocatorWaitForOptions { Timeout = 10_000 }); - } -} diff --git a/MakerPrompt.MAUI/App.xaml.cs b/MakerPrompt.MAUI/App.xaml.cs deleted file mode 100644 index 5757c97..0000000 --- a/MakerPrompt.MAUI/App.xaml.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace MakerPrompt.MAUI -{ - public partial class App : Application - { - public App() - { - InitializeComponent(); - } - - protected override Window CreateWindow(IActivationState? activationState) - { - return new Window(new MainPage()) { Title = "MakerPrompt.MAUI" }; - } - } -} diff --git a/MakerPrompt.MAUI/Components/Routes.razor b/MakerPrompt.MAUI/Components/Routes.razor deleted file mode 100644 index e9503ef..0000000 --- a/MakerPrompt.MAUI/Components/Routes.razor +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/MakerPrompt.MAUI/MainPage.xaml.cs b/MakerPrompt.MAUI/MainPage.xaml.cs deleted file mode 100644 index 2d1f5f2..0000000 --- a/MakerPrompt.MAUI/MainPage.xaml.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MakerPrompt.MAUI -{ - public partial class MainPage : ContentPage - { - public MainPage() - { - InitializeComponent(); - } - } -} diff --git a/MakerPrompt.MAUI/MakerPrompt.MAUI.csproj b/MakerPrompt.MAUI/MakerPrompt.MAUI.csproj deleted file mode 100644 index d64c25b..0000000 --- a/MakerPrompt.MAUI/MakerPrompt.MAUI.csproj +++ /dev/null @@ -1,83 +0,0 @@ - - - net10.0-android;net10.0-maccatalyst - $(TargetFrameworks);net10.0-windows10.0.19041.0;net10.0-windows10.0.22000.0 - android-arm64;android-x64 - maccatalyst-x64;maccatalyst-arm64 - - Exe - MakerPrompt.MAUI - true - true - enable - false - enable - MakerPrompt - com.makerprompt.maui - $(Version) - $(Version) - 0.4.0 - - - None - - 15.0 - 24.0 - 10.0.17763.0 - 10.0.17763.0 - 6.5 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MakerPrompt.MAUI/MauiProgram.cs b/MakerPrompt.MAUI/MauiProgram.cs deleted file mode 100644 index 99e2689..0000000 --- a/MakerPrompt.MAUI/MauiProgram.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using MakerPrompt.Shared.Utils; -using MakerPrompt.Shared.Services; -using MakerPrompt.MAUI.Services; -using Microsoft.AspNetCore.Builder; -using MakerPrompt.MAUI.Storage; -using MakerPrompt.Shared.Infrastructure; - -namespace MakerPrompt.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 WebGL in the embedded WebView2 on Windows. - // WebView2 silently disables WebGL for GPUs on its blocklist — bypassing that - // is required for the GCode visual viewer (Three.js / WebGL canvas) to work. - var webViewArgs = "--ignore-gpu-blocklist --enable-gpu-rasterization"; -#if DEBUG - // Append remote-debugging port so E2E tests can connect via CDP / Playwright. - webViewArgs += " --remote-debugging-port=9222"; - builder.Services.AddBlazorWebViewDeveloperTools(); - builder.Logging.AddDebug(); -#endif - Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", webViewArgs); - - builder.Services.AddLocalization(options => - { - options.ResourcesPath = "Resources"; - }); - - var supportedCultures = new AppConfiguration().SupportedCultures; - var localizationOptions = new RequestLocalizationOptions() - .SetDefaultCulture(supportedCultures[0]) - .AddSupportedCultures(supportedCultures) - .AddSupportedUICultures(supportedCultures); - - // Restore the user's saved language from MAUI Preferences. - // The Blazor WASM host reads this from JS localStorage in Program.cs, - // but MAUI can't invoke JS before Blazor starts. Read from the native - // Preferences store (same data that AppConfigurationService persists). - RestoreSavedCulture(supportedCultures); - - builder.Services.RegisterMakerPromptSharedServices(); - // Override the passthrough camera proxy with MAUI native HttpClient fetcher - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - // MAUI: Use AES-256-GCM encryption for stored credentials - var deviceId = DeviceInfo.Current.Idiom.ToString(); - var deviceName = DeviceInfo.Current.Name ?? "default"; - builder.Services.AddSingleton( - new AesConnectionEncryptionService($"MakerPrompt-{deviceId}-{deviceName}")); - return builder.Build(); - } - - /// - /// Reads the saved language from MAUI Preferences and applies it to - /// the current thread before Blazor starts, so the first render uses - /// the correct culture. Without this, a force-reload after a language - /// change in CultureSelector would revert to the default culture. - /// - private static void RestoreSavedCulture(string[] supportedCultures) - { - try - { - var json = Preferences.Get("Mak3rPromptAppConfig", (string?)null); - if (json == null) return; - - using var doc = JsonDocument.Parse(json); - if (!doc.RootElement.TryGetProperty("Language", out var langProp)) return; - - var lang = langProp.GetString(); - if (string.IsNullOrEmpty(lang)) return; - if (!supportedCultures.Contains(lang, StringComparer.OrdinalIgnoreCase)) return; - - var culture = new CultureInfo(lang); - CultureInfo.DefaultThreadCurrentCulture = culture; - CultureInfo.DefaultThreadCurrentUICulture = culture; - } - catch - { - // Config not saved yet or corrupt — use default culture - } - } - } -} diff --git a/MakerPrompt.MAUI/Properties/launchSettings.json b/MakerPrompt.MAUI/Properties/launchSettings.json deleted file mode 100644 index f4c6c8d..0000000 --- a/MakerPrompt.MAUI/Properties/launchSettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "profiles": { - "Windows Machine": { - "commandName": "Project", - "nativeDebugging": false - } - } -} \ No newline at end of file diff --git a/MakerPrompt.MAUI/Resources/AppIcon/appicon.svg b/MakerPrompt.MAUI/Resources/AppIcon/appicon.svg deleted file mode 100644 index 83624a6..0000000 --- a/MakerPrompt.MAUI/Resources/AppIcon/appicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/MakerPrompt.MAUI/Resources/AppIcon/appiconfg.svg b/MakerPrompt.MAUI/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index bb65df3..0000000 --- a/MakerPrompt.MAUI/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - diff --git a/MakerPrompt.MAUI/Resources/Fonts/OpenSans-Regular.ttf b/MakerPrompt.MAUI/Resources/Fonts/OpenSans-Regular.ttf deleted file mode 100644 index 5b46960..0000000 Binary files a/MakerPrompt.MAUI/Resources/Fonts/OpenSans-Regular.ttf and /dev/null differ diff --git a/MakerPrompt.MAUI/Resources/Splash/splash.svg b/MakerPrompt.MAUI/Resources/Splash/splash.svg deleted file mode 100644 index 62d66d7..0000000 --- a/MakerPrompt.MAUI/Resources/Splash/splash.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/MakerPrompt.MAUI/Services/SerialService.Android.cs b/MakerPrompt.MAUI/Services/SerialService.Android.cs deleted file mode 100644 index a383e10..0000000 --- a/MakerPrompt.MAUI/Services/SerialService.Android.cs +++ /dev/null @@ -1,117 +0,0 @@ -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; -using UsbSerialForAndroid.Net; -using UsbSerialForAndroid.Net.Drivers; -using UsbSerialForAndroid.Net.Helper; -using System.Text; - -namespace MakerPrompt.MAUI.Services -{ - public class SerialService : BaseSerialService, ISerialService - { - private UsbDriverBase? _usbDriver; - public bool IsSupported => true; - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) - { - if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Serial == null) - throw new ArgumentException("Invalid connection settings"); - - try - { - var deviceName = connectionSettings.Serial.PortName; // fix to id - var baudRate = connectionSettings.Serial.BaudRate == 0 - ? 250000 - : connectionSettings.Serial.BaudRate; - var dataBits = (byte)8; - var stopBits = UsbSerialForAndroid.Net.Enums.StopBits.One; - var parity = UsbSerialForAndroid.Net.Enums.Parity.None; - - // Get the USB device - var usbDevice = UsbManagerHelper.GetAllUsbDevices().FirstOrDefault(d => d.DeviceName == deviceName); - if (usbDevice == null) - throw new InvalidOperationException("USB device not found"); - - // Request permission if needed - if (!UsbManagerHelper.HasPermission(usbDevice)) - UsbManagerHelper.RequestPermission(usbDevice); - - // Create and open the USB driver - _usbDriver = UsbDriverFactory.CreateUsbDriver(usbDevice.DeviceId); - _usbDriver.Open(baudRate, dataBits, stopBits, parity); - - IsConnected = true; - RaiseConnectionChanged(); - } - catch (Exception ex) - { - IsConnected = false; - Console.WriteLine($"Error connecting to device: {ex.Message}"); - } - - return IsConnected; - } - - public async Task DisconnectAsync() - { - if (IsConnected && _usbDriver != null) - { - _usbDriver.Close(); - _usbDriver = null; - IsConnected = false; - RaiseConnectionChanged(); - } - } - - public override async Task WriteDataAsync(string data) - { - if (!IsConnected || _usbDriver == null) - throw new InvalidOperationException("Device is not connected"); - - var buffer = Encoding.ASCII.GetBytes(data); - _usbDriver.Write(buffer); - } - - public Task StartPrint(GCodeDoc gcodeDoc) - { - if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) - { - return Task.CompletedTask; - } - - // Android path does not currently expose a CancellationToken; use a simple IsConnected check. - return Task.Run(async () => - { - await foreach (var command in gcodeDoc.EnumerateCommandsAsync()) - { - if (!IsConnected) - { - break; - } - - await WriteDataAsync(command); - } - }); - } - - public override async ValueTask DisposeAsync() - { - await DisconnectAsync(); - } - - public async Task> GetAvailablePortsAsync() - { - var devices = UsbManagerHelper.GetAllUsbDevices(); - return devices.Select(d => d.DeviceId.ToString()).ToList(); - } - - public async Task CheckSupportedAsync() - { - // Assuming USB support is always available on Android - return true; - } - - public Task RequestPortAsync() => Task.CompletedTask; - } -} \ No newline at end of file diff --git a/MakerPrompt.MAUI/Services/SerialService.MacOS.cs b/MakerPrompt.MAUI/Services/SerialService.MacOS.cs deleted file mode 100644 index d66d298..0000000 --- a/MakerPrompt.MAUI/Services/SerialService.MacOS.cs +++ /dev/null @@ -1,190 +0,0 @@ -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; -using MakerPrompt.Shared.Utils; -using System.Text; -using System.Threading.Tasks.Dataflow; -using UsbSerialForMacOS; -namespace MakerPrompt.MAUI.Services -{ - public class SerialService : BaseSerialService, ISerialService - { - private UsbSerialManager? _manager = new(); - private readonly BufferBlock _commandQueue = new(); - private CancellationTokenSource? _cts; - private Task? _sendTask; - private Task? _receiveTask; - private bool _disposed = false; - public bool IsSupported => true; - - public SerialService() { } - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) - { - if (IsConnected) return IsConnected; - if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Serial == null) - throw new ArgumentException("Invalid connection settings"); - - var portName = connectionSettings.Serial.PortName; - var baudRate = connectionSettings.Serial.BaudRate == 0 - ? 250000 - : connectionSettings.Serial.BaudRate; - - try - { - _manager ??= new UsbSerialManager(); - _cts?.Dispose(); - _cts = new CancellationTokenSource(); - - IsConnected = _manager.Open(portName, baudRate); - ConnectionName = portName; - - _sendTask = Task.Run(() => SendLoopAsync(_cts.Token)); - _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); - RaiseConnectionChanged(); - } - catch (Exception ex) - { - IsConnected = false; - throw new SerialException("Connection failed", ex); - } - - return IsConnected; - } - - public async Task DisconnectAsync() - { - if (!IsConnected) return; - _cts?.Cancel(); - - try - { - if (_sendTask != null) - await _sendTask.ConfigureAwait(false); - if (_receiveTask != null) - await _receiveTask.ConfigureAwait(false); - } - finally - { - try - { - _manager?.Close(); - } - catch - { - // swallow close exceptions on shutdown - } - - _manager = null; - IsConnected = false; - RaiseConnectionChanged(); - } - } - - public override async Task WriteDataAsync(string data) - { - if (!IsConnected) return; - await _commandQueue.SendAsync(data); - } - - public async Task> GetAvailablePortsAsync() - { - return _manager?.AvailablePorts().OrderBy(p => p).ToList() ?? []; - } - - private async Task SendLoopAsync(CancellationToken ct) - { - try - { - while (IsConnected && !ct.IsCancellationRequested) - { - var command = await _commandQueue.ReceiveAsync(ct); - var manager = _manager; - if (manager == null || ct.IsCancellationRequested) - { - break; - } - manager.Write(command); - await Task.Delay(10, ct); - } - } - catch (OperationCanceledException) - { - // Normal shutdown - } - } - - private async Task ReceiveLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested && IsConnected) - { - try - { - var manager = _manager; - if (manager == null || ct.IsCancellationRequested) - { - break; - } - var bytesRead = manager.Read(4096); - if (bytesRead.Length > 0) - { - var received = Encoding.UTF8.GetString(bytesRead.ToArray()); - ProcessReceivedData(received); - } - await Task.Delay(10, ct); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - Console.WriteLine($"Receive error: {ex.Message}"); - IsConnected = false; - await DisposeAsync(); - } - } - } - - public override async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - try - { - await DisconnectAsync(); - _cts?.Dispose(); - } - catch (ObjectDisposedException) - { - // Ignore if already disposed to ensure idempotent disposal - } - } - - public Task CheckSupportedAsync() => Task.FromResult(true); - - public Task RequestPortAsync() => Task.CompletedTask; - - public Task StartPrint(GCodeDoc gcodeDoc) - { - if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) - { - return Task.CompletedTask; - } - - return Task.Run(async () => - { - await foreach (var command in gcodeDoc.EnumerateCommandsAsync(_cts?.Token ?? CancellationToken.None)) - { - if (!IsConnected) - { - break; - } - - await WriteDataAsync(command); - } - }); - } - } -} \ No newline at end of file diff --git a/MakerPrompt.MAUI/Services/SerialService.Windows.cs b/MakerPrompt.MAUI/Services/SerialService.Windows.cs deleted file mode 100644 index 2bb2bc8..0000000 --- a/MakerPrompt.MAUI/Services/SerialService.Windows.cs +++ /dev/null @@ -1,204 +0,0 @@ -using System.Text; -using System.IO.Ports; -using System.Threading.Tasks.Dataflow; -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Utils; -using MakerPrompt.Shared.Services; - -namespace MakerPrompt.MAUI.Services -{ - public class SerialService : BaseSerialService, ISerialService - { - private readonly SerialPort _serialPort; - private readonly BufferBlock _commandQueue = new(); - private readonly CancellationTokenSource _cts = new(); - private Task? _sendTask; - private Task? _receiveTask; - public bool IsSupported => true; - - public SerialService() - { - _serialPort = new SerialPort - { - DataBits = 8, - Parity = Parity.None, - StopBits = StopBits.One, - Handshake = Handshake.None, // was RequestToSend - DtrEnable = true, // assert DTR for many CDC devices - RtsEnable = true, // assert RTS manually (optional) - ReadTimeout = 2000, - WriteTimeout = 5000, - NewLine = "\n", - Encoding = Encoding.ASCII - }; - } - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) - { - if (IsConnected) return IsConnected; - - if (connectionSettings.ConnectionType != ConnectionType || string.IsNullOrWhiteSpace(connectionSettings.Serial.PortName)) return false; - - _serialPort.PortName = connectionSettings.Serial.PortName; - _serialPort.BaudRate = connectionSettings.Serial.BaudRate == 0 - ? 250000 - : connectionSettings.Serial.BaudRate; - - try - { - await Task.Run(() => _serialPort.Open()); - IsConnected = true; - ConnectionName = connectionSettings.Serial.PortName; - _sendTask = Task.Run(() => SendLoopAsync(_cts.Token)); - _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); - updateTimer.Elapsed += async (s, e) => await GetPrinterTelemetryAsync(); - updateTimer.Start(); - RaiseConnectionChanged(); - } - catch (Exception ex) - { - IsConnected = false; - throw new SerialException("Connection failed", ex); - } - - return IsConnected; - } - - public async Task DisconnectAsync() - { - updateTimer.Stop(); - _serialPort.Close(); - IsConnected = false; - RaiseConnectionChanged(); - } - - public override async Task WriteDataAsync(string data) - { - if (!IsConnected) return; - await _commandQueue.SendAsync(data); - } - - public async Task> GetAvailablePortsAsync() - { - return await Task.Run(() => SerialPort.GetPortNames() - .OrderBy(p => p) - .ToList()); - } - - private async Task SendLoopAsync(CancellationToken ct) - { - try - { - while (IsConnected && !ct.IsCancellationRequested) - { - var command = await _commandQueue.ReceiveAsync(ct); - if (!_serialPort.IsOpen) break; - - var payload = Encoding.ASCII.GetBytes(command + _serialPort.NewLine); - await _serialPort.BaseStream.WriteAsync(payload, 0, payload.Length, ct); - await _serialPort.BaseStream.FlushAsync(ct); - await Task.Delay(10, ct); - } - } - catch (OperationCanceledException) { } - } - - private async Task ReceiveLoopAsync(CancellationToken ct) - { - var buffer = new byte[4096]; - - while (!ct.IsCancellationRequested && IsConnected) - { - try - { - var bytesRead = await _serialPort.BaseStream - .ReadAsync(buffer, 0, buffer.Length, ct); - - if (bytesRead > 0) - { - var received = Encoding.ASCII.GetString(buffer, 0, bytesRead); - ProcessReceivedData(received); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - Console.WriteLine($"Receive error: {ex.Message}"); - await DisposeAsync(); - } - } - } - - public override async ValueTask DisposeAsync() - { - if (!IsConnected) return; - - IsConnected = false; - - try - { - // First cancel operations - _cts.Cancel(); - - // Then await tasks completion - if (_sendTask != null) - await _sendTask.ContinueWith(_ => { }); // Suppress exceptions - if (_receiveTask != null) - await _receiveTask.ContinueWith(_ => { }); - } - finally - { - // Then close the port - if (_serialPort.IsOpen) - { - await Task.Run(() => - { - try - { - _serialPort.DiscardInBuffer(); - _serialPort.DiscardOutBuffer(); - _serialPort.Close(); - } - catch { /* Ignore close errors */ } - }); - } - - // Dispose resources in reverse order - _serialPort.Dispose(); - _cts.Dispose(); - updateTimer.Dispose(); - RaiseConnectionChanged(); - } - } - - public Task CheckSupportedAsync() => Task.FromResult(true); - - public Task RequestPortAsync() => Task.CompletedTask; - - public Task StartPrint(GCodeDoc gcodeDoc) - { - // Stream G-code through the existing send queue without blocking. - if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) - { - return Task.CompletedTask; - } - - return Task.Run(async () => - { - await foreach (var command in gcodeDoc.EnumerateCommandsAsync(_cts.Token)) - { - if (!IsConnected) - { - break; - } - - await WriteDataAsync(command); - } - }); - } - } -} \ No newline at end of file diff --git a/MakerPrompt.MAUI/Services/SerialService.iOS.cs b/MakerPrompt.MAUI/Services/SerialService.iOS.cs deleted file mode 100644 index 58974f2..0000000 --- a/MakerPrompt.MAUI/Services/SerialService.iOS.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.IO.Ports; -using System.Threading.Tasks.Dataflow; -using MakerPrompt.Shared.Infrastructure; -using System.Text; -using MakerPrompt.Shared.Models; - -namespace MakerPrompt.MAUI.Services -{ - public class SerialService : BaseSerialService, ISerialService - { - public bool IsSupported => false; - - public SerialService() - { - - } - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) => throw new NotSupportedException(); - public async Task DisconnectAsync() => throw new NotSupportedException(); - public override async Task WriteDataAsync(string data) => throw new NotSupportedException(); - public async Task> GetAvailablePortsAsync() => throw new NotSupportedException(); - - public override async ValueTask DisposeAsync() - { - } - - public Task CheckSupportedAsync() => Task.FromResult(false); - - public Task RequestPortAsync() => throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/MakerPrompt.Shared/Components/PrusaConnectImportModal.razor b/MakerPrompt.Shared/Components/PrusaConnectImportModal.razor deleted file mode 100644 index 2276d14..0000000 --- a/MakerPrompt.Shared/Components/PrusaConnectImportModal.razor +++ /dev/null @@ -1,266 +0,0 @@ -@* ───────────────────────────────────────────────────────────────────── - PrusaConnectImportModal — Two-step fleet import from PrusaConnect. - - Step 1: User enters a Bearer token and clicks "Discover Printers". - Step 2: A list of discovered printers is shown with checkboxes. - Clicking "Import Selected" saves each as a PrusaConnect printer. - - Call ShowAsync() to open. - ───────────────────────────────────────────────────────────────────── *@ -@using MakerPrompt.Shared.Infrastructure -@using static MakerPrompt.Shared.Utils.Enums -@inject PrusaConnectProvider Provider -@inject PrinterConnectionManager ConnectionManager -@inject ToastService ToastService -@inject ILogger Logger - - - - @if (_step == Step.Token) - { -

- Enter your PrusaConnect Bearer token. You can obtain it from - connect.prusa3d.com - account settings (API keys) or by capturing the Authorization header from a PrusaConnect session. -

- -
- - -
- - @if (!string.IsNullOrEmpty(_error)) - { - - } - } - else - { - @if (_discoveredPrinters.Count == 0) - { -
- - No printers found on this PrusaConnect account. -
- } - else - { -

- @_discoveredPrinters.Count printer(s) found. Select the ones you want to import. -

- -
- @foreach (var printer in _discoveredPrinters) - { - var isChecked = _selected.Contains(printer.Id); - var alreadyAdded = _alreadyImported.Contains(printer.Id); - - } -
- } - - @if (!string.IsNullOrEmpty(_error)) - { - - } - } -
- - - - - @if (_step == Step.Token) - { - - } - else - { - - - } - -
- -@code { - private enum Step { Token, Printers } - - private Modal _modal = default!; - private Step _step = Step.Token; - private string _bearerToken = string.Empty; - private bool _isBusy; - private string? _error; - - private List _discoveredPrinters = []; - private HashSet _selected = []; - private HashSet _alreadyImported = []; - - public async Task ShowAsync() - { - _step = Step.Token; - _bearerToken = string.Empty; - _error = null; - _isBusy = false; - _discoveredPrinters = []; - _selected = []; - _alreadyImported = BuildAlreadyImportedSet(); - await _modal.ShowAsync(); - } - - private async Task DiscoverAsync() - { - if (string.IsNullOrWhiteSpace(_bearerToken)) return; - - _isBusy = true; - _error = null; - try - { - Provider.Configure(_bearerToken.Trim()); - _discoveredPrinters = (await Provider.GetPrintersAsync()).ToList(); - - // Pre-select printers not yet imported. - _selected = _discoveredPrinters - .Where(p => !_alreadyImported.Contains(p.Id)) - .Select(p => p.Id) - .ToHashSet(); - - _step = Step.Printers; - } - catch (Exception ex) - { - Logger.LogError(ex, "PrusaConnect discovery failed"); - _error = "Could not reach PrusaConnect. Check your Bearer token and network connection."; - } - finally - { - _isBusy = false; - } - } - - private async Task ImportAsync() - { - if (_selected.Count == 0) return; - - _isBusy = true; - _error = null; - var imported = 0; - try - { - foreach (var printer in _discoveredPrinters.Where(p => _selected.Contains(p.Id))) - { - var displayName = string.IsNullOrEmpty(printer.Name) ? printer.Model : printer.Name; - if (string.IsNullOrEmpty(displayName)) displayName = $"PrusaConnect {printer.Id[..8]}"; - - var definition = new PrinterConnectionDefinition - { - Name = displayName, - ConnectionType = PrinterConnectionType.PrusaConnect, - Settings = new PrinterConnectionSettings( - new ApiConnectionSettings(string.Empty, printer.Id, _bearerToken.Trim()), - PrinterConnectionType.PrusaConnect) - }; - - await ConnectionManager.AddPrinterAsync(definition); - imported++; - } - - var noun = imported == 1 ? "printer" : "printers"; - ToastService.Notify(new ToastMessage(ToastType.Success, $"{imported} {noun} imported from PrusaConnect")); - await _modal.HideAsync(); - } - catch (Exception ex) - { - Logger.LogError(ex, "PrusaConnect import failed"); - _error = $"Import failed: {ex.Message}"; - } - finally - { - _isBusy = false; - } - } - - private async Task GoBackAsync() - { - _step = Step.Token; - _error = null; - await Task.CompletedTask; - } - - private async Task CloseAsync() - { - _error = null; - await _modal.HideAsync(); - } - - private void ToggleSelection(string id, bool isChecked) - { - if (isChecked) _selected.Add(id); - else _selected.Remove(id); - } - - private HashSet BuildAlreadyImportedSet() => - ConnectionManager.Printers - .Where(p => p.Definition.ConnectionType == PrinterConnectionType.PrusaConnect - && p.Definition.Settings.Api is not null) - .Select(p => p.Definition.Settings.Api!.UserName) - .ToHashSet(); -} diff --git a/MakerPrompt.Shared/Infrastructure/IPrinterCommunicationService.cs b/MakerPrompt.Shared/Infrastructure/IPrinterCommunicationService.cs deleted file mode 100644 index a8cbaf3..0000000 --- a/MakerPrompt.Shared/Infrastructure/IPrinterCommunicationService.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace MakerPrompt.Shared.Infrastructure -{ - public interface IPrinterCommunicationService : IAsyncDisposable - { - event EventHandler ConnectionStateChanged; - event EventHandler TelemetryUpdated; - - PrinterConnectionType ConnectionType { get; } - PrinterTelemetry LastTelemetry { get; } - string ConnectionName { get; } - bool IsConnected { get; } - bool IsPrinting { get; } - - /// - /// True when the backend supports direct printer control commands - /// (temperature set, motion, extrusion, fan, etc.). - /// Monitoring-only backends (PrusaLink, PrusaConnect) return false. - /// - bool SupportsDirectControl => true; - - /// - /// True when the backend exposes a queryable printer-side print queue. - /// Currently only Moonraker supports this. - /// - bool SupportsPrinterQueue => false; - - /// - /// True when the backend supports sending raw G-code commands via . - /// Backends that use a proprietary protocol with no command terminal should return false. - /// - bool SupportsCommandPrompt => true; - - Task ConnectAsync(PrinterConnectionSettings connectionSettings); - Task DisconnectAsync(); - Task WriteDataAsync(string command); - Task GetPrinterTelemetryAsync(); - Task> GetFilesAsync(); - /// - /// Returns the printer-side print queue entries. Returns an empty list - /// for backends that do not support a print queue ( is false). - /// - Task> GetPrinterQueueAsync() => Task.FromResult(new List()); - Task SetHotendTemp(int targetTemp = 0); - Task SetBedTemp(int targetTemp = 0); - Task Home(bool x = true, bool y = true, bool z = true); - Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f); - Task SetFanSpeed(int fanSpeedPercentage = 0); - Task SetPrintSpeed(int speed); - Task SetPrintFlow(int flow); - Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f); - Task RunPidTuning(int cycles, int targetTemp, int extruderIndex); - Task RunThermalModelCalibration(int cycles, int targetTemp); - Task StartPrint(FileEntry file); - Task StartPrint(GCodeDoc document); - Task SaveEEPROM(); - } -} diff --git a/MakerPrompt.Shared/Infrastructure/IPrinterProvider.cs b/MakerPrompt.Shared/Infrastructure/IPrinterProvider.cs deleted file mode 100644 index 06cd5ed..0000000 --- a/MakerPrompt.Shared/Infrastructure/IPrinterProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace MakerPrompt.Shared.Infrastructure; - -/// -/// Abstracts services that manage a fleet of printers under a single account. -/// Call Configure(token) before GetPrintersAsync(). -/// -public interface IPrinterProvider -{ - void Configure(string bearerToken); - Task> GetPrintersAsync(); -} diff --git a/MakerPrompt.Shared/Models/FarmConfiguration.cs b/MakerPrompt.Shared/Models/FarmConfiguration.cs deleted file mode 100644 index 569bd13..0000000 --- a/MakerPrompt.Shared/Models/FarmConfiguration.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - /// - /// Represents a saved farm profile. Each farm bundles a set of printer connections - /// and a display name so users can switch between different physical setups. - /// - public class FarmConfiguration - { - public Guid Id { get; set; } = Guid.NewGuid(); - public string Name { get; set; } = string.Empty; - public List Printers { get; set; } = []; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - } -} diff --git a/MakerPrompt.Shared/Models/FilamentSpool.cs b/MakerPrompt.Shared/Models/FilamentSpool.cs deleted file mode 100644 index afe8570..0000000 --- a/MakerPrompt.Shared/Models/FilamentSpool.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - public 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; - public double Diameter { get; set; } = 1.75; - public double TotalWeightGrams { get; set; } = 1000; - public double RemainingWeightGrams { get; set; } = 1000; - public decimal Cost { get; set; } - public DateTime PurchaseDate { get; set; } = DateTime.UtcNow; - public bool IsArchived { get; set; } - } -} diff --git a/MakerPrompt.Shared/Models/NotificationRecord.cs b/MakerPrompt.Shared/Models/NotificationRecord.cs deleted file mode 100644 index 19a283d..0000000 --- a/MakerPrompt.Shared/Models/NotificationRecord.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - public enum NotificationLevel - { - Info, - Warning, - Error, - Critical - } - - public 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; - public Guid? PrinterId { get; set; } - public Guid? FilamentSpoolId { get; set; } - public bool IsRead { get; set; } - } -} diff --git a/MakerPrompt.Shared/Models/PrintJobUsageRecord.cs b/MakerPrompt.Shared/Models/PrintJobUsageRecord.cs deleted file mode 100644 index 1a8163c..0000000 --- a/MakerPrompt.Shared/Models/PrintJobUsageRecord.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - public class PrintJobUsageRecord - { - public Guid Id { get; set; } = Guid.NewGuid(); - public Guid PrinterId { get; set; } - public Guid FilamentSpoolId { get; set; } - public string JobName { get; set; } = string.Empty; - public TimeSpan Duration { get; set; } - public double EstimatedFilamentUsedGrams { get; set; } - public double ActualFilamentUsedGrams { get; set; } - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - } -} diff --git a/MakerPrompt.Shared/Models/PrintProject.cs b/MakerPrompt.Shared/Models/PrintProject.cs deleted file mode 100644 index 0fd9f09..0000000 --- a/MakerPrompt.Shared/Models/PrintProject.cs +++ /dev/null @@ -1,77 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - /// - /// A print project groups multiple G-code files under a folder/name. - /// Files are uploaded locally to app storage and can be dispatched to any connected printer. - /// - public class PrintProject - { - public Guid Id { get; set; } = Guid.NewGuid(); - - /// - /// Human-readable project name (also acts as the folder name). - /// - public string Name { get; set; } = string.Empty; - - /// - /// Optional description or notes. - /// - public string? Notes { get; set; } - - /// - /// When the project was created. - /// - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - /// - /// The individual print jobs in this project. - /// - public List Jobs { get; set; } = []; - } - - /// - /// A single print job within a project — one G-code file plus tracking state. - /// - public class PrintJob - { - public Guid Id { get; set; } = Guid.NewGuid(); - - /// - /// Original uploaded filename (e.g. "benchy.gcode"). - /// - public string FileName { get; set; } = string.Empty; - - /// - /// Storage path within IAppLocalStorageProvider (e.g. "PrintProjects/{projectId}/{filename}"). - /// - public string StoragePath { get; set; } = string.Empty; - - /// - /// File size in bytes. - /// - public long Size { get; set; } - - /// - /// Current status of this job. - /// - public PrintJobStatus Status { get; set; } = PrintJobStatus.Queued; - - /// - /// The printer this job was assigned/sent to (null = unassigned). - /// - public Guid? AssignedPrinterId { get; set; } - - /// - /// Friendly name of the assigned printer (for display when printer is offline). - /// - public string? AssignedPrinterName { get; set; } - } - - public enum PrintJobStatus - { - Queued, - Printing, - Completed, - Failed - } -} diff --git a/MakerPrompt.Shared/Models/PrinterConnectionDefinition.cs b/MakerPrompt.Shared/Models/PrinterConnectionDefinition.cs deleted file mode 100644 index bbdc131..0000000 --- a/MakerPrompt.Shared/Models/PrinterConnectionDefinition.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - /// - /// Persistent model for a saved printer connection. Stored via IAppLocalStorageProvider. - /// Inspired by PrintQue multi-printer management and OctoPrint connection profiles. - /// - public class PrinterConnectionDefinition - { - /// - /// Unique identifier for this printer connection. - /// - public Guid Id { get; set; } = Guid.NewGuid(); - - /// - /// User-friendly display name (e.g. "Workshop Prusa MK4", "BambuLab X1C #2"). - /// - public string Name { get; set; } = string.Empty; - - /// - /// Backend type for this printer connection. - /// - public PrinterConnectionType ConnectionType { get; set; } = PrinterConnectionType.Demo; - - /// - /// Connection details — API settings for HTTP/WS backends, serial settings for USB. - /// - public PrinterConnectionSettings Settings { get; set; } = new(); - - /// - /// Whether MakerPrompt should attempt to auto-connect this printer on startup. - /// - public bool AutoConnect { get; set; } - - /// - /// Optional user-assigned color for the printer card in the Fleet dashboard. - /// - public string? Color { get; set; } - - /// - /// Optional notes for this printer connection. - /// - public string? Notes { get; set; } - - /// - /// Timestamp of when this definition was created. - /// - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - /// - /// Timestamp of the last successful connection. - /// - public DateTime? LastConnectedAt { get; set; } - - /// - /// The ID of the currently assigned filament spool. - /// - public Guid? AssignedFilamentSpoolId { get; set; } - } -} diff --git a/MakerPrompt.Shared/Models/PrinterConnectionSettings.cs b/MakerPrompt.Shared/Models/PrinterConnectionSettings.cs deleted file mode 100644 index ae5bcb9..0000000 --- a/MakerPrompt.Shared/Models/PrinterConnectionSettings.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace MakerPrompt.Shared.Models -{ - public class PrinterConnectionSettings - { - public PrinterConnectionType ConnectionType { get; set; } - - public SerialConnectionSettings? Serial { get; set; } - - public ApiConnectionSettings? Api { get; set; } - - public PrinterConnectionSettings() - { - ConnectionType = PrinterConnectionType.Demo; - } - - public PrinterConnectionSettings(SerialConnectionSettings serialConnectionSettings) - { - ConnectionType = PrinterConnectionType.Serial; - Serial = serialConnectionSettings; - } - - public PrinterConnectionSettings(ApiConnectionSettings apiConnectionSettings, PrinterConnectionType connectionType) - { - if (connectionType == PrinterConnectionType.Serial) throw new ArgumentOutOfRangeException(nameof(connectionType)); - ConnectionType = connectionType; - Api = apiConnectionSettings; - } - } - - public record SerialConnectionSettings - { - public string PortName { get; set; } = string.Empty; - - public int BaudRate { get; set; } = 115200; - } - - public class ApiConnectionSettings - { - public ApiConnectionSettings() - { - } - public ApiConnectionSettings(string url, string username, string password) - { - Url = url; - UserName = username; - Password = password; - } - public string Url { get; set; } = string.Empty; - - public string UserName { get; set; } = string.Empty; - - public string Password { get; set; } = string.Empty; - } -} diff --git a/MakerPrompt.Shared/Models/PrinterTelemetry.cs b/MakerPrompt.Shared/Models/PrinterTelemetry.cs deleted file mode 100644 index 24ed015..0000000 --- a/MakerPrompt.Shared/Models/PrinterTelemetry.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.ComponentModel; - -namespace MakerPrompt.Shared.Models -{ - public class PrinterTelemetry : INotifyPropertyChanged - { - private readonly object _lock = new(); - - private string _lastResponse = ""; - public string LastResponse - { - get => _lastResponse; - set => SetField(ref _lastResponse, value, nameof(LastResponse)); - } - - private string _printerName = "My 3D Printer"; - public string PrinterName - { - get => _printerName; - set => SetField(ref _printerName, value, nameof(PrinterName)); - } - - private DateTime? _connectionTime; - public DateTime? ConnectionTime - { - get => _connectionTime; - set => SetField(ref _connectionTime, value, nameof(ConnectionTime)); - } - - private double _hotendTemp; - public double HotendTemp - { - get => _hotendTemp; - set => SetField(ref _hotendTemp, value, nameof(HotendTemp)); - } - - private double _hotendTarget; - public double HotendTarget - { - get => _hotendTarget; - set => SetField(ref _hotendTarget, value, nameof(HotendTarget)); - } - - private double _bedTemp; - public double BedTemp - { - get => _bedTemp; - set => SetField(ref _bedTemp, value, nameof(BedTemp)); - } - - private double _bedTarget; - public double BedTarget - { - get => _bedTarget; - set => SetField(ref _bedTarget, value, nameof(BedTarget)); - } - - private double _chamberTemp; - public double ChamberTemp - { - get => _chamberTemp; - set => SetField(ref _chamberTemp, value, nameof(ChamberTemp)); - } - - private double _chamberTarget; - public double ChamberTarget - { - get => _chamberTarget; - set => SetField(ref _chamberTarget, value, nameof(ChamberTarget)); - } - - private Vector3 _position = new(); - public Vector3 Position - { - get => _position; - set => SetField(ref _position, value, nameof(Position)); - } - - private PrinterStatus _status = PrinterStatus.Disconnected; - public PrinterStatus Status - { - get => _status; - set => SetField(ref _status, value, nameof(Status)); - } - - private int _feedRate; - public int FeedRate - { - get => _feedRate; - set => SetField(ref _feedRate, value, nameof(FeedRate)); - } - - private int _flowRate; - public int FlowRate - { - get => _flowRate; - set => SetField(ref _flowRate, value, nameof(FlowRate)); - } - - private int _fanSpeed; - public int FanSpeed - { - get => _fanSpeed; - set => SetField(ref _fanSpeed, value, nameof(FanSpeed)); - } - - private string _printJobName = ""; - public string PrintJobName - { - get => _printJobName; - set => SetField(ref _printJobName, value, nameof(PrintJobName)); - } - - private TimeSpan _printDuration; - public TimeSpan PrintDuration - { - get => _printDuration; - set => SetField(ref _printDuration, value, nameof(PrintDuration)); - } - - private double _filamentUsed; - public double FilamentUsed - { - get => _filamentUsed; - set => SetField(ref _filamentUsed, value, nameof(FilamentUsed)); - } - - public SDCardStatus SDCard { get; } = new(); - - - public event PropertyChangedEventHandler? PropertyChanged; - - protected virtual void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - protected bool SetField(ref T field, T value, string propertyName) - { - lock (_lock) - { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(propertyName); - return true; - } - } - } - - public class SDCardStatus - { - public bool Present { get; set; } - public bool Printing { get; set; } - public double Progress { get; set; } // 0-100% - } -} diff --git a/MakerPrompt.Shared/Models/RemotePrinterInfo.cs b/MakerPrompt.Shared/Models/RemotePrinterInfo.cs deleted file mode 100644 index b638571..0000000 --- a/MakerPrompt.Shared/Models/RemotePrinterInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace MakerPrompt.Shared.Models; - -/// -/// Printer discovered from a fleet provider (e.g. PrusaConnect account). -/// -public class RemotePrinterInfo -{ - public string Id { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string Model { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; -} diff --git a/MakerPrompt.Shared/Pages/ProjectHub.razor b/MakerPrompt.Shared/Pages/ProjectHub.razor deleted file mode 100644 index bcbe563..0000000 --- a/MakerPrompt.Shared/Pages/ProjectHub.razor +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/MakerPrompt.Shared/Services/GCodeDocumentService.cs b/MakerPrompt.Shared/Services/GCodeDocumentService.cs deleted file mode 100644 index ecc6ac9..0000000 --- a/MakerPrompt.Shared/Services/GCodeDocumentService.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace MakerPrompt.Shared.Services -{ - // Simple wrapper around the current G-code text; can be extended later to expose parsed structures - public class GCodeDocumentService - { - private string? _current; - public string? CurrentGCode => _current; - public event Action? Changed; - - // Expose a lightweight document wrapper for higher-level APIs - public GCodeDoc Document => new(_current ?? string.Empty); - - public void SetGCode(string? gcode) - { - _current = gcode ?? string.Empty; - Changed?.Invoke(); - } - - public void Clear() - { - _current = string.Empty; - Changed?.Invoke(); - } - } - - public readonly record struct GCodeDoc(string Content) - { - // Async, streaming enumeration of non-empty, non-comment commands. - public async IAsyncEnumerable EnumerateCommandsAsync( - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(Content)) yield break; - - using var reader = new StringReader(Content); - string? line; - - while (!cancellationToken.IsCancellationRequested && - (line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) - { - line = line.Trim(); - if (string.IsNullOrEmpty(line) || line.StartsWith(";", StringComparison.Ordinal)) - continue; - - yield return line; - } - } - } -} diff --git a/MakerPrompt.Shared/Utils/AppConfiguration.cs b/MakerPrompt.Shared/Utils/AppConfiguration.cs deleted file mode 100644 index 8c62d91..0000000 --- a/MakerPrompt.Shared/Utils/AppConfiguration.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace MakerPrompt.Shared.Utils -{ - public class AppConfiguration - { - public Theme Theme { get; set; } = Theme.Auto; - public string[] SupportedCultures { get; } = new string[] { "en-US", "de-DE", "tr-TR", "es-ES", "fr-FR", "it-IT", "pl-PL", "he-IL", "zh-CN" }; - public string Language { get; set; } = "en-US"; - public string FarmName { get; set; } = string.Empty; - public bool FarmModeEnabled { get; set; } = false; - public Guid? ActiveFarmId { get; set; } - public bool AnalyticsEnabled { get; set; } = true; - public bool EnableFilamentInventory { get; set; } = false; - public bool EnablePrintAnalytics { get; set; } = false; - public DateTime? LastUpdated { get; set; } - } -} diff --git a/MakerPrompt.Shared/_Imports.razor b/MakerPrompt.Shared/_Imports.razor deleted file mode 100644 index b75125d..0000000 --- a/MakerPrompt.Shared/_Imports.razor +++ /dev/null @@ -1,16 +0,0 @@ -@using System.ComponentModel -@using System.ComponentModel.DataAnnotations -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.Extensions.Localization -@using Microsoft.Extensions.Logging -@using MakerPrompt.Shared.Models -@using MakerPrompt.Shared.Infrastructure -@using MakerPrompt.Shared.Utils -@using MakerPrompt.Shared.Components -@using MakerPrompt.Shared.Components.Calculators -@using MakerPrompt.Shared.Properties -@using MakerPrompt.Shared.Services -@using BlazorBootstrap -@using EnumExtensions = MakerPrompt.Shared.Utils.EnumExtensions \ No newline at end of file diff --git a/MakerPrompt.Shared/usings.cs b/MakerPrompt.Shared/usings.cs deleted file mode 100644 index 7a3a9d7..0000000 --- a/MakerPrompt.Shared/usings.cs +++ /dev/null @@ -1,15 +0,0 @@ -global using static MakerPrompt.Shared.Utils.Enums; -global using System.Text; -global using System.Text.Json; -global using System.Text.RegularExpressions; -global using System.Text.Json.Serialization; -global using System.Reflection; -global using System.Numerics; -global using System.Net; -global using System.Net.Http.Headers; -global using MakerPrompt.Shared.Infrastructure; -global using MakerPrompt.Shared.Properties; -global using MakerPrompt.Shared.Services; -global using MakerPrompt.Shared.Models; -global using MakerPrompt.Shared.Utils; -global using Microsoft.JSInterop; \ No newline at end of file diff --git a/MakerPrompt.Tests/AnalyticsServiceTests.cs b/MakerPrompt.Tests/AnalyticsServiceTests.cs deleted file mode 100644 index 95ef6cb..0000000 --- a/MakerPrompt.Tests/AnalyticsServiceTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace MakerPrompt.Tests; - -public class AnalyticsServiceTests -{ - private static AnalyticsService Create() => - new(new InMemoryStorageProvider(), NullLogger.Instance); - - private static PrintJobUsageRecord BuildRecord( - Guid? printerId = null, - Guid? spoolId = null, - double estimated = 0, - double actual = 0, - TimeSpan? duration = null) => - new() - { - PrinterId = printerId ?? Guid.NewGuid(), - FilamentSpoolId = spoolId ?? Guid.NewGuid(), - JobName = "test-job", - EstimatedFilamentUsedGrams = estimated, - ActualFilamentUsedGrams = actual, - Duration = duration ?? TimeSpan.FromMinutes(30) - }; - - // ── Record / Read ── - - [Fact] - public async Task RecordUsageAsync_AddsToInMemoryList() - { - var svc = Create(); - await svc.RecordUsageAsync(BuildRecord()); - Assert.Single(svc.GetRecords()); - } - - [Fact] - public async Task RecordUsageAsync_MultipleRecords_AllAppear() - { - var svc = Create(); - await svc.RecordUsageAsync(BuildRecord()); - await svc.RecordUsageAsync(BuildRecord()); - await svc.RecordUsageAsync(BuildRecord()); - Assert.Equal(3, svc.GetRecords().Count); - } - - [Fact] - public async Task RecordUsageAsync_FiresAnalyticsUpdatedEvent() - { - var svc = Create(); - bool fired = false; - svc.AnalyticsUpdated += (_, _) => fired = true; - await svc.RecordUsageAsync(BuildRecord()); - Assert.True(fired); - } - - // ── Aggregates ── - - [Fact] - public async Task GetTotalPrintHours_SumsAllDurations() - { - var svc = Create(); - await svc.RecordUsageAsync(BuildRecord(duration: TimeSpan.FromHours(1))); - await svc.RecordUsageAsync(BuildRecord(duration: TimeSpan.FromHours(2))); - Assert.Equal(TimeSpan.FromHours(3), svc.GetTotalPrintHours()); - } - - [Fact] - public async Task GetTotalFilamentConsumed_PrefersActualOverEstimated() - { - var svc = Create(); - // actual > 0 → use actual (20g), not estimated (50g) - await svc.RecordUsageAsync(BuildRecord(estimated: 50, actual: 20)); - // actual == 0 → fall back to estimated (30g) - await svc.RecordUsageAsync(BuildRecord(estimated: 30, actual: 0)); - Assert.Equal(50.0, svc.GetTotalFilamentConsumed(), 2); - } - - [Fact] - public async Task GetFilamentConsumedByPrinter_FiltersCorrectly() - { - var svc = Create(); - var printerId = Guid.NewGuid(); - await svc.RecordUsageAsync(BuildRecord(printerId: printerId, actual: 15)); - await svc.RecordUsageAsync(BuildRecord(actual: 25)); // different printer - Assert.Equal(15.0, svc.GetFilamentConsumedByPrinter(printerId), 2); - } - - [Fact] - public async Task GetFilamentConsumedBySpool_FiltersCorrectly() - { - var svc = Create(); - var spoolId = Guid.NewGuid(); - await svc.RecordUsageAsync(BuildRecord(spoolId: spoolId, actual: 10)); - await svc.RecordUsageAsync(BuildRecord(actual: 40)); // different spool - Assert.Equal(10.0, svc.GetFilamentConsumedBySpool(spoolId), 2); - } - - [Fact] - public void GetRecords_EmptyService_ReturnsEmpty() - { - var svc = Create(); - Assert.Empty(svc.GetRecords()); - } - - [Fact] - public void GetTotalPrintHours_EmptyService_ReturnsZero() - { - var svc = Create(); - Assert.Equal(TimeSpan.Zero, svc.GetTotalPrintHours()); - } -} diff --git a/MakerPrompt.Tests/FilamentInventoryServiceTests.cs b/MakerPrompt.Tests/FilamentInventoryServiceTests.cs deleted file mode 100644 index 7206cc6..0000000 --- a/MakerPrompt.Tests/FilamentInventoryServiceTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace MakerPrompt.Tests; - -public class FilamentInventoryServiceTests -{ - private static FilamentInventoryService Create() => - new(new InMemoryStorageProvider(), NullLogger.Instance); - - private static FilamentSpool BuildSpool(string name = "PLA White", double remaining = 800) => - new() { Name = name, Material = "PLA", TotalWeightGrams = 1000, RemainingWeightGrams = remaining }; - - // ── Add / Get ── - - [Fact] - public async Task AddSpoolAsync_AppearsInGetSpools() - { - var svc = Create(); - var spool = BuildSpool(); - await svc.AddSpoolAsync(spool); - Assert.Single(svc.GetSpools()); - Assert.Equal("PLA White", svc.GetSpools()[0].Name); - } - - [Fact] - public async Task AddSpoolAsync_MultipleSpools_AllVisible() - { - var svc = Create(); - await svc.AddSpoolAsync(BuildSpool("PLA Red")); - await svc.AddSpoolAsync(BuildSpool("PETG Black")); - Assert.Equal(2, svc.GetSpools().Count); - } - - [Fact] - public async Task GetSpool_ExistingId_ReturnsCorrectSpool() - { - var svc = Create(); - var spool = BuildSpool("ABS Blue"); - await svc.AddSpoolAsync(spool); - var retrieved = svc.GetSpool(spool.Id); - Assert.NotNull(retrieved); - Assert.Equal("ABS Blue", retrieved.Name); - } - - [Fact] - public async Task GetSpool_UnknownId_ReturnsNull() - { - var svc = Create(); - Assert.Null(svc.GetSpool(Guid.NewGuid())); - } - - // ── Update ── - - [Fact] - public async Task UpdateSpoolAsync_ChangesFieldsInPlace() - { - var svc = Create(); - var spool = BuildSpool("Old Name"); - await svc.AddSpoolAsync(spool); - - spool.Name = "New Name"; - spool.RemainingWeightGrams = 500; - await svc.UpdateSpoolAsync(spool); - - var updated = svc.GetSpool(spool.Id); - Assert.NotNull(updated); - Assert.Equal("New Name", updated.Name); - Assert.Equal(500, updated.RemainingWeightGrams); - } - - // ── Delete ── - - [Fact] - public async Task DeleteSpoolAsync_RemovesFromList() - { - var svc = Create(); - var spool = BuildSpool(); - await svc.AddSpoolAsync(spool); - await svc.DeleteSpoolAsync(spool.Id); - Assert.Empty(svc.GetSpools()); - } - - [Fact] - public async Task DeleteSpoolAsync_UnknownId_DoesNotThrow() - { - var svc = Create(); - // Should be a no-op - await svc.DeleteSpoolAsync(Guid.NewGuid()); - } - - // ── Deduct ── - - [Fact] - public async Task DeductFilamentAsync_ReducesRemainingWeight() - { - var svc = Create(); - var spool = BuildSpool(remaining: 500); - await svc.AddSpoolAsync(spool); - - await svc.DeductFilamentAsync(spool.Id, 100); - - var updated = svc.GetSpool(spool.Id); - Assert.NotNull(updated); - Assert.Equal(400, updated.RemainingWeightGrams); - } - - [Fact] - public async Task DeductFilamentAsync_MoreThanRemaining_ClampsAtZero() - { - var svc = Create(); - var spool = BuildSpool(remaining: 50); - await svc.AddSpoolAsync(spool); - - await svc.DeductFilamentAsync(spool.Id, 200); - - var updated = svc.GetSpool(spool.Id); - Assert.NotNull(updated); - Assert.Equal(0, updated.RemainingWeightGrams); - } - - // ── Events ── - - [Fact] - public async Task AddSpoolAsync_FiresInventoryChangedEvent() - { - var svc = Create(); - bool fired = false; - svc.InventoryChanged += (_, _) => fired = true; - await svc.AddSpoolAsync(BuildSpool()); - Assert.True(fired); - } - - [Fact] - public async Task DeleteSpoolAsync_FiresInventoryChangedEvent() - { - var svc = Create(); - var spool = BuildSpool(); - await svc.AddSpoolAsync(spool); - bool fired = false; - svc.InventoryChanged += (_, _) => fired = true; - await svc.DeleteSpoolAsync(spool.Id); - Assert.True(fired); - } -} diff --git a/MakerPrompt.Tests/Helpers/InMemoryStorageProvider.cs b/MakerPrompt.Tests/Helpers/InMemoryStorageProvider.cs deleted file mode 100644 index 67d9d8b..0000000 --- a/MakerPrompt.Tests/Helpers/InMemoryStorageProvider.cs +++ /dev/null @@ -1,50 +0,0 @@ -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Models; - -namespace MakerPrompt.Tests; - -/// -/// In-memory implementation of IAppLocalStorageProvider used by unit tests. -/// -internal sealed class InMemoryStorageProvider : IAppLocalStorageProvider -{ - private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); - - public string DisplayName => "Test"; - public string Key => "test"; - public string RootPath => "/test/"; - - public Task> ListFilesAsync(CancellationToken cancellationToken = default) - { - var entries = _files.Select(kv => new FileEntry - { - FullPath = kv.Key, - Size = kv.Value.Length, - IsAvailable = true - }).ToList(); - return Task.FromResult(entries); - } - - public Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default) - { - if (_files.TryGetValue(fullPath, out var bytes)) - return Task.FromResult(new MemoryStream(bytes)); - return Task.FromResult(null); - } - - public async Task SaveFileAsync(string fullPath, Stream content, CancellationToken cancellationToken = default) - { - using var ms = new MemoryStream(); - await content.CopyToAsync(ms, cancellationToken); - _files[fullPath] = ms.ToArray(); - } - - public Task DeleteFileAsync(string fullPath, CancellationToken cancellationToken = default) - { - _files.Remove(fullPath); - return Task.CompletedTask; - } - - public string GetString(string path) - => _files.TryGetValue(path, out var b) ? System.Text.Encoding.UTF8.GetString(b) : string.Empty; -} diff --git a/MakerPrompt.Tests/PrintProjectServiceTests.cs b/MakerPrompt.Tests/PrintProjectServiceTests.cs deleted file mode 100644 index 79be0b4..0000000 --- a/MakerPrompt.Tests/PrintProjectServiceTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace MakerPrompt.Tests; - -public class PrintProjectServiceTests -{ - private static PrintProjectService Create() => - new(new InMemoryStorageProvider(), NullLogger.Instance); - - private static Stream TextStream(string text) => - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text)); - - // ── Project CRUD ── - - [Fact] - public async Task AddProjectAsync_AppearsInProjects() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("My Project"); - Assert.Single(svc.Projects); - Assert.Equal("My Project", svc.Projects[0].Name); - } - - [Fact] - public async Task AddProjectAsync_TrimsName() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync(" Trimmed "); - Assert.Equal("Trimmed", svc.Projects[0].Name); - } - - [Fact] - public async Task RenameProjectAsync_UpdatesName() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("Original"); - var id = svc.Projects[0].Id; - - await svc.RenameProjectAsync(id, "Renamed"); - Assert.Equal("Renamed", svc.Projects[0].Name); - } - - [Fact] - public async Task RenameProjectAsync_UnknownId_IsNoOp() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.RenameProjectAsync(Guid.NewGuid(), "Should not throw"); - Assert.Empty(svc.Projects); - } - - [Fact] - public async Task DeleteProjectAsync_RemovesFromList() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("To Delete"); - var id = svc.Projects[0].Id; - - await svc.DeleteProjectAsync(id); - Assert.Empty(svc.Projects); - } - - [Fact] - public async Task ProjectsChanged_FiredOnAdd() - { - var svc = Create(); - await svc.InitializeAsync(); - bool fired = false; - svc.ProjectsChanged += (_, _) => fired = true; - await svc.AddProjectAsync("Test"); - Assert.True(fired); - } - - // ── Job management ── - - [Fact] - public async Task AddJobAsync_AppearsInProjectJobs() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("Project"); - var projectId = svc.Projects[0].Id; - - await svc.AddJobAsync(projectId, "benchy.gcode", TextStream("G28\nM104 S200")); - - Assert.Single(svc.Projects[0].Jobs); - Assert.Equal("benchy.gcode", svc.Projects[0].Jobs[0].FileName); - } - - [Fact] - public async Task AddJobAsync_UnknownProject_ThrowsInvalidOperation() - { - var svc = Create(); - await svc.InitializeAsync(); - await Assert.ThrowsAsync( - () => svc.AddJobAsync(Guid.NewGuid(), "test.gcode", TextStream("G28"))); - } - - [Fact] - public async Task RemoveJobAsync_RemovesJobFromProject() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("Project"); - var projectId = svc.Projects[0].Id; - await svc.AddJobAsync(projectId, "test.gcode", TextStream("G28")); - var jobId = svc.Projects[0].Jobs[0].Id; - - await svc.RemoveJobAsync(projectId, jobId); - Assert.Empty(svc.Projects[0].Jobs); - } - - [Fact] - public async Task AssignJobAsync_SetsPrinterAndPrintingStatus() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("Project"); - var projectId = svc.Projects[0].Id; - await svc.AddJobAsync(projectId, "test.gcode", TextStream("G28")); - var job = svc.Projects[0].Jobs[0]; - var printerId = Guid.NewGuid(); - - await svc.AssignJobAsync(projectId, job.Id, printerId, "Prusa MK4"); - - var assigned = svc.Projects[0].Jobs[0]; - Assert.Equal(printerId, assigned.AssignedPrinterId); - Assert.Equal("Prusa MK4", assigned.AssignedPrinterName); - Assert.Equal(PrintJobStatus.Printing, assigned.Status); - } - - [Fact] - public async Task UpdateJobStatusAsync_ChangesStatus() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("Project"); - var projectId = svc.Projects[0].Id; - await svc.AddJobAsync(projectId, "test.gcode", TextStream("G28")); - var jobId = svc.Projects[0].Jobs[0].Id; - - await svc.UpdateJobStatusAsync(projectId, jobId, PrintJobStatus.Completed); - Assert.Equal(PrintJobStatus.Completed, svc.Projects[0].Jobs[0].Status); - } - - [Fact] - public async Task OpenJobFileAsync_ReturnsStoredContent() - { - var svc = Create(); - await svc.InitializeAsync(); - await svc.AddProjectAsync("Project"); - var projectId = svc.Projects[0].Id; - const string gcode = "G28\nM104 S200\nM109 S200"; - await svc.AddJobAsync(projectId, "test.gcode", TextStream(gcode)); - var jobId = svc.Projects[0].Jobs[0].Id; - - await using var stream = await svc.OpenJobFileAsync(projectId, jobId); - Assert.NotNull(stream); - using var reader = new StreamReader(stream); - var content = await reader.ReadToEndAsync(); - Assert.Equal(gcode, content); - } - - [Fact] - public async Task OpenJobFileAsync_UnknownJob_ReturnsNull() - { - var svc = Create(); - await svc.InitializeAsync(); - var result = await svc.OpenJobFileAsync(Guid.NewGuid(), Guid.NewGuid()); - Assert.Null(result); - } -} diff --git a/MakerPrompt.Tests/ThemeServiceTests.cs b/MakerPrompt.Tests/ThemeServiceTests.cs deleted file mode 100644 index 9bc591f..0000000 --- a/MakerPrompt.Tests/ThemeServiceTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using Microsoft.JSInterop; -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; -using MakerPrompt.Shared.Utils; -using static MakerPrompt.Shared.Utils.Enums; - -namespace MakerPrompt.Tests; - -public class ThemeServiceTests -{ - // ── Disposal ────────────────────────────────────────────────────────────── - - [Fact] - public async Task DisposeAsync_DoesNotThrow_WhenJSModuleThrowsJSException() - { - // Reproduces: switching language triggers page reload which disposes - // ThemeService while the JS side is already gone. - var module = new FakeJSModule(throwsOnDispose: true); - var service = BuildService(module); - await service.InitializeAsync(); - - var ex = await Record.ExceptionAsync(() => service.DisposeAsync().AsTask()); - - Assert.Null(ex); - } - - [Fact] - public async Task DisposeAsync_DoesNotThrow_WhenModuleNotYetInitialized() - { - var service = BuildService(new FakeJSModule()); - // InitializeAsync NOT called — _moduleTask.IsValueCreated == false - - var ex = await Record.ExceptionAsync(() => service.DisposeAsync().AsTask()); - - Assert.Null(ex); - } - - [Fact] - public async Task DisposeAsync_DoesNotThrow_OnSecondCall() - { - var service = BuildService(new FakeJSModule()); - await service.InitializeAsync(); - await service.DisposeAsync(); - - var ex = await Record.ExceptionAsync(() => service.DisposeAsync().AsTask()); - - Assert.Null(ex); - } - - // ── Theme state ─────────────────────────────────────────────────────────── - - [Fact] - public async Task InitializeAsync_SetsCurrentThemeFromConfig() - { - var config = new FakeConfigService { InitialTheme = Theme.Dark }; - var service = new ThemeService(new FakeJSRuntime(new FakeJSModule()), config); - - await service.InitializeAsync(); - - Assert.Equal(Theme.Dark, service.CurrentTheme); - } - - [Fact] - public async Task SetThemeAsync_UpdatesCurrentTheme() - { - var service = BuildService(new FakeJSModule()); - await service.InitializeAsync(); - - await service.SetThemeAsync(Theme.Dark); - - Assert.Equal(Theme.Dark, service.CurrentTheme); - } - - [Fact] - public async Task SetThemeAsync_RaisesOnThemeChanged() - { - var service = BuildService(new FakeJSModule()); - await service.InitializeAsync(); - var raised = false; - service.OnThemeChanged += () => raised = true; - - await service.SetThemeAsync(Theme.Light); - - Assert.True(raised); - } - - // ── System theme change ─────────────────────────────────────────────────── - - [Fact] - public async Task HandleSystemThemeChange_RaisesOnThemeChanged_WhenModeIsAuto() - { - var service = BuildService(new FakeJSModule()); - await service.InitializeAsync(); - await service.SetThemeAsync(Theme.Auto); - var raised = false; - service.OnThemeChanged += () => raised = true; - - await service.HandleSystemThemeChange(isDark: true); - - Assert.True(raised); - } - - [Fact] - public async Task HandleSystemThemeChange_DoesNotRaiseOnThemeChanged_WhenModeIsNotAuto() - { - var service = BuildService(new FakeJSModule()); - await service.InitializeAsync(); - await service.SetThemeAsync(Theme.Dark); - var raised = false; - service.OnThemeChanged += () => raised = true; - - await service.HandleSystemThemeChange(isDark: true); - - Assert.False(raised); - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - private static ThemeService BuildService(FakeJSModule module) - => new(new FakeJSRuntime(module), new FakeConfigService()); - - private sealed class FakeJSModule : IJSObjectReference - { - private readonly bool _throwsOnDispose; - - public FakeJSModule(bool throwsOnDispose = false) => _throwsOnDispose = throwsOnDispose; - - public ValueTask InvokeAsync(string identifier, object?[]? args) - { - if (_throwsOnDispose && identifier == "dispose") - return ValueTask.FromException( - new JSException("JS object instance with ID 1 does not exist (has it been disposed?).")); - - if (typeof(TValue) == typeof(bool)) - return ValueTask.FromResult((TValue)(object)false); - - return ValueTask.FromResult(default(TValue)!); - } - - public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) - => InvokeAsync(identifier, args); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } - - private sealed class FakeJSRuntime : IJSRuntime - { - private readonly FakeJSModule _module; - - public FakeJSRuntime(FakeJSModule module) => _module = module; - - public ValueTask InvokeAsync<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers( - System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors | - System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicFields | - System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] TValue>( - string identifier, object?[]? args) - => ValueTask.FromResult((TValue)(object)_module); - - public ValueTask InvokeAsync<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers( - System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors | - System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicFields | - System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] TValue>( - string identifier, CancellationToken cancellationToken, object?[]? args) - => InvokeAsync(identifier, args); - } - - private sealed class FakeConfigService : IAppConfigurationService - { - public Theme InitialTheme { get; init; } = Theme.Auto; - public AppConfiguration Configuration => _config ??= new AppConfiguration { Theme = InitialTheme }; - private AppConfiguration? _config; - - public Task InitializeAsync() => Task.CompletedTask; - public Task SaveConfigurationAsync() => Task.CompletedTask; - public Task ResetToDefaultsAsync() => Task.CompletedTask; - } -} diff --git a/MakerPrompt.sln b/MakerPrompt.sln index 210fac7..9b19c6d 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -1,14 +1,8 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35913.81 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11822.322 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Shared", "MakerPrompt.Shared\MakerPrompt.Shared.csproj", "{1659CECC-713A-4F76-B7F3-4D74C0603E97}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Blazor", "MakerPrompt.Blazor\MakerPrompt.Blazor.csproj", "{15F52CFF-63A3-427D-8795-95370626C764}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.MAUI", "MakerPrompt.MAUI\MakerPrompt.MAUI.csproj", "{67704D6B-0609-472D-A78A-473B780D4D1A}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject .github\workflows\azure-static-web-apps-yellow-sea-04668c503.yml = .github\workflows\azure-static-web-apps-yellow-sea-04668c503.yml @@ -25,11 +19,31 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gh-pages", "gh-pages", "{02 .github\workflows\gh-pages\index.html = .github\workflows\gh-pages\index.html EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests", "MakerPrompt.Tests\MakerPrompt.Tests.csproj", "{7BD83CCA-F9F2-4B00-B373-209946049150}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B4F2D4E1-0000-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.UI.Components", "src\MakerPrompt.UI.Components\MakerPrompt.UI.Components.csproj", "{B4F2D4E1-0007-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.Core", "src\MakerPrompt.Core\MakerPrompt.Core.csproj", "{F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A3D968B3-E563-4E96-9E66-9273E6D29024}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests.Unit", "tests\MakerPrompt.Tests.Unit\MakerPrompt.Tests.Unit.csproj", "{E8B1509E-7390-FDF0-EEA5-664EE640E08C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Tests.E2E.Wasm", "tests\MakerPrompt.Tests.E2E.Wasm\MakerPrompt.Tests.E2E.Wasm.csproj", "{42B6100C-D9D6-C74B-8220-3255CAF41D09}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Test.E2E.Maui", "tests\MakerPrompt.Tests.E2E.Maui\MakerPrompt.Test.E2E.Maui.csproj", "{50C312A0-A718-950E-425B-B6EACF134401}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Cloud", "src\MakerPrompt.Cloud\MakerPrompt.Cloud.csproj", "{EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.EdgeAgent", "src\MakerPrompt.EdgeAgent\MakerPrompt.EdgeAgent.csproj", "{7E522ABD-C347-97F1-AD17-2B2177D780B8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Wasm", "MakerPrompt.E2E.Wasm\MakerPrompt.E2E.Wasm.csproj", "{73B2A329-BE69-4E67-912B-0CB2E65088E2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.UI.Blazor", "src\MakerPrompt.UI.Blazor\MakerPrompt.UI.Blazor.csproj", "{2BC29A9D-C78C-FAC4-90DF-482450FFD80D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.E2E.Maui", "MakerPrompt.E2E.Maui\MakerPrompt.E2E.Maui.csproj", "{A03CF971-E571-4E00-849E-48675DAB24D7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MakerPrompt.Infrastructure.Sqlite", "src\MakerPrompt.Infrastructure.Sqlite\MakerPrompt.Infrastructure.Sqlite.csproj", "{18375969-1461-7C9D-5F1E-D078436D3114}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -41,85 +55,156 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Debug|x64.ActiveCfg = Debug|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Debug|x64.Build.0 = Debug|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Debug|x86.ActiveCfg = Debug|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Debug|x86.Build.0 = Debug|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|Any CPU.Build.0 = Release|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|x64.ActiveCfg = Release|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|x64.Build.0 = Release|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|x86.ActiveCfg = Release|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|x86.Build.0 = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Debug|Any CPU.Build.0 = Debug|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Debug|x64.ActiveCfg = Debug|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Debug|x64.Build.0 = Debug|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Debug|x86.ActiveCfg = Debug|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Debug|x86.Build.0 = Debug|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|Any CPU.ActiveCfg = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|Any CPU.Build.0 = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|x64.ActiveCfg = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|x64.Build.0 = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|x86.ActiveCfg = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|x86.Build.0 = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|x64.ActiveCfg = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|x64.Build.0 = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|x86.ActiveCfg = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Debug|x86.Build.0 = Debug|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|Any CPU.Build.0 = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|x64.ActiveCfg = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|x64.Build.0 = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|x86.ActiveCfg = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|x86.Build.0 = Release|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Debug|x64.ActiveCfg = Debug|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Debug|x64.Build.0 = Debug|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Debug|x86.ActiveCfg = Debug|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Debug|x86.Build.0 = Debug|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Release|Any CPU.Build.0 = Release|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Release|x64.ActiveCfg = Release|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Release|x64.Build.0 = Release|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Release|x86.ActiveCfg = Release|Any CPU - {7BD83CCA-F9F2-4B00-B373-209946049150}.Release|x86.Build.0 = Release|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Debug|x64.ActiveCfg = Debug|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Debug|x64.Build.0 = Debug|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Debug|x86.ActiveCfg = Debug|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Debug|x86.Build.0 = Debug|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Release|Any CPU.Build.0 = Release|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Release|x64.ActiveCfg = Release|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Release|x64.Build.0 = Release|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Release|x86.ActiveCfg = Release|Any CPU - {73B2A329-BE69-4E67-912B-0CB2E65088E2}.Release|x86.Build.0 = Release|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Debug|x64.ActiveCfg = Debug|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Debug|x64.Build.0 = Debug|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Debug|x86.ActiveCfg = Debug|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Debug|x86.Build.0 = Debug|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|Any CPU.Build.0 = Release|Any CPU - {A03CF971-E571-4E00-849E-48675DAB24D7}.Release|x64.ActiveCfg = Release|Any CPU - {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-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-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-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 + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Debug|x64.Build.0 = Debug|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Debug|x86.Build.0 = Debug|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Release|Any CPU.Build.0 = Release|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Release|x64.ActiveCfg = Release|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Release|x64.Build.0 = Release|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Release|x86.ActiveCfg = Release|Any CPU + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6}.Release|x86.Build.0 = Release|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Debug|x64.ActiveCfg = Debug|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Debug|x64.Build.0 = Debug|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Debug|x86.ActiveCfg = Debug|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Debug|x86.Build.0 = Debug|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Release|Any CPU.Build.0 = Release|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Release|x64.ActiveCfg = Release|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Release|x64.Build.0 = Release|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Release|x86.ActiveCfg = Release|Any CPU + {E8B1509E-7390-FDF0-EEA5-664EE640E08C}.Release|x86.Build.0 = Release|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Debug|x64.ActiveCfg = Debug|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Debug|x64.Build.0 = Debug|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Debug|x86.ActiveCfg = Debug|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Debug|x86.Build.0 = Debug|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Release|Any CPU.Build.0 = Release|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Release|x64.ActiveCfg = Release|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Release|x64.Build.0 = Release|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Release|x86.ActiveCfg = Release|Any CPU + {42B6100C-D9D6-C74B-8220-3255CAF41D09}.Release|x86.Build.0 = Release|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Debug|x64.ActiveCfg = Debug|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Debug|x64.Build.0 = Debug|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Debug|x86.ActiveCfg = Debug|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Debug|x86.Build.0 = Debug|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Release|Any CPU.Build.0 = Release|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Release|x64.ActiveCfg = Release|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Release|x64.Build.0 = Release|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Release|x86.ActiveCfg = Release|Any CPU + {50C312A0-A718-950E-425B-B6EACF134401}.Release|x86.Build.0 = Release|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Debug|x64.Build.0 = Debug|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Debug|x86.Build.0 = Debug|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Release|Any CPU.Build.0 = Release|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Release|x64.ActiveCfg = Release|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Release|x64.Build.0 = Release|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Release|x86.ActiveCfg = Release|Any CPU + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C}.Release|x86.Build.0 = Release|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Debug|x64.Build.0 = Debug|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Debug|x86.Build.0 = Debug|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Release|Any CPU.Build.0 = Release|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Release|x64.ActiveCfg = Release|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Release|x64.Build.0 = Release|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Release|x86.ActiveCfg = Release|Any CPU + {7E522ABD-C347-97F1-AD17-2B2177D780B8}.Release|x86.Build.0 = Release|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Debug|x64.ActiveCfg = Debug|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Debug|x64.Build.0 = Debug|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Debug|x86.ActiveCfg = Debug|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Debug|x86.Build.0 = Debug|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Release|Any CPU.Build.0 = Release|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Release|x64.ActiveCfg = Release|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Release|x64.Build.0 = Release|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Release|x86.ActiveCfg = Release|Any CPU + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D}.Release|x86.Build.0 = Release|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Debug|x64.ActiveCfg = Debug|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Debug|x64.Build.0 = Debug|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Debug|x86.ActiveCfg = Debug|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Debug|x86.Build.0 = Debug|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Release|Any CPU.Build.0 = Release|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Release|x64.ActiveCfg = Release|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Release|x64.Build.0 = Release|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.Release|x86.ActiveCfg = Release|Any CPU + {18375969-1461-7C9D-5F1E-D078436D3114}.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-0003-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0007-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {B4F2D4E1-0009-0000-0000-000000000001} = {B4F2D4E1-0000-0000-0000-000000000001} + {F5AE4DDA-B749-DB0A-C32C-3982C67CCFA6} = {B4F2D4E1-0000-0000-0000-000000000001} + {E8B1509E-7390-FDF0-EEA5-664EE640E08C} = {A3D968B3-E563-4E96-9E66-9273E6D29024} + {42B6100C-D9D6-C74B-8220-3255CAF41D09} = {A3D968B3-E563-4E96-9E66-9273E6D29024} + {50C312A0-A718-950E-425B-B6EACF134401} = {A3D968B3-E563-4E96-9E66-9273E6D29024} + {EC3F1A30-FCDB-4C9A-3925-4CAF6819DE6C} = {B4F2D4E1-0000-0000-0000-000000000001} + {7E522ABD-C347-97F1-AD17-2B2177D780B8} = {B4F2D4E1-0000-0000-0000-000000000001} + {2BC29A9D-C78C-FAC4-90DF-482450FFD80D} = {B4F2D4E1-0000-0000-0000-000000000001} + {18375969-1461-7C9D-5F1E-D078436D3114} = {B4F2D4E1-0000-0000-0000-000000000001} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d70d66a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +version: "3.9" + +# MakerPrompt local development stack. +# +# Usage: +# docker compose up # start everything +# docker compose up cloud # start only the Cloud API + UI +# docker compose up edge # start only the EdgeAgent +# +# To rebuild after code changes: +# docker compose build && docker compose up +# +# To configure a real printer edit the edge-agent section of this file +# or supply an appsettings.Production.json volume mount. + +services: + + # ── Cloud API + Blazor WASM UI ─────────────────────────────────────────────── + cloud: + build: + context: . + dockerfile: src/MakerPrompt.Cloud/Dockerfile.dev + image: makerprompt/cloud:dev + container_name: makerprompt-cloud + restart: unless-stopped + ports: + - "8080:8080" + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: "http://+:8080" + # Override in .env or compose.override.yml for production: + # MakerPrompt__Auth__Authority: https://your-oidc-provider/ + # MakerPrompt__Auth__Audience: https://api.makerprompt.io + # CloudApi__AgentApiKeyHash: + networks: + - makerprompt + + # ── EdgeAgent (local x64 dev mode) ────────────────────────────────────────── + # For ARM64 / Jetson Nano use: + # dockerfile: src/MakerPrompt.EdgeAgent/Dockerfile + edge: + build: + context: . + dockerfile: src/MakerPrompt.EdgeAgent/Dockerfile.dev + image: makerprompt/edge-agent:dev + container_name: makerprompt-edge + restart: unless-stopped + depends_on: + - cloud + environment: + ASPNETCORE_ENVIRONMENT: Development + # Point to the cloud container by service name. + CloudApi__BaseUrl: "http://cloud:8080" + # Set CloudApi__ApiToken to the plain-text key whose SHA-256 hash is in + # CloudApi__AgentApiKeyHash on the cloud service. + CloudApi__ApiToken: "" + # Configure printers via environment overrides or a volume-mounted + # appsettings.Production.json (recommended for real deployments). + # Example single-printer override: + # EdgeAgent__Printers__0__PrinterId: "printer-1" + # EdgeAgent__Printers__0__Protocol: "Moonraker" + # EdgeAgent__Printers__0__ApiUrl: "http://192.168.1.100" + networks: + - makerprompt + +networks: + makerprompt: + driver: bridge diff --git a/src/MakerPrompt.Cloud/Dockerfile b/src/MakerPrompt.Cloud/Dockerfile new file mode 100644 index 0000000..c212e3a --- /dev/null +++ b/src/MakerPrompt.Cloud/Dockerfile @@ -0,0 +1,36 @@ +# Cloud — Production (x64) +# Build: docker build -f Dockerfile -t makerprompt/cloud:latest . +# (Run from repo root) + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore layer — only copy project files first to cache packages. +COPY MakerPrompt.sln . +COPY src/MakerPrompt.Core/MakerPrompt.Core.csproj src/MakerPrompt.Core/ +COPY src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj src/MakerPrompt.Infrastructure/ +COPY src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj src/MakerPrompt.UI.Components/ +COPY src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj src/MakerPrompt.UI.Blazor/ +COPY src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj src/MakerPrompt.Cloud/ +RUN dotnet restore src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj + +# Publish (includes Blazor WASM bundle via project reference). +COPY src/ src/ +RUN dotnet publish src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained false \ + -o /app/publish + +# ── Runtime image ───────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime +WORKDIR /app + +RUN adduser --disabled-password --gecos "" appuser && chown appuser /app +USER appuser + +COPY --from=build /app/publish . + +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENTRYPOINT ["dotnet", "MakerPrompt.Cloud.dll"] diff --git a/src/MakerPrompt.Cloud/Dockerfile.dev b/src/MakerPrompt.Cloud/Dockerfile.dev new file mode 100644 index 0000000..4b07070 --- /dev/null +++ b/src/MakerPrompt.Cloud/Dockerfile.dev @@ -0,0 +1,30 @@ +# Cloud — Local dev (x64, hot-reload friendly) +# Build: docker build -f Dockerfile.dev -t makerprompt/cloud:dev . +# (Run from repo root) + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY MakerPrompt.sln . +COPY src/MakerPrompt.Core/MakerPrompt.Core.csproj src/MakerPrompt.Core/ +COPY src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj src/MakerPrompt.Infrastructure/ +COPY src/MakerPrompt.UI.Components/MakerPrompt.UI.Components.csproj src/MakerPrompt.UI.Components/ +COPY src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj src/MakerPrompt.UI.Blazor/ +COPY src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj src/MakerPrompt.Cloud/ +RUN dotnet restore src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj + +COPY src/ src/ +RUN dotnet publish src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj \ + -c Debug \ + -r linux-x64 \ + --self-contained false \ + -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Development +ENTRYPOINT ["dotnet", "MakerPrompt.Cloud.dll"] diff --git a/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj new file mode 100644 index 0000000..de88751 --- /dev/null +++ b/src/MakerPrompt.Cloud/MakerPrompt.Cloud.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.Cloud/Program.cs b/src/MakerPrompt.Cloud/Program.cs new file mode 100644 index 0000000..215bd45 --- /dev/null +++ b/src/MakerPrompt.Cloud/Program.cs @@ -0,0 +1,279 @@ +using System.Security.Cryptography; +using System.Text; +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.InMemoryStores; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; +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(); + +// Health checks — available at /health (no auth required). +builder.Services.AddHealthChecks(); + +// API Explorer for potential future Swagger integration. +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +// ── Middleware ──────────────────────────────────────────────────────────────── + +app.UseHttpsRedirection(); + +// ── Blazor WASM static files ────────────────────────────────────────────────── +// Serve the Blazor WASM app from wwwroot/ (published by UI.Blazor project reference). +app.UseBlazorFrameworkFiles(); +app.UseStaticFiles(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// ── Endpoints ───────────────────────────────────────────────────────────────── + +// Health check — public, no auth required. +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = async (ctx, report) => + { + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsJsonAsync(new + { + status = report.Status.ToString().ToLowerInvariant(), + utc = DateTimeOffset.UtcNow, + }); + } +}).AllowAnonymous(); + +// Ingest telemetry from an EdgeAgent. +// Accepts either a JWT with the "makerprompt:ingest" scope OR a pre-shared +// SHA-256 API key configured in CloudApi:AgentApiKeyHash. +app.MapPost("/api/telemetry/{printerId}", async ( + string printerId, + [FromBody] PrinterTelemetry telemetry, + HttpContext httpContext, + ITelemetryStore store, + IConfiguration config, + CancellationToken ct) => +{ + // ── SHA-256 API key auth (alternative to JWT) ───────────────────────── + var keyHash = config["CloudApi:AgentApiKeyHash"]; + if (!string.IsNullOrWhiteSpace(keyHash)) + { + var authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); + var token = authHeader?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) is true + ? authHeader[7..] + : null; + + if (token is null) + return Results.Unauthorized(); + + var actualHash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(); + + if (!actualHash.Equals(keyHash.ToLowerInvariant(), StringComparison.Ordinal)) + return Results.Unauthorized(); + } + + // ── Staleness guard ──────────────────────────────────────────────────── + // If the snapshot is older than 30 s, treat the printer as disconnected. + const int StaleThresholdSeconds = 30; + if ((DateTime.UtcNow - telemetry.CapturedAt).TotalSeconds > StaleThresholdSeconds) + telemetry.Status = PrinterStatus.Disconnected; + + 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 ─────────────────────────────────────────────────────────────────────── + +// Fallback — serve Blazor WASM for any non-API route (client-side routing). +app.MapFallbackToFile("index.html"); + +app.Run(); + +// Make the Program class visible for integration tests +public partial class Program { } diff --git a/src/MakerPrompt.Cloud/Properties/launchSettings.json b/src/MakerPrompt.Cloud/Properties/launchSettings.json new file mode 100644 index 0000000..8c5b2a9 --- /dev/null +++ b/src/MakerPrompt.Cloud/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "MakerPrompt.Cloud": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54778;http://localhost:54779" + } + } +} \ No newline at end of file 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..483b890 --- /dev/null +++ b/src/MakerPrompt.Core/Abstractions/IPrinterCommunicationService.cs @@ -0,0 +1,98 @@ +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; } + + /// + /// true when this backend supports sending arbitrary G-code commands + /// via and displaying the response in the command prompt. + /// Backends that communicate over read-only APIs (e.g. PrusaLink) should return false. + /// + bool SupportsCommandPrompt => true; + + // ── 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); + + /// Sends a G-code document to the printer for immediate streaming and printing. + Task StartPrintAsync(GCodeDoc gcode, 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/MakerPrompt.Shared/Models/FileEntry.cs b/src/MakerPrompt.Core/Models/FileEntry.cs similarity index 87% rename from MakerPrompt.Shared/Models/FileEntry.cs rename to src/MakerPrompt.Core/Models/FileEntry.cs index 72c3aa5..ef3f5ca 100644 --- a/MakerPrompt.Shared/Models/FileEntry.cs +++ b/src/MakerPrompt.Core/Models/FileEntry.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Models +namespace MakerPrompt.Core.Models { public class FileEntry { diff --git a/src/MakerPrompt.Core/Models/GCodeDoc.cs b/src/MakerPrompt.Core/Models/GCodeDoc.cs new file mode 100644 index 0000000..eb6a27e --- /dev/null +++ b/src/MakerPrompt.Core/Models/GCodeDoc.cs @@ -0,0 +1,27 @@ +using System.Runtime.CompilerServices; + +namespace MakerPrompt.Core.Models; + +/// Lightweight wrapper around G-code text content. +public readonly record struct GCodeDoc(string Content) +{ + /// Async, streaming enumeration of non-empty, non-comment G-code commands. + public async IAsyncEnumerable EnumerateCommandsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(Content)) yield break; + + using var reader = new StringReader(Content); + string? line; + + while (!cancellationToken.IsCancellationRequested && + (line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + line = line.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith(";", StringComparison.Ordinal)) + continue; + + yield return line; + } + } +} 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/MakerPrompt.Shared/Models/PrinterCamera.cs b/src/MakerPrompt.Core/Models/PrinterCamera.cs similarity index 95% rename from MakerPrompt.Shared/Models/PrinterCamera.cs rename to src/MakerPrompt.Core/Models/PrinterCamera.cs index 85314e1..5c78fa9 100644 --- a/MakerPrompt.Shared/Models/PrinterCamera.cs +++ b/src/MakerPrompt.Core/Models/PrinterCamera.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Models +namespace MakerPrompt.Core.Models { /// /// Neutral printer camera description consumed by UI components. 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..f75464b --- /dev/null +++ b/src/MakerPrompt.Core/Models/PrinterTelemetry.cs @@ -0,0 +1,82 @@ +using System.Numerics; + +namespace MakerPrompt.Core.Models; + +/// +/// Snapshot of live telemetry data received from a connected printer. +/// +public class PrinterTelemetry +{ + /// Last raw response line received from the printer (G-code terminal output). + public string LastResponse { get; set; } = string.Empty; + + /// Timestamp when the connection to this printer was established. + public DateTime? ConnectionTime { get; set; } + + /// Current print-head position reported by the printer. + public Vector3 Position { get; set; } + + /// SD card status reported by the printer. + public SDCardStatus SDCard { get; } = new(); + + /// 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; +} + +/// SD card status reported by firmware. +public class SDCardStatus +{ + /// Whether the SD card is present and mounted. + public bool Present { get; set; } + + /// Whether a print from SD is currently active. + public bool Printing { get; set; } + + /// SD print progress (0–100 %). + public double Progress { get; set; } +} diff --git a/src/MakerPrompt.EdgeAgent/Dockerfile b/src/MakerPrompt.EdgeAgent/Dockerfile new file mode 100644 index 0000000..7c0990f --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Dockerfile @@ -0,0 +1,38 @@ +# EdgeAgent — Production (ARM64 / Jetson Nano / Raspberry Pi) +# Build: docker build -f Dockerfile -t makerprompt/edge-agent:latest . +# (Run from repo root so the full src/ tree is in context) + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy solution and restore in a separate layer to cache NuGet packages. +COPY MakerPrompt.sln . +COPY src/MakerPrompt.Core/MakerPrompt.Core.csproj src/MakerPrompt.Core/ +COPY src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj src/MakerPrompt.Infrastructure/ +COPY src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj src/MakerPrompt.EdgeAgent/ +RUN dotnet restore src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj + +# Copy source and publish. +COPY src/ src/ +RUN dotnet publish src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj \ + -c Release \ + -r linux-arm64 \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:EnableCompressionInSingleFile=true \ + -o /app/publish + +# ── Runtime image ───────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-arm64v8 AS runtime +WORKDIR /app + +# Non-root user for security. +RUN adduser --disabled-password --gecos "" appuser && chown appuser /app +USER appuser + +COPY --from=build /app/publish . + +# Allow overriding config at runtime via environment variables or volume mounts. +# Example: docker run -v /path/to/appsettings.Production.json:/app/appsettings.Production.json +EXPOSE 8080 +ENTRYPOINT ["./MakerPrompt.EdgeAgent"] diff --git a/src/MakerPrompt.EdgeAgent/Dockerfile.dev b/src/MakerPrompt.EdgeAgent/Dockerfile.dev new file mode 100644 index 0000000..9c49d30 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Dockerfile.dev @@ -0,0 +1,26 @@ +# EdgeAgent — Local dev (Mac Intel x64 / Linux x64) +# Build: docker build -f Dockerfile.dev -t makerprompt/edge-agent:dev . +# (Run from repo root) + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY MakerPrompt.sln . +COPY src/MakerPrompt.Core/MakerPrompt.Core.csproj src/MakerPrompt.Core/ +COPY src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj src/MakerPrompt.Infrastructure/ +COPY src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj src/MakerPrompt.EdgeAgent/ +RUN dotnet restore src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj + +COPY src/ src/ +RUN dotnet publish src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj \ + -c Debug \ + -r linux-x64 \ + --self-contained true \ + -p:PublishSingleFile=true \ + -o /app/publish + +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS runtime +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["./MakerPrompt.EdgeAgent"] diff --git a/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj b/src/MakerPrompt.EdgeAgent/MakerPrompt.EdgeAgent.csproj new file mode 100644 index 0000000..fe5efb4 --- /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/Models/PrinterConfig.cs b/src/MakerPrompt.EdgeAgent/Models/PrinterConfig.cs new file mode 100644 index 0000000..2f20298 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Models/PrinterConfig.cs @@ -0,0 +1,23 @@ +namespace MakerPrompt.EdgeAgent.Models; + +/// +/// Represents a single printer entry from EdgeAgent:Printers in appsettings. +/// +public sealed class PrinterConfig +{ + public string PrinterId { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + + /// + /// Protocol name matching : + /// Demo | Moonraker | PrusaLink | PrusaConnect | BambuLab | OctoPrint + /// + public string Protocol { get; set; } = "Demo"; + + public string ApiUrl { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + + /// Optional MJPEG snapshot URL for this printer's camera. + public string CameraUrl { get; set; } = string.Empty; +} diff --git a/src/MakerPrompt.EdgeAgent/Program.cs b/src/MakerPrompt.EdgeAgent/Program.cs new file mode 100644 index 0000000..f0788a0 --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Program.cs @@ -0,0 +1,105 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.EdgeAgent.Models; +using MakerPrompt.EdgeAgent.Workers; +using MakerPrompt.Infrastructure.InMemoryStores; +using MakerPrompt.Infrastructure.Services; +using MakerPrompt.Infrastructure.Services.Printers; +using MakerPrompt.Infrastructure.Utils; + +// Disambiguate: both Core.Abstractions and Infrastructure.Printers define IPrinterCommunicationService +using IPrinterService = MakerPrompt.Core.Abstractions.IPrinterCommunicationService; + +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(); + +// ── Cloud client (optional — only registered when CloudApi:BaseUrl is set) ──── +var cloudBaseUrl = configuration["CloudApi:BaseUrl"]; +var cloudApiToken = configuration["CloudApi:ApiToken"]; +if (!string.IsNullOrWhiteSpace(cloudBaseUrl)) +{ + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri(cloudBaseUrl.TrimEnd('/') + "/"); + if (!string.IsNullOrWhiteSpace(cloudApiToken)) + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", cloudApiToken); + }); + builder.Services.AddSingleton(sp => + sp.GetRequiredService()); +} + +// ── 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 ───────────────────────────────────────────────────────────────────── +var host = builder.Build(); + +// ── Bootstrap printers from EdgeAgent:Printers config ──────────────────────── +var fleet = host.Services.GetRequiredService(); +var startupLogger = host.Services.GetRequiredService>(); +var printerConfigs = configuration.GetSection("EdgeAgent:Printers") + .Get>() ?? []; + +foreach (var cfg in printerConfigs) +{ + if (string.IsNullOrWhiteSpace(cfg.PrinterId)) + { + startupLogger.LogWarning("Printer entry missing PrinterId — skipping"); + continue; + } + + if (!Enum.TryParse(cfg.Protocol, ignoreCase: true, out var connectionType)) + { + startupLogger.LogWarning( + "Unknown protocol '{Protocol}' for printer {PrinterId} — skipping", + cfg.Protocol, cfg.PrinterId); + continue; + } + + IPrinterService service = connectionType switch + { + PrinterConnectionType.Moonraker => new MoonrakerApiService(), + PrinterConnectionType.PrusaLink => new PrusaLinkApiService(), + PrinterConnectionType.PrusaConnect => new PrusaConnectPrinterService(), + PrinterConnectionType.BambuLab => new BambuLabApiService(), + PrinterConnectionType.OctoPrint => new OctoPrintApiService(), + _ => new DemoPrinterService(), + }; + + var settings = new PrinterConnectionSettings + { + ConnectionType = connectionType, + ApiUrl = cfg.ApiUrl, + UserName = cfg.UserName, + Password = cfg.Password, + }; + + startupLogger.LogInformation( + "Connecting printer {PrinterId} ({Protocol}) at {Url}", + cfg.PrinterId, cfg.Protocol, cfg.ApiUrl); + + var connected = await fleet.AddAndConnectAsync(cfg.PrinterId, service, settings); + if (!connected) + startupLogger.LogWarning( + "Initial connection failed for printer {PrinterId} — will retry on next poll", + cfg.PrinterId); +} + +// ── Run ─────────────────────────────────────────────────────────────────────── +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..dbb215f --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Workers/CameraPollingWorker.cs @@ -0,0 +1,138 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; +using MakerPrompt.Infrastructure.Utils; + +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 EdgeAgent:Printers entries that have a CameraUrl. + var printersSection = configuration.GetSection("EdgeAgent:Printers"); + foreach (var printer in printersSection.GetChildren()) + { + var cameraId = printer["PrinterId"] ?? string.Empty; + var label = printer["Label"] ?? cameraId; + var url = printer["CameraUrl"] ?? string.Empty; + + if (string.IsNullOrWhiteSpace(cameraId) || string.IsNullOrWhiteSpace(url)) + 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..403ec2f --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/Workers/PrinterPollingWorker.cs @@ -0,0 +1,148 @@ +using MakerPrompt.Core.Abstractions; +using MakerPrompt.Core.Models; +using MakerPrompt.Infrastructure.Services; + +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. +/// After consecutive failures the printer is +/// marked offline in the store and the cloud is notified. +/// +public sealed class PrinterPollingWorker : BackgroundService +{ + private const int OfflineThreshold = 3; + + private readonly PrinterFleetService _fleet; + private readonly ITelemetryStore _store; + private readonly IEdgeAgentClient? _cloudClient; + private readonly ILogger _logger; + private readonly TimeSpan _pollInterval; + + // Tracks consecutive poll failures per printer. + private readonly Dictionary _failureCounts = new(); + + public PrinterPollingWorker( + PrinterFleetService fleet, + ITelemetryStore store, + ILogger logger, + IConfiguration configuration, + IEdgeAgentClient? cloudClient = null) + { + _fleet = fleet; + _store = store; + _logger = logger; + _cloudClient = cloudClient; + + 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) continue; + + // If the service reports disconnected, count that as a failure without polling. + if (!service.IsConnected) + { + await RecordFailureAsync(printerId, cancellationToken); + continue; + } + + try + { + var telemetry = await service.GetTelemetryAsync(cancellationToken); + await _store.SaveAsync(printerId, telemetry, cancellationToken); + + // Successful poll — reset failure counter and push to cloud. + _failureCounts[printerId] = 0; + await TrySendToCloudAsync(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); + await RecordFailureAsync(printerId, cancellationToken); + } + } + } + + private async Task RecordFailureAsync(string printerId, CancellationToken cancellationToken) + { + _failureCounts.TryGetValue(printerId, out var count); + _failureCounts[printerId] = ++count; + + if (count < OfflineThreshold) return; + + // Hit the threshold — mark offline locally and notify cloud. + _logger.LogWarning( + "Printer {PrinterId} unreachable after {Count} consecutive failures — marking offline", + printerId, count); + + var offlineTelemetry = new PrinterTelemetry + { + Status = PrinterStatus.Disconnected, + CapturedAt = DateTime.UtcNow, + }; + + try + { + await _store.SaveAsync(printerId, offlineTelemetry, cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to persist offline telemetry for {PrinterId}", printerId); + } + + await TrySendToCloudAsync(printerId, offlineTelemetry, cancellationToken); + } + + private async Task TrySendToCloudAsync( + string printerId, + PrinterTelemetry telemetry, + CancellationToken cancellationToken) + { + if (_cloudClient is null) return; + + try + { + await _cloudClient.SendTelemetryAsync(printerId, telemetry, cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Cloud push failed for printer {PrinterId}", printerId); + } + } +} + diff --git a/src/MakerPrompt.EdgeAgent/appsettings.json b/src/MakerPrompt.EdgeAgent/appsettings.json new file mode 100644 index 0000000..47cf70d --- /dev/null +++ b/src/MakerPrompt.EdgeAgent/appsettings.json @@ -0,0 +1,28 @@ +{ + "Logging": { + "Console": { "FormatterName": "json" }, + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "EdgeAgent": { + "PollIntervalSeconds": 5, + "CameraIntervalSeconds": 10, + "Printers": [ + { + "PrinterId": "printer-1", + "Label": "Moonraker Printer 1", + "Protocol": "Moonraker", + "ApiUrl": "http://192.168.1.100", + "UserName": "", + "Password": "", + "CameraUrl": "" + } + ] + }, + "CloudApi": { + "BaseUrl": "http://localhost:8080", + "ApiToken": "" + } +} 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/InMemoryStores/InMemoryCameraSnapshotStore.cs b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryCameraSnapshotStore.cs new file mode 100644 index 0000000..3b8649d --- /dev/null +++ b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryCameraSnapshotStore.cs @@ -0,0 +1,76 @@ +namespace MakerPrompt.Infrastructure.InMemoryStores; + +/// +/// 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. +/// +/// Maximum snapshots retained per camera (default 50). +public sealed class InMemoryCameraSnapshotStore(int maxPerCamera = 50) : ICameraSnapshotStore +{ + private readonly int _maxPerCamera = maxPerCamera; + private readonly Dictionary> _data = []; + private readonly SemaphoreSlim _lock = new(1, 1); + + 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/InMemoryStores/InMemoryFarmRepository.cs b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryFarmRepository.cs new file mode 100644 index 0000000..318c99f --- /dev/null +++ b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryFarmRepository.cs @@ -0,0 +1,38 @@ +namespace MakerPrompt.Infrastructure.InMemoryStores; + +/// +/// Thread-safe, in-memory implementation of . +/// +public sealed class InMemoryFarmRepository : IFarmRepository +{ + private readonly Dictionary _farms = []; + 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/InMemoryStores/InMemoryPrintProjectRepository.cs b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryPrintProjectRepository.cs new file mode 100644 index 0000000..155dec6 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryPrintProjectRepository.cs @@ -0,0 +1,73 @@ +namespace MakerPrompt.Infrastructure.InMemoryStores; + +/// +/// 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 = []; + private readonly Dictionary _files = []; + 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/InMemoryStores/InMemoryTelemetryStore.cs b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryTelemetryStore.cs new file mode 100644 index 0000000..52d02df --- /dev/null +++ b/src/MakerPrompt.Infrastructure/InMemoryStores/InMemoryTelemetryStore.cs @@ -0,0 +1,68 @@ +namespace MakerPrompt.Infrastructure.InMemoryStores; + +/// +/// In-memory implementation of . +/// Retains the last N snapshots per printer in a bounded ring buffer. +/// Suitable for development; does not survive process restart. +/// +/// Maximum snapshots retained per printer ID (default 500). +public sealed class InMemoryTelemetryStore(int maxPerPrinter = 500) : ITelemetryStore +{ + private readonly int _maxPerPrinter = maxPerPrinter; + private readonly Dictionary> _data = []; + private readonly SemaphoreSlim _lock = new(1, 1); + + 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, + 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.Infrastructure/MakerPrompt.Infrastructure.csproj b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj new file mode 100644 index 0000000..553495c --- /dev/null +++ b/src/MakerPrompt.Infrastructure/MakerPrompt.Infrastructure.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/MakerPrompt.Infrastructure/Services/FarmService.cs b/src/MakerPrompt.Infrastructure/Services/FarmService.cs new file mode 100644 index 0000000..e0dd488 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Services/FarmService.cs @@ -0,0 +1,97 @@ +namespace MakerPrompt.Infrastructure.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(IFarmRepository repo, ILogger logger) +{ + private readonly IFarmRepository _repo = repo; + private readonly ILogger _logger = logger; + + /// Raised whenever the list of farms changes. + public event EventHandler? FarmsChanged; + + 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.Infrastructure/Services/PrinterFleetService.cs b/src/MakerPrompt.Infrastructure/Services/PrinterFleetService.cs new file mode 100644 index 0000000..aafbbf8 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Services/PrinterFleetService.cs @@ -0,0 +1,153 @@ +namespace MakerPrompt.Infrastructure.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(ILogger logger) : IAsyncDisposable +{ + private readonly ILogger _logger = logger; + private readonly Dictionary _connections = []; + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Raised when any printer in the fleet changes state. + public event EventHandler? FleetChanged; + + /// + /// 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/MakerPrompt.Shared/Services/BambuLabApiService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/BambuLabApiService.cs similarity index 89% rename from MakerPrompt.Shared/Services/BambuLabApiService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/BambuLabApiService.cs index e7d9000..5058f10 100644 --- a/MakerPrompt.Shared/Services/BambuLabApiService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/BambuLabApiService.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; -namespace MakerPrompt.Shared.Services; +namespace MakerPrompt.Infrastructure.Services.Printers; /// /// BambuLab printer backend using WebSocket MQTT + HTTP REST API. @@ -59,7 +59,7 @@ private HttpClient Client } } - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { if (IsConnected) return true; if (connectionSettings.ConnectionType != ConnectionType) @@ -67,16 +67,16 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting throw new ArgumentException("BambuLab connection type mismatch.", nameof(connectionSettings)); } - if (connectionSettings.Api is null) + if (string.IsNullOrEmpty(connectionSettings.ApiUrl)) { return false; } - _httpBaseUri = new Uri(connectionSettings.Api.Url); - ConfigureClient(connectionSettings.Api); + _httpBaseUri = new Uri(connectionSettings.ApiUrl); + ConfigureClient(connectionSettings); - _accessCode = connectionSettings.Api.Password; - _serial = connectionSettings.Api.UserName; + _accessCode = connectionSettings.Password; + _serial = connectionSettings.UserName; if (string.IsNullOrWhiteSpace(_accessCode) || string.IsNullOrWhiteSpace(_serial)) { @@ -393,14 +393,14 @@ private async Task SafeTelemetryAsync() { try { - await GetPrinterTelemetryAsync().ConfigureAwait(false); + await GetTelemetryAsync().ConfigureAwait(false); } catch { } } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); _cts.Cancel(); @@ -428,12 +428,12 @@ public async Task DisconnectAsync() RaiseConnectionChanged(); } - public Task WriteDataAsync(string command) + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) { return Task.CompletedTask; } - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { if (!IsConnected || _httpBaseUri is null) { @@ -458,9 +458,9 @@ public async Task GetPrinterTelemetryAsync() return LastTelemetry; } - public Task> GetFilesAsync() + public Task> GetFilesAsync(CancellationToken cancellationToken = default) { - return Task.FromResult(new List()); + return Task.FromResult>(Array.Empty()); } private Task SendCommandAsync(string name, object payload) @@ -474,25 +474,25 @@ private Task SendCommandAsync(string name, object payload) return SendRawAsync(envelope, _cts.Token); } - public Task SetHotendTemp(int targetTemp = 0) => + public Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => SendCommandAsync("set_nozzle_temp", new { target = targetTemp }); - public Task SetBedTemp(int targetTemp = 0) => + public Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => SendCommandAsync("set_bed_temp", new { target = targetTemp }); - public Task Home(bool x = true, bool y = true, bool z = true) => + public Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) => SendCommandAsync("home", new { x, y, z }); - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + public Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) => SendCommandAsync("move_relative", new { feedRate, x, y, z, e }); - public Task SetFanSpeed(int fanSpeedPercentage = 0) => + public Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) => SendCommandAsync("set_fan_speed", new { speed = fanSpeedPercentage }); - public Task SetPrintSpeed(int speed) => + public Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) => SendCommandAsync("set_print_speed", new { speed }); - public Task SetPrintFlow(int flow) => + public Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) => SendCommandAsync("set_print_flow", new { flow }); public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => @@ -504,10 +504,10 @@ public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => public Task RunThermalModelCalibration(int cycles, int targetTemp) => SendCommandAsync("run_thermal_model_calibration", new { cycles, targetTemp }); - public Task StartPrint(FileEntry file) => - SendCommandAsync("start_print_file", new { path = file.FullPath }); + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) => + SendCommandAsync("start_print_file", new { path = fileName }); - public Task StartPrint(GCodeDoc gcodeDoc) + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) { if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) { @@ -568,7 +568,7 @@ public override ValueTask DisposeAsync() return ValueTask.CompletedTask; } - private void ConfigureClient(ApiConnectionSettings settings) + private void ConfigureClient(PrinterConnectionSettings settings) { var client = Client; client.BaseAddress = _httpBaseUri; diff --git a/MakerPrompt.Shared/Infrastructure/BasePrinterConnectionService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/BasePrinterConnectionService.cs similarity index 92% rename from MakerPrompt.Shared/Infrastructure/BasePrinterConnectionService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/BasePrinterConnectionService.cs index 9acaa5e..f9fa280 100644 --- a/MakerPrompt.Shared/Infrastructure/BasePrinterConnectionService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/BasePrinterConnectionService.cs @@ -1,32 +1,32 @@ -namespace MakerPrompt.Shared.Infrastructure -{ - public abstract class BasePrinterConnectionService : IAsyncDisposable - { - public event EventHandler? ConnectionStateChanged; - public event EventHandler? TelemetryUpdated; - public PrinterTelemetry LastTelemetry { get; set; } = new(); - - public abstract PrinterConnectionType ConnectionType { get; } - - public string ConnectionName { get; set; } = string.Empty; - - public bool IsConnected { get; set; } = false; - - public readonly System.Timers.Timer updateTimer = new(TimeSpan.FromMilliseconds(3000)); - - // True while a print job is actively streaming G-code to the printer. - public bool IsPrinting { get; protected set; } - - public void RaiseConnectionChanged() - { - ConnectionStateChanged?.Invoke(this, IsConnected); - } - - public void RaiseTelemetryUpdated() - { - TelemetryUpdated?.Invoke(this, LastTelemetry); - } - - public abstract ValueTask DisposeAsync(); - } -} +namespace MakerPrompt.Infrastructure.Services.Printers +{ + public abstract class BasePrinterConnectionService : IAsyncDisposable + { + public event EventHandler? ConnectionStateChanged; + public event EventHandler? TelemetryUpdated; + public PrinterTelemetry LastTelemetry { get; set; } = new(); + + public abstract PrinterConnectionType ConnectionType { get; } + + public string ConnectionName { get; set; } = string.Empty; + + public bool IsConnected { get; set; } = false; + + public readonly System.Timers.Timer updateTimer = new(TimeSpan.FromMilliseconds(3000)); + + // True while a print job is actively streaming G-code to the printer. + public bool IsPrinting { get; protected set; } + + public void RaiseConnectionChanged() + { + ConnectionStateChanged?.Invoke(this, IsConnected); + } + + public void RaiseTelemetryUpdated() + { + TelemetryUpdated?.Invoke(this, LastTelemetry); + } + + public abstract ValueTask DisposeAsync(); + } +} diff --git a/MakerPrompt.Shared/Services/DemoPrinterService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/DemoPrinterService.cs similarity index 83% rename from MakerPrompt.Shared/Services/DemoPrinterService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/DemoPrinterService.cs index e1b1691..7b876c2 100644 --- a/MakerPrompt.Shared/Services/DemoPrinterService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/DemoPrinterService.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Services +namespace MakerPrompt.Infrastructure.Services.Printers { public class DemoPrinterService : BasePrinterConnectionService, IPrinterCommunicationService { @@ -21,7 +21,7 @@ public DemoPrinterService() updateTimer.Elapsed += (s, e) => SimulateTelemetry(); } - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { IsConnected = true; LastTelemetry = new PrinterTelemetry @@ -45,7 +45,7 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting return true; } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); IsConnected = false; @@ -55,48 +55,48 @@ public async Task DisconnectAsync() RaiseTelemetryUpdated(); } - public async Task WriteDataAsync(string command) + public async Task WriteDataAsync(string command, CancellationToken cancellationToken = default) { LastTelemetry.LastResponse = $"Received command: {command}"; RaiseTelemetryUpdated(); await Task.Delay(50); } - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { await Task.Delay(50); return LastTelemetry; } - public async Task> GetFilesAsync() + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) { await Task.Delay(100); return [ - new() { FullPath = "/gcodes/DemoCube.gcode", Size = 123456, ModifiedDate = DateTime.Now.AddDays(-1), IsAvailable = true }, - new() { FullPath = "/gcodes/Benchy.gcode", Size = 654321, ModifiedDate = DateTime.Now.AddDays(-2), IsAvailable = true } + "/gcodes/DemoCube.gcode", + "/gcodes/Benchy.gcode" ]; } - public async Task SetHotendTemp(int targetTemp = 0) + public async Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) { _hotendTarget = Math.Clamp(targetTemp, 0, 300); LastTelemetry.HotendTarget = _hotendTarget; - LastTelemetry.LastResponse = $"Set hotend target to {_hotendTarget}°C"; + LastTelemetry.LastResponse = $"Set hotend target to {_hotendTarget}�C"; RaiseTelemetryUpdated(); await Task.Delay(50); } - public async Task SetBedTemp(int targetTemp = 0) + public async Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) { _bedTarget = Math.Clamp(targetTemp, 0, 120); LastTelemetry.BedTarget = _bedTarget; - LastTelemetry.LastResponse = $"Set bed target to {_bedTarget}°C"; + LastTelemetry.LastResponse = $"Set bed target to {_bedTarget}�C"; RaiseTelemetryUpdated(); await Task.Delay(50); } - public async Task Home(bool x = true, bool y = true, bool z = true) + public async Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) { if (x) _position.X = 0; if (y) _position.Y = 0; @@ -107,7 +107,7 @@ public async Task Home(bool x = true, bool y = true, bool z = true) await Task.Delay(100); } - public async Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + public async Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) { _position += new Vector3(x, y, z); LastTelemetry.Position = _position; @@ -116,7 +116,7 @@ public async Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, flo await Task.Delay(100); } - public async Task SetFanSpeed(int fanSpeedPercentage = 0) + public async Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) { _fanSpeed = Math.Clamp(fanSpeedPercentage, 0, 100); LastTelemetry.FanSpeed = _fanSpeed; @@ -125,7 +125,7 @@ public async Task SetFanSpeed(int fanSpeedPercentage = 0) await Task.Delay(50); } - public async Task SetPrintSpeed(int speed) + public async Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) { _feedRate = Math.Clamp(speed, 1, 200); LastTelemetry.FeedRate = _feedRate; @@ -134,7 +134,7 @@ public async Task SetPrintSpeed(int speed) await Task.Delay(50); } - public async Task SetPrintFlow(int flow) + public async Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) { _flowRate = Math.Clamp(flow, 1, 200); LastTelemetry.FlowRate = _flowRate; @@ -175,29 +175,29 @@ public async Task SaveEEPROM() await Task.Delay(100); } - public async Task StartPrint(FileEntry file) + public async Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) { // Simulate starting a print job in demo mode - if (file == null) + if (string.IsNullOrEmpty(fileName)) { LastTelemetry.LastResponse = "No file selected to print."; RaiseTelemetryUpdated(); return; } - LastTelemetry.LastResponse = $"Started print job: {file.FullPath}"; + LastTelemetry.LastResponse = $"Started print job: {fileName}"; LastTelemetry.Status = PrinterStatus.Printing; RaiseTelemetryUpdated(); // Simulate print duration await Task.Delay(1000); - LastTelemetry.LastResponse = $"Print job completed: {file.FullPath}"; + LastTelemetry.LastResponse = $"Print job completed: {fileName}"; LastTelemetry.Status = PrinterStatus.Connected; RaiseTelemetryUpdated(); } - public Task StartPrint(GCodeDoc gcodeDoc) + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) { // For the demo printer, just log that we would print the provided G-code. LastTelemetry.LastResponse = string.IsNullOrWhiteSpace(gcodeDoc.Content) diff --git a/MakerPrompt.Shared/Infrastructure/ISerialService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/ISerialService.cs similarity index 87% rename from MakerPrompt.Shared/Infrastructure/ISerialService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/ISerialService.cs index 3a96c90..e81058d 100644 --- a/MakerPrompt.Shared/Infrastructure/ISerialService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/ISerialService.cs @@ -1,15 +1,15 @@ -namespace MakerPrompt.Shared.Infrastructure -{ - public interface ISerialService : IPrinterCommunicationService - { - bool IsSupported { get; } - - Task> GetAvailablePortsAsync(); - - Task CheckSupportedAsync(); - Task RequestPortAsync(); - - //Task OpenPortAsync(string port, int baudRate, int dataBits = 8, int stopBits = 1, - // string parity = "none", string flowControl = "none"); - } -} +namespace MakerPrompt.Infrastructure.Services.Printers +{ + public interface ISerialService : IPrinterCommunicationService + { + bool IsSupported { get; } + + Task> GetAvailablePortsAsync(); + + Task CheckSupportedAsync(); + Task RequestPortAsync(); + + //Task OpenPortAsync(string port, int baudRate, int dataBits = 8, int stopBits = 1, + // string parity = "none", string flowControl = "none"); + } +} diff --git a/MakerPrompt.Shared/Services/MoonrakerApiService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/MoonrakerApiService.cs similarity index 84% rename from MakerPrompt.Shared/Services/MoonrakerApiService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/MoonrakerApiService.cs index a8fe3da..bb01064 100644 --- a/MakerPrompt.Shared/Services/MoonrakerApiService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/MoonrakerApiService.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Services +namespace MakerPrompt.Infrastructure.Services.Printers { public class MoonrakerApiService : BasePrinterConnectionService, IPrinterCommunicationService { @@ -9,7 +9,6 @@ public class MoonrakerApiService : BasePrinterConnectionService, IPrinterCommuni private string _jwtToken = string.Empty; private string _refreshToken = string.Empty; public override PrinterConnectionType ConnectionType { get; } = PrinterConnectionType.Moonraker; - public bool SupportsPrinterQueue => true; public MoonrakerApiService() { @@ -49,11 +48,11 @@ public MoonrakerApiService(HttpMessageHandler handler) private bool _telemetryTimerInitialized; - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { if (IsConnected) return IsConnected; - if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Api == null) throw new ArgumentException(); + if (connectionSettings.ConnectionType != ConnectionType || string.IsNullOrEmpty(connectionSettings.ApiUrl)) throw new ArgumentException(); if (_cts.IsCancellationRequested) { @@ -61,12 +60,12 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting _cts = new CancellationTokenSource(); } - _baseUri = new Uri(connectionSettings.Api.Url); - ConfigureClient(connectionSettings.Api); + _baseUri = new Uri(connectionSettings.ApiUrl); + ConfigureClient(connectionSettings); - if (!string.IsNullOrEmpty(connectionSettings.Api.UserName) && !string.IsNullOrEmpty(connectionSettings.Api.Password)) + if (!string.IsNullOrEmpty(connectionSettings.UserName) && !string.IsNullOrEmpty(connectionSettings.Password)) { - IsConnected = await AuthenticateAsync(connectionSettings.Api.UserName, connectionSettings.Api.Password); + IsConnected = await AuthenticateAsync(connectionSettings.UserName, connectionSettings.Password); if (!IsConnected) return IsConnected; } @@ -94,7 +93,7 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting return IsConnected; } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); _cts.Cancel(); @@ -104,7 +103,7 @@ public async Task DisconnectAsync() await Task.CompletedTask; } - public async Task WriteDataAsync(string command) + public async Task WriteDataAsync(string command, CancellationToken cancellationToken = default) { if (!IsConnected) return; @@ -117,7 +116,7 @@ public async Task WriteDataAsync(string command) LastTelemetry.LastResponse = content; RaiseTelemetryUpdated(); } - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { if (!IsConnected) return LastTelemetry; @@ -274,7 +273,7 @@ public async Task> GetCamerasAsync(CancellationToke } } - public async Task> GetFilesAsync() + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) { if (!IsConnected) return []; @@ -283,52 +282,7 @@ public async Task> GetFilesAsync() response.EnsureSuccessStatusCode(); var content = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); var files = content?.Files ?? []; - return files.Select(f => new FileEntry - { - FullPath = f.Path, - Size = f.Size, - ModifiedDate = f.ModifiedDate, - IsAvailable = f.Permissions.Contains("rw"), - }).ToList(); - } - - /// - /// Returns the current Moonraker print queue (queued job entries). - /// Returns an empty list when not connected or the API call fails. - /// - public async Task> GetPrinterQueueAsync() - { - if (!IsConnected) return []; - - try - { - var response = await Client.GetAsync("/server/print_queue", _cts.Token); - if (!response.IsSuccessStatusCode) return []; - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - if (!doc.RootElement.TryGetProperty("result", out var result)) return []; - if (!result.TryGetProperty("queued_jobs", out var jobs) || - jobs.ValueKind != JsonValueKind.Array) return []; - - var entries = new List(); - foreach (var job in jobs.EnumerateArray()) - { - entries.Add(new PrintQueueEntry - { - JobId = job.TryGetProperty("job_id", out var id) ? id.GetString() ?? string.Empty : string.Empty, - FileName = job.TryGetProperty("filename", out var fn) ? fn.GetString() ?? string.Empty : string.Empty, - TimeAdded = job.TryGetProperty("time_added", out var ta) ? ta.GetDouble() : 0, - }); - } - return entries; - } - catch - { - // Swallow API errors silently — the caller (ControlPanel) handles - // the empty-list case and uses RunAsync for user-facing error reporting. - return []; - } + return files.Select(f => f.Path).ToList(); } public async Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default) @@ -415,7 +369,7 @@ public override ValueTask DisposeAsync() return ValueTask.CompletedTask; } - private void ConfigureClient(ApiConnectionSettings settings) + private void ConfigureClient(PrinterConnectionSettings settings) { var client = Client; client.BaseAddress = _baseUri; @@ -435,7 +389,7 @@ private async Task SafeTelemetryAsync() { try { - await GetPrinterTelemetryAsync(); + await GetTelemetryAsync(); } catch { @@ -445,13 +399,13 @@ private async Task SafeTelemetryAsync() private Task SendGcodeAsync(string gcode) => WriteDataAsync(gcode); - public Task SetHotendTemp(int targetTemp = 0) => + public Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => SendGcodeAsync($"M104 S{targetTemp}"); - public Task SetBedTemp(int targetTemp = 0) => + public Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => SendGcodeAsync($"M140 S{targetTemp}"); - public Task Home(bool x = true, bool y = true, bool z = true) + public Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) { var axes = new StringBuilder(); if (x) axes.Append(" X"); @@ -461,7 +415,7 @@ public Task Home(bool x = true, bool y = true, bool z = true) return SendGcodeAsync(command); } - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + public Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) { var sb = new StringBuilder(); sb.Append("G91\nG1"); @@ -473,20 +427,20 @@ public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = return SendGcodeAsync(sb.ToString()); } - public Task SetFanSpeed(int speed) + public Task SetFanSpeedAsync(int speed, CancellationToken cancellationToken = default) { var clamped = Math.Clamp(speed, 0, 100); var duty = (int)Math.Round(clamped * 255.0 / 100.0, MidpointRounding.AwayFromZero); return SendGcodeAsync($"M106 S{duty}"); } - public Task SetPrintSpeed(int speed) + public Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) { var clamped = Math.Clamp(speed, 1, 200); return SendGcodeAsync($"M220 S{clamped}"); } - public Task SetPrintFlow(int flow) + public Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) { var clamped = Math.Clamp(flow, 1, 200); return SendGcodeAsync($"M221 S{clamped}"); @@ -514,13 +468,13 @@ public Task RunThermalModelCalibration(int cycles, int targetTemp) return SendGcodeAsync($"PID_CALIBRATE HEATER=heater_bed TARGET={targetTemp}"); } - public async Task StartPrint(FileEntry file) + public async Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) { - var filename = WebUtility.UrlEncode(file.FullPath); - await Client.PostAsync($"/printer/print/start?filename={filename}", null, _cts.Token); + var encoded = WebUtility.UrlEncode(fileName); + await Client.PostAsync($"/printer/print/start?filename={encoded}", null, _cts.Token); } - public Task StartPrint(GCodeDoc gcodeDoc) + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) { if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) { @@ -659,14 +613,3 @@ private sealed record WebcamEntry } } - -/// -/// Represents a single entry in the Moonraker print queue. -/// -public sealed class PrintQueueEntry -{ - public string JobId { get; init; } = string.Empty; - public string FileName { get; init; } = string.Empty; - /// Unix timestamp when the job was added to the queue. - public double TimeAdded { get; init; } -} diff --git a/MakerPrompt.Shared/Services/OctoPrintApiService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/OctoPrintApiService.cs similarity index 89% rename from MakerPrompt.Shared/Services/OctoPrintApiService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/OctoPrintApiService.cs index 205998d..9cd2a0b 100644 --- a/MakerPrompt.Shared/Services/OctoPrintApiService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/OctoPrintApiService.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Services; +namespace MakerPrompt.Infrastructure.Services.Printers; /// /// OctoPrint REST API backend. @@ -48,15 +48,15 @@ private HttpClient Client } } - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { if (IsConnected) return true; - if (connectionSettings.Api is null) + if (string.IsNullOrEmpty(connectionSettings.ApiUrl)) throw new ArgumentException("OctoPrint connection requires API settings.", nameof(connectionSettings)); - _baseUri = new Uri(connectionSettings.Api.Url); - ConfigureClient(connectionSettings.Api); + _baseUri = new Uri(connectionSettings.ApiUrl); + ConfigureClient(connectionSettings); try { @@ -135,7 +135,7 @@ private async Task SendConnectCommandAsync() } } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); _cts.Cancel(); @@ -145,7 +145,7 @@ public async Task DisconnectAsync() await Task.CompletedTask; } - public async Task WriteDataAsync(string command) + public async Task WriteDataAsync(string command, CancellationToken cancellationToken = default) { if (!IsConnected) return; @@ -169,7 +169,7 @@ private async Task SendGcodeAsync(params string[] commands) await Client.PostAsync("/api/printer/command", content, _cts.Token).ConfigureAwait(false); } - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { if (!IsConnected) return LastTelemetry; @@ -283,7 +283,7 @@ private void ParseJobState(string json) } } - public async Task> GetFilesAsync() + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) { if (!IsConnected) return []; @@ -300,7 +300,7 @@ public async Task> GetFilesAsync() var files = new List(); ParseFilesRecursive(filesArray, files); - return files; + return files.Select(fe => fe.FullPath).ToList(); } catch { @@ -388,13 +388,13 @@ public async Task> GetCamerasAsync(CancellationToke // ── Printer control commands via G-code ──────────────────────────── - public Task SetHotendTemp(int targetTemp = 0) => + public Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => SendGcodeAsync($"M104 S{targetTemp}"); - public Task SetBedTemp(int targetTemp = 0) => + public Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => SendGcodeAsync($"M140 S{targetTemp}"); - public Task Home(bool x = true, bool y = true, bool z = true) + public Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) { var axes = new StringBuilder("G28"); if (x) axes.Append(" X"); @@ -403,7 +403,7 @@ public Task Home(bool x = true, bool y = true, bool z = true) return SendGcodeAsync(axes.ToString()); } - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + public Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) { var sb = new StringBuilder(); sb.Append("G91\nG1"); @@ -415,16 +415,16 @@ public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = return SendGcodeAsync(sb.ToString().Split('\n')); } - public Task SetFanSpeed(int fanSpeedPercentage = 0) + public Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) { var duty = (int)Math.Round(Math.Clamp(fanSpeedPercentage, 0, 100) * 255.0 / 100.0); return SendGcodeAsync($"M106 S{duty}"); } - public Task SetPrintSpeed(int speed) => + public Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) => SendGcodeAsync($"M220 S{Math.Clamp(speed, 1, 200)}"); - public Task SetPrintFlow(int flow) => + public Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) => SendGcodeAsync($"M221 S{Math.Clamp(flow, 1, 200)}"); public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) @@ -443,9 +443,9 @@ public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => public Task RunThermalModelCalibration(int cycles, int targetTemp) => SendGcodeAsync($"M303 E-1 S{targetTemp} C{cycles}"); - public async Task StartPrint(FileEntry file) + public async Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) { - if (!IsConnected || string.IsNullOrWhiteSpace(file.FullPath)) return; + if (!IsConnected || string.IsNullOrWhiteSpace(fileName)) return; var payload = JsonSerializer.Serialize(new { @@ -456,11 +456,11 @@ public async Task StartPrint(FileEntry file) // OctoPrint expects POST to /api/files/{location}/{filename} var location = "local"; - var filePath = file.FullPath.TrimStart('/'); + var filePath = fileName.TrimStart('/'); await Client.PostAsync($"/api/files/{location}/{filePath}", content, _cts.Token).ConfigureAwait(false); } - public Task StartPrint(GCodeDoc gcodeDoc) + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) { if (!IsConnected || string.IsNullOrEmpty(gcodeDoc.Content)) return Task.CompletedTask; @@ -483,7 +483,7 @@ private async Task SafeTelemetryAsync() { try { - await GetPrinterTelemetryAsync().ConfigureAwait(false); + await GetTelemetryAsync().ConfigureAwait(false); } catch { @@ -491,10 +491,10 @@ private async Task SafeTelemetryAsync() } } - private void ConfigureClient(ApiConnectionSettings settings) + private void ConfigureClient(PrinterConnectionSettings settings) { var client = Client; - _baseUri = new Uri(settings.Url); + _baseUri = new Uri(settings.ApiUrl!); client.BaseAddress = _baseUri; client.Timeout = TimeSpan.FromSeconds(30); client.DefaultRequestHeaders.Accept.Clear(); diff --git a/MakerPrompt.Shared/Services/PrinterCommunicationServiceFactory.cs b/src/MakerPrompt.Infrastructure/Services/Printers/PrinterCommunicationServiceFactory.cs similarity index 96% rename from MakerPrompt.Shared/Services/PrinterCommunicationServiceFactory.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/PrinterCommunicationServiceFactory.cs index ce4396d..8d64c17 100644 --- a/MakerPrompt.Shared/Services/PrinterCommunicationServiceFactory.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/PrinterCommunicationServiceFactory.cs @@ -1,83 +1,83 @@ -namespace MakerPrompt.Shared.Services -{ - public class PrinterCommunicationServiceFactory( - ISerialService serialService, - PrusaLinkApiService prusaLinkApiService, - PrusaConnectApiService prusaConnectApiService, - PrusaConnectPrinterService prusaConnectPrinterService, - MoonrakerApiService moonrakerApiService, - BambuLabApiService bambuLabApiService, - OctoPrintApiService octoPrintApiService) : IAsyncDisposable - { - public event EventHandler? ConnectionStateChanged; - public bool IsConnected { get; private set; } - public IPrinterCommunicationService? Current { get; private set; } - - private readonly ISerialService serialService = serialService; - private readonly PrusaLinkApiService prusaLinkApiService = prusaLinkApiService; - private readonly PrusaConnectApiService prusaConnectApiService = prusaConnectApiService; - private readonly PrusaConnectPrinterService prusaConnectPrinterService = prusaConnectPrinterService; - private readonly MoonrakerApiService moonrakerApiService = moonrakerApiService; - private readonly BambuLabApiService bambuLabApiService = bambuLabApiService; - private readonly OctoPrintApiService octoPrintApiService = octoPrintApiService; - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) - { - if (Current != null && Current.ConnectionType != connectionSettings.ConnectionType) - { - await Current.DisposeAsync(); - } - - IPrinterCommunicationService service = connectionSettings.ConnectionType switch - { - PrinterConnectionType.Demo => new DemoPrinterService(), - PrinterConnectionType.Serial => serialService, - PrinterConnectionType.PrusaLink => prusaLinkApiService, - PrinterConnectionType.PrusaConnect => prusaConnectPrinterService, - PrinterConnectionType.Moonraker => moonrakerApiService, - PrinterConnectionType.BambuLab => bambuLabApiService, - PrinterConnectionType.OctoPrint => octoPrintApiService, - _ => throw new NotImplementedException(), - }; - - if (await service.ConnectAsync(connectionSettings)) - { - Current = service; - IsConnected = Current.IsConnected; - ConnectionStateChanged?.Invoke(this, IsConnected); - } - } - - public async Task DisconnectAsync() - { - if (Current == null) return; - await Current.DisconnectAsync(); - IsConnected = Current.IsConnected; - ConnectionStateChanged?.Invoke(this, IsConnected); - } - - /// - /// Called by PrinterConnectionManager to keep this factory in sync with the - /// currently active managed printer. Preserves backward compatibility with - /// all existing single-printer UI components that read factory.Current. - /// - public void SetManagedCurrent(IPrinterCommunicationService? service) - { - Current = service; - IsConnected = service?.IsConnected ?? false; - ConnectionStateChanged?.Invoke(this, IsConnected); - } - - public async ValueTask DisposeAsync() - { - await DisconnectAsync(); - await serialService.DisposeAsync(); - await prusaLinkApiService.DisposeAsync(); - await prusaConnectPrinterService.DisposeAsync(); - await moonrakerApiService.DisposeAsync(); - await bambuLabApiService.DisposeAsync(); - await octoPrintApiService.DisposeAsync(); - GC.SuppressFinalize(this); - } - } -} +namespace MakerPrompt.Infrastructure.Services.Printers +{ + public class PrinterCommunicationServiceFactory( + ISerialService serialService, + PrusaLinkApiService prusaLinkApiService, + PrusaConnectApiService prusaConnectApiService, + PrusaConnectPrinterService prusaConnectPrinterService, + MoonrakerApiService moonrakerApiService, + BambuLabApiService bambuLabApiService, + OctoPrintApiService octoPrintApiService) : IAsyncDisposable + { + public event EventHandler? ConnectionStateChanged; + public bool IsConnected { get; private set; } + public IPrinterCommunicationService? Current { get; private set; } + + private readonly ISerialService serialService = serialService; + private readonly PrusaLinkApiService prusaLinkApiService = prusaLinkApiService; + private readonly PrusaConnectApiService prusaConnectApiService = prusaConnectApiService; + private readonly PrusaConnectPrinterService prusaConnectPrinterService = prusaConnectPrinterService; + private readonly MoonrakerApiService moonrakerApiService = moonrakerApiService; + private readonly BambuLabApiService bambuLabApiService = bambuLabApiService; + private readonly OctoPrintApiService octoPrintApiService = octoPrintApiService; + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (Current != null && Current.ConnectionType != connectionSettings.ConnectionType) + { + await Current.DisposeAsync(); + } + + IPrinterCommunicationService service = connectionSettings.ConnectionType switch + { + PrinterConnectionType.Demo => new DemoPrinterService(), + PrinterConnectionType.Serial => serialService, + PrinterConnectionType.PrusaLink => prusaLinkApiService, + PrinterConnectionType.PrusaConnect => prusaConnectPrinterService, + PrinterConnectionType.Moonraker => moonrakerApiService, + PrinterConnectionType.BambuLab => bambuLabApiService, + PrinterConnectionType.OctoPrint => octoPrintApiService, + _ => throw new NotImplementedException(), + }; + + if (await service.ConnectAsync(connectionSettings)) + { + Current = service; + IsConnected = Current.IsConnected; + ConnectionStateChanged?.Invoke(this, IsConnected); + } + } + + public async Task DisconnectAsync() + { + if (Current == null) return; + await Current.DisconnectAsync(); + IsConnected = Current.IsConnected; + ConnectionStateChanged?.Invoke(this, IsConnected); + } + + /// + /// Called by PrinterConnectionManager to keep this factory in sync with the + /// currently active managed printer. Preserves backward compatibility with + /// all existing single-printer UI components that read factory.Current. + /// + public void SetManagedCurrent(IPrinterCommunicationService? service) + { + Current = service; + IsConnected = service?.IsConnected ?? false; + ConnectionStateChanged?.Invoke(this, IsConnected); + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(); + await serialService.DisposeAsync(); + await prusaLinkApiService.DisposeAsync(); + await prusaConnectPrinterService.DisposeAsync(); + await moonrakerApiService.DisposeAsync(); + await bambuLabApiService.DisposeAsync(); + await octoPrintApiService.DisposeAsync(); + GC.SuppressFinalize(this); + } + } +} diff --git a/MakerPrompt.Shared/Services/PrusaConnectApiService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/PrusaConnectApiService.cs similarity index 87% rename from MakerPrompt.Shared/Services/PrusaConnectApiService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/PrusaConnectApiService.cs index 4601626..32f74ad 100644 --- a/MakerPrompt.Shared/Services/PrusaConnectApiService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/PrusaConnectApiService.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Services; +namespace MakerPrompt.Infrastructure.Services.Printers; /// /// PrusaConnect cloud API backend. @@ -33,7 +33,6 @@ public sealed class PrusaConnectApiService : BasePrinterConnectionService, IPrin private string? _printerUuid; public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaConnect; - public bool SupportsDirectControl => false; public PrusaConnectApiService() { } @@ -44,13 +43,13 @@ public PrusaConnectApiService(HttpMessageHandler handler) private HttpClient Client => _httpClient ??= new HttpClient { BaseAddress = new Uri(BaseUrl) }; - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { - if (connectionSettings.Api is null) + if (string.IsNullOrEmpty(connectionSettings.UserName)) throw new ArgumentException("PrusaConnect connection requires API settings.", nameof(connectionSettings)); - _printerUuid = connectionSettings.Api.UserName; - ConfigureClient(connectionSettings.Api.Password); + _printerUuid = connectionSettings.UserName; + ConfigureClient(connectionSettings.Password); try { @@ -84,7 +83,7 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting return IsConnected; } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); _cts.Cancel(); @@ -94,10 +93,10 @@ public async Task DisconnectAsync() await Task.CompletedTask; } - public Task WriteDataAsync(string command) => + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support direct G-code commands.")); - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(_printerUuid)) return LastTelemetry; @@ -133,8 +132,8 @@ public async Task GetPrinterTelemetryAsync() return LastTelemetry; } - public Task> GetFilesAsync() => - Task.FromResult(new List()); + public Task> GetFilesAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); /// /// Retrieves cameras registered for this printer in Prusa Connect. @@ -197,25 +196,25 @@ public async Task> GetCamerasAsync(CancellationToke // ── Unsupported operations (cloud API — no direct printer control) ──── - public Task SetHotendTemp(int targetTemp = 0) => + public Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); - public Task SetBedTemp(int targetTemp = 0) => + public Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); - public Task Home(bool x = true, bool y = true, bool z = true) => + public Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support homing commands.")); - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + public Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support move commands.")); - public Task SetFanSpeed(int fanSpeedPercentage = 0) => + public Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support fan control.")); - public Task SetPrintSpeed(int speed) => + public Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support print speed control.")); - public Task SetPrintFlow(int flow) => + public Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support flow control.")); public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => @@ -227,10 +226,10 @@ public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => public Task RunThermalModelCalibration(int cycles, int targetTemp) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support thermal model calibration.")); - public Task StartPrint(FileEntry file) => + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support starting prints remotely.")); - public Task StartPrint(GCodeDoc gcodeDoc) => + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support direct G-code printing.")); public Task SaveEEPROM() => @@ -279,7 +278,7 @@ private async Task SafeTelemetryAsync() { try { - await GetPrinterTelemetryAsync(); + await GetTelemetryAsync(); } catch { diff --git a/MakerPrompt.Shared/Services/PrusaConnectPrinterService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/PrusaConnectPrinterService.cs similarity index 71% rename from MakerPrompt.Shared/Services/PrusaConnectPrinterService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/PrusaConnectPrinterService.cs index fbbe7b8..2c316ec 100644 --- a/MakerPrompt.Shared/Services/PrusaConnectPrinterService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/PrusaConnectPrinterService.cs @@ -1,6 +1,4 @@ -using Microsoft.Extensions.Logging; - -namespace MakerPrompt.Shared.Services; +namespace MakerPrompt.Infrastructure.Services.Printers; /// /// PrusaConnect printer backend using the mobile API. @@ -31,7 +29,6 @@ public sealed class PrusaConnectPrinterService : BasePrinterConnectionService, I private HttpClient? _httpClient; private bool _timerInitialized; private string? _printerUuid; - private readonly ILogger? _logger; private static readonly JsonSerializerOptions s_jsonOptions = new() { @@ -41,13 +38,8 @@ public sealed class PrusaConnectPrinterService : BasePrinterConnectionService, I public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaConnect; - // DI constructor — logger is injected automatically when registered in the container. - public PrusaConnectPrinterService(ILogger? logger = null) - { - _logger = logger; - } + public PrusaConnectPrinterService() { } - // Test constructor — bypasses DI; logger is unavailable. public PrusaConnectPrinterService(HttpMessageHandler handler) { _httpClient = new HttpClient(handler, false) { BaseAddress = new Uri(BaseUrl) }; @@ -55,13 +47,13 @@ public PrusaConnectPrinterService(HttpMessageHandler handler) private HttpClient Client => _httpClient ??= new HttpClient { BaseAddress = new Uri(BaseUrl) }; - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { - if (connectionSettings.Api is null) + if (string.IsNullOrEmpty(connectionSettings.UserName)) throw new ArgumentException("PrusaConnect requires API settings.", nameof(connectionSettings)); - _printerUuid = connectionSettings.Api.UserName; - ConfigureClient(connectionSettings.Api.Password); + _printerUuid = connectionSettings.UserName; + ConfigureClient(connectionSettings.Password); try { @@ -95,7 +87,7 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting return IsConnected; } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); _cts.Cancel(); @@ -108,7 +100,7 @@ public async Task DisconnectAsync() /// /// Sends a raw G-code command via POST /app/printers/{uuid}/command. /// - public async Task WriteDataAsync(string command) + public async Task WriteDataAsync(string command, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(_printerUuid)) return; @@ -119,7 +111,7 @@ public async Task WriteDataAsync(string command) response.EnsureSuccessStatusCode(); } - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(_printerUuid)) return LastTelemetry; @@ -142,7 +134,7 @@ public async Task GetPrinterTelemetryAsync() return LastTelemetry; } - public async Task> GetFilesAsync() + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(_printerUuid)) return []; @@ -177,7 +169,7 @@ public async Task> GetFilesAsync() result.Add(new FileEntry { FullPath = path, Size = size, ModifiedDate = modified }); } - return result; + return result.Select(fe => fe.FullPath).ToList(); } catch { @@ -245,25 +237,25 @@ public async Task> GetCamerasAsync(CancellationToke // ── Unsupported cloud operations ────────────────────────────────────── - public Task SetHotendTemp(int targetTemp = 0) => + public Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); - public Task SetBedTemp(int targetTemp = 0) => + public Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support temperature control.")); - public Task Home(bool x = true, bool y = true, bool z = true) => + public Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support homing commands.")); - public Task RelativeMove(int feedRate, float x = 0, float y = 0, float z = 0, float e = 0) => + public Task RelativeMoveAsync(int feedRate, float x = 0, float y = 0, float z = 0, float e = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support move commands.")); - public Task SetFanSpeed(int fanSpeedPercentage = 0) => + public Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support fan control.")); - public Task SetPrintSpeed(int speed) => + public Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support print speed control.")); - public Task SetPrintFlow(int flow) => + public Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support flow control.")); public Task SetAxisPerUnit(float x = 0, float y = 0, float z = 0, float e = 0) => @@ -275,10 +267,10 @@ public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => public Task RunThermalModelCalibration(int cycles, int targetTemp) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support thermal model calibration.")); - public Task StartPrint(FileEntry file) => + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support starting prints remotely.")); - public Task StartPrint(GCodeDoc gcodeDoc) => + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaConnect cloud API does not support direct G-code printing.")); public Task SaveEEPROM() => @@ -309,91 +301,56 @@ private void ConfigureClient(string? bearerToken) private async Task FetchPrinterAsync(CancellationToken ct) { using var response = await Client.GetAsync($"/api/v1/printers/{_printerUuid}", ct); - if (!response.IsSuccessStatusCode) - { - _logger?.LogWarning("PrusaConnect printer endpoint returned {Status}", response.StatusCode); - return null; - } - - var json = await response.Content.ReadAsStringAsync(ct); - _logger?.LogDebug("PrusaConnect GET /api/v1/printers/{Uuid} response: {Json}", _printerUuid, json); + if (!response.IsSuccessStatusCode) return null; - var result = JsonSerializer.Deserialize(json, s_jsonOptions); - if (result is not null) - { - if (result.EffectiveState is null) - _logger?.LogWarning("PrusaConnect: 'state'/'printer_state' field is null/missing in printer response"); - if (result.Telemetry is null) - _logger?.LogWarning("PrusaConnect: 'telemetry' object is null/missing in printer response"); - if (result.EffectiveJobInfo is null) - _logger?.LogDebug("PrusaConnect: 'job_info'/'job' is null in printer response (expected when idle)"); - } - return result; + await using var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, ct); } private async Task FetchTelemetryAsync(CancellationToken ct) { using var response = await Client.GetAsync($"/api/v1/printers/{_printerUuid}/telemetry", ct); - if (!response.IsSuccessStatusCode) - { - _logger?.LogWarning("PrusaConnect telemetry endpoint returned {Status} — telemetry will use inline printer data only", response.StatusCode); - return null; - } + if (!response.IsSuccessStatusCode) return null; - var json = await response.Content.ReadAsStringAsync(ct); - _logger?.LogDebug("PrusaConnect GET /api/v1/printers/{Uuid}/telemetry response: {Json}", _printerUuid, json); - - return JsonSerializer.Deserialize(json, s_jsonOptions); + await using var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, ct); } private void ApplyTelemetry(PrusaConnectMobileTelemetryResponse t) { - LastTelemetry.HotendTemp = t.TempNozzle ?? LastTelemetry.HotendTemp; + LastTelemetry.HotendTemp = t.TempNozzle ?? LastTelemetry.HotendTemp; LastTelemetry.HotendTarget = t.TargetNozzle ?? LastTelemetry.HotendTarget; LastTelemetry.BedTemp = t.TempBed ?? LastTelemetry.BedTemp; LastTelemetry.BedTarget = t.TargetBed ?? LastTelemetry.BedTarget; LastTelemetry.FeedRate = t.PrintSpeed ?? LastTelemetry.FeedRate; - var z = t.EffectiveZHeight; - if (z.HasValue) - LastTelemetry.Position = LastTelemetry.Position with { Z = z.Value }; - - if (t.ExtraFields?.Count > 0) - _logger?.LogDebug("PrusaConnect telemetry has unmapped fields: {Fields}", - string.Join(", ", t.ExtraFields.Keys)); + if (t.ZHeight.HasValue) + LastTelemetry.Position = LastTelemetry.Position with { Z = t.ZHeight.Value }; } private void ApplyPrinterState(PrusaConnectMobilePrinterResponse p) { - LastTelemetry.Status = MapState(p.EffectiveState); + LastTelemetry.Status = MapState(p.State); if (p.Telemetry is not null) ApplyTelemetry(p.Telemetry); - var job = p.EffectiveJobInfo; - if (job is not null) + if (p.JobInfo is not null) { - LastTelemetry.SDCard.Progress = job.Progress ?? LastTelemetry.SDCard.Progress; + LastTelemetry.SDCard.Progress = p.JobInfo.Progress ?? LastTelemetry.SDCard.Progress; LastTelemetry.SDCard.Printing = LastTelemetry.Status == PrinterStatus.Printing; IsPrinting = LastTelemetry.SDCard.Printing; - if (job.TimePrinting.HasValue) - LastTelemetry.PrintDuration = TimeSpan.FromSeconds(job.TimePrinting.Value); - - if (!string.IsNullOrEmpty(job.FileName)) - LastTelemetry.PrintJobName = job.FileName; + if (p.JobInfo.TimePrinting.HasValue) + LastTelemetry.PrintDuration = TimeSpan.FromSeconds(p.JobInfo.TimePrinting.Value); } - if (p.ExtraFields?.Count > 0) - _logger?.LogDebug("PrusaConnect printer response has unmapped fields: {Fields}", - string.Join(", ", p.ExtraFields.Keys)); - LastTelemetry.LastResponse = "PrusaConnect telemetry update"; } private async Task SafePollAsync() { - try { await GetPrinterTelemetryAsync(); } + try { await GetTelemetryAsync(); } catch { } } @@ -421,47 +378,14 @@ public sealed class PrusaConnectMobilePrinterResponse [JsonPropertyName("printer_type")] public string? PrinterType { get; set; } - // Some API versions nest state in { "printer_state": { "text": "PRINTING" } }; - // we keep both and resolve in ApplyPrinterState. [JsonPropertyName("state")] public string? State { get; set; } - [JsonPropertyName("printer_state")] - public PrusaConnectMobilePrinterState? PrinterState { get; set; } - [JsonPropertyName("telemetry")] public PrusaConnectMobileTelemetryResponse? Telemetry { get; set; } - // PrusaConnect cloud uses "job_info"; some endpoints use "job". [JsonPropertyName("job_info")] public PrusaConnectMobileJobInfo? JobInfo { get; set; } - - [JsonPropertyName("job")] - public PrusaConnectMobileJobInfo? Job { get; set; } - - /// Captures any fields not matched above so they appear in debug logs. - [JsonExtensionData] - public Dictionary? ExtraFields { get; set; } - - /// Returns the effective job info regardless of which field is present. - [JsonIgnore] - public PrusaConnectMobileJobInfo? EffectiveJobInfo => JobInfo ?? Job; - - /// Returns the effective state string, handling nested printer_state objects. - [JsonIgnore] - public string? EffectiveState => State ?? PrinterState?.Text; -} - -public sealed class PrusaConnectMobilePrinterState -{ - [JsonPropertyName("text")] - public string? Text { get; set; } - - [JsonPropertyName("state")] - public string? State { get; set; } - - [JsonIgnore] - public string? EffectiveState => Text ?? State; } public sealed class PrusaConnectMobileTelemetryResponse @@ -481,20 +405,9 @@ public sealed class PrusaConnectMobileTelemetryResponse [JsonPropertyName("print_speed")] public int? PrintSpeed { get; set; } - // Some firmware reports Z as "axis_z"; others use "z_height". [JsonPropertyName("z_height")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public float? ZHeight { get; set; } - - [JsonPropertyName("axis_z")] - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] - public float? AxisZ { get; set; } - - [JsonExtensionData] - public Dictionary? ExtraFields { get; set; } - - [JsonIgnore] - public float? EffectiveZHeight => ZHeight ?? AxisZ; } public sealed class PrusaConnectMobileJobInfo @@ -507,7 +420,4 @@ public sealed class PrusaConnectMobileJobInfo [JsonPropertyName("time_printing")] public int? TimePrinting { get; set; } - - [JsonPropertyName("file_name")] - public string? FileName { get; set; } } diff --git a/MakerPrompt.Shared/Services/PrusaLinkApiService.cs b/src/MakerPrompt.Infrastructure/Services/Printers/PrusaLinkApiService.cs similarity index 90% rename from MakerPrompt.Shared/Services/PrusaLinkApiService.cs rename to src/MakerPrompt.Infrastructure/Services/Printers/PrusaLinkApiService.cs index 97930e7..5d78750 100644 --- a/MakerPrompt.Shared/Services/PrusaLinkApiService.cs +++ b/src/MakerPrompt.Infrastructure/Services/Printers/PrusaLinkApiService.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Services; +namespace MakerPrompt.Infrastructure.Services.Printers; public class PrusaLinkApiService : BasePrinterConnectionService, IPrinterCommunicationService { @@ -6,11 +6,9 @@ public class PrusaLinkApiService : BasePrinterConnectionService, IPrinterCommuni private readonly HttpMessageHandler? _customHandler; private HttpClient? _httpClient; private bool _ownsClient; - private ApiConnectionSettings? _connectionSettings; private Uri? _baseUri; public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaLink; - public bool SupportsDirectControl => false; public PrusaLinkApiService() { @@ -32,15 +30,14 @@ private HttpClient Client } } - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { - if (connectionSettings.Api is null) + if (string.IsNullOrEmpty(connectionSettings.ApiUrl)) { throw new ArgumentException("PrusaLink connection requires API settings.", nameof(connectionSettings)); } - _connectionSettings = connectionSettings.Api; - ConfigureClient(_connectionSettings); + ConfigureClient(connectionSettings); try { @@ -53,7 +50,7 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting } var info = await GetInfoAsync(_cts.Token); - ConnectionName = _baseUri?.AbsoluteUri ?? _connectionSettings.Url; + ConnectionName = _baseUri?.AbsoluteUri ?? connectionSettings.ApiUrl; LastTelemetry.PrinterName = info?.Name ?? LastTelemetry.PrinterName; LastTelemetry.ConnectionTime ??= DateTime.UtcNow; @@ -75,7 +72,7 @@ private async Task SafeTelemetryAsync() { try { - await GetPrinterTelemetryAsync(); + await GetTelemetryAsync(); } catch { @@ -83,7 +80,7 @@ private async Task SafeTelemetryAsync() } } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { updateTimer.Stop(); _cts.Cancel(); @@ -93,12 +90,12 @@ public async Task DisconnectAsync() await Task.CompletedTask; } - public Task WriteDataAsync(string command) + public Task WriteDataAsync(string command, CancellationToken cancellationToken = default) { throw new NotSupportedException("Direct G-code injection is not supported by the PrusaLink API."); } - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { var status = await GetStatusAsync(_cts.Token); if (status?.Printer is null) @@ -138,7 +135,7 @@ public async Task GetPrinterTelemetryAsync() return LastTelemetry; } - public async Task> GetFilesAsync() + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) { var storages = await GetStorageAsync(_cts.Token); var firstStorage = storages?.StorageList?.FirstOrDefault(); @@ -157,7 +154,7 @@ public async Task> GetFilesAsync() var storagePath = firstStorage.Path.TrimEnd('/'); var result = new List(); CollectFiles(folder.Children, storagePath, result); - return result; + return result.Select(fe => fe.FullPath).ToList(); } private static void CollectFiles(List entries, string parentPath, List result) @@ -186,25 +183,25 @@ private static void CollectFiles(List entries, string pare } } - public Task SetHotendTemp(int targetTemp = 0) => + public Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose hotend temperature control.")); - public Task SetBedTemp(int targetTemp = 0) => + public Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose bed temperature control.")); - public Task Home(bool x = true, bool y = true, bool z = true) => + public Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose homing commands.")); - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => + public Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose move commands.")); - public Task SetFanSpeed(int fanSpeedPercentage = 0) => + public Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose fan control.")); - public Task SetPrintSpeed(int speed) => + public Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose print speed control.")); - public Task SetPrintFlow(int flow) => + public Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("PrusaLink API does not expose flow control.")); public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) => @@ -216,10 +213,10 @@ public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => public Task RunThermalModelCalibration(int cycles, int targetTemp) => Task.FromException(new NotSupportedException("PrusaLink API does not expose thermal model calibration.")); - public Task StartPrint(FileEntry file) => + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("Starting prints requires uploading with Print-After-Upload per PrusaLink spec.")); - public Task StartPrint(GCodeDoc gcodeDoc) => + public Task StartPrintAsync(GCodeDoc gcodeDoc, CancellationToken cancellationToken = default) => Task.FromException(new NotSupportedException("Direct G-code printing is not supported by the PrusaLink API.")); public Task SaveEEPROM() => @@ -247,9 +244,9 @@ public Task> GetCamerasAsync(CancellationToken canc return Task.FromResult((IReadOnlyList)Array.Empty()); } - private void ConfigureClient(ApiConnectionSettings settings) + private void ConfigureClient(PrinterConnectionSettings settings) { - _baseUri = new Uri(settings.Url); + _baseUri = new Uri(settings.ApiUrl!); // In Blazor WebAssembly (browser) we cannot use HttpClientHandler.Credentials or // most handler-specific features. Instead, rely on the platform HttpClient and // send Basic auth via headers when credentials are supplied. diff --git a/src/MakerPrompt.Infrastructure/Services/SerialCommunicationServiceBase.cs b/src/MakerPrompt.Infrastructure/Services/SerialCommunicationServiceBase.cs new file mode 100644 index 0000000..54ae8a8 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Services/SerialCommunicationServiceBase.cs @@ -0,0 +1,347 @@ +namespace MakerPrompt.Infrastructure.Services; + +/// +/// 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); + } + + public async Task StartPrintAsync(GCodeDoc gcode, CancellationToken cancellationToken = default) + { + if (!IsConnected || string.IsNullOrWhiteSpace(gcode.Content)) return; + IsPrinting = true; + await foreach (var command in gcode.EnumerateCommandsAsync(cancellationToken)) + await WriteTransportAsync(command, 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/Utils/HttpEdgeAgentClient.cs b/src/MakerPrompt.Infrastructure/Utils/HttpEdgeAgentClient.cs new file mode 100644 index 0000000..47050c7 --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Utils/HttpEdgeAgentClient.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Json; + +namespace MakerPrompt.Infrastructure.Utils; + +/// +/// Pushes telemetry snapshots to the MakerPrompt Cloud backend over HTTP/JSON. +/// +/// The passed in must be pre-configured with: +/// • BaseAddress — the cloud base URL (e.g. http://localhost:8080). +/// • DefaultRequestHeaders.Authorization — Bearer token. +/// +/// All network failures are swallowed at Debug level so they never crash the +/// EdgeAgent polling loop. +/// +public sealed class HttpEdgeAgentClient(HttpClient http, ILogger logger) : IEdgeAgentClient +{ + private readonly HttpClient _http = http; + private readonly ILogger _logger = logger; + + /// + public async Task SendTelemetryAsync( + string printerId, + PrinterTelemetry telemetry, + CancellationToken cancellationToken = default) + { + try + { + var response = await _http.PostAsJsonAsync( + $"api/telemetry/{Uri.EscapeDataString(printerId)}", + telemetry, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug( + "Cloud rejected telemetry for {PrinterId}: HTTP {StatusCode}", + printerId, (int)response.StatusCode); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Cloud telemetry push failed for printer {PrinterId}", printerId); + } + } + + /// + public async Task IsReachableAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _http.GetAsync("health", cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Cloud health check failed"); + return false; + } + } +} diff --git a/src/MakerPrompt.Infrastructure/Utils/MjpegCameraProvider.cs b/src/MakerPrompt.Infrastructure/Utils/MjpegCameraProvider.cs new file mode 100644 index 0000000..0b3c27f --- /dev/null +++ b/src/MakerPrompt.Infrastructure/Utils/MjpegCameraProvider.cs @@ -0,0 +1,170 @@ +namespace MakerPrompt.Infrastructure.Utils; + +/// +/// 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. +/// +/// 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 sealed class MjpegCameraProvider( + string cameraId, + string label, + string streamUrl, + ILogger logger) : ICameraProvider +{ + private static readonly HttpClient SharedClient = new() + { + Timeout = TimeSpan.FromSeconds(10) + }; + + private readonly string _streamUrl = streamUrl; + private readonly ILogger _logger = logger; + + /// + public string CameraId { get; } = cameraId; + + /// + public string Label { get; } = label; + + /// + public bool IsAvailable { get; private set; } + + /// + 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/usings.cs b/src/MakerPrompt.Infrastructure/usings.cs new file mode 100644 index 0000000..07a55dc --- /dev/null +++ b/src/MakerPrompt.Infrastructure/usings.cs @@ -0,0 +1,10 @@ +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using System.Text.RegularExpressions; +global using System.Numerics; +global using System.Net; +global using System.Net.Http.Headers; +global using Microsoft.Extensions.Logging; +global using MakerPrompt.Core.Models; +global using MakerPrompt.Core.Abstractions; diff --git a/MakerPrompt.Blazor/MakerPrompt.Blazor.csproj b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj similarity index 70% rename from MakerPrompt.Blazor/MakerPrompt.Blazor.csproj rename to src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj index 976f6fe..155bc98 100644 --- a/MakerPrompt.Blazor/MakerPrompt.Blazor.csproj +++ b/src/MakerPrompt.UI.Blazor/MakerPrompt.UI.Blazor.csproj @@ -1,31 +1,31 @@ - - - - net10.0 - enable - enable - service-worker-assets.js - true - - 2 - 0.4.0 - $(Version) - $(Version) - $(Version) - - - - - - - - - - - - - - - - + + + + net10.0 + enable + enable + service-worker-assets.js + true + + 2 + 0.4.0 + $(Version) + $(Version) + $(Version) + + + + + + + + + + + + + + + + + diff --git a/src/MakerPrompt.UI.Blazor/Program.cs b/src/MakerPrompt.UI.Blazor/Program.cs new file mode 100644 index 0000000..f14595c --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Program.cs @@ -0,0 +1,60 @@ +using System.Globalization; +using MakerPrompt.UI.Blazor.Services; +using MakerPrompt.UI.Blazor.Storage; +using MakerPrompt.UI.Components.Infrastructure; +using MakerPrompt.UI.Components.Services; +using MakerPrompt.Infrastructure.Services.Printers; +using MakerPrompt.UI.Components.Utils; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.JSInterop; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.RegisterMakerPromptSharedServices(); +builder.Services.AddScoped(); +// WASM: AES-GCM not supported in browser — use Base64 encoding fallback +builder.Services.AddSingleton(); + +// ── Deployment-mode-specific services ──────────────────────────────────────── +// OIDC is enabled when an Authority URL is present in the "Local" config section. +// This drives IDeploymentModeService.IsAuthEnabled and locks the farm in +// cloud/makerspace mode. Config lives in wwwroot/appsettings.json under "Local". +var oidcAuthority = builder.Configuration["Local:Authority"] ?? string.Empty; +if (!string.IsNullOrWhiteSpace(oidcAuthority)) +{ + // Authorization Code + PKCE (never implicit grant). + // All options (ClientId, ResponseType, DefaultScopes, etc.) are bound from + // the "Local" section, which follows the Microsoft.AspNetCore.Components + // .WebAssembly.Authentication convention. + builder.Services.AddOidcAuthentication(options => + { + builder.Configuration.Bind("Local", options.ProviderOptions); + // Enforce PKCE / code flow explicitly. + options.ProviderOptions.ResponseType = "code"; + }); +} + +var host = builder.Build(); +const string defaultCulture = "en-US"; + +// Initialize configuration from localStorage before the app renders so that +// components see the persisted values on the very first render. +var configService = host.Services.GetRequiredService(); +await configService.InitializeAsync(); + +var js = host.Services.GetRequiredService(); +var result = await js.InvokeAsync("blazorCulture.get"); +var culture = CultureInfo.GetCultureInfo(result ?? defaultCulture); + +if (result == null) +{ + await js.InvokeVoidAsync("blazorCulture.set", defaultCulture); +} + +CultureInfo.DefaultThreadCurrentCulture = culture; +CultureInfo.DefaultThreadCurrentUICulture = culture; + +await host.RunAsync(); diff --git a/MakerPrompt.Blazor/Properties/launchSettings.json b/src/MakerPrompt.UI.Blazor/Properties/launchSettings.json similarity index 100% rename from MakerPrompt.Blazor/Properties/launchSettings.json rename to src/MakerPrompt.UI.Blazor/Properties/launchSettings.json diff --git a/src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs b/src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs new file mode 100644 index 0000000..1615124 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/Services/AppConfigurationService.cs @@ -0,0 +1,79 @@ +using MakerPrompt.UI.Components.Infrastructure; +using MakerPrompt.UI.Components.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.JSInterop; +using System.Text.Json; + +namespace MakerPrompt.UI.Blazor.Services +{ + public class AppConfigurationService : IAppConfigurationService, IAsyncDisposable + { + private const string StorageKey = "AppConfig"; + private readonly IJSRuntime _jsRuntime; + private readonly IConfiguration _configuration; + private AppConfiguration _config = new(); + + public AppConfiguration Configuration => _config; + + public AppConfigurationService(IJSRuntime jsRuntime, IConfiguration configuration) + { + _jsRuntime = jsRuntime; + _configuration = configuration; + } + + public async Task InitializeAsync() + { + var json = await _jsRuntime.InvokeAsync("localStorage.getItem", StorageKey); + _config = json != null + ? JsonSerializer.Deserialize(json) ?? new AppConfiguration() + : new AppConfiguration(); + + // Overlay deployment-time settings from appsettings.json. + // These cannot be changed at runtime — they reflect the deployment environment. + ApplyDeploymentSettings(); + } + + public async Task SaveConfigurationAsync() + { + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", StorageKey, + JsonSerializer.Serialize(_config)); + } + + public async Task ResetToDefaultsAsync() + { + _config = new AppConfiguration(); + ApplyDeploymentSettings(); + await SaveConfigurationAsync(); + } + + public async ValueTask DisposeAsync() => await SaveConfigurationAsync(); + + // ── private ────────────────────────────────────────────────────────── + + /// + /// Reads deployment-time settings from appsettings.json and overlays them + /// onto the in-memory configuration. These values are not stored in localStorage + /// because they are controlled by whoever deploys the application, not the end user. + /// + private void ApplyDeploymentSettings() + { + var modeStr = _configuration["MakerPrompt:DeploymentMode"] ?? string.Empty; + _config.DeploymentMode = Enum.TryParse(modeStr, true, out var parsedMode) + ? parsedMode + : AppDeploymentMode.Standalone; + + _config.CloudApiBaseUrl = _configuration["MakerPrompt:CloudApiBaseUrl"] ?? string.Empty; + + // In CloudMakerspace mode, farm mode is always enabled and the farm name + // comes from the deployment configuration. + if (_config.DeploymentMode == AppDeploymentMode.CloudMakerspace) + { + _config.FarmModeEnabled = true; + + var configuredFarmName = _configuration["MakerPrompt:FarmName"]; + if (!string.IsNullOrWhiteSpace(configuredFarmName)) + _config.FarmName = configuredFarmName; + } + } + } +} diff --git a/MakerPrompt.Blazor/Services/WebSerialService.cs b/src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs similarity index 85% rename from MakerPrompt.Blazor/Services/WebSerialService.cs rename to src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs index 5f4b407..7061a06 100644 --- a/MakerPrompt.Blazor/Services/WebSerialService.cs +++ b/src/MakerPrompt.UI.Blazor/Services/WebSerialService.cs @@ -1,9 +1,9 @@ -using MakerPrompt.Shared.Infrastructure; -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Services; +using MakerPrompt.Core.Models; +using MakerPrompt.UI.Components.Infrastructure; using Microsoft.JSInterop; +using MakerPrompt.Infrastructure.Services.Printers; -namespace MakerPrompt.Blazor.Services +namespace MakerPrompt.UI.Blazor.Services { public class WebSerialService : BaseSerialService, ISerialService, IAsyncDisposable { @@ -41,7 +41,7 @@ public async Task> GetAvailablePortsAsync() return ports.Select(p => $"{p.Name} ({p.Manufacturer})"); } - public async Task DisconnectAsync() + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { // Stop telemetry timer and detach handler first updateTimer.Stop(); @@ -58,10 +58,10 @@ public async Task DisconnectAsync() RaiseConnectionChanged(); } - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings, CancellationToken cancellationToken = default) { - if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Serial == null) throw new ArgumentException(); - await OpenPortAsync(connectionSettings.Serial.PortName, connectionSettings.Serial.BaudRate); + if (connectionSettings.ConnectionType != ConnectionType || string.IsNullOrEmpty(connectionSettings.PortName)) throw new ArgumentException(); + await OpenPortAsync(connectionSettings.PortName, connectionSettings.BaudRate); return IsConnected; } @@ -93,7 +93,7 @@ private async void OnUpdateTimerElapsed(object? sender, System.Timers.ElapsedEve try { - await GetPrinterTelemetryAsync(); + await GetTelemetryAsync(); } catch { @@ -101,7 +101,7 @@ private async void OnUpdateTimerElapsed(object? sender, System.Timers.ElapsedEve } } - public override async Task WriteDataAsync(string data) + public override async Task WriteDataAsync(string data, CancellationToken cancellationToken = default) { if (_portReference == null) throw new InvalidOperationException("Port not open"); var module = await _moduleTask.Value; diff --git a/MakerPrompt.Blazor/Storage/BlazorAppLocalStorageProvider.cs b/src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs similarity index 97% rename from MakerPrompt.Blazor/Storage/BlazorAppLocalStorageProvider.cs rename to src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs index aaf6f55..7f238fc 100644 --- a/MakerPrompt.Blazor/Storage/BlazorAppLocalStorageProvider.cs +++ b/src/MakerPrompt.UI.Blazor/Storage/BlazorAppLocalStorageProvider.cs @@ -1,9 +1,9 @@ -using MakerPrompt.Shared.Models; -using MakerPrompt.Shared.Infrastructure; +using MakerPrompt.Core.Models; +using MakerPrompt.UI.Components.Infrastructure; using Microsoft.JSInterop; using System.Text.Json; -namespace MakerPrompt.Blazor.Storage +namespace MakerPrompt.UI.Blazor.Storage { public sealed class BlazorAppLocalStorageProvider : IAppLocalStorageProvider { diff --git a/MakerPrompt.Blazor/_Imports.razor b/src/MakerPrompt.UI.Blazor/_Imports.razor similarity index 50% rename from MakerPrompt.Blazor/_Imports.razor rename to src/MakerPrompt.UI.Blazor/_Imports.razor index 5419af9..5d61dc9 100644 --- a/MakerPrompt.Blazor/_Imports.razor +++ b/src/MakerPrompt.UI.Blazor/_Imports.razor @@ -1,16 +1,17 @@ -@using System.Text.Json -@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.AspNetCore.Components.WebAssembly.Http -@using Microsoft.JSInterop -@using MakerPrompt.Blazor -@using MakerPrompt.Shared.Components -@using MakerPrompt.Shared.Layout -@using MakerPrompt.Shared.Pages -@using MakerPrompt.Shared.Infrastructure -@using MakerPrompt.Shared.Utils -@using BlazorBootstrap \ No newline at end of file +@using System.Text.Json +@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.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using MakerPrompt.UI.Blazor +@using MakerPrompt.UI.Components +@using MakerPrompt.UI.Components.Components +@using MakerPrompt.UI.Components.Layout +@using MakerPrompt.UI.Components.Pages +@using MakerPrompt.UI.Components.Infrastructure +@using MakerPrompt.UI.Components.Utils +@using BlazorBootstrap diff --git a/src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json b/src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json new file mode 100644 index 0000000..d037a99 --- /dev/null +++ b/src/MakerPrompt.UI.Blazor/wwwroot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Local": { + "Authority": "", + "ClientId": "", + "ResponseType": "code", + "ValidateAuthority": true, + "DefaultScopes": [ "openid", "profile", "email" ] + } +} 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/MakerPrompt.Blazor/wwwroot/index.html b/src/MakerPrompt.UI.Blazor/wwwroot/index.html similarity index 81% rename from MakerPrompt.Blazor/wwwroot/index.html rename to src/MakerPrompt.UI.Blazor/wwwroot/index.html index 97024d9..96ce954 100644 --- a/MakerPrompt.Blazor/wwwroot/index.html +++ b/src/MakerPrompt.UI.Blazor/wwwroot/index.html @@ -1,85 +1,87 @@ - - - - - - - MakerPrompt - - - - - - - - - - - - - - - - - - -
- - - - -
-
- -
- An unhandled error has occurred. - Reload - 🗙 -
- - - - - - - - - + + + + + + + MakerPrompt + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + + + + + diff --git a/MakerPrompt.Blazor/wwwroot/manifest.webmanifest b/src/MakerPrompt.UI.Blazor/wwwroot/manifest.webmanifest similarity index 100% rename from MakerPrompt.Blazor/wwwroot/manifest.webmanifest rename to src/MakerPrompt.UI.Blazor/wwwroot/manifest.webmanifest diff --git a/MakerPrompt.Blazor/wwwroot/serialJsInterop.js b/src/MakerPrompt.UI.Blazor/wwwroot/serialJsInterop.js similarity index 100% rename from MakerPrompt.Blazor/wwwroot/serialJsInterop.js rename to src/MakerPrompt.UI.Blazor/wwwroot/serialJsInterop.js diff --git a/MakerPrompt.Blazor/wwwroot/service-worker.js b/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.js similarity index 100% rename from MakerPrompt.Blazor/wwwroot/service-worker.js rename to src/MakerPrompt.UI.Blazor/wwwroot/service-worker.js diff --git a/MakerPrompt.Blazor/wwwroot/service-worker.published.js b/src/MakerPrompt.UI.Blazor/wwwroot/service-worker.published.js similarity index 100% rename from MakerPrompt.Blazor/wwwroot/service-worker.published.js rename to src/MakerPrompt.UI.Blazor/wwwroot/service-worker.published.js diff --git a/MakerPrompt.Blazor/App.razor b/src/MakerPrompt.UI.Components/App.razor similarity index 84% rename from MakerPrompt.Blazor/App.razor rename to src/MakerPrompt.UI.Components/App.razor index 9eb44bb..e0938f6 100644 --- a/MakerPrompt.Blazor/App.razor +++ b/src/MakerPrompt.UI.Components/App.razor @@ -1,7 +1,7 @@  - + diff --git a/MakerPrompt.Shared/BrailleRAP/Models/BrailleCell.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs similarity index 95% rename from MakerPrompt.Shared/BrailleRAP/Models/BrailleCell.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs index c8770b4..67b60a2 100644 --- a/MakerPrompt.Shared/BrailleRAP/Models/BrailleCell.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleCell.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Models +namespace MakerPrompt.UI.Components.BrailleRAP.Models { /// /// Represents a single Braille cell using Unicode Braille Patterns (U+2800 to U+28FF). diff --git a/MakerPrompt.Shared/BrailleRAP/Models/BrailleDot.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs similarity index 90% rename from MakerPrompt.Shared/BrailleRAP/Models/BrailleDot.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs index df4011a..d401e25 100644 --- a/MakerPrompt.Shared/BrailleRAP/Models/BrailleDot.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BrailleDot.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Models +namespace MakerPrompt.UI.Components.BrailleRAP.Models { /// /// Represents the position of a dot in a Braille cell (0-7 for 8-dot Braille). diff --git a/MakerPrompt.Shared/BrailleRAP/Models/BraillePageLayout.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs similarity index 93% rename from MakerPrompt.Shared/BrailleRAP/Models/BraillePageLayout.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs index a88dda8..d68d2fa 100644 --- a/MakerPrompt.Shared/BrailleRAP/Models/BraillePageLayout.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/BraillePageLayout.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Models +namespace MakerPrompt.UI.Components.BrailleRAP.Models { /// /// Represents a paginated layout of Braille text. diff --git a/MakerPrompt.Shared/BrailleRAP/Models/GeomPoint.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs similarity index 75% rename from MakerPrompt.Shared/BrailleRAP/Models/GeomPoint.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs index 4f6d17d..006641f 100644 --- a/MakerPrompt.Shared/BrailleRAP/Models/GeomPoint.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/GeomPoint.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Models +namespace MakerPrompt.UI.Components.BrailleRAP.Models { /// /// Represents a geometric point in 2D space for BrailleRAP positioning. diff --git a/MakerPrompt.Shared/BrailleRAP/Models/MachineConfig.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs similarity index 96% rename from MakerPrompt.Shared/BrailleRAP/Models/MachineConfig.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs index 9852b6c..2301852 100644 --- a/MakerPrompt.Shared/BrailleRAP/Models/MachineConfig.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/MachineConfig.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Models +namespace MakerPrompt.UI.Components.BrailleRAP.Models { /// /// Configuration for BrailleRAP machine parameters. diff --git a/MakerPrompt.Shared/BrailleRAP/Models/PageConfig.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs similarity index 93% rename from MakerPrompt.Shared/BrailleRAP/Models/PageConfig.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs index 6dec25f..d0ba44c 100644 --- a/MakerPrompt.Shared/BrailleRAP/Models/PageConfig.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Models/PageConfig.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Models +namespace MakerPrompt.UI.Components.BrailleRAP.Models { /// /// Configuration for Braille page layout. diff --git a/MakerPrompt.Shared/BrailleRAP/Services/BrailleGCodeGenerator.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs similarity index 94% rename from MakerPrompt.Shared/BrailleRAP/Services/BrailleGCodeGenerator.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs index be2e6ff..fd673e4 100644 --- a/MakerPrompt.Shared/BrailleRAP/Services/BrailleGCodeGenerator.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleGCodeGenerator.cs @@ -1,6 +1,6 @@ -using MakerPrompt.Shared.BrailleRAP.Models; +using MakerPrompt.UI.Components.BrailleRAP.Models; -namespace MakerPrompt.Shared.BrailleRAP.Services +namespace MakerPrompt.UI.Components.BrailleRAP.Services { /// /// Generates G-code for BrailleRAP embossing from geometric points. @@ -23,7 +23,7 @@ public string GenerateGCode(List points) var gcode = new StringBuilder(); // Initialize - gcode.Append(Home()); + gcode.Append(HomeAsync()); gcode.Append(SetSpeed(_config.FeedRate)); gcode.Append(MoveTo(0, 0)); @@ -61,7 +61,7 @@ private string MotorOff() return "M84;\r\n"; } - private string Home() + private string HomeAsync() { var sb = new StringBuilder(); sb.Append("G28 X;\r\n"); diff --git a/MakerPrompt.Shared/BrailleRAP/Services/BraillePaginator.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs similarity index 97% rename from MakerPrompt.Shared/BrailleRAP/Services/BraillePaginator.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs index 9c46b8a..e1713d7 100644 --- a/MakerPrompt.Shared/BrailleRAP/Services/BraillePaginator.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BraillePaginator.cs @@ -1,6 +1,6 @@ -using MakerPrompt.Shared.BrailleRAP.Models; +using MakerPrompt.UI.Components.BrailleRAP.Models; -namespace MakerPrompt.Shared.BrailleRAP.Services +namespace MakerPrompt.UI.Components.BrailleRAP.Services { /// /// Paginates Braille text into pages based on column and row constraints. diff --git a/MakerPrompt.Shared/BrailleRAP/Services/BrailleRAPService.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs similarity index 97% rename from MakerPrompt.Shared/BrailleRAP/Services/BrailleRAPService.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs index bf3dfa8..28c744c 100644 --- a/MakerPrompt.Shared/BrailleRAP/Services/BrailleRAPService.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleRAPService.cs @@ -1,6 +1,6 @@ -using MakerPrompt.Shared.BrailleRAP.Models; +using MakerPrompt.UI.Components.BrailleRAP.Models; -namespace MakerPrompt.Shared.BrailleRAP.Services +namespace MakerPrompt.UI.Components.BrailleRAP.Services { /// /// Main service for BrailleRAP operations. diff --git a/MakerPrompt.Shared/BrailleRAP/Services/BrailleToGeometry.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs similarity index 95% rename from MakerPrompt.Shared/BrailleRAP/Services/BrailleToGeometry.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs index 924ff75..ea2d53a 100644 --- a/MakerPrompt.Shared/BrailleRAP/Services/BrailleToGeometry.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleToGeometry.cs @@ -1,6 +1,6 @@ -using MakerPrompt.Shared.BrailleRAP.Models; +using MakerPrompt.UI.Components.BrailleRAP.Models; -namespace MakerPrompt.Shared.BrailleRAP.Services +namespace MakerPrompt.UI.Components.BrailleRAP.Services { /// /// Converts Braille cells to geometric points for embossing. @@ -9,8 +9,8 @@ namespace MakerPrompt.Shared.BrailleRAP.Services public class BrailleToGeometry { // Standard 8-dot Braille dot positions - private static readonly (int X, int Y)[] DotPositions = new[] - { + private static readonly (int X, int Y)[] DotPositions = + [ (0, 0), // Dot 1 (0, 1), // Dot 2 (0, 2), // Dot 3 @@ -19,7 +19,7 @@ private static readonly (int X, int Y)[] DotPositions = new[] (1, 2), // Dot 6 (0, 3), // Dot 7 (1, 3) // Dot 8 - }; + ]; private readonly MachineConfig _config; diff --git a/MakerPrompt.Shared/BrailleRAP/Services/BrailleTranslator.cs b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs similarity index 99% rename from MakerPrompt.Shared/BrailleRAP/Services/BrailleTranslator.cs rename to src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs index e175fd1..ff15942 100644 --- a/MakerPrompt.Shared/BrailleRAP/Services/BrailleTranslator.cs +++ b/src/MakerPrompt.UI.Components/BrailleRAP/Services/BrailleTranslator.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.BrailleRAP.Services +namespace MakerPrompt.UI.Components.BrailleRAP.Services { /// /// Supported Braille languages and translation tables. @@ -251,7 +251,7 @@ public List Translate(string text) var translationMap = GetTranslationMap(); // Split by newlines but preserve form feeds - var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None) + var lines = text.Split(['\r', '\n'], StringSplitOptions.None) .Where(line => !string.IsNullOrEmpty(line) || line == string.Empty) .ToList(); diff --git a/MakerPrompt.Shared/Components/AppInitializer.razor b/src/MakerPrompt.UI.Components/Components/AppInitializer.razor similarity index 100% rename from MakerPrompt.Shared/Components/AppInitializer.razor rename to src/MakerPrompt.UI.Components/Components/AppInitializer.razor diff --git a/MakerPrompt.Shared/Components/Calculators/BeltStepsCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/BeltStepsCalculator.razor similarity index 100% rename from MakerPrompt.Shared/Components/Calculators/BeltStepsCalculator.razor rename to src/MakerPrompt.UI.Components/Components/Calculators/BeltStepsCalculator.razor diff --git a/MakerPrompt.Shared/Components/Calculators/ExtruderStepsCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/ExtruderStepsCalculator.razor similarity index 100% rename from MakerPrompt.Shared/Components/Calculators/ExtruderStepsCalculator.razor rename to src/MakerPrompt.UI.Components/Components/Calculators/ExtruderStepsCalculator.razor diff --git a/MakerPrompt.Shared/Components/Calculators/LeadScrewCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/LeadScrewCalculator.razor similarity index 100% rename from MakerPrompt.Shared/Components/Calculators/LeadScrewCalculator.razor rename to src/MakerPrompt.UI.Components/Components/Calculators/LeadScrewCalculator.razor diff --git a/MakerPrompt.Shared/Components/Calculators/PrintPriceCalculator.razor b/src/MakerPrompt.UI.Components/Components/Calculators/PrintPriceCalculator.razor similarity index 100% rename from MakerPrompt.Shared/Components/Calculators/PrintPriceCalculator.razor rename to src/MakerPrompt.UI.Components/Components/Calculators/PrintPriceCalculator.razor diff --git a/MakerPrompt.Shared/Components/Calibration.razor b/src/MakerPrompt.UI.Components/Components/Calibration.razor similarity index 100% rename from MakerPrompt.Shared/Components/Calibration.razor rename to src/MakerPrompt.UI.Components/Components/Calibration.razor diff --git a/MakerPrompt.Shared/Components/CommandPrompt.razor b/src/MakerPrompt.UI.Components/Components/CommandPrompt.razor similarity index 95% rename from MakerPrompt.Shared/Components/CommandPrompt.razor rename to src/MakerPrompt.UI.Components/Components/CommandPrompt.razor index 7713b19..27bb93e 100644 --- a/MakerPrompt.Shared/Components/CommandPrompt.razor +++ b/src/MakerPrompt.UI.Components/Components/CommandPrompt.razor @@ -38,10 +38,10 @@
diff --git a/MakerPrompt.Shared/Components/ControlPanel.razor b/src/MakerPrompt.UI.Components/Components/ControlPanel.razor similarity index 70% rename from MakerPrompt.Shared/Components/ControlPanel.razor rename to src/MakerPrompt.UI.Components/Components/ControlPanel.razor index 9907353..5c4b88b 100644 --- a/MakerPrompt.Shared/Components/ControlPanel.razor +++ b/src/MakerPrompt.UI.Components/Components/ControlPanel.razor @@ -66,8 +66,6 @@
@* ── Left: Position + Webcam ── *@ - @if (SupportsDirectControl) - {
@@ -146,15 +144,6 @@
- } - else - { -
-
- -
-
- } @* ── Right: Temps + Extrude + Speed/Flow + Calibration ── *@
@@ -166,21 +155,18 @@ @Localizer[Resources.ControlPanel_Heating]
- @* Preheat presets (control-capable backends only) *@ - @if (SupportsDirectControl) - { -
- @foreach (var (label, hotend, bed) in PreheatProfiles) - { - var h = hotend; var b = bed; - - } -
- } + @* Preheat presets *@ +
+ @foreach (var (label, hotend, bed) in PreheatProfiles) + { + var h = hotend; var b = bed; + + } +
@* Hotend *@
@@ -197,14 +183,11 @@
60 ? "bg-warning" : "bg-secondary")" style="width:@($"{Math.Min(Telemetry.HotendTemp / 300.0 * 100, 100):F0}%")">
- @if (SupportsDirectControl) - { -
- - °C - -
- } +
+ + °C + +
@* Bed *@
@@ -221,14 +204,11 @@
- @if (SupportsDirectControl) - { -
- - °C - -
- } +
+ + °C + +
@* Chamber temp (shown when data is available from printer) *@ @if (Telemetry.ChamberTemp > 0 || Telemetry.ChamberTarget > 0) @@ -261,21 +241,16 @@
- @if (SupportsDirectControl) - { -
- - % - -
- } +
+ + % + +
- @* Extrude (direct-control backends only) *@ - @if (SupportsDirectControl) - { + @* Extrude *@
@@ -308,7 +283,6 @@
- } @* Speed / Flow *@
@@ -323,34 +297,26 @@ @Localizer[Resources.ControlPanel_PrintSpeed] @Telemetry.FeedRate% - @if (SupportsDirectControl) - { -
- - % -
- } +
+ + % +
- @if (SupportsDirectControl) - { -
- - % -
- } +
+ + % +
- @* Calibration (direct-control backends only) *@ - @if (SupportsDirectControl) - { + @* Calibration *@
@@ -360,45 +326,6 @@
- } - - @* Printer Queue (Moonraker only) *@ - @if (SupportsPrinterQueue) - { -
-
- - @Localizer[Resources.ControlPanel_PrinterQueue] - -
-
- @if (!_printerQueue.Any()) - { -
- -

@Localizer[Resources.ControlPanel_PrinterQueueEmpty]

-
- } - else - { -
- @foreach (var job in _printerQueue) - { -
-
- - @job.FileName - @DateTimeOffset.FromUnixTimeSeconds((long)job.TimeAdded).ToLocalTime().ToString("HH:mm") -
-
- } -
- } -
-
- } @* ── File storage ── *@ @@ -407,15 +334,15 @@ EnableUpload="false" EnableDelete="true" EnableOpen="true" EnableCopy="true" /> - @* ── Command Prompt / Project Hub ── *@ -
- @if (SupportsCommandPrompt) + @* ── Command Prompt (when supported) or Project Hub ── *@ +
+ @if (IsConnected && (PrinterServiceFactory.Current?.SupportsCommandPrompt ?? false)) { } else { - + }
@@ -431,12 +358,6 @@ private int printSpeed = 100; private int printFlow = 100; - private List _printerQueue = []; - - private bool SupportsDirectControl => PrinterServiceFactory.Current?.SupportsDirectControl ?? true; - private bool SupportsPrinterQueue => PrinterServiceFactory.Current?.SupportsPrinterQueue ?? false; - private bool SupportsCommandPrompt => PrinterServiceFactory.Current?.SupportsCommandPrompt ?? true; - private static readonly (string Label, int Hotend, int Bed)[] PreheatProfiles = [ ("PLA", 200, 60), @@ -464,7 +385,7 @@ private Task HomeAllAsync() { var printer = PrinterServiceFactory.Current; - return printer?.Home() ?? Task.CompletedTask; + return printer?.HomeAsync() ?? Task.CompletedTask; } private async Task HomeSelectedAxis() @@ -475,13 +396,13 @@ switch (ActiveTab) { case AxisTab.X: - await printer.Home(true, false, false); + await printer.HomeAsync(true, false, false); break; case AxisTab.Y: - await printer.Home(false, true, false); + await printer.HomeAsync(false, true, false); break; case AxisTab.Z: - await printer.Home(false, false, true); + await printer.HomeAsync(false, false, true); break; } } @@ -494,13 +415,13 @@ switch (ActiveTab) { case AxisTab.X: - await printer.RelativeMove(xySpeed, length, 0, 0); + await printer.RelativeMoveAsync(xySpeed, length, 0, 0); break; case AxisTab.Y: - await printer.RelativeMove(xySpeed, 0, length, 0); + await printer.RelativeMoveAsync(xySpeed, 0, length, 0); break; case AxisTab.Z: - await printer.RelativeMove(zSpeed, 0, 0, length); + await printer.RelativeMoveAsync(zSpeed, 0, 0, length); break; } } @@ -508,19 +429,19 @@ private Task SetHotendTempAsync() { var printer = PrinterServiceFactory.Current; - return printer?.SetHotendTemp(hotendTarget) ?? Task.CompletedTask; + return printer?.SetHotendTempAsync(hotendTarget) ?? Task.CompletedTask; } private Task SetBedTempAsync() { var printer = PrinterServiceFactory.Current; - return printer?.SetBedTemp(bedTarget) ?? Task.CompletedTask; + return printer?.SetBedTempAsync(bedTarget) ?? Task.CompletedTask; } private Task SetFanSpeedAsync() { var printer = PrinterServiceFactory.Current; - return printer?.SetFanSpeed(fanTarget) ?? Task.CompletedTask; + return printer?.SetFanSpeedAsync(fanTarget) ?? Task.CompletedTask; } private async Task PreheatAsync(int hotend, int bed) @@ -529,55 +450,28 @@ if (printer == null) return; hotendTarget = hotend; bedTarget = bed; - await printer.SetHotendTemp(hotend); - await printer.SetBedTemp(bed); + await printer.SetHotendTempAsync(hotend); + await printer.SetBedTempAsync(bed); } private Task ExtrudeAsync(int amount) { var printer = PrinterServiceFactory.Current; - return printer?.RelativeMove(extrudeSpeed, 0, 0, 0, amount) ?? Task.CompletedTask; + return printer?.RelativeMoveAsync(extrudeSpeed, 0, 0, 0, amount) ?? Task.CompletedTask; } private Task UpdatePrintSpeedAsync() { var printer = PrinterServiceFactory.Current; - return printer?.SetPrintSpeed(printSpeed) ?? Task.CompletedTask; + return printer?.SetPrintSpeedAsync(printSpeed) ?? Task.CompletedTask; } private Task UpdatePrintFlowAsync() { var printer = PrinterServiceFactory.Current; - return printer?.SetPrintFlow(printFlow) ?? Task.CompletedTask; + return printer?.SetPrintFlowAsync(printFlow) ?? Task.CompletedTask; } - protected override void OnInitialized() - { - base.OnInitialized(); - if (SupportsPrinterQueue) - _ = SafeRefreshPrinterQueueAsync(); - } - - protected override void HandleConnectionChanged(object? sender, bool connected) - { - base.HandleConnectionChanged(sender, connected); - if (connected && SupportsPrinterQueue) - _ = SafeRefreshPrinterQueueAsync(); - } - - private async Task RefreshPrinterQueueAsync() - { - if (PrinterServiceFactory.Current is { SupportsPrinterQueue: true } printer) - { - _printerQueue = await printer.GetPrinterQueueAsync(); - await InvokeAsync(StateHasChanged); - } - } - - // Wraps RefreshPrinterQueueAsync with error handling for fire-and-forget calls - private Task SafeRefreshPrinterQueueAsync() => - RunAsync(RefreshPrinterQueueAsync, Localizer[Resources.ControlPanel_PrinterQueue]); - private readonly PrinterTelemetry fallbackTelemetry = new(); private PrinterTelemetry Telemetry => PrinterServiceFactory.Current?.LastTelemetry ?? fallbackTelemetry; } diff --git a/MakerPrompt.Shared/Components/CultureSelector.razor b/src/MakerPrompt.UI.Components/Components/CultureSelector.razor similarity index 100% rename from MakerPrompt.Shared/Components/CultureSelector.razor rename to src/MakerPrompt.UI.Components/Components/CultureSelector.razor diff --git a/MakerPrompt.Shared/Components/FileExplorer.razor b/src/MakerPrompt.UI.Components/Components/FileExplorer.razor similarity index 96% rename from MakerPrompt.Shared/Components/FileExplorer.razor rename to src/MakerPrompt.UI.Components/Components/FileExplorer.razor index 10c1f00..ae5cf93 100644 --- a/MakerPrompt.Shared/Components/FileExplorer.razor +++ b/src/MakerPrompt.UI.Components/Components/FileExplorer.razor @@ -119,7 +119,8 @@ { await RunAsync(async () => { - _files = await printerService.GetFilesAsync() ?? new List(); + var paths = await printerService.GetFilesAsync(); + _files = paths.Select(p => new FileEntry { FullPath = p }).ToList(); }, "Failed to load files"); } else @@ -164,7 +165,7 @@ return Task.CompletedTask; var file = selectedFile; var printer = PrinterServiceFactory.Current; - return RunAsync(() => printer.StartPrint(file), "Failed to start print"); + return RunAsync(() => printer.StartPrintAsync(file.FullPath), "Failed to start print"); } private async Task CopySelectedToApp() diff --git a/MakerPrompt.Shared/Components/GCodeViewer.razor b/src/MakerPrompt.UI.Components/Components/GCodeViewer.razor similarity index 99% rename from MakerPrompt.Shared/Components/GCodeViewer.razor rename to src/MakerPrompt.UI.Components/Components/GCodeViewer.razor index a851826..843518a 100644 --- a/MakerPrompt.Shared/Components/GCodeViewer.razor +++ b/src/MakerPrompt.UI.Components/Components/GCodeViewer.razor @@ -187,7 +187,7 @@ try { - await service.StartPrint(GCodeDoc.Document); + await service.StartPrintAsync(GCodeDoc.Document); } catch { diff --git a/MakerPrompt.Shared/Components/GlobalErrorBoundary.cs b/src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs similarity index 96% rename from MakerPrompt.Shared/Components/GlobalErrorBoundary.cs rename to src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs index 96a423b..9be1f10 100644 --- a/MakerPrompt.Shared/Components/GlobalErrorBoundary.cs +++ b/src/MakerPrompt.UI.Components/Components/GlobalErrorBoundary.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Logging; -namespace MakerPrompt.Shared.Components; +namespace MakerPrompt.UI.Components.Components; /// /// Global error boundary that catches unhandled UI/component exceptions, diff --git a/MakerPrompt.Shared/Components/LocalizedLabel.razor b/src/MakerPrompt.UI.Components/Components/LocalizedLabel.razor similarity index 100% rename from MakerPrompt.Shared/Components/LocalizedLabel.razor rename to src/MakerPrompt.UI.Components/Components/LocalizedLabel.razor diff --git a/MakerPrompt.Shared/Components/LocalizedTitle.razor b/src/MakerPrompt.UI.Components/Components/LocalizedTitle.razor similarity index 100% rename from MakerPrompt.Shared/Components/LocalizedTitle.razor rename to src/MakerPrompt.UI.Components/Components/LocalizedTitle.razor diff --git a/MakerPrompt.Shared/Components/PidCalibration.razor b/src/MakerPrompt.UI.Components/Components/PidCalibration.razor similarity index 100% rename from MakerPrompt.Shared/Components/PidCalibration.razor rename to src/MakerPrompt.UI.Components/Components/PidCalibration.razor diff --git a/MakerPrompt.Shared/Components/PrintQueue.razor b/src/MakerPrompt.UI.Components/Components/PrintQueue.razor similarity index 99% rename from MakerPrompt.Shared/Components/PrintQueue.razor rename to src/MakerPrompt.UI.Components/Components/PrintQueue.razor index dab19ca..1035482 100644 --- a/MakerPrompt.Shared/Components/PrintQueue.razor +++ b/src/MakerPrompt.UI.Components/Components/PrintQueue.razor @@ -4,8 +4,8 @@ Upload G-code files, organize them into project folders, and dispatch individual jobs to any connected idle printer in the fleet. ───────────────────────────────────────────────────────────────────── *@ -@using MakerPrompt.Shared.Infrastructure -@using static MakerPrompt.Shared.Utils.Enums +@using MakerPrompt.UI.Components.Infrastructure +@using static MakerPrompt.UI.Components.Utils.Enums @inject PrintProjectService ProjectService @inject PrinterConnectionManager ConnectionManager @inject IStringLocalizer localizer @@ -327,7 +327,7 @@ await ProjectService.AssignJobAsync(projectId, job.Id, target.Definition.Id, target.Definition.Name); // Start print - await target.Service.StartPrint(doc); + await target.Service.StartPrintAsync(doc); ToastService.Notify(new ToastMessage( ToastType.Success, diff --git a/MakerPrompt.Shared/Components/PrinterConnectionModal.razor b/src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor similarity index 93% rename from MakerPrompt.Shared/Components/PrinterConnectionModal.razor rename to src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor index 0f0b8ad..57b6aa7 100644 --- a/MakerPrompt.Shared/Components/PrinterConnectionModal.razor +++ b/src/MakerPrompt.UI.Components/Components/PrinterConnectionModal.razor @@ -5,8 +5,8 @@ Connects automatically after saving when _connectAfterSave is set (default true for new printers in single-printer mode). ───────────────────────────────────────────────────────────────────── *@ -@using MakerPrompt.Shared.Infrastructure -@using static MakerPrompt.Shared.Utils.Enums +@using MakerPrompt.UI.Components.Infrastructure +@using static MakerPrompt.UI.Components.Utils.Enums @inject PrinterConnectionManager ConnectionManager @inject FilamentInventoryService FilamentInventoryService @inject ISerialService SerialService @@ -201,12 +201,12 @@ AssignedFilamentSpoolId = definition.AssignedFilamentSpoolId, }; _editConnectionType = definition.ConnectionType; - _editApiSettings = definition.Settings.Api is not null - ? new ApiConnectionSettings(definition.Settings.Api.Url, definition.Settings.Api.UserName, definition.Settings.Api.Password) - : new ApiConnectionSettings(); - _editSerialSettings = definition.Settings.Serial is not null - ? new SerialConnectionSettings { PortName = definition.Settings.Serial.PortName, BaudRate = definition.Settings.Serial.BaudRate } - : new SerialConnectionSettings(); + _editApiSettings = string.IsNullOrEmpty(definition.Settings.ApiUrl) + ? new ApiConnectionSettings() + : new ApiConnectionSettings(definition.Settings.ApiUrl, definition.Settings.UserName ?? string.Empty, definition.Settings.Password ?? string.Empty); + _editSerialSettings = string.IsNullOrEmpty(definition.Settings.PortName) + ? new SerialConnectionSettings() + : new SerialConnectionSettings { PortName = definition.Settings.PortName, BaudRate = definition.Settings.BaudRate }; await _modal.ShowAsync(); } @@ -235,9 +235,9 @@ _editDefinition.ConnectionType = _editConnectionType; _editDefinition.Settings = _editConnectionType switch { - PrinterConnectionType.Serial => new PrinterConnectionSettings(_editSerialSettings), + PrinterConnectionType.Serial => new PrinterConnectionSettings { ConnectionType = PrinterConnectionType.Serial, PortName = _editSerialSettings.PortName, BaudRate = _editSerialSettings.BaudRate }, PrinterConnectionType.Demo => new PrinterConnectionSettings(), - _ => new PrinterConnectionSettings(_editApiSettings, _editConnectionType) + _ => new PrinterConnectionSettings { ConnectionType = _editConnectionType, ApiUrl = _editApiSettings.Url, UserName = _editApiSettings.UserName, Password = _editApiSettings.Password } }; _isSaving = true; diff --git a/MakerPrompt.Shared/Components/ProcessError.razor b/src/MakerPrompt.UI.Components/Components/ProcessError.razor similarity index 100% rename from MakerPrompt.Shared/Components/ProcessError.razor rename to src/MakerPrompt.UI.Components/Components/ProcessError.razor diff --git a/MakerPrompt.Shared/Components/StorageExplorer.razor b/src/MakerPrompt.UI.Components/Components/StorageExplorer.razor similarity index 100% rename from MakerPrompt.Shared/Components/StorageExplorer.razor rename to src/MakerPrompt.UI.Components/Components/StorageExplorer.razor diff --git a/MakerPrompt.Shared/Components/TestCommandModal.razor b/src/MakerPrompt.UI.Components/Components/TestCommandModal.razor similarity index 100% rename from MakerPrompt.Shared/Components/TestCommandModal.razor rename to src/MakerPrompt.UI.Components/Components/TestCommandModal.razor diff --git a/MakerPrompt.Shared/Components/ThemeSelector.razor b/src/MakerPrompt.UI.Components/Components/ThemeSelector.razor similarity index 93% rename from MakerPrompt.Shared/Components/ThemeSelector.razor rename to src/MakerPrompt.UI.Components/Components/ThemeSelector.razor index 60d4839..5eba727 100644 --- a/MakerPrompt.Shared/Components/ThemeSelector.razor +++ b/src/MakerPrompt.UI.Components/Components/ThemeSelector.razor @@ -1,4 +1,4 @@ -@using static MakerPrompt.Shared.Utils.Enums +@using static MakerPrompt.UI.Components.Utils.Enums @inject ThemeService ThemeService @inject IStringLocalizer localizer diff --git a/MakerPrompt.Shared/Components/ThermalModelCalibration.razor b/src/MakerPrompt.UI.Components/Components/ThermalModelCalibration.razor similarity index 100% rename from MakerPrompt.Shared/Components/ThermalModelCalibration.razor rename to src/MakerPrompt.UI.Components/Components/ThermalModelCalibration.razor diff --git a/MakerPrompt.Shared/Components/WebcamPanel.razor b/src/MakerPrompt.UI.Components/Components/WebcamPanel.razor similarity index 100% rename from MakerPrompt.Shared/Components/WebcamPanel.razor rename to src/MakerPrompt.UI.Components/Components/WebcamPanel.razor diff --git a/MakerPrompt.Shared/Components/WebcamViewer.razor b/src/MakerPrompt.UI.Components/Components/WebcamViewer.razor similarity index 97% rename from MakerPrompt.Shared/Components/WebcamViewer.razor rename to src/MakerPrompt.UI.Components/Components/WebcamViewer.razor index 96ae9bf..02d91ed 100644 --- a/MakerPrompt.Shared/Components/WebcamViewer.razor +++ b/src/MakerPrompt.UI.Components/Components/WebcamViewer.razor @@ -1,5 +1,5 @@ -@using MakerPrompt.Shared.Models -@using MakerPrompt.Shared.Services +@using MakerPrompt.UI.Components.Models +@using MakerPrompt.UI.Components.Services @inject ICameraProxyService CameraProxy @implements IAsyncDisposable diff --git a/MakerPrompt.Shared/Infrastructure/BaseSerialService.cs b/src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs similarity index 82% rename from MakerPrompt.Shared/Infrastructure/BaseSerialService.cs rename to src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs index e43121d..0827ecf 100644 --- a/MakerPrompt.Shared/Infrastructure/BaseSerialService.cs +++ b/src/MakerPrompt.UI.Components/Infrastructure/BaseSerialService.cs @@ -1,16 +1,18 @@ -namespace MakerPrompt.Shared.Infrastructure +using MakerPrompt.Infrastructure.Services.Printers; + +namespace MakerPrompt.UI.Components.Infrastructure { public abstract class BaseSerialService : BasePrinterConnectionService { private readonly Regex _tempRegex = new(@"T:([\d.]+)\s/\s*([\d.]+)\sB:([\d.]+)\s/\s*([\d.]+)"); private readonly Regex _posRegex = new(@"X:([\d.]+)\sY:([\d.]+)\sZ:([\d.]+)"); - public override Enums.PrinterConnectionType ConnectionType => Enums.PrinterConnectionType.Serial; + public override PrinterConnectionType ConnectionType => PrinterConnectionType.Serial; StringBuilder _receiveBuffer = new(); // Core write entry point used by higher-level services. Implementations should // enqueue commands with appropriate metadata where available. - public abstract Task WriteDataAsync(string command); + public abstract Task WriteDataAsync(string command, CancellationToken cancellationToken = default); // Convenience helpers for tagging commands with their intent. Implementations // that support a queued sender can use this classification to prioritise work. @@ -18,7 +20,7 @@ public abstract class BaseSerialService : BasePrinterConnectionService public virtual Task WriteTelemetryCommandAsync(string command) => WriteDataAsync(command); public virtual Task WritePrintCommandAsync(string command) => WriteDataAsync(command); - public async Task GetPrinterTelemetryAsync() + public async Task GetTelemetryAsync(CancellationToken cancellationToken = default) { // Treat telemetry polling distinctly so queueing code can prioritise // active print commands when necessary. @@ -32,7 +34,7 @@ public async Task GetPrinterTelemetryAsync() return LastTelemetry; } - public async Task> GetFilesAsync() + public async Task> GetFilesAsync(CancellationToken cancellationToken = default) { // await WriteDataAsync("M20 L T"); // await Task.Delay(500); // Wait for response @@ -40,7 +42,7 @@ public async Task> GetFilesAsync() return []; } - public async Task SetHotendTemp(int targetTemp = 0) + public async Task SetHotendTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) { if (!IsConnected || (targetTemp < 0 || targetTemp > 300)) return; var command = GCodeCommands.SetTemp @@ -49,7 +51,7 @@ public async Task SetHotendTemp(int targetTemp = 0) await WriteUserCommandAsync(command); } - public async Task SetBedTemp(int targetTemp = 0) + public async Task SetBedTempAsync(int targetTemp = 0, CancellationToken cancellationToken = default) { if (!IsConnected || (targetTemp < 0 || targetTemp > 120)) return; var command = GCodeCommands.SetBedTemp @@ -58,7 +60,7 @@ public async Task SetBedTemp(int targetTemp = 0) await WriteUserCommandAsync(command); } - public async Task Home(bool x = true, bool y = true, bool z = true) + public async Task HomeAsync(bool x = true, bool y = true, bool z = true, CancellationToken cancellationToken = default) { if (!IsConnected) return; var command = GCodeCommands.Home; @@ -72,7 +74,7 @@ public async Task Home(bool x = true, bool y = true, bool z = true) await WriteUserCommandAsync(command.ToString()); } - public async Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + public async Task RelativeMoveAsync(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f, CancellationToken cancellationToken = default) { if (!IsConnected) return; var command = GCodeCommands.MoveLinear; @@ -88,7 +90,7 @@ public async Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, flo await WriteUserCommandAsync(GCodeCommands.AbsolutePositioning.ToString()); } - public async Task SetFanSpeed(int fanSpeedPercentage = 0) + public async Task SetFanSpeedAsync(int fanSpeedPercentage = 0, CancellationToken cancellationToken = default) { if (!IsConnected || (fanSpeedPercentage < 0 || fanSpeedPercentage > 100)) return; var command = fanSpeedPercentage == 0 ? GCodeCommands.FanOff @@ -96,14 +98,14 @@ public async Task SetFanSpeed(int fanSpeedPercentage = 0) await WriteUserCommandAsync(command.ToString()); } - public async Task SetPrintSpeed(int speed) + public async Task SetPrintSpeedAsync(int speed, CancellationToken cancellationToken = default) { if (!IsConnected || (speed < 1 || speed > 200)) return; var command = GCodeCommands.SetFeedratePercentage.SetParameterValue(GCodeParameters.RatePercentage.Label, speed.ToString()); await WriteUserCommandAsync(command.ToString()); } - public async Task SetPrintFlow(int flow) + public async Task SetPrintFlowAsync(int flow, CancellationToken cancellationToken = default) { if (!IsConnected || (flow < 1 || flow > 200)) return; var command = GCodeCommands.SetFlowratePercentage.SetParameterValue(GCodeParameters.RatePercentage.Label, flow.ToString()); @@ -143,9 +145,19 @@ public async Task RunThermalModelCalibration(int cycles, int targetTemp) await WriteUserCommandAsync(command); } - public Task StartPrint(FileEntry file) + public Task StartPrintAsync(string fileName, CancellationToken cancellationToken = default) + { + if (!IsConnected) return Task.CompletedTask; + IsPrinting = true; + return WriteDataAsync($"M23 {fileName}", cancellationToken); + } + + public async Task StartPrintAsync(GCodeDoc gcode, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + if (!IsConnected || string.IsNullOrWhiteSpace(gcode.Content)) return; + IsPrinting = true; + await foreach (var command in gcode.EnumerateCommandsAsync(cancellationToken)) + await WriteDataAsync(command, cancellationToken); } public async Task SaveEEPROM() diff --git a/MakerPrompt.Shared/Infrastructure/ConnectionComponentBase.cs b/src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs similarity index 96% rename from MakerPrompt.Shared/Infrastructure/ConnectionComponentBase.cs rename to src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs index 90d12e3..40b4b09 100644 --- a/MakerPrompt.Shared/Infrastructure/ConnectionComponentBase.cs +++ b/src/MakerPrompt.UI.Components/Infrastructure/ConnectionComponentBase.cs @@ -1,9 +1,10 @@ using BlazorBootstrap; +using MakerPrompt.Infrastructure.Services.Printers; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; -namespace MakerPrompt.Shared.Infrastructure +namespace MakerPrompt.UI.Components.Infrastructure { public abstract class ConnectionComponentBase : ComponentBase, IAsyncDisposable { diff --git a/MakerPrompt.Shared/Infrastructure/IAppConfigurationService.cs b/src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs similarity index 77% rename from MakerPrompt.Shared/Infrastructure/IAppConfigurationService.cs rename to src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs index f561d26..07ff421 100644 --- a/MakerPrompt.Shared/Infrastructure/IAppConfigurationService.cs +++ b/src/MakerPrompt.UI.Components/Infrastructure/IAppConfigurationService.cs @@ -1,4 +1,4 @@ -namespace MakerPrompt.Shared.Infrastructure +namespace MakerPrompt.UI.Components.Infrastructure { public interface IAppConfigurationService { diff --git a/MakerPrompt.Shared/Infrastructure/IStorageProvider.cs b/src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs similarity index 86% rename from MakerPrompt.Shared/Infrastructure/IStorageProvider.cs rename to src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs index 6e7c6e2..5559938 100644 --- a/MakerPrompt.Shared/Infrastructure/IStorageProvider.cs +++ b/src/MakerPrompt.UI.Components/Infrastructure/IStorageProvider.cs @@ -1,6 +1,6 @@ -namespace MakerPrompt.Shared.Infrastructure +namespace MakerPrompt.UI.Components.Infrastructure { - using MakerPrompt.Shared.Models; + using MakerPrompt.UI.Components.Models; public interface IStorageProvider { diff --git a/MakerPrompt.Shared/Infrastructure/PrinterStorageProvider.cs b/src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs similarity index 75% rename from MakerPrompt.Shared/Infrastructure/PrinterStorageProvider.cs rename to src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs index c00e200..34d58a9 100644 --- a/MakerPrompt.Shared/Infrastructure/PrinterStorageProvider.cs +++ b/src/MakerPrompt.UI.Components/Infrastructure/PrinterStorageProvider.cs @@ -1,6 +1,7 @@ -namespace MakerPrompt.Shared.Infrastructure +using MakerPrompt.Infrastructure.Services.Printers; + +namespace MakerPrompt.UI.Components.Infrastructure { - using MakerPrompt.Shared.Models; public sealed class PrinterStorageProvider : IStorageProvider { @@ -17,16 +18,17 @@ public async Task> ListFilesAsync(CancellationToken cancellation { var svc = factory.Current; if (svc == null) return []; - return await svc.GetFilesAsync() ?? []; + var paths = await svc.GetFilesAsync(cancellationToken); + return paths.Select(p => new FileEntry { FullPath = p }).ToList(); } public async Task OpenReadAsync(string fullPath, CancellationToken cancellationToken = default) { switch (factory.Current) { - case Services.DemoPrinterService demo: + case DemoPrinterService demo: return await demo.OpenReadAsync(fullPath); - case Services.MoonrakerApiService moonraker: + case MoonrakerApiService moonraker: return await moonraker.OpenReadAsync(fullPath, cancellationToken); default: return null; @@ -35,7 +37,7 @@ public async Task> ListFilesAsync(CancellationToken cancellation public async Task SaveFileAsync(string fullPath, Stream content, CancellationToken cancellationToken = default) { - if (factory.Current is Services.DemoPrinterService svc) + if (factory.Current is DemoPrinterService svc) { await svc.SaveFileAsync(fullPath, content); } @@ -43,7 +45,7 @@ public async Task SaveFileAsync(string fullPath, Stream content, CancellationTok public async Task DeleteFileAsync(string fullPath, CancellationToken cancellationToken = default) { - if (factory.Current is Services.DemoPrinterService svc) + if (factory.Current is DemoPrinterService svc) { await svc.DeleteFileAsync(fullPath); } diff --git a/MakerPrompt.Shared/Layout/MainLayout.razor b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor similarity index 91% rename from MakerPrompt.Shared/Layout/MainLayout.razor rename to src/MakerPrompt.UI.Components/Layout/MainLayout.razor index 20bad52..7e3276d 100644 --- a/MakerPrompt.Shared/Layout/MainLayout.razor +++ b/src/MakerPrompt.UI.Components/Layout/MainLayout.razor @@ -6,12 +6,12 @@