diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e000068..2afa8b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,4 +34,7 @@ jobs: run: dotnet build MakerPrompt.MAUI/MakerPrompt.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 \ No newline at end of file + run: dotnet build MakerPrompt.Blazor/MakerPrompt.Blazor.csproj -c Release --no-restore + + - name: Test (Unit) + run: dotnet test MakerPrompt.Tests/MakerPrompt.Tests.csproj -c Release diff --git a/MakerPrompt.Blazor/Services/AppConfigurationService.cs b/MakerPrompt.Blazor/Services/AppConfigurationService.cs index 2058426..aa2471d 100644 --- a/MakerPrompt.Blazor/Services/AppConfigurationService.cs +++ b/MakerPrompt.Blazor/Services/AppConfigurationService.cs @@ -21,10 +21,10 @@ public AppConfigurationService(IJSRuntime jsRuntime) public async Task InitializeAsync() { var json = await _jsRuntime.InvokeAsync("localStorage.getItem", StorageKey); - _config = json != null - ? JsonSerializer.Deserialize(json) - : new AppConfiguration(); - } + _config = json != null + ? JsonSerializer.Deserialize(json) ?? new AppConfiguration() + : new AppConfiguration(); + } public async Task SaveConfigurationAsync() { diff --git a/MakerPrompt.Shared/Components/ControlPanel.razor b/MakerPrompt.Shared/Components/ControlPanel.razor index 87cd6e4..b5b3065 100644 --- a/MakerPrompt.Shared/Components/ControlPanel.razor +++ b/MakerPrompt.Shared/Components/ControlPanel.razor @@ -11,9 +11,9 @@ disabled="@(!IsConnected)"> - X: @PrinterServiceFactory.Current?.LastTelemetry.Position.X.ToString("F2") - Y: @PrinterServiceFactory.Current?.LastTelemetry.Position.Y.ToString("F2") - Z: @PrinterServiceFactory.Current?.LastTelemetry.Position.Z.ToString("F2") + X: @Telemetry.Position.X.ToString("F2") + Y: @Telemetry.Position.Y.ToString("F2") + Z: @Telemetry.Position.Z.ToString("F2")
XY: @@ -30,7 +30,7 @@
- + @@ -59,26 +59,26 @@

@Localizer[Resources.ControlPanel_Heating]

- C: @PrinterServiceFactory.Current?.LastTelemetry.HotendTemp.ToString("F1") - T: @PrinterServiceFactory.Current?.LastTelemetry.HotendTarget.ToString("F1") - + C: @Telemetry.HotendTemp.ToString("F1") + T: @Telemetry.HotendTarget.ToString("F1") + °C - +
- C: @PrinterServiceFactory.Current?.LastTelemetry.BedTemp.ToString("F1") - T: @PrinterServiceFactory.Current?.LastTelemetry.BedTarget.ToString("F1") - + C: @Telemetry.BedTemp.ToString("F1") + T: @Telemetry.BedTarget.ToString("F1") + °C - +
- C: @PrinterServiceFactory.Current?.LastTelemetry.FanSpeed.ToString() + C: @Telemetry.FanSpeed.ToString() @Localizer[Resources.ControlPanel_FanSpeed]: % -
@@ -100,8 +100,8 @@
- - + +
@@ -111,16 +111,16 @@

@Localizer[Resources.ControlPanel_Status]

- C: @PrinterServiceFactory.Current?.LastTelemetry.FeedRate.ToString() + C: @Telemetry.FeedRate.ToString() @Localizer[Resources.ControlPanel_PrintSpeed]: - + %
- C: @PrinterServiceFactory.Current?.LastTelemetry.FlowRate.ToString() + C: @Telemetry.FlowRate.ToString() @Localizer[Resources.ControlPanel_PrintFlow]: - + %
@@ -132,13 +132,12 @@ @code { private int hotendTarget = 0; private int bedTarget = 0; - private int fanSpeed = 0; - private int extrudeLength = 5; + private int extrudeLength = 5; private int extrudeSpeed = 100; private int xySpeed = 1000; private int zSpeed = 100; private int printSpeed = 100; - private int printFlow = 100; + private int printFlow = 100; private bool isInverse; @@ -146,50 +145,101 @@ private AxisTab ActiveTab = AxisTab.Y; private enum AxisTab { X, Y, Z } - private void MotionReversed(ChangeEventArgs e) - { - // get the checkbox state - isInverse = (bool)e.Value; - } + private void MotionReversed(ChangeEventArgs e) + { + // get the checkbox state + isInverse = e.Value as bool? ?? false; + } - private void SetAxis(AxisTab tab) - { - ActiveTab = tab; - } - - private async Task HomeSelectedAxis() - { - switch (ActiveTab) - { - case AxisTab.X: - await PrinterServiceFactory.Current?.Home(true,false, false); - break; - case AxisTab.Y: - await PrinterServiceFactory.Current?.Home(false, true, false); - break; - case AxisTab.Z: - await PrinterServiceFactory.Current?.Home(false, false, true); - break; - default: - return; - } - } - - private async Task MoveSelectedAxis(int length) - { - switch (ActiveTab) - { - case AxisTab.X: - await PrinterServiceFactory.Current?.RelativeMove(xySpeed, length, 0, 0); - break; - case AxisTab.Y: - await PrinterServiceFactory.Current?.RelativeMove(xySpeed, 0, length, 0); - break; - case AxisTab.Z: - await PrinterServiceFactory.Current?.RelativeMove(zSpeed, 0, 0, length); - break; - default: - return; - } - } -} \ No newline at end of file + private void SetAxis(AxisTab tab) + { + ActiveTab = tab; + } + + private Task HomeAllAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.Home() ?? Task.CompletedTask; + } + + private async Task HomeSelectedAxis() + { + var printer = PrinterServiceFactory.Current; + if (printer == null) return; + + switch (ActiveTab) + { + case AxisTab.X: + await printer.Home(true,false, false); + break; + case AxisTab.Y: + await printer.Home(false, true, false); + break; + case AxisTab.Z: + await printer.Home(false, false, true); + break; + default: + return; + } + } + + private async Task MoveSelectedAxis(int length) + { + var printer = PrinterServiceFactory.Current; + if (printer == null) return; + + switch (ActiveTab) + { + case AxisTab.X: + await printer.RelativeMove(xySpeed, length, 0, 0); + break; + case AxisTab.Y: + await printer.RelativeMove(xySpeed, 0, length, 0); + break; + case AxisTab.Z: + await printer.RelativeMove(zSpeed, 0, 0, length); + break; + default: + return; + } + } + + private Task SetHotendTempAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetHotendTemp(hotendTarget) ?? Task.CompletedTask; + } + + private Task SetBedTempAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetBedTemp(bedTarget) ?? Task.CompletedTask; + } + + private Task SetFanSpeedAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetFanSpeed(printSpeed) ?? Task.CompletedTask; + } + + private Task ExtrudeAsync(int amount) + { + var printer = PrinterServiceFactory.Current; + return printer?.RelativeMove(extrudeSpeed, 0, 0, 0, amount) ?? Task.CompletedTask; + } + + private Task UpdatePrintSpeedAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetPrintSpeed(printSpeed) ?? Task.CompletedTask; + } + + private Task UpdatePrintFlowAsync() + { + var printer = PrinterServiceFactory.Current; + return printer?.SetPrintFlow(printFlow) ?? Task.CompletedTask; + } + + private readonly PrinterTelemetry fallbackTelemetry = new(); + private PrinterTelemetry Telemetry => PrinterServiceFactory.Current?.LastTelemetry ?? fallbackTelemetry; +} diff --git a/MakerPrompt.Shared/Components/FileExplorer.razor b/MakerPrompt.Shared/Components/FileExplorer.razor index 48a5b87..1f1da6a 100644 --- a/MakerPrompt.Shared/Components/FileExplorer.razor +++ b/MakerPrompt.Shared/Components/FileExplorer.razor @@ -2,7 +2,7 @@
-
@code { - private CalibrationParameters Calibration { get; set; } = new(); - private string calibrationResult = ""; + private CalibrationParameters Calibration { get; set; } = new(); private async Task RunThermalModelCalibration() { diff --git a/MakerPrompt.Shared/Layout/NavConnection.razor b/MakerPrompt.Shared/Layout/NavConnection.razor index f85e964..a642afd 100644 --- a/MakerPrompt.Shared/Layout/NavConnection.razor +++ b/MakerPrompt.Shared/Layout/NavConnection.razor @@ -143,7 +143,7 @@ private bool HasValidSelection => ActiveTab == PrinterConnectionType.Demo || !string.IsNullOrEmpty(SerialConnectionSettings.PortName) || !string.IsNullOrEmpty(ApiConnectionSettings.Url); private string ConnectButtonClass => IsConnected ? "btn-danger" : "btn-success"; private string ConnectButtonText => IsConnected ? localizer[Resources.NavConnection_Disconnect] : localizer[Resources.NavConnection_Connect]; - private string NavButtonText => IsConnected ? ServiceFactory.Current.ConnectionName : localizer[Resources.NavConnection_Connect]; + private string NavButtonText => IsConnected ? ServiceFactory.Current?.ConnectionName ?? localizer[Resources.NavConnection_Connect] : localizer[Resources.NavConnection_Connect]; protected override async Task OnInitializedAsync() { @@ -178,13 +178,14 @@ else { - var connectionSettings = ActiveTab switch - { - PrinterConnectionType.Demo => new PrinterConnectionSettings(), - PrinterConnectionType.Serial => new PrinterConnectionSettings(SerialConnectionSettings), - PrinterConnectionType.Moonraker => new PrinterConnectionSettings(ApiConnectionSettings, ActiveTab), - PrinterConnectionType.PrusaLink => new PrinterConnectionSettings(ApiConnectionSettings, ActiveTab) - }; + var connectionSettings = ActiveTab switch + { + PrinterConnectionType.Demo => new PrinterConnectionSettings(), + PrinterConnectionType.Serial => new PrinterConnectionSettings(SerialConnectionSettings), + PrinterConnectionType.Moonraker => new PrinterConnectionSettings(ApiConnectionSettings, ActiveTab), + PrinterConnectionType.PrusaLink => new PrinterConnectionSettings(ApiConnectionSettings, ActiveTab), + _ => new PrinterConnectionSettings() + }; await ServiceFactory.ConnectAsync(connectionSettings); } @@ -195,11 +196,11 @@ } } - private void HandleConnectionChanged(object sender, bool isConnected) - { - IsConnected = isConnected; - StateHasChanged(); - } + private void HandleConnectionChanged(object? sender, bool isConnected) + { + IsConnected = isConnected; + StateHasChanged(); + } private void ShowTab(PrinterConnectionType tab) { @@ -210,4 +211,4 @@ { ServiceFactory.ConnectionStateChanged -= HandleConnectionChanged; } -} \ No newline at end of file +} diff --git a/MakerPrompt.Shared/Services/MoonrakerApiService.cs b/MakerPrompt.Shared/Services/MoonrakerApiService.cs index d7bddfa..e3c1fab 100644 --- a/MakerPrompt.Shared/Services/MoonrakerApiService.cs +++ b/MakerPrompt.Shared/Services/MoonrakerApiService.cs @@ -1,45 +1,64 @@ -using System.Net; -using System.Net.Http.Headers; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using MakerPrompt.Shared.Infrastructure; +using MakerPrompt.Shared.Models; +using MakerPrompt.Shared.Utils; +using static MakerPrompt.Shared.Utils.Enums; namespace MakerPrompt.Shared.Services { - public class MoonrakerApiService : BasePrinterConnectionService, IPrinterCommunicationService - { - private HttpClient _httpClient; - private Uri _baseUri; - private string _jwtToken = string.Empty; - private string _refreshToken = string.Empty; - public override PrinterConnectionType ConnectionType { get; } = PrinterConnectionType.Moonraker; - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) - { - if (IsConnected) return IsConnected; - - if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Api == null) throw new ArgumentException(); - - _baseUri = new Uri(connectionSettings.Api.Url); - _httpClient = new HttpClient - { - BaseAddress = _baseUri, - Timeout = TimeSpan.FromSeconds(30) - }; - - if (!string.IsNullOrEmpty(connectionSettings.Api.UserName) && !string.IsNullOrEmpty(connectionSettings.Api.Password)) - { - IsConnected = await AuthenticateAsync(connectionSettings.Api.UserName, connectionSettings.Api.Password); - if (!IsConnected) return IsConnected; + public class MoonrakerApiService : BasePrinterConnectionService, IPrinterCommunicationService + { + private readonly CancellationTokenSource _cts = new(); + private readonly HttpMessageHandler? _customHandler; + private HttpClient? _httpClient; + private Uri _baseUri = null!; + private string _jwtToken = string.Empty; + private string _refreshToken = string.Empty; + public override PrinterConnectionType ConnectionType { get; } = PrinterConnectionType.Moonraker; + + public MoonrakerApiService() + { + } + + public MoonrakerApiService(HttpMessageHandler handler) + { + _customHandler = handler; + _httpClient = new HttpClient(handler, false); + } + + private HttpClient Client => _httpClient ??= _customHandler != null + ? new HttpClient(_customHandler, false) + : new HttpClient(); + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (IsConnected) return IsConnected; + + if (connectionSettings.ConnectionType != ConnectionType || connectionSettings.Api == null) throw new ArgumentException(); + + _baseUri = new Uri(connectionSettings.Api.Url); + ConfigureClient(connectionSettings.Api); + + if (!string.IsNullOrEmpty(connectionSettings.Api.UserName) && !string.IsNullOrEmpty(connectionSettings.Api.Password)) + { + IsConnected = await AuthenticateAsync(connectionSettings.Api.UserName, connectionSettings.Api.Password); + if (!IsConnected) return IsConnected; } - try - { - var response = await _httpClient.GetAsync("/printer/info"); - IsConnected = response.IsSuccessStatusCode; - updateTimer.Elapsed += async (s, e) => await GetPrinterTelemetryAsync(); - updateTimer.Start(); - ConnectionName = _baseUri.AbsoluteUri; - } - catch - { + try + { + var response = await Client.GetAsync("/printer/info", _cts.Token); + IsConnected = response.IsSuccessStatusCode; + updateTimer.Elapsed += async (s, e) => await SafeTelemetryAsync(); + updateTimer.Start(); + ConnectionName = _baseUri.AbsoluteUri; + } + catch + { IsConnected = false; } @@ -47,39 +66,41 @@ public async Task ConnectAsync(PrinterConnectionSettings connectionSetting return IsConnected; } - public async Task DisconnectAsync() - { - updateTimer.Stop(); - _httpClient.CancelPendingRequests(); - IsConnected = false; - RaiseConnectionChanged(); - await Task.CompletedTask; - } - - public async Task WriteDataAsync(string command) - { - if (!IsConnected) return; - - var response = await _httpClient.PostAsync( - $"/printer/gcode/script?script={WebUtility.UrlEncode(command)}", - null); - - var content = await response.Content.ReadAsStringAsync(); - LastTelemetry.LastResponse = content; - RaiseTelemetryUpdated(); - } + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + public async Task WriteDataAsync(string command) + { + if (!IsConnected) return; + + var response = await Client.PostAsync( + $"/printer/gcode/script?script={WebUtility.UrlEncode(command)}", + null, + _cts.Token); + + var content = await response.Content.ReadAsStringAsync(); + LastTelemetry.LastResponse = content; + RaiseTelemetryUpdated(); + } public async Task GetPrinterTelemetryAsync() { if (!IsConnected) return LastTelemetry; // Get temperature data - var tempResponse = await _httpClient.GetAsync("/printer/objects/query?heater_bed&extruder"); - if (tempResponse.IsSuccessStatusCode) - { - var json = await tempResponse.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement.GetProperty("result").GetProperty("status"); + var tempResponse = await Client.GetAsync("/printer/objects/query?heater_bed&extruder", _cts.Token); + if (tempResponse.IsSuccessStatusCode) + { + var json = await tempResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement.GetProperty("result").GetProperty("status"); LastTelemetry.BedTemp = root.GetProperty("heater_bed").GetProperty("temperature").GetDouble(); LastTelemetry.BedTarget = root.GetProperty("heater_bed").GetProperty("target").GetDouble(); @@ -87,12 +108,12 @@ public async Task GetPrinterTelemetryAsync() LastTelemetry.HotendTarget = root.GetProperty("extruder").GetProperty("target").GetDouble(); } - var motionResponse = await _httpClient.GetAsync("/printer/objects/query?gcode_move,fan"); - if (motionResponse.IsSuccessStatusCode) - { - var json = await motionResponse.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - var status = doc.RootElement.GetProperty("result").GetProperty("status"); + var motionResponse = await Client.GetAsync("/printer/objects/query?gcode_move,fan", _cts.Token); + if (motionResponse.IsSuccessStatusCode) + { + var json = await motionResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var status = doc.RootElement.GetProperty("result").GetProperty("status"); // Position data var position = status.GetProperty("gcode_move").GetProperty("position"); @@ -113,12 +134,12 @@ public async Task GetPrinterTelemetryAsync() } // Get printer status - var statusResponse = await _httpClient.GetAsync("/printer/objects/query?print_stats"); - if (statusResponse.IsSuccessStatusCode) - { - var json = await statusResponse.Content.ReadAsStringAsync(); - LastTelemetry.Status = json.Contains("printing") ? - PrinterStatus.Printing : + var statusResponse = await Client.GetAsync("/printer/objects/query?print_stats", _cts.Token); + if (statusResponse.IsSuccessStatusCode) + { + var json = await statusResponse.Content.ReadAsStringAsync(); + LastTelemetry.Status = json.Contains("printing") ? + PrinterStatus.Printing : PrinterStatus.Connected; } @@ -130,18 +151,19 @@ public async Task> GetFilesAsync() { if (!IsConnected) return []; - var response = await _httpClient.GetAsync( - $"/server/files/list?root=gcodes"); - response.EnsureSuccessStatusCode(); - var content = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); - return content.Files.Select(f => new FileEntry - { - FullPath = f.Path, - Size = f.Size, - ModifiedDate = f.ModifiedDate, - IsAvailable = f.Permissions.Contains("rw"), - }).ToList() ?? []; - } + var response = await Client.GetAsync( + $"/server/files/list?root=gcodes", _cts.Token); + 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(); + } public async Task AuthenticateAsync(string username, string password) { @@ -157,20 +179,20 @@ public async Task AuthenticateAsync(string username, string password) var json = JsonSerializer.Serialize(request); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("/access/login", content); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(); - var authResponse = JsonSerializer.Deserialize(responseJson); - - _jwtToken = authResponse?.Token ?? string.Empty; - _refreshToken = authResponse?.RefreshToken ?? string.Empty; - - if (!string.IsNullOrEmpty(_jwtToken)) - { - _httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", _jwtToken); - } + var response = await Client.PostAsync("/access/login", content, _cts.Token); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + var authResponse = JsonSerializer.Deserialize(responseJson); + + _jwtToken = authResponse?.Token ?? string.Empty; + _refreshToken = authResponse?.RefreshToken ?? string.Empty; + + if (!string.IsNullOrEmpty(_jwtToken)) + { + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _jwtToken); + } return true; } @@ -180,18 +202,126 @@ public async Task AuthenticateAsync(string username, string password) } } - public void Dispose() - { - updateTimer.Dispose(); - _httpClient.Dispose(); - GC.SuppressFinalize(this); - } - - public override ValueTask DisposeAsync() - { - Dispose(); - return ValueTask.CompletedTask; - } + public void Dispose() + { + updateTimer.Dispose(); + _httpClient?.Dispose(); + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + public override ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + private void ConfigureClient(ApiConnectionSettings settings) + { + var client = Client; + client.BaseAddress = _baseUri; + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(settings.UserName) && !string.IsNullOrEmpty(settings.Password)) + { + var credentialBytes = Encoding.ASCII.GetBytes($"{settings.UserName}:{settings.Password}"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes)); + } + } + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync(); + } + catch + { + // swallow background telemetry errors + } + } + + private Task SendGcodeAsync(string gcode) => WriteDataAsync(gcode); + + public Task SetHotendTemp(int targetTemp = 0) => + SendGcodeAsync($"M104 S{targetTemp}"); + + public Task SetBedTemp(int targetTemp = 0) => + SendGcodeAsync($"M140 S{targetTemp}"); + + public Task Home(bool x = true, bool y = true, bool z = true) + { + var axes = new StringBuilder(); + if (x) axes.Append(" X"); + if (y) axes.Append(" Y"); + if (z) axes.Append(" Z"); + var command = axes.Length == 0 ? "G28" : $"G28{axes}"; + return SendGcodeAsync(command); + } + + public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + var sb = new StringBuilder(); + sb.Append("G91\nG1"); + if (Math.Abs(x) > 0.0001f) sb.Append($" X{x}"); + if (Math.Abs(y) > 0.0001f) sb.Append($" Y{y}"); + if (Math.Abs(z) > 0.0001f) sb.Append($" Z{z}"); + if (Math.Abs(e) > 0.0001f) sb.Append($" E{e}"); + sb.Append($" F{feedRate}\nG90"); + return SendGcodeAsync(sb.ToString()); + } + + public Task SetFanSpeed(int speed) + { + 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) + { + var clamped = Math.Clamp(speed, 1, 200); + return SendGcodeAsync($"M220 S{clamped}"); + } + + public Task SetPrintFlow(int flow) + { + var clamped = Math.Clamp(flow, 1, 200); + return SendGcodeAsync($"M221 S{clamped}"); + } + + public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) + { + var sb = new StringBuilder("M92"); + if (x > 0) sb.Append($" X{x}"); + if (y > 0) sb.Append($" Y{y}"); + if (z > 0) sb.Append($" Z{z}"); + if (e > 0) sb.Append($" E{e}"); + return SendGcodeAsync(sb.ToString()); + } + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) + { + var heater = extruderIndex == 0 ? "extruder" : $"extruder{extruderIndex}"; + return SendGcodeAsync($"PID_CALIBRATE HEATER={heater} TARGET={targetTemp}"); + } + + public Task RunThermalModelCalibration(int cycles, int targetTemp) + { + // No direct Moonraker endpoint; fall back to PID tuning for the bed as closest available. + return SendGcodeAsync($"PID_CALIBRATE HEATER=heater_bed TARGET={targetTemp}"); + } + + public async Task StartPrint(FileEntry file) + { + var filename = WebUtility.UrlEncode(file.FullPath); + await Client.PostAsync($"/printer/print/start?filename={filename}", null, _cts.Token); + } + + public Task SaveEEPROM() => SendGcodeAsync("SAVE_CONFIG"); private record AuthResponse { @@ -239,65 +369,6 @@ private record FileItem public bool IsDirectory => Size == 0; } - public Task SetHotendTemp(int targetTemp = 0) - { - throw new NotImplementedException(); - } - - public Task SetBedTemp(int targetTemp = 0) - { - throw new NotImplementedException(); - } - - public Task Home(bool x = true, bool y = true, bool z = true) - { - throw new NotImplementedException(); - } - - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) - { - throw new NotImplementedException(); - } - - public Task SetFanSpeed(int speed) - { - throw new NotImplementedException(); - } - - public Task SetPrintSpeed(int speed) - { - throw new NotImplementedException(); - } - - public Task SetPrintFlow(int flow) - { - throw new NotImplementedException(); - } - - public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) - { - throw new NotImplementedException(); - } - - public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) - { - throw new NotImplementedException(); - } - - public Task RunThermalModelCalibration(int cycles, int targetTemp) - { - throw new NotImplementedException(); - } - - public Task StartPrint(FileEntry file) - { - throw new NotImplementedException(); - } - - public Task SaveEEPROM() - { - throw new NotImplementedException(); - } - } - -} \ No newline at end of file + } + +} diff --git a/MakerPrompt.Shared/Services/PrusaLinkApiService.cs b/MakerPrompt.Shared/Services/PrusaLinkApiService.cs index 6e6a38f..f7ff502 100644 --- a/MakerPrompt.Shared/Services/PrusaLinkApiService.cs +++ b/MakerPrompt.Shared/Services/PrusaLinkApiService.cs @@ -1,255 +1,475 @@ -namespace MakerPrompt.Shared.Services -{ - public class PrusaLinkApiService : BasePrinterConnectionService, IPrinterCommunicationService - { - private readonly HttpClient _httpClient; - private readonly Uri _baseUri; - private readonly CancellationTokenSource _cts = new(); - - public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaLink; - - public PrusaLinkApiService() - { - // TODO check compatibility warning - //_baseUri = new Uri(connectionSettings.Url); - //var handler = new HttpClientHandler - //{ - // PreAuthenticate = true - //}; - - //if (true) - //{ - // var credentials = new NetworkCredential( - // connectionSettings.UserName, - // connectionSettings.Password); - // handler.Credentials = credentials; - //} - - //_httpClient = new HttpClient(handler) { BaseAddress = _baseUri }; - } - - public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) - { - try - { - var version = await GetVersionAsync(); - if (version == null) return IsConnected = false; - - IsConnected = true; - RaiseConnectionChanged(); - } - catch - { - IsConnected = false; - RaiseConnectionChanged(); - } - - return IsConnected; - } - - public async Task DisconnectAsync() - { - updateTimer?.Stop(); - _httpClient.CancelPendingRequests(); - IsConnected = false; - RaiseConnectionChanged(); - await Task.CompletedTask; - } - - public async Task WriteDataAsync(string command) - { - // PrusaLink doesn't support direct G-code injection like Moonraker - // This would need to be implemented differently based on actual capabilities - throw new NotSupportedException("Direct G-code commands not supported in PrusaLink"); - } - - public async Task GetPrinterTelemetryAsync() - { - var response = await _httpClient.GetStringAsync("/api/v1/status"); - var status = JsonSerializer.Deserialize(response); - - LastTelemetry = new PrinterTelemetry - { - LastResponse = "PrusaLink status update", - ConnectionTime = IsConnected ? DateTime.Now : null, - HotendTemp = status?.Printer.TempNozzle ?? 0, - HotendTarget = status?.Printer.TargetNozzle ?? 0, - BedTemp = status?.Printer.TempBed ?? 0, - BedTarget = status?.Printer.TargetBed ?? 0, - Position = new Vector3( - status?.Printer.AxisX ?? 0, - status?.Printer.AxisY ?? 0, - status?.Printer.AxisZ ?? 0 - ), - Status = MapPrusaStatus(status?.Printer.State), - FanSpeed = status?.Printer.FanPrint ?? 0 , - SDCard = { - Present = status?.Storage?.Any(s => s.Type == "SDCARD" && s.Available) ?? false, - Printing = status?.Printer.State == "PRINTING" - } - }; - - RaiseTelemetryUpdated(); - return LastTelemetry; - } - - public async Task> GetFilesAsync() - { - return []; - // var response = await _httpClient.GetAsync("/api/v1/storage"); - // response.EnsureSuccessStatusCode(); - // var content = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); - // return response?.Storage.Select(s => new FileEntry - // { - // FullPath = s.Path, - // Size = s.Size, - // Available = !s.Readonly, - // }).ToList() ?? new List(); - } - - private async Task GetVersionAsync() - { - var response = await _httpClient.GetStringAsync("/api/version"); - return JsonSerializer.Deserialize(response); - } - - private static PrinterStatus MapPrusaStatus(string? status) => status?.ToUpper() switch - { - "PRINTING" => PrinterStatus.Printing, - "PAUSED" => PrinterStatus.Paused, - "ERROR" => PrinterStatus.Error, - "READY" => PrinterStatus.Connected, - _ => PrinterStatus.Disconnected - }; - - public void Dispose() - { - _cts?.Cancel(); - _httpClient.Dispose(); - GC.SuppressFinalize(this); - } - - public override ValueTask DisposeAsync() - { - Dispose(); - return ValueTask.CompletedTask; - } - - public Task SetHotendTemp(int targetTemp = 0) - { - throw new NotImplementedException(); - } - - public Task SetBedTemp(int targetTemp = 0) - { - throw new NotImplementedException(); - } - - public Task Home(bool x = true, bool y = true, bool z = true) - { - throw new NotImplementedException(); - } - - public Task RelativeMove(int feedRate, float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) - { - throw new NotImplementedException(); - } - - public Task SetFanSpeed(int speed) - { - throw new NotImplementedException(); - } - - public Task SetPrintSpeed(int speed) - { - throw new NotImplementedException(); - } - - public Task SetPrintFlow(int flow) - { - throw new NotImplementedException(); - } - - public Task SetAxisPerUnit(float x = 0.0f, float y = 0.0f, float z = 0.0f, float e = 0.0f) - { - throw new NotImplementedException(); - } - - public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) - { - throw new NotImplementedException(); - } - - public Task RunThermalModelCalibration(int cycles, int targetTemp) - { - throw new NotImplementedException(); - } - - public Task StartPrint(FileEntry file) - { - throw new NotImplementedException(); - } - - public Task SaveEEPROM() - { - throw new NotImplementedException(); - } - } - - // PrusaLink DTOs - public class PrusaVersionResponse - { - public string Api { get; set; } - public string Version { get; set; } - public string Printer { get; set; } - public string Text { get; set; } - public string Firmware { get; set; } - } - - public class PrusaStatusResponse - { - public PrusaStatusPrinter Printer { get; set; } - public PrusaStatusJob? Job { get; set; } - public List? Storage { get; set; } - } - - public class PrusaStatusPrinter - { - public string State { get; set; } - public double? TempNozzle { get; set; } - public double? TargetNozzle { get; set; } - public double? TempBed { get; set; } - public double? TargetBed { get; set; } - public float? AxisX { get; set; } - public float? AxisY { get; set; } - public float? AxisZ { get; set; } - public int? FanPrint { get; set; } - } - - public class PrusaStatusJob - { - public int Id { get; set; } - public double Progress { get; set; } - public int TimeRemaining { get; set; } - public int TimePrinting { get; set; } - } - - public class PrusaStorage - { - public string Name { get; set; } - public string Type { get; set; } - public bool Available { get; set; } - } - - public class PrusaFileItem - { - [JsonPropertyName("name")] - public string Name { get; set; } - [JsonPropertyName("path")] - public string Path { get; set; } - [JsonPropertyName("read_only")] - public bool Readonly { get; set; } - [JsonPropertyName("free_space")] - public long Size { get; set; } - } -} \ No newline at end of file +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using MakerPrompt.Shared.Infrastructure; +using MakerPrompt.Shared.Models; +using MakerPrompt.Shared.Utils; +using static MakerPrompt.Shared.Utils.Enums; + +namespace MakerPrompt.Shared.Services; + +public class PrusaLinkApiService : BasePrinterConnectionService, IPrinterCommunicationService +{ + private readonly CancellationTokenSource _cts = new(); + private readonly HttpMessageHandler? _customHandler; + private HttpClient? _httpClient; + private bool _ownsClient; + private ApiConnectionSettings? _connectionSettings; + private Uri? _baseUri; + + public override PrinterConnectionType ConnectionType => PrinterConnectionType.PrusaLink; + + public PrusaLinkApiService() + { + } + + public PrusaLinkApiService(HttpMessageHandler handler) + { + _customHandler = handler; + _httpClient = new HttpClient(handler, false); + _ownsClient = true; + } + + private HttpClient Client + { + get + { + _httpClient ??= new HttpClient(); + return _httpClient; + } + } + + public async Task ConnectAsync(PrinterConnectionSettings connectionSettings) + { + if (connectionSettings.Api is null) + { + throw new ArgumentException("PrusaLink connection requires API settings.", nameof(connectionSettings)); + } + + _connectionSettings = connectionSettings.Api; + ConfigureClient(_connectionSettings); + + try + { + var version = await GetVersionAsync(_cts.Token); + if (version == null) + { + IsConnected = false; + RaiseConnectionChanged(); + return IsConnected; + } + + var info = await GetInfoAsync(_cts.Token); + ConnectionName = _baseUri?.AbsoluteUri ?? _connectionSettings.Url; + LastTelemetry.PrinterName = info?.Name ?? LastTelemetry.PrinterName; + LastTelemetry.ConnectionTime ??= DateTime.UtcNow; + + updateTimer.Elapsed += async (_, _) => await SafeTelemetryAsync(); + updateTimer.Start(); + + IsConnected = true; + } + catch + { + IsConnected = false; + } + + RaiseConnectionChanged(); + return IsConnected; + } + + private async Task SafeTelemetryAsync() + { + try + { + await GetPrinterTelemetryAsync(); + } + catch + { + // swallow background polling errors to avoid breaking UI + } + } + + public async Task DisconnectAsync() + { + updateTimer.Stop(); + _cts.Cancel(); + _httpClient?.CancelPendingRequests(); + IsConnected = false; + RaiseConnectionChanged(); + await Task.CompletedTask; + } + + public Task WriteDataAsync(string command) + { + throw new NotSupportedException("Direct G-code injection is not supported by the PrusaLink API."); + } + + public async Task GetPrinterTelemetryAsync() + { + var status = await GetStatusAsync(_cts.Token); + if (status?.Printer is null) + { + return LastTelemetry; + } + + LastTelemetry.HotendTemp = status.Printer.TempNozzle ?? LastTelemetry.HotendTemp; + LastTelemetry.HotendTarget = status.Printer.TargetNozzle ?? LastTelemetry.HotendTarget; + LastTelemetry.BedTemp = status.Printer.TempBed ?? LastTelemetry.BedTemp; + LastTelemetry.BedTarget = status.Printer.TargetBed ?? LastTelemetry.BedTarget; + LastTelemetry.Position = new Vector3( + status.Printer.AxisX ?? LastTelemetry.Position.X, + status.Printer.AxisY ?? LastTelemetry.Position.Y, + status.Printer.AxisZ ?? LastTelemetry.Position.Z); + LastTelemetry.Status = MapPrusaStatus(status.Printer.State); + LastTelemetry.FanSpeed = status.Printer.FanPrint ?? LastTelemetry.FanSpeed; + LastTelemetry.FeedRate = status.Printer.Speed ?? LastTelemetry.FeedRate; + LastTelemetry.FlowRate = status.Printer.Flow ?? LastTelemetry.FlowRate; + + if (status.Job != null) + { + LastTelemetry.SDCard.Printing = status.Job.State?.Equals("PRINTING", StringComparison.OrdinalIgnoreCase) == true; + } + + if (status.Storage != null) + { + LastTelemetry.SDCard.Present = !status.Storage.ReadOnly; + } + + LastTelemetry.LastResponse = "PrusaLink status update"; + RaiseTelemetryUpdated(); + return LastTelemetry; + } + + public async Task> GetFilesAsync() + { + var storages = await GetStorageAsync(_cts.Token); + var firstStorage = storages?.StorageList?.FirstOrDefault(); + if (firstStorage == null) + { + return []; + } + + var storageKey = firstStorage.Path.Trim('/'); + var folder = await GetFolderAsync(storageKey, "/", _cts.Token); + if (folder?.Children == null) + { + return []; + } + + return folder.Children.Select(child => new FileEntry + { + FullPath = $"{firstStorage.Path}{child.Path ?? string.Empty}", + Size = child.Size ?? 0, + ModifiedDate = child.Timestamp.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(child.Timestamp.Value).DateTime + : null, + IsAvailable = !child.ReadOnly + }).ToList(); + } + + public Task SetHotendTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose hotend temperature control.")); + + public Task SetBedTemp(int targetTemp = 0) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose bed temperature control.")); + + public Task Home(bool x = true, bool y = true, bool z = true) => + 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) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose move commands.")); + + public Task SetFanSpeed(int fanSpeedPercentage = 0) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose fan control.")); + + public Task SetPrintSpeed(int speed) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose print speed control.")); + + public Task SetPrintFlow(int flow) => + 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) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose steps-per-unit control.")); + + public Task RunPidTuning(int cycles, int targetTemp, int extruderIndex) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose PID tuning commands.")); + + public Task RunThermalModelCalibration(int cycles, int targetTemp) => + Task.FromException(new NotSupportedException("PrusaLink API does not expose thermal model calibration.")); + + public Task StartPrint(FileEntry file) => + Task.FromException(new NotSupportedException("Starting prints requires uploading with Print-After-Upload per PrusaLink spec.")); + + public Task SaveEEPROM() => + Task.FromException(new NotSupportedException("PrusaLink API does not expose EEPROM save commands.")); + + public override ValueTask DisposeAsync() + { + _cts.Cancel(); + if (_ownsClient) + { + _httpClient?.Dispose(); + } + + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + private void ConfigureClient(ApiConnectionSettings settings) + { + _baseUri = new Uri(settings.Url); + + if (_customHandler != null) + { + _httpClient ??= new HttpClient(_customHandler, false); + _ownsClient = false; + } + else + { + _httpClient?.Dispose(); + var handler = new HttpClientHandler(); + if (!string.IsNullOrEmpty(settings.UserName) || !string.IsNullOrEmpty(settings.Password)) + { +#pragma warning disable CA1416 + handler.Credentials = new NetworkCredential(settings.UserName, settings.Password); + handler.PreAuthenticate = true; +#pragma warning restore CA1416 + } + + _httpClient = new HttpClient(handler); + _ownsClient = true; + } + + Client.BaseAddress = _baseUri; + Client.Timeout = TimeSpan.FromSeconds(30); + Client.DefaultRequestHeaders.Accept.Clear(); + Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (!string.IsNullOrEmpty(settings.UserName) || !string.IsNullOrEmpty(settings.Password)) + { + var credentialBytes = Encoding.ASCII.GetBytes($"{settings.UserName}:{settings.Password}"); + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes)); + } + } + + private async Task GetVersionAsync(CancellationToken cancellationToken) => + await GetAsync("/api/version", cancellationToken); + + private async Task GetInfoAsync(CancellationToken cancellationToken) => + await GetAsync("/api/v1/info", cancellationToken); + + private async Task GetStatusAsync(CancellationToken cancellationToken) => + await GetAsync("/api/v1/status", cancellationToken); + + private async Task GetStorageAsync(CancellationToken cancellationToken) => + await GetAsync("/api/v1/storage", cancellationToken); + + private async Task GetFolderAsync(string storage, string path, CancellationToken cancellationToken) + { + var sanitizedStorage = storage.Trim('/'); + var encodedPath = Uri.EscapeDataString(path.TrimStart('/')); + var requestPath = $"/api/v1/files/{sanitizedStorage}/{encodedPath}"; + return await GetAsync(requestPath, cancellationToken); + } + + private async Task GetAsync(string path, CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + var response = await SendWithRetryAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return default; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + } + + private async Task SendWithRetryAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + const int maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var response = await Client.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode || attempt == maxAttempts || (int)response.StatusCode < 500) + { + return response; + } + } + catch when (attempt < maxAttempts) + { + // transient failure, retry below + } + + await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt), cancellationToken); + } + + return await Client.SendAsync(request, cancellationToken); + } + + private static PrinterStatus MapPrusaStatus(string? status) => status?.ToUpperInvariant() switch + { + "PRINTING" => PrinterStatus.Printing, + "PAUSED" => PrinterStatus.Paused, + "ERROR" => PrinterStatus.Error, + "READY" or "IDLE" => PrinterStatus.Connected, + _ => PrinterStatus.Disconnected + }; +} + +public sealed class PrusaVersionResponse +{ + [JsonPropertyName("api")] + public string Api { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("printer")] + public string Printer { get; set; } = string.Empty; + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("firmware")] + public string Firmware { get; set; } = string.Empty; +} + +public sealed class PrusaInfoResponse +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("hostname")] + public string? Hostname { get; set; } +} + +public sealed class PrusaStatusResponse +{ + [JsonPropertyName("printer")] + public PrusaStatusPrinter? Printer { get; set; } + + [JsonPropertyName("job")] + public PrusaStatusJob? Job { get; set; } + + [JsonPropertyName("storage")] + public PrusaStatusStorage? Storage { get; set; } +} + +public sealed class PrusaStatusPrinter +{ + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("temp_nozzle")] + public double? TempNozzle { get; set; } + + [JsonPropertyName("target_nozzle")] + public double? TargetNozzle { get; set; } + + [JsonPropertyName("temp_bed")] + public double? TempBed { get; set; } + + [JsonPropertyName("target_bed")] + public double? TargetBed { get; set; } + + [JsonPropertyName("axis_x")] + public float? AxisX { get; set; } + + [JsonPropertyName("axis_y")] + public float? AxisY { get; set; } + + [JsonPropertyName("axis_z")] + public float? AxisZ { get; set; } + + [JsonPropertyName("flow")] + public int? Flow { get; set; } + + [JsonPropertyName("speed")] + public int? Speed { get; set; } + + [JsonPropertyName("fan_print")] + public int? FanPrint { get; set; } +} + +public sealed class PrusaStatusJob +{ + [JsonPropertyName("id")] + public int? Id { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("progress")] + public double? Progress { get; set; } + + [JsonPropertyName("time_remaining")] + public int? TimeRemaining { get; set; } + + [JsonPropertyName("time_printing")] + public int? TimePrinting { get; set; } +} + +public sealed class PrusaStatusStorage +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("read_only")] + public bool ReadOnly { get; set; } + + [JsonPropertyName("free_space")] + public long? FreeSpace { get; set; } +} + +public sealed class PrusaStorageListResponse +{ + [JsonPropertyName("storage_list")] + public List? StorageList { get; set; } +} + +public sealed class PrusaStorageItem +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; +} + +public sealed class PrusaFileSystemEntry +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("read_only")] + public bool ReadOnly { get; set; } + + [JsonPropertyName("size")] + public long? Size { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("m_timestamp")] + public long? Timestamp { get; set; } + + [JsonPropertyName("children")] + public List? Children { get; set; } +} diff --git a/MakerPrompt.Shared/Utils/GCodeCommands.cs b/MakerPrompt.Shared/Utils/GCodeCommands.cs index 809b344..d6a7e5a 100644 --- a/MakerPrompt.Shared/Utils/GCodeCommands.cs +++ b/MakerPrompt.Shared/Utils/GCodeCommands.cs @@ -51,11 +51,12 @@ public static GCodeCommand SetParameterValue(this GCodeCommand command, char lab return command; } - public static List AllCommands() => typeof(GCodeCommands) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.FieldType == typeof(GCodeCommand)) - .Select(f => (GCodeCommand)f.GetValue(null)) - .ToList(); + public static List AllCommands() => typeof(GCodeCommands) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(GCodeCommand)) + .Select(f => f.GetValue(null)) + .OfType() + .ToList(); // Movement Commands public static GCodeCommand MoveLinearRapid = diff --git a/MakerPrompt.Tests/MakerPrompt.Tests.csproj b/MakerPrompt.Tests/MakerPrompt.Tests.csproj new file mode 100644 index 0000000..59767ed --- /dev/null +++ b/MakerPrompt.Tests/MakerPrompt.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MakerPrompt.Tests/MoonrakerApiServiceTests.cs b/MakerPrompt.Tests/MoonrakerApiServiceTests.cs new file mode 100644 index 0000000..041ec20 --- /dev/null +++ b/MakerPrompt.Tests/MoonrakerApiServiceTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Text; +using MakerPrompt.Shared.Models; +using MakerPrompt.Shared.Services; +using MakerPrompt.Shared.Utils; +using static MakerPrompt.Shared.Utils.Enums; + +namespace MakerPrompt.Tests; + +public class MoonrakerApiServiceTests +{ + [Fact] + public async Task ConnectAsync_SucceedsWithInfo() + { + var handler = new FakeMoonrakerHandler(BuildDefaultResponses()); + var service = new MoonrakerApiService(handler); + + var connected = await service.ConnectAsync(BuildSettings()); + + Assert.True(connected); + Assert.True(service.IsConnected); + Assert.Equal("http://moonraker.local/", service.ConnectionName); + } + + [Fact] + public async Task GetPrinterTelemetryAsync_MapsStatus() + { + var handler = new FakeMoonrakerHandler(BuildDefaultResponses()); + var service = new MoonrakerApiService(handler); + await service.ConnectAsync(BuildSettings()); + + var telemetry = await service.GetPrinterTelemetryAsync(); + + Assert.Equal(215, telemetry.HotendTarget); + Assert.Equal(60, telemetry.BedTarget); + Assert.Equal(PrinterStatus.Printing, telemetry.Status); + Assert.Equal(100, telemetry.FeedRate); + } + + [Fact] + public async Task SetHotendTemp_SendsGcodeScript() + { + var handler = new FakeMoonrakerHandler(BuildDefaultResponses()); + var service = new MoonrakerApiService(handler); + await service.ConnectAsync(BuildSettings()); + + await service.SetHotendTemp(200); + + Assert.Contains(handler.RequestPaths, p => p.Contains("/printer/gcode/script", StringComparison.Ordinal)); + Assert.Contains("M104+S200", handler.RequestPaths.Last()); + } + + [Fact] + public async Task StartPrint_InvokesPrintStartEndpoint() + { + var handler = new FakeMoonrakerHandler(BuildDefaultResponses()); + var service = new MoonrakerApiService(handler); + await service.ConnectAsync(BuildSettings()); + + await service.StartPrint(new FileEntry { FullPath = "gcodes/test.gcode" }); + + Assert.Contains(handler.RequestPaths, p => p.Contains("/printer/print/start", StringComparison.Ordinal)); + } + + private static Func BuildDefaultResponses() + { + return request => + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + var query = request.RequestUri?.Query ?? string.Empty; + return path switch + { + "/printer/info" => JsonResponse("""{"result":{"state":"ready"}}"""), + "/printer/objects/query" when query.Contains("heater_bed", StringComparison.OrdinalIgnoreCase) => + JsonResponse("""{"result":{"status":{"heater_bed":{"temperature":59.5,"target":60},"extruder":{"temperature":214.9,"target":215}}}}"""), + "/printer/objects/query" when query.Contains("gcode_move", StringComparison.OrdinalIgnoreCase) => + JsonResponse("""{"result":{"status":{"gcode_move":{"position":[1,2,3],"speed":100,"extrude_factor":1.0},"fan":{"speed":1}}}}"""), + "/printer/objects/query" when query.Contains("print_stats", StringComparison.OrdinalIgnoreCase) => + JsonResponse("""{"result":{"status":{"print_stats":{"state":"printing"}}}}"""), + "/server/files/list" => JsonResponse("""{"result":[{"path":"gcodes/test.gcode","modified":1700000000,"size":1234,"permissions":"rw"}]}"""), + "/printer/gcode/script" => new HttpResponseMessage(HttpStatusCode.OK), + "/printer/print/start" => new HttpResponseMessage(HttpStatusCode.OK), + _ => new HttpResponseMessage(HttpStatusCode.NotFound) + }; + }; + } + + private static PrinterConnectionSettings BuildSettings() => + new(new ApiConnectionSettings("http://moonraker.local", string.Empty, string.Empty), PrinterConnectionType.Moonraker); + + private static HttpResponseMessage JsonResponse(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class FakeMoonrakerHandler : HttpMessageHandler + { + private readonly Func responder; + public List RequestPaths { get; } = new(); + + public FakeMoonrakerHandler(Func responder) + { + this.responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri?.AbsoluteUri ?? string.Empty); + return Task.FromResult(responder(request)); + } + } +} diff --git a/MakerPrompt.Tests/PrusaLinkApiServiceTests.cs b/MakerPrompt.Tests/PrusaLinkApiServiceTests.cs new file mode 100644 index 0000000..a14d19e --- /dev/null +++ b/MakerPrompt.Tests/PrusaLinkApiServiceTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Text; +using MakerPrompt.Shared.Models; +using MakerPrompt.Shared.Services; +using MakerPrompt.Shared.Utils; +using static MakerPrompt.Shared.Utils.Enums; + +namespace MakerPrompt.Tests; + +public class PrusaLinkApiServiceTests +{ + [Fact] + public async Task ConnectAsync_UsesVersionAndInfoEndpoints() + { + var handler = new FakePrusaLinkHandler(BuildDefaultResponses()); + var service = new PrusaLinkApiService(handler); + + var connected = await service.ConnectAsync(BuildSettings()); + + Assert.True(connected); + Assert.True(service.IsConnected); + Assert.Equal("http://printer.local/", service.ConnectionName); + Assert.Equal("Test Printer", service.LastTelemetry.PrinterName); + } + + [Fact] + public async Task GetPrinterTelemetryAsync_MapsStatusFields() + { + var handler = new FakePrusaLinkHandler(BuildDefaultResponses()); + var service = new PrusaLinkApiService(handler); + await service.ConnectAsync(BuildSettings()); + + var telemetry = await service.GetPrinterTelemetryAsync(); + + Assert.Equal(215, telemetry.HotendTarget); + Assert.Equal(60, telemetry.BedTarget); + Assert.Equal(PrinterStatus.Printing, telemetry.Status); + Assert.Equal(420, telemetry.FanSpeed); + } + + [Fact] + public async Task GetFilesAsync_ReturnsChildrenFromDefaultStorage() + { + var handler = new FakePrusaLinkHandler(BuildDefaultResponses()); + var service = new PrusaLinkApiService(handler); + await service.ConnectAsync(BuildSettings()); + + var files = await service.GetFilesAsync(); + + Assert.Single(files); + Assert.Equal("/local/examples/file.gcode", files[0].FullPath); + Assert.Equal(424242, files[0].Size); + } + + private static Func BuildDefaultResponses() + { + return request => + { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + return path switch + { + "/api/version" => JsonResponse(""" + {"api":"1.0.0","version":"0.7.0","printer":"1.3.1","text":"PrusaLink 0.7.0","firmware":"3.10.1"} + """), + "/api/v1/info" => JsonResponse(""" + {"name":"Test Printer","hostname":"prusa.local"} + """), + "/api/v1/status" => JsonResponse(""" + {"printer":{"state":"PRINTING","temp_nozzle":214.9,"target_nozzle":215.0,"temp_bed":59.5,"target_bed":60.0,"axis_x":23.2,"axis_y":24.3,"axis_z":0.5,"flow":100,"speed":100,"fan_print":420},"job":{"id":42,"state":"PRINTING","progress":42.0,"time_remaining":520,"time_printing":526},"storage":{"name":"LOCAL","path":"/local","read_only":false,"free_space":123456}} + """), + "/api/v1/storage" => JsonResponse(""" + {"storage_list":[{"name":"PrusaLink gcodes","type":"LOCAL","path":"/local"}]} + """), + var p when p.StartsWith("/api/v1/files/local/") => JsonResponse(""" + {"name":"examples","path":"/examples","read_only":false,"type":"FOLDER","m_timestamp":1648042843,"children":[{"name":"file.gcode","path":"/examples/file.gcode","read_only":false,"size":424242,"type":"PRINT_FILE","m_timestamp":1648042843}]} + """), + _ => new HttpResponseMessage(HttpStatusCode.NotFound) + }; + }; + } + + private static PrinterConnectionSettings BuildSettings() => + new(new ApiConnectionSettings("http://printer.local", "user", "pass"), PrinterConnectionType.PrusaLink); + + private static HttpResponseMessage JsonResponse(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class FakePrusaLinkHandler : HttpMessageHandler + { + private readonly Func responder; + + public FakePrusaLinkHandler(Func responder) + { + this.responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(responder(request)); + } +} diff --git a/MakerPrompt.sln b/MakerPrompt.sln index ba96932..b296ddc 100644 --- a/MakerPrompt.sln +++ b/MakerPrompt.sln @@ -1,57 +1,99 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35913.81 -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 - .github\workflows\build.yml = .github\workflows\build.yml - .github\workflows\publish-github-pages.yml = .github\workflows\publish-github-pages.yml - .github\workflows\publish-maui.yml = .github\workflows\publish-maui.yml - .github\workflows\versionize-release.yml = .github\workflows\versionize-release.yml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gh-pages", "gh-pages", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - .github\workflows\gh-pages\404.html = .github\workflows\gh-pages\404.html - .github\workflows\gh-pages\decode.js = .github\workflows\gh-pages\decode.js - .github\workflows\gh-pages\index.html = .github\workflows\gh-pages\index.html - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - 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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1659CECC-713A-4F76-B7F3-4D74C0603E97}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {15F52CFF-63A3-427D-8795-95370626C764}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {67704D6B-0609-472D-A78A-473B780D4D1A}.Release|Any CPU.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} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35913.81 +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 + .github\workflows\build.yml = .github\workflows\build.yml + .github\workflows\publish-github-pages.yml = .github\workflows\publish-github-pages.yml + .github\workflows\publish-maui.yml = .github\workflows\publish-maui.yml + .github\workflows\versionize-release.yml = .github\workflows\versionize-release.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gh-pages", "gh-pages", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + .github\workflows\gh-pages\404.html = .github\workflows\gh-pages\404.html + .github\workflows\gh-pages\decode.js = .github\workflows\gh-pages\decode.js + .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}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + 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 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {545A45A2-4075-429A-AC75-ABFBE72CC15A} + EndGlobalSection +EndGlobal