From 5a5f5e96fdad0d5d560af5d7264d14629806b9aa Mon Sep 17 00:00:00 2001 From: Justin Moore Date: Sun, 19 Apr 2026 21:21:46 -0700 Subject: [PATCH 1/2] Make non-self contained, installer as to pull runtime --- .github/workflows/dotnet-desktop.yml | 4 +- .../StatusLightChecker.Installer.csproj | 4 +- .../StatusLightCheckerSetup.iss | 71 +++++++++++++++++++ .../StatusLightChecker.csproj | 3 +- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 711856d..7735614 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -39,13 +39,13 @@ jobs: - name: Publish client run: > dotnet publish $env:Client_Project - -c Release -f net10.0-windows -r win-x64 --self-contained true + -c Release -f net10.0-windows -r win-x64 --self-contained false -o $env:Client_Publish_Dir - name: Publish service run: > dotnet publish $env:Service_Project - -c Release -f net10.0-windows -r win-x64 --self-contained true + -c Release -f net10.0-windows -r win-x64 --self-contained false -o $env:Service_Publish_Dir - name: Build installer diff --git a/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj b/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj index 731d24c..8f7c5b5 100644 --- a/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj +++ b/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj @@ -39,13 +39,13 @@ Publish - Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=true;TargetFramework=net10.0-windows;PublishDir=$(ClientPublishDir) + Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=false;TargetFramework=net10.0-windows;PublishDir=$(ClientPublishDir) false true Publish - Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=true;TargetFramework=net10.0-windows;PublishDir=$(ServicePublishDir) + Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=false;TargetFramework=net10.0-windows;PublishDir=$(ServicePublishDir) false true diff --git a/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss b/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss index bf23232..1f47a77 100644 --- a/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss +++ b/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss @@ -177,6 +177,77 @@ begin Result := ExpandConstant('{userappdata}\Jublin\StatusLightChecker'); end; +{ Check registry for any installed .NET Desktop Runtime 10.x (64-bit). } +function IsDotNetDesktopRuntimeInstalled: Boolean; +const + RegKey = 'SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App'; +var + Names: TArrayOfString; + I: Integer; +begin + Result := False; + if RegGetValueNames(HKLM64, RegKey, Names) then + for I := 0 to High(Names) do + if Copy(Names[I], 1, 3) = '10.' then + begin + Result := True; + Break; + end; +end; + +{ Download .NET Desktop Runtime 10 via PowerShell and install silently. } +function DownloadAndInstallDotNet: Boolean; +var + InstallerPath, PSArgs: String; + RC: Integer; +begin + Result := False; + InstallerPath := ExpandConstant('{tmp}\windowsdesktop-runtime-10-x64.exe'); + + PSArgs := '-NoProfile -NonInteractive -Command ' + + '"Invoke-WebRequest -Uri ''https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe''' + + ' -OutFile ''' + InstallerPath + '''"'; + + if not Exec('powershell.exe', PSArgs, '', SW_HIDE, ewWaitUntilTerminated, RC) or (RC <> 0) then + begin + MsgBox( + 'Failed to download .NET Desktop Runtime 10.' + #13#10 + + 'Please install it manually from https://dotnet.microsoft.com/download/dotnet/10.0' + #13#10 + + 'then re-run this installer.', + mbError, MB_OK); + Exit; + end; + + Exec(InstallerPath, '/install /quiet /norestart', '', SW_SHOW, ewWaitUntilTerminated, RC); + { RC=0: success; RC=3010: success, reboot required — both are fine. } + Result := (RC = 0) or (RC = 3010); + if not Result then + MsgBox( + '.NET Desktop Runtime 10 installer exited with code ' + IntToStr(RC) + '.' + #13#10 + + 'Please install it manually and re-run this installer.', + mbError, MB_OK); +end; + +{ Abort setup if .NET Desktop Runtime 10 is absent and user declines to install it. } +function InitializeSetup: Boolean; +begin + Result := True; + if IsDotNetDesktopRuntimeInstalled then + Exit; + + if MsgBox( + '.NET Desktop Runtime 10.0 is required but was not found on this machine.' + #13#10 + #13#10 + + 'Click OK to download and install it now (~55 MB),' + #13#10 + + 'or Cancel to exit the installer.', + mbConfirmation, MB_OKCANCEL) <> IDOK then + begin + Result := False; + Exit; + end; + + Result := DownloadAndInstallDotNet; +end; + { Warn the user if they chose non-admin install: service won't run. } procedure CurStepChanged(CurStep: TSetupStep); begin diff --git a/src/StatusLightChecker/StatusLightChecker.csproj b/src/StatusLightChecker/StatusLightChecker.csproj index b5bd822..0fc08aa 100644 --- a/src/StatusLightChecker/StatusLightChecker.csproj +++ b/src/StatusLightChecker/StatusLightChecker.csproj @@ -8,8 +8,7 @@ true false icon.ico - win-x64 - true + win-x64 statuslightchecker-wpf-2025 From 17ab8d379b13370745a0bd1952173ab1c60eb613 Mon Sep 17 00:00:00 2001 From: Justin Moore Date: Tue, 21 Apr 2026 07:24:14 -0700 Subject: [PATCH 2/2] Add config for COM port --- .../Protos/settings.proto | 30 ++++- .../Services/ColorConfigurationService.cs | 121 +++++++++++++++++- .../Services/Interfaces.cs | 12 ++ src/StatusLightChecker.Service/Program.cs | 8 ++ .../Services/GrpcSettingsService.cs | 53 +++++++- .../appsettings.json | 4 + .../Services/GrpcClientService.cs | 49 ++++++- .../ViewModels/SettingsViewModel.cs | 26 +++- 8 files changed, 288 insertions(+), 15 deletions(-) diff --git a/src/StatusLightChecker.Contracts/Protos/settings.proto b/src/StatusLightChecker.Contracts/Protos/settings.proto index c44dabc..4b35fe8 100644 --- a/src/StatusLightChecker.Contracts/Protos/settings.proto +++ b/src/StatusLightChecker.Contracts/Protos/settings.proto @@ -7,12 +7,18 @@ package settings; service SettingsService { // Get current color configuration rpc GetColorConfig (ColorConfigRequest) returns (ColorConfigResponse); - + // Update color configuration rpc UpdateColorConfig (UpdateColorConfigRequest) returns (ColorConfigResponse); - + // Subscribe to color config changes rpc StreamColorConfigUpdates (ColorConfigRequest) returns (stream ColorConfigResponse); + + // Get serial port configuration + rpc GetSerialPortConfig (SerialPortConfigRequest) returns (SerialPortConfigResponse); + + // Update serial port configuration + rpc UpdateSerialPortConfig (UpdateSerialPortConfigRequest) returns (SerialPortConfigResponse); } message ColorConfigRequest { @@ -45,4 +51,24 @@ message ColorConfigResponse { int64 lastUpdatedUnixMs = 2; bool success = 3; string errorMessage = 4; +} + +message SerialPortConfig { + string comPort = 1; + int32 baudRate = 2; +} + +message SerialPortConfigRequest { + string clientId = 1; +} + +message SerialPortConfigResponse { + SerialPortConfig config = 1; + bool success = 2; + string errorMessage = 3; +} + +message UpdateSerialPortConfigRequest { + string clientId = 1; + SerialPortConfig config = 2; } \ No newline at end of file diff --git a/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs b/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs index a169adc..2fc11e3 100644 --- a/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs +++ b/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using StatusLightChecker.Core.Models; using System.Text.Json; @@ -116,13 +117,127 @@ private async Task SaveConfigurationInternalAsync(ColorConfiguration configurati var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); using var command = new SqliteCommand(@" - INSERT OR REPLACE INTO ColorConfiguration (Id, ConfigurationJson, LastUpdated) + INSERT OR REPLACE INTO ColorConfiguration (Id, ConfigurationJson, LastUpdated) VALUES (1, @json, @timestamp) ", connection); - + command.Parameters.AddWithValue("@json", json); command.Parameters.AddWithValue("@timestamp", timestamp); - + + await command.ExecuteNonQueryAsync(); + } +} + +public class SerialPortConfigurationService : ISerialPortConfigurationService +{ + private const string SettingsKey = "SerialPort"; + + private readonly ILogger _logger; + private readonly string _connectionString; + private SerialPortConfiguration _currentConfig; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public SerialPortConfigurationService( + ILogger logger, + string connectionString, + IConfiguration configuration) + { + _logger = logger; + _connectionString = connectionString; + + var comPort = configuration["SerialPort:ComPort"] ?? "COM3"; + var baudRate = int.TryParse(configuration["SerialPort:BaudRate"], out var br) ? br : 115200; + _currentConfig = new SerialPortConfiguration { ComPort = comPort, BaudRate = baudRate }; + + EnsureTableAsync().Wait(); + LoadConfigurationAsync().Wait(); + } + + private async Task EnsureTableAsync() + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new SqliteCommand(@" + CREATE TABLE IF NOT EXISTS Settings ( + Key TEXT PRIMARY KEY, + Value TEXT NOT NULL, + LastUpdated INTEGER NOT NULL + );", connection); + await command.ExecuteNonQueryAsync(); + } + + private async Task LoadConfigurationAsync() + { + await _semaphore.WaitAsync(); + try + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new SqliteCommand( + "SELECT Value FROM Settings WHERE Key = @key", connection); + command.Parameters.AddWithValue("@key", SettingsKey); + + var result = await command.ExecuteScalarAsync(); + if (result is string json) + { + var config = JsonSerializer.Deserialize(json); + if (config != null) + { + _currentConfig = config; + _logger.LogInformation("Loaded serial port configuration from database"); + } + } + else + { + await SaveInternalAsync(_currentConfig); + _logger.LogInformation("Saved default serial port configuration to database"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading serial port configuration"); + } + finally + { + _semaphore.Release(); + } + } + + public SerialPortConfiguration GetCurrentConfiguration() => _currentConfig; + + public async Task UpdateConfigurationAsync(SerialPortConfiguration configuration) + { + await _semaphore.WaitAsync(); + try + { + await SaveInternalAsync(configuration); + _currentConfig = configuration; + _logger.LogInformation("Serial port configuration updated: {Port} @ {Baud}", + configuration.ComPort, configuration.BaudRate); + } + finally + { + _semaphore.Release(); + } + } + + private async Task SaveInternalAsync(SerialPortConfiguration configuration) + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var json = JsonSerializer.Serialize(configuration); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + using var command = new SqliteCommand(@" + INSERT OR REPLACE INTO Settings (Key, Value, LastUpdated) + VALUES (@key, @value, @timestamp)", connection); + command.Parameters.AddWithValue("@key", SettingsKey); + command.Parameters.AddWithValue("@value", json); + command.Parameters.AddWithValue("@timestamp", timestamp); + await command.ExecuteNonQueryAsync(); } } diff --git a/src/StatusLightChecker.Core/Services/Interfaces.cs b/src/StatusLightChecker.Core/Services/Interfaces.cs index 950c8c0..8a68c49 100644 --- a/src/StatusLightChecker.Core/Services/Interfaces.cs +++ b/src/StatusLightChecker.Core/Services/Interfaces.cs @@ -9,6 +9,18 @@ public interface IColorConfigurationService event EventHandler? ConfigurationChanged; } +public record SerialPortConfiguration +{ + public string ComPort { get; init; } = "COM3"; + public int BaudRate { get; init; } = 115200; +} + +public interface ISerialPortConfigurationService +{ + SerialPortConfiguration GetCurrentConfiguration(); + Task UpdateConfigurationAsync(SerialPortConfiguration configuration); +} + public interface IStatusDetector { string ApplicationName { get; } diff --git a/src/StatusLightChecker.Service/Program.cs b/src/StatusLightChecker.Service/Program.cs index 4777fb1..656ffe2 100644 --- a/src/StatusLightChecker.Service/Program.cs +++ b/src/StatusLightChecker.Service/Program.cs @@ -2,6 +2,7 @@ using StatusLightChecker.Core.Services; using StatusLightChecker.Service.Services; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -57,6 +58,13 @@ return new ColorConfigurationService(logger, dbConnectionString); }); +builder.Services.AddSingleton(sp => +{ + var logger = sp.GetRequiredService>(); + var config = sp.GetRequiredService(); + return new SerialPortConfigurationService(logger, dbConnectionString, config); +}); + builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs b/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs index b5113ac..7d148b4 100644 --- a/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs +++ b/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs @@ -11,14 +11,17 @@ public class GrpcSettingsService : global::StatusLightChecker.Contracts.Settings { private readonly ILogger _logger; private readonly IColorConfigurationService _colorConfigService; + private readonly ISerialPortConfigurationService _serialPortConfigService; private readonly ConcurrentDictionary> _activeStreams = new(); public GrpcSettingsService( ILogger logger, - IColorConfigurationService colorConfigService) + IColorConfigurationService colorConfigService, + ISerialPortConfigurationService serialPortConfigService) { _logger = logger; _colorConfigService = colorConfigService; + _serialPortConfigService = serialPortConfigService; _colorConfigService.ConfigurationChanged += OnConfigurationChanged; } @@ -80,6 +83,54 @@ public override async Task StreamColorConfigUpdates( } } + public override Task GetSerialPortConfig( + SerialPortConfigRequest request, + ServerCallContext context) + { + _logger.LogDebug("GetSerialPortConfig called by {ClientId}", request.ClientId); + + var config = _serialPortConfigService.GetCurrentConfiguration(); + return Task.FromResult(new SerialPortConfigResponse + { + Config = new SerialPortConfig { ComPort = config.ComPort, BaudRate = config.BaudRate }, + Success = true + }); + } + + public override async Task UpdateSerialPortConfig( + UpdateSerialPortConfigRequest request, + ServerCallContext context) + { + _logger.LogInformation("UpdateSerialPortConfig called by {ClientId}", request.ClientId); + + try + { + var config = new Core.Services.SerialPortConfiguration + { + ComPort = request.Config.ComPort, + BaudRate = request.Config.BaudRate + }; + await _serialPortConfigService.UpdateConfigurationAsync(config); + + return new SerialPortConfigResponse + { + Config = new SerialPortConfig { ComPort = config.ComPort, BaudRate = config.BaudRate }, + Success = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update serial port configuration"); + var current = _serialPortConfigService.GetCurrentConfiguration(); + return new SerialPortConfigResponse + { + Config = new SerialPortConfig { ComPort = current.ComPort, BaudRate = current.BaudRate }, + Success = false, + ErrorMessage = ex.Message + }; + } + } + private void OnConfigurationChanged(object? sender, ColorConfigurationChangedEventArgs e) { var response = MapToResponse(e.Configuration, true, null); diff --git a/src/StatusLightChecker.Service/appsettings.json b/src/StatusLightChecker.Service/appsettings.json index c9c2c21..82464c6 100644 --- a/src/StatusLightChecker.Service/appsettings.json +++ b/src/StatusLightChecker.Service/appsettings.json @@ -37,6 +37,10 @@ "Database": { "ConnectionString": "Data Source=StatusLightChecker.db" }, + "SerialPort": { + "ComPort": "COM3", + "BaudRate": 115200 + }, "Kestrel": { "Endpoints": { "Grpc": { diff --git a/src/StatusLightChecker/Services/GrpcClientService.cs b/src/StatusLightChecker/Services/GrpcClientService.cs index 97ea93d..146c9ee 100644 --- a/src/StatusLightChecker/Services/GrpcClientService.cs +++ b/src/StatusLightChecker/Services/GrpcClientService.cs @@ -151,14 +151,14 @@ public async Task StartConfigurationStreamAsync() public async Task UpdateColorConfigurationAsync(StatusLightChecker.Core.Models.ColorConfiguration config) { if (_settingsClient == null) return false; - + try { var protoConfig = MapToProto(config); - var response = await _settingsClient.UpdateColorConfigAsync(new UpdateColorConfigRequest - { - ClientId = _clientId, - Config = protoConfig + var response = await _settingsClient.UpdateColorConfigAsync(new UpdateColorConfigRequest + { + ClientId = _clientId, + Config = protoConfig }); return response.Success; } @@ -169,6 +169,45 @@ public async Task UpdateColorConfigurationAsync(StatusLightChecker.Core.Mo } } + public async Task<(string ComPort, int BaudRate)?> GetSerialPortConfigAsync() + { + if (_settingsClient == null) return null; + + try + { + var response = await _settingsClient.GetSerialPortConfigAsync( + new SerialPortConfigRequest { ClientId = _clientId }); + if (response.Success) + return (response.Config.ComPort, response.Config.BaudRate); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get serial port configuration"); + } + return null; + } + + public async Task UpdateSerialPortConfigAsync(string comPort, int baudRate) + { + if (_settingsClient == null) return false; + + try + { + var response = await _settingsClient.UpdateSerialPortConfigAsync( + new UpdateSerialPortConfigRequest + { + ClientId = _clientId, + Config = new SerialPortConfig { ComPort = comPort, BaudRate = baudRate } + }); + return response.Success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update serial port configuration"); + return false; + } + } + public void StopStreams() { _statusStreamCts?.Cancel(); diff --git a/src/StatusLightChecker/ViewModels/SettingsViewModel.cs b/src/StatusLightChecker/ViewModels/SettingsViewModel.cs index 034d179..730cb7f 100644 --- a/src/StatusLightChecker/ViewModels/SettingsViewModel.cs +++ b/src/StatusLightChecker/ViewModels/SettingsViewModel.cs @@ -106,6 +106,12 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private ColorConfigurationViewModel _configuration; + [ObservableProperty] + private string _comPort = "COM3"; + + [ObservableProperty] + private int _baudRate = 115200; + [ObservableProperty] private string _statusMessage = string.Empty; @@ -116,6 +122,18 @@ public SettingsViewModel(GrpcClientService grpcClient, ColorConfigurationViewMod { _grpcClient = grpcClient; Configuration = initialConfig; + + _ = LoadSerialPortConfigAsync(); + } + + private async Task LoadSerialPortConfigAsync() + { + var result = await _grpcClient.GetSerialPortConfigAsync(); + if (result.HasValue) + { + ComPort = result.Value.ComPort; + BaudRate = result.Value.BaudRate; + } } [RelayCommand] @@ -127,17 +145,17 @@ private async Task SaveConfigurationAsync() StatusMessage = "Saving..."; var config = Configuration.ToConfiguration(); - var success = await _grpcClient.UpdateColorConfigurationAsync(config); + var colorSuccess = await _grpcClient.UpdateColorConfigurationAsync(config); + var serialSuccess = await _grpcClient.UpdateSerialPortConfigAsync(ComPort, BaudRate); - if (success) + if (colorSuccess && serialSuccess) { StatusMessage = "Settings saved successfully!"; - // Apply the colors dynamically await App.Current.Dispatcher.InvokeAsync(() => ApplyColorsToTheme(config)); } else { - StatusMessage = "Failed to save settings."; + StatusMessage = "Failed to save one or more settings."; } } catch (Exception ex)