diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml
index bae3550..711856d 100644
--- a/.github/workflows/dotnet-desktop.yml
+++ b/.github/workflows/dotnet-desktop.yml
@@ -1,41 +1,3 @@
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
-
-# This workflow will build, test, sign and package a WPF or Windows Forms desktop application
-# built on .NET Core.
-# To learn how to migrate your existing application to .NET Core,
-# refer to https://docs.microsoft.com/en-us/dotnet/desktop-wpf/migration/convert-project-from-net-framework
-#
-# To configure this workflow:
-#
-# 1. Configure environment variables
-# GitHub sets default environment variables for every workflow run.
-# Replace the variables relative to your project in the "env" section below.
-#
-# 2. Signing
-# Generate a signing certificate in the Windows Application
-# Packaging Project or add an existing signing certificate to the project.
-# Next, use PowerShell to encode the .pfx file using Base64 encoding
-# by running the following Powershell script to generate the output string:
-#
-# $pfx_cert = Get-Content '.\SigningCertificate.pfx' -Encoding Byte
-# [System.Convert]::ToBase64String($pfx_cert) | Out-File 'SigningCertificate_Encoded.txt'
-#
-# Open the output file, SigningCertificate_Encoded.txt, and copy the
-# string inside. Then, add the string to the repo as a GitHub secret
-# and name it "Base64_Encoded_Pfx."
-# For more information on how to configure your signing certificate for
-# this workflow, refer to https://github.com/microsoft/github-actions-for-desktop-apps#signing
-#
-# Finally, add the signing certificate password to the repo as a secret and name it "Pfx_Key".
-# See "Build the Windows Application Packaging project" below to see how the secret is used.
-#
-# For more information on GitHub Actions, refer to https://github.com/features/actions
-# For a complete CI/CD sample to get started with GitHub Action workflows for Desktop Applications,
-# refer to https://github.com/microsoft/github-actions-for-desktop-apps
-
name: .NET Core Desktop
on:
@@ -44,20 +6,19 @@ on:
pull_request:
branches: [ "main" ]
-jobs:
+env:
+ Solution_Name: src/StatusLightChecker.sln
+ Client_Project: src/StatusLightChecker/StatusLightChecker.csproj
+ Service_Project: src/StatusLightChecker.Service/StatusLightChecker.Service.csproj
+ Installer_Project: src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj
+ Client_Publish_Dir: publish/client
+ Service_Publish_Dir: publish/service
+jobs:
build:
-
- strategy:
- matrix:
- configuration: [Release]
-
- runs-on: windows-latest # For a list of available runner types, refer to
- # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on
-
- env:
- Solution_Name: src/StatusLightChecker.sln # Replace with your solution name, i.e. MyWpfApp.sln.
-
+ runs-on: windows-latest
+ # windows-latest has Inno Setup 6 pre-installed at
+ # C:\Program Files (x86)\Inno Setup 6\ISCC.exe — no extra install step needed.
steps:
- name: Checkout
@@ -65,34 +26,37 @@ jobs:
with:
fetch-depth: 0
- # Install the .NET Core workload
- - name: Install .NET Core
+ - name: Install .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: 8.0.x
-
- # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild
- - name: Setup MSBuild.exe
- uses: microsoft/setup-msbuild@v2
-
- # Restore the application to populate the obj folder with RuntimeIdentifiers
- - name: Restore the application
- run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
- env:
- Configuration: ${{ matrix.configuration }}
-
- - name: Publish Self-contained
- run: dotnet publish $env:Solution_Name -c Release -f net8.0-windows -r win-x64 --self-contained True -o ./StatusCheckerSelfContained
- env:
- Configuration: ${{ matrix.configuration }}
-
- # Create the app package by building and packaging the Windows Application Packaging project
- - name: Zip the output zip file
- run: |
- Compress-Archive -Path ./StatusCheckerSelfContained/* -DestinationPath ./StatusCheckerSelfContained.zip
-
- - name: Upload zip as artifact
- uses: actions/upload-artifact@v3
+ dotnet-version: 10.0.x
+
+ - name: Restore solution
+ run: dotnet restore $env:Solution_Name
+
+ # Publish client and service into separate dirs — the installer csproj reads these
+ # via /p:ClientPublishDir and /p:ServicePublishDir overrides below.
+ - name: Publish client
+ run: >
+ dotnet publish $env:Client_Project
+ -c Release -f net10.0-windows -r win-x64 --self-contained true
+ -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
+ -o $env:Service_Publish_Dir
+
+ - name: Build installer
+ run: >
+ dotnet build $env:Installer_Project
+ -c Release
+ /p:ClientPublishDir=${{ github.workspace }}\${{ env.Client_Publish_Dir }}\
+ /p:ServicePublishDir=${{ github.workspace }}\${{ env.Service_Publish_Dir }}\
+
+ - name: Upload installer
+ uses: actions/upload-artifact@v4
with:
- name: StatusCheckerSelfContained
- path: ./StatusCheckerSelfContained.zip
+ name: StatusLightCheckerSetup
+ path: build/bin/StatusLightChecker.Installer/Release/StatusLightCheckerSetup.exe
diff --git a/.gitignore b/.gitignore
index 6bbcd38..9ed71ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@
# Mono auto generated files
mono_crash.*
+# Centralized build output (BaseOutputPath / BaseIntermediateOutputPath)
+/build/
+
# Build results
[Dd]ebug/
[Dd]ebugPublic/
@@ -396,3 +399,4 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+**/.claude
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..6a56455
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+
+ 2.0.0
+ true
+
+
+
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
new file mode 100644
index 0000000..c698fa6
--- /dev/null
+++ b/src/Directory.Build.props
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Jublin.xyz
+ $(VersionPrefix)
+ $(VersionPrefix)
+
+
+
+ $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)../'))
+ $(RepoRoot)build\obj\$(MSBuildProjectName)\
+ $(RepoRoot)build\bin\$(MSBuildProjectName)\
+
+
+
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
new file mode 100644
index 0000000..d78531f
--- /dev/null
+++ b/src/Directory.Packages.props
@@ -0,0 +1,50 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/StatusLightChecker.Contracts/Protos/settings.proto b/src/StatusLightChecker.Contracts/Protos/settings.proto
new file mode 100644
index 0000000..c44dabc
--- /dev/null
+++ b/src/StatusLightChecker.Contracts/Protos/settings.proto
@@ -0,0 +1,48 @@
+syntax = "proto3";
+
+option csharp_namespace = "StatusLightChecker.Contracts";
+
+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);
+}
+
+message ColorConfigRequest {
+ string clientId = 1;
+}
+
+message UpdateColorConfigRequest {
+ string clientId = 1;
+ ColorConfiguration config = 2;
+}
+
+message ColorConfiguration {
+ StatusColor available = 1;
+ StatusColor busy = 2;
+ StatusColor doNotDisturb = 3;
+ StatusColor away = 4;
+ StatusColor offline = 5;
+ StatusColor unknown = 6;
+}
+
+message StatusColor {
+ int32 statusLevel = 1;
+ string name = 2;
+ string hexColor = 3;
+ bool isBlinking = 4;
+}
+
+message ColorConfigResponse {
+ ColorConfiguration config = 1;
+ int64 lastUpdatedUnixMs = 2;
+ bool success = 3;
+ string errorMessage = 4;
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Contracts/Protos/status_service.proto b/src/StatusLightChecker.Contracts/Protos/status_service.proto
new file mode 100644
index 0000000..4c93d2c
--- /dev/null
+++ b/src/StatusLightChecker.Contracts/Protos/status_service.proto
@@ -0,0 +1,51 @@
+syntax = "proto3";
+
+option csharp_namespace = "StatusLightChecker.Contracts";
+
+package statusservice;
+
+service StatusService {
+ // Stream status updates from service to client
+ rpc StreamStatusUpdates (StatusRequest) returns (stream StatusUpdate);
+
+ // Get current status immediately
+ rpc GetCurrentStatus (StatusRequest) returns (StatusUpdate);
+
+ // Get service health
+ rpc GetServiceHealth (HealthRequest) returns (HealthResponse);
+}
+
+message StatusRequest {
+ string clientId = 1;
+}
+
+message HealthRequest {
+ // Empty request
+}
+
+message StatusUpdate {
+ string statusId = 1;
+ string applicationName = 2;
+ string statusValue = 3;
+ string statusLabel = 4;
+ int32 statusLevel = 5; // 0=Unknown, 1=Available, 2=Busy, 3=DoNotDisturb, 4=Away, 5=Offline
+ string colorHex = 6;
+ int64 timestampUnixMs = 7;
+ string details = 8;
+}
+
+message HealthResponse {
+ ServiceState state = 1;
+ string version = 2;
+ int64 uptimeSeconds = 3;
+ string message = 4;
+}
+
+enum ServiceState {
+ UNKNOWN = 0;
+ RUNNING = 1;
+ STOPPED = 2;
+ PAUSED = 3;
+ STARTING = 4;
+ STOPPING = 5;
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Contracts/StatusLightChecker.Contracts.csproj b/src/StatusLightChecker.Contracts/StatusLightChecker.Contracts.csproj
new file mode 100644
index 0000000..0d2aff5
--- /dev/null
+++ b/src/StatusLightChecker.Contracts/StatusLightChecker.Contracts.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/src/StatusLightChecker.Core/Colors/StatusColors.cs b/src/StatusLightChecker.Core/Colors/StatusColors.cs
new file mode 100644
index 0000000..07d38d2
--- /dev/null
+++ b/src/StatusLightChecker.Core/Colors/StatusColors.cs
@@ -0,0 +1,37 @@
+namespace StatusLightChecker.Core.Colors;
+
+public class StatusColors
+{
+ public ColorHex Available { get; set; } = new ColorHex(0x00FF00);
+ public ColorHex Busy { get; set; } = new ColorHex(0xFF0000);
+ public ColorHex DoNotDisturb { get; set; } = new ColorHex(0x8B0000);
+ public ColorHex Away { get; set; } = new ColorHex(0xFFFF00);
+ public ColorHex Offline { get; set; } = new ColorHex(0x000000);
+ public ColorHex OutOfOffice { get; set; } = new ColorHex(0x800080);
+ public ColorHex Unknown { get; set; } = new ColorHex(0x808080);
+}
+
+public class ColorHex
+{
+ public byte R { get; set; }
+ public byte G { get; set; }
+ public byte B { get; set; }
+
+ public ColorHex() { }
+
+ public ColorHex(uint rgb)
+ {
+ R = (byte)(rgb >> 16);
+ G = (byte)(rgb >> 8);
+ B = (byte)rgb;
+ }
+
+ public ColorHex(byte r, byte g, byte b)
+ {
+ R = r;
+ G = g;
+ B = b;
+ }
+
+ public override string ToString() => $"#{R:X2}{G:X2}{B:X2}";
+}
diff --git a/src/StatusLightChecker.Core/Configuration/ApplicationSettings.cs b/src/StatusLightChecker.Core/Configuration/ApplicationSettings.cs
new file mode 100644
index 0000000..47244cc
--- /dev/null
+++ b/src/StatusLightChecker.Core/Configuration/ApplicationSettings.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+using StatusLightChecker.Core.Colors;
+using System.IO;
+using System;
+
+namespace StatusLightChecker.Core.Configuration;
+
+public class ApplicationSettings
+{
+ public string ComPort { get; set; } = "COM3";
+ public int BaudRate { get; set; } = 115200;
+ public string SelectedApp { get; set; } = "MicrosoftTeams";
+ public bool AutoStart { get; set; } = false;
+ public StatusColors StatusColors { get; set; } = new StatusColors();
+ public bool ShowTrayIcon { get; set; } = true;
+
+ private static string SettingsPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "StatusLightChecker",
+ "settings.json");
+
+ public static ApplicationSettings? Load()
+ {
+ try
+ {
+ if (!File.Exists(SettingsPath))
+ return null;
+
+ var json = File.ReadAllText(SettingsPath);
+ return JsonSerializer.Deserialize(json);
+ }
+ catch (Exception ex)
+ {
+ LogError(ex, "Failed to load settings");
+ return null;
+ }
+ }
+
+ public static void Save(ApplicationSettings settings)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(SettingsPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory!);
+
+ var options = new JsonSerializerOptions
+ {
+ WriteIndented = true
+ };
+ var json = JsonSerializer.Serialize(settings, options);
+ File.WriteAllText(SettingsPath, json);
+ }
+ catch (Exception ex)
+ {
+ LogError(ex, "Failed to save settings");
+ }
+ }
+
+ private static void LogError(Exception ex, string message)
+ {
+ // Silent fail - logger may not be initialized yet
+ Console.Error.WriteLine($"{message}: {ex.Message}");
+ }
+}
diff --git a/src/StatusLightChecker.Core/Models/StatusModels.cs b/src/StatusLightChecker.Core/Models/StatusModels.cs
new file mode 100644
index 0000000..0763ca3
--- /dev/null
+++ b/src/StatusLightChecker.Core/Models/StatusModels.cs
@@ -0,0 +1,59 @@
+namespace StatusLightChecker.Core.Models;
+
+public enum StatusLevel
+{
+ Unknown = 0,
+ Available = 1,
+ Busy = 2,
+ DoNotDisturb = 3,
+ Away = 4,
+ Offline = 5
+}
+
+public record ApplicationStatus
+{
+ public string ApplicationName { get; init; } = string.Empty;
+ public string StatusValue { get; init; } = string.Empty;
+ public string StatusLabel { get; init; } = string.Empty;
+ public StatusLevel StatusLevel { get; init; } = StatusLevel.Unknown;
+ public string? Details { get; init; }
+ public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
+}
+
+public record StatusColor
+{
+ public StatusLevel StatusLevel { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string HexColor { get; init; } = "#808080";
+ public bool IsBlinking { get; init; }
+}
+
+public record ColorConfiguration
+{
+ public StatusColor Available { get; init; } = new() { StatusLevel = StatusLevel.Available, Name = "Available", HexColor = "#00CC6A" };
+ public StatusColor Busy { get; init; } = new() { StatusLevel = StatusLevel.Busy, Name = "Busy", HexColor = "#FF0000" };
+ public StatusColor DoNotDisturb { get; init; } = new() { StatusLevel = StatusLevel.DoNotDisturb, Name = "Do Not Disturb", HexColor = "#B30000", IsBlinking = true };
+ public StatusColor Away { get; init; } = new() { StatusLevel = StatusLevel.Away, Name = "Away", HexColor = "#FFCC00" };
+ public StatusColor Offline { get; init; } = new() { StatusLevel = StatusLevel.Offline, Name = "Offline", HexColor = "#808080" };
+ public StatusColor Unknown { get; init; } = new() { StatusLevel = StatusLevel.Unknown, Name = "Unknown", HexColor = "#CCCCCC" };
+}
+
+public class StatusChangedEventArgs : EventArgs
+{
+ public ApplicationStatus Status { get; }
+
+ public StatusChangedEventArgs(ApplicationStatus status)
+ {
+ Status = status;
+ }
+}
+
+public class ColorConfigurationChangedEventArgs : EventArgs
+{
+ public ColorConfiguration Configuration { get; }
+
+ public ColorConfigurationChangedEventArgs(ColorConfiguration configuration)
+ {
+ Configuration = configuration;
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Core/Serial/SerialPortManager.cs b/src/StatusLightChecker.Core/Serial/SerialPortManager.cs
new file mode 100644
index 0000000..8bb9f9b
--- /dev/null
+++ b/src/StatusLightChecker.Core/Serial/SerialPortManager.cs
@@ -0,0 +1,103 @@
+using System.IO.Ports;
+using StatusLightChecker.Core.Colors;
+
+namespace StatusLightChecker.Core.Serial;
+
+public class SerialPortManager : IDisposable
+{
+ private SerialPort? _serialPort;
+ private bool _disposed;
+
+ public bool IsOpen => _serialPort?.IsOpen ?? false;
+
+ public async Task Open(string portName, int baudRate, CancellationToken token)
+ {
+ try
+ {
+ if (_serialPort?.IsOpen == true)
+ return true;
+
+ _serialPort?.Dispose();
+
+ _serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
+ {
+ ReadTimeout = 1000,
+ WriteTimeout = 1000
+ };
+
+ await Task.Run(() => _serialPort.Open(), token);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to open serial port {portName}: {ex.Message}");
+ return false;
+ }
+ }
+
+ public async Task CloseAsync()
+ {
+ if (_serialPort?.IsOpen == true)
+ {
+ await Task.Run(() =>
+ {
+ try
+ {
+ _serialPort?.Close();
+ }
+ catch { }
+ });
+ }
+ }
+
+ public void WriteColor(StatusColors colors)
+ {
+ if (_serialPort?.IsOpen != true) return;
+
+ try
+ {
+ _serialPort.DiscardOutBuffer();
+ var data = new[] { colors.Unknown.R, colors.Unknown.G, colors.Unknown.B };
+ _serialPort.Write(data, 0, data.Length);
+ // _serialPort.DrainWriter();
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to write to serial port: {ex.Message}");
+ }
+ }
+
+ public void WriteColorRaw(byte r, byte g, byte b)
+ {
+ if (_serialPort?.IsOpen != true) return;
+
+ try
+ {
+ _serialPort.DiscardOutBuffer();
+ var data = new[] { r, g, b };
+ _serialPort.Write(data, 0, data.Length);
+ // _serialPort.DrainWriter();
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to write to serial port: {ex.Message}");
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ try
+ {
+ _serialPort?.Close();
+ _serialPort?.Dispose();
+ }
+ catch { }
+
+ _disposed = true;
+ GC.SuppressFinalize(this);
+ }
+
+ ~SerialPortManager() => Dispose();
+}
diff --git a/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs b/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs
new file mode 100644
index 0000000..a169adc
--- /dev/null
+++ b/src/StatusLightChecker.Core/Services/ColorConfigurationService.cs
@@ -0,0 +1,128 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Core.Models;
+using System.Text.Json;
+
+namespace StatusLightChecker.Core.Services;
+
+public class ColorConfigurationService : IColorConfigurationService
+{
+ private readonly ILogger _logger;
+ private readonly string _connectionString;
+ private ColorConfiguration _currentConfig;
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+
+ public event EventHandler? ConfigurationChanged;
+
+ public ColorConfigurationService(ILogger logger, string connectionString)
+ {
+ _logger = logger;
+ _connectionString = connectionString;
+ _currentConfig = new ColorConfiguration();
+
+ InitializeDatabaseAsync().Wait();
+ LoadConfigurationAsync().Wait();
+ }
+
+ private async Task InitializeDatabaseAsync()
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ var createTableSql = @"
+ CREATE TABLE IF NOT EXISTS ColorConfiguration (
+ Id INTEGER PRIMARY KEY CHECK (Id = 1),
+ ConfigurationJson TEXT NOT NULL,
+ LastUpdated INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS Settings (
+ Key TEXT PRIMARY KEY,
+ Value TEXT NOT NULL,
+ LastUpdated INTEGER NOT NULL
+ );
+ ";
+
+ using var command = new SqliteCommand(createTableSql, connection);
+ await command.ExecuteNonQueryAsync();
+ _logger.LogDebug("Database initialized successfully");
+ }
+
+ private async Task LoadConfigurationAsync()
+ {
+ await _semaphore.WaitAsync();
+ try
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ using var command = new SqliteCommand(
+ "SELECT ConfigurationJson FROM ColorConfiguration WHERE Id = 1", connection);
+
+ var result = await command.ExecuteScalarAsync();
+ if (result is string json)
+ {
+ var config = JsonSerializer.Deserialize(json);
+ if (config != null)
+ {
+ _currentConfig = config;
+ _logger.LogInformation("Loaded color configuration from database");
+ }
+ }
+ else
+ {
+ await SaveConfigurationInternalAsync(_currentConfig);
+ _logger.LogInformation("Saved default color configuration to database");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error loading color configuration");
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ public ColorConfiguration GetCurrentConfiguration()
+ {
+ return _currentConfig;
+ }
+
+ public async Task UpdateConfigurationAsync(ColorConfiguration configuration)
+ {
+ await _semaphore.WaitAsync();
+ try
+ {
+ await SaveConfigurationInternalAsync(configuration);
+ _currentConfig = configuration;
+
+ ConfigurationChanged?.Invoke(this, new ColorConfigurationChangedEventArgs(configuration));
+ _logger.LogInformation("Color configuration updated");
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ private async Task SaveConfigurationInternalAsync(ColorConfiguration 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 ColorConfiguration (Id, ConfigurationJson, LastUpdated)
+ VALUES (1, @json, @timestamp)
+ ", connection);
+
+ command.Parameters.AddWithValue("@json", 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
new file mode 100644
index 0000000..950c8c0
--- /dev/null
+++ b/src/StatusLightChecker.Core/Services/Interfaces.cs
@@ -0,0 +1,42 @@
+using StatusLightChecker.Core.Models;
+
+namespace StatusLightChecker.Core.Services;
+
+public interface IColorConfigurationService
+{
+ ColorConfiguration GetCurrentConfiguration();
+ Task UpdateConfigurationAsync(ColorConfiguration configuration);
+ event EventHandler? ConfigurationChanged;
+}
+
+public interface IStatusDetector
+{
+ string ApplicationName { get; }
+ Task DetectStatusAsync(CancellationToken cancellationToken = default);
+}
+
+public interface IServiceHealthTracker
+{
+ ServiceHealth GetCurrentHealth();
+ void SetState(ServiceState state);
+ void RecordHeartbeat();
+}
+
+public record ServiceHealth
+{
+ public ServiceState State { get; init; } = ServiceState.Stopped;
+ public string Version { get; init; } = "1.0.0";
+ public long UptimeSeconds { get; init; }
+ public string Message { get; init; } = string.Empty;
+ public DateTimeOffset LastHeartbeat { get; init; } = DateTimeOffset.UtcNow;
+}
+
+public enum ServiceState
+{
+ Unknown = 0,
+ Running = 1,
+ Stopped = 2,
+ Paused = 3,
+ Starting = 4,
+ Stopping = 5
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Core/Services/ServiceHealthTracker.cs b/src/StatusLightChecker.Core/Services/ServiceHealthTracker.cs
new file mode 100644
index 0000000..14ed56d
--- /dev/null
+++ b/src/StatusLightChecker.Core/Services/ServiceHealthTracker.cs
@@ -0,0 +1,55 @@
+using System.Diagnostics;
+
+namespace StatusLightChecker.Core.Services;
+
+public class ServiceHealthTracker : IServiceHealthTracker
+{
+ private readonly Stopwatch _uptimeStopwatch = Stopwatch.StartNew();
+ private ServiceState _currentState = ServiceState.Stopped;
+ private DateTimeOffset _lastHeartbeat = DateTimeOffset.UtcNow;
+ private readonly string _version;
+
+ public ServiceHealthTracker()
+ {
+ _version = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0";
+ }
+
+ public ServiceHealth GetCurrentHealth()
+ {
+ return new ServiceHealth
+ {
+ State = _currentState,
+ Version = _version,
+ UptimeSeconds = (long)_uptimeStopwatch.Elapsed.TotalSeconds,
+ Message = GetStateMessage(_currentState),
+ LastHeartbeat = _lastHeartbeat
+ };
+ }
+
+ public void SetState(ServiceState state)
+ {
+ if (_currentState != state)
+ {
+ _currentState = state;
+ if (state == ServiceState.Running)
+ {
+ _uptimeStopwatch.Restart();
+ }
+ }
+ }
+
+ public void RecordHeartbeat()
+ {
+ _lastHeartbeat = DateTimeOffset.UtcNow;
+ }
+
+ private static string GetStateMessage(ServiceState state) => state switch
+ {
+ ServiceState.Running => "Service is running normally",
+ ServiceState.Stopped => "Service is stopped",
+ ServiceState.Paused => "Service is paused",
+ ServiceState.Starting => "Service is starting...",
+ ServiceState.Stopping => "Service is stopping...",
+ _ => "Service state unknown"
+ };
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Core/Services/StatusMonitoringService.cs b/src/StatusLightChecker.Core/Services/StatusMonitoringService.cs
new file mode 100644
index 0000000..f7a128c
--- /dev/null
+++ b/src/StatusLightChecker.Core/Services/StatusMonitoringService.cs
@@ -0,0 +1,97 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Core.Models;
+
+namespace StatusLightChecker.Core.Services;
+
+public class StatusMonitoringService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly IEnumerable _detectors;
+ private readonly IColorConfigurationService _colorConfigService;
+ private ApplicationStatus? _currentStatus;
+ private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
+
+ public event EventHandler? StatusChanged;
+
+ public StatusMonitoringService(
+ ILogger logger,
+ IEnumerable detectors,
+ IColorConfigurationService colorConfigService)
+ {
+ _logger = logger;
+ _detectors = detectors;
+ _colorConfigService = colorConfigService;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Status Monitoring Service starting...");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await CheckAllDetectorsAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking status detectors");
+ }
+
+ await Task.Delay(_checkInterval, stoppingToken);
+ }
+
+ _logger.LogInformation("Status Monitoring Service stopped");
+ }
+
+ private async Task CheckAllDetectorsAsync(CancellationToken cancellationToken)
+ {
+ ApplicationStatus? highestPriorityStatus = null;
+
+ foreach (var detector in _detectors)
+ {
+ try
+ {
+ var status = await detector.DetectStatusAsync(cancellationToken);
+ if (status != null)
+ {
+ _logger.LogDebug("Detected status from {App}: {Status}",
+ detector.ApplicationName, status.StatusLabel);
+
+ // Higher status level = higher priority
+ if (highestPriorityStatus == null || status.StatusLevel > highestPriorityStatus.StatusLevel)
+ {
+ highestPriorityStatus = status;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error detecting status from {App}", detector.ApplicationName);
+ }
+ }
+
+ if (highestPriorityStatus != null && !IsSameStatus(_currentStatus, highestPriorityStatus))
+ {
+ _currentStatus = highestPriorityStatus;
+ _logger.LogInformation("Status changed to: {Status} from {App}",
+ highestPriorityStatus.StatusLabel, highestPriorityStatus.ApplicationName);
+
+ StatusChanged?.Invoke(this, new StatusChangedEventArgs(highestPriorityStatus));
+ }
+ }
+
+ public Task GetCurrentStatusAsync()
+ {
+ return Task.FromResult(_currentStatus);
+ }
+
+ private static bool IsSameStatus(ApplicationStatus? a, ApplicationStatus? b)
+ {
+ if (a == null || b == null) return false;
+ return a.StatusLevel == b.StatusLevel &&
+ a.ApplicationName == b.ApplicationName &&
+ a.StatusValue == b.StatusValue;
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Core/StatusCheckers/IStatusChecker.cs b/src/StatusLightChecker.Core/StatusCheckers/IStatusChecker.cs
new file mode 100644
index 0000000..8b162b0
--- /dev/null
+++ b/src/StatusLightChecker.Core/StatusCheckers/IStatusChecker.cs
@@ -0,0 +1,18 @@
+using StatusLightChecker.Core.Colors;
+
+namespace StatusLightChecker.Core.StatusCheckers;
+
+public delegate void StatusChangedEventHandler();
+
+public interface IStatusChecker : IDisposable
+{
+ event StatusChangedEventHandler StatusChanged;
+
+ string StatusButtonId { get; set; }
+
+ Task GetCurrentStatus();
+ void StartChecking();
+ void StopChecking();
+
+ StatusColors GetColorFromStatus(StatusColors? colors);
+}
diff --git a/src/StatusLightChecker.Core/StatusCheckers/TeamsApplicationStatusChecker.cs b/src/StatusLightChecker.Core/StatusCheckers/TeamsApplicationStatusChecker.cs
new file mode 100644
index 0000000..fba49d9
--- /dev/null
+++ b/src/StatusLightChecker.Core/StatusCheckers/TeamsApplicationStatusChecker.cs
@@ -0,0 +1,239 @@
+using System.Windows.Threading;
+using FlaUI.Core.AutomationElements;
+using FlaUI.UIA3;
+using Serilog;
+using StatusLightChecker.Core.Colors;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace StatusLightChecker.Core.StatusCheckers;
+
+public enum TeamsStatus
+{
+ Available,
+ Busy,
+ DoNotDisturb,
+ Away,
+ Offline,
+ Unknown,
+ OutOfOffice,
+ InAMeeting
+}
+
+public class TeamsApplicationStatusChecker : IStatusChecker
+{
+ public event StatusChangedEventHandler? StatusChanged;
+ public string StatusButtonId { get; set; } = "idna-me-control-avatar-trigger";
+
+ private readonly ILogger _logger;
+ private AutomationElement? _storedWindow;
+ private TeamsStatus _lastStatus = TeamsStatus.Unknown;
+ private DispatcherTimer? _statusTimer;
+ private readonly int _poolingIntervalSeconds = 3;
+ private CancellationTokenSource? _cts;
+ private readonly SemaphoreSlim _statusCheckSemaphore = new(1, 1);
+ private readonly SemaphoreSlim _windowFinderSemaphore = new(1, 1);
+
+ private const string WindowTitle = "Microsoft Teams";
+
+ public TeamsApplicationStatusChecker()
+ {
+ _logger = new LoggerConfiguration()
+ .WriteTo.Console()
+ .CreateLogger();
+ }
+
+ public void StartChecking()
+ {
+ _cts = new CancellationTokenSource();
+ InitializeTimer();
+ _statusTimer?.Start();
+ _logger.Information("Started checking Teams status");
+ }
+
+ public void StopChecking()
+ {
+ _statusTimer?.Stop();
+ _cts?.Cancel();
+ _logger.Information("Stopped checking Teams status");
+ }
+
+ private void InitializeTimer()
+ {
+ _statusTimer = new DispatcherTimer(
+ TimeSpan.FromSeconds(_poolingIntervalSeconds),
+ DispatcherPriority.Normal,
+ StatusTimerCallback,
+ Dispatcher.CurrentDispatcher);
+ }
+
+ private async void StatusTimerCallback(object? sender, EventArgs e)
+ {
+ await GetCurrentStatus();
+ _statusTimer?.Start();
+ }
+
+ public async Task GetCurrentStatus()
+ {
+ var canEnter = await _statusCheckSemaphore.WaitAsync(1000);
+ if (!canEnter)
+ return;
+
+ try
+ {
+ var presenceStatus = "Unknown";
+
+ if (_storedWindow != null)
+ {
+ try
+ {
+ _ = _storedWindow.Name;
+ }
+ catch
+ {
+ _storedWindow = null;
+ }
+ }
+
+ if (_storedWindow == null)
+ {
+ _storedWindow = FindWindow();
+ if (_storedWindow == null)
+ {
+ _logger.Warning("Could not find Teams window");
+ _lastStatus = TeamsStatus.Unknown;
+ return;
+ }
+ }
+
+ var buttons = _storedWindow.FindAllDescendants(
+ cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button));
+
+ var accountButton = buttons.FirstOrDefault(b =>
+ b.AutomationId.Equals(StatusButtonId, StringComparison.OrdinalIgnoreCase));
+
+ if (accountButton == null)
+ {
+ _logger.Error("Couldn't find button holding status info.");
+ _lastStatus = TeamsStatus.Unknown;
+ StatusChanged?.Invoke();
+ return;
+ }
+
+ var statusWords = accountButton.Name.Split(' ');
+ var status = statusWords.Skip(3).ToList();
+ var forIndex = status.IndexOf("for");
+ status = forIndex > 0 ? status.Take(forIndex).ToList() : status;
+ presenceStatus = string.Join(" ", status);
+ _logger.Information($"Status found: {presenceStatus}");
+
+ var newStatus = GetStatusFromElementStatus(presenceStatus);
+ if (newStatus != _lastStatus)
+ {
+ _lastStatus = newStatus;
+ _logger.Information($"Status set to {_lastStatus}");
+ StatusChanged?.Invoke();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Error reading status");
+ }
+ finally
+ {
+ _statusCheckSemaphore.Release();
+ }
+ }
+
+ private TeamsStatus GetStatusFromElementStatus(string statusString)
+ {
+ var status = statusString switch
+ {
+ "Available" => TeamsStatus.Available,
+ "Busy" or "In a call" or "In a meeting" => TeamsStatus.Busy,
+ "Presenting" or "Do not disturb" => TeamsStatus.DoNotDisturb,
+ "Away" or "Be right back" => TeamsStatus.Away,
+ _ => TeamsStatus.Unknown
+ };
+
+ if (status == TeamsStatus.Unknown)
+ {
+ _logger.Warning($"Unknown status: {statusString}");
+ }
+
+ return status;
+ }
+
+ public StatusColors GetColorFromStatus(StatusColors? colors)
+ {
+ var defaultColors = new StatusColors();
+ colors ??= defaultColors;
+
+ var statusColors = _lastStatus switch
+ {
+ TeamsStatus.Available => colors.Available,
+ TeamsStatus.Busy => colors.Busy,
+ TeamsStatus.DoNotDisturb => colors.DoNotDisturb,
+ TeamsStatus.Away => colors.Away,
+ TeamsStatus.Offline => colors.Offline,
+ TeamsStatus.OutOfOffice => colors.OutOfOffice,
+ _ => colors.Unknown
+ };
+
+ return new StatusColors
+ {
+ Available = statusColors,
+ Busy = statusColors,
+ DoNotDisturb = statusColors,
+ Away = statusColors,
+ Offline = statusColors,
+ OutOfOffice = statusColors,
+ Unknown = statusColors
+ };
+ }
+
+ private AutomationElement? FindWindow()
+ {
+ using var automation = new UIA3Automation();
+ var desktop = automation.GetDesktop();
+ var windows = desktop.FindAllChildren(
+ cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Window));
+
+ var foundWindows = windows.Where(w =>
+ w.Name.Contains(WindowTitle, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (!foundWindows.Any())
+ return null;
+
+ if (foundWindows.Count == 1)
+ return foundWindows[0];
+
+ foreach (var window in foundWindows)
+ {
+ var buttons = window.FindAllDescendants(
+ cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button));
+ var accountButton = buttons.FirstOrDefault(b =>
+ b.AutomationId.Equals(StatusButtonId, StringComparison.OrdinalIgnoreCase));
+ if (accountButton != null)
+ return window;
+ }
+
+ return foundWindows[0];
+ }
+
+ public void Dispose()
+ {
+ _statusTimer?.Stop();
+ _statusCheckSemaphore.Dispose();
+ _windowFinderSemaphore.Dispose();
+ _cts?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ ~TeamsApplicationStatusChecker() => Dispose();
+}
diff --git a/src/StatusLightChecker.Core/StatusLightChecker.Core.csproj b/src/StatusLightChecker.Core/StatusLightChecker.Core.csproj
new file mode 100644
index 0000000..dffdd2e
--- /dev/null
+++ b/src/StatusLightChecker.Core/StatusLightChecker.Core.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0-windows
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj b/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj
new file mode 100644
index 0000000..731d24c
--- /dev/null
+++ b/src/StatusLightChecker.Installer/StatusLightChecker.Installer.csproj
@@ -0,0 +1,59 @@
+
+
+
+
+ net10.0-windows
+
+
+
+
+
+
+
+
+ $(PkgTools_InnoSetup)\tools\ISCC.exe
+ C:\Program Files (x86)\Inno Setup 6\ISCC.exe
+ ISCC.exe
+
+
+
+ $(MSBuildThisFileDirectory)..\..\build\bin\StatusLightChecker\$(Configuration)\net10.0-windows\win-x64\publish\
+ $(MSBuildThisFileDirectory)..\..\build\bin\StatusLightChecker.Service\$(Configuration)\net10.0-windows\win-x64\publish\
+ $(BaseOutputPath)$(Configuration)\
+
+
+
+
+
+ Publish
+ Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=true;TargetFramework=net10.0-windows;PublishDir=$(ClientPublishDir)
+ false
+ true
+
+
+ Publish
+ Configuration=$(Configuration);RuntimeIdentifier=win-x64;SelfContained=true;TargetFramework=net10.0-windows;PublishDir=$(ServicePublishDir)
+ false
+ true
+
+
+
+
+
+
+
+
+
diff --git a/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss b/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss
new file mode 100644
index 0000000..bf23232
--- /dev/null
+++ b/src/StatusLightChecker.Installer/StatusLightCheckerSetup.iss
@@ -0,0 +1,195 @@
+; StatusLightCheckerSetup.iss
+; Inno Setup 6 installer script for Status Light Checker
+;
+; Build via MSBuild (StatusLightChecker.Installer.csproj) which passes all
+; /D defines automatically. To compile manually:
+; ISCC.exe /DAppVersion=2.0.0 /DClientPublishDir= /DServicePublishDir= /DOutputDir= StatusLightCheckerSetup.iss
+
+; ---------------------------------------------------------------------------
+; External defines — all supplied by the csproj at build time via /D flags.
+; If any are missing, the #error directive stops the build with a clear message.
+; ---------------------------------------------------------------------------
+
+#ifndef AppVersion
+ #error AppVersion not defined. Pass /DAppVersion=x.y.z to ISCC.exe.
+#endif
+
+#ifndef ClientPublishDir
+ #error ClientPublishDir not defined. Pass /DClientPublishDir= to ISCC.exe.
+#endif
+
+#ifndef ServicePublishDir
+ #error ServicePublishDir not defined. Pass /DServicePublishDir= to ISCC.exe.
+#endif
+
+#ifndef OutputDir
+ #define OutputDir "..\..\build\bin\StatusLightChecker.Installer\Release"
+#endif
+
+; ---------------------------------------------------------------------------
+; [Setup]
+; ---------------------------------------------------------------------------
+
+[Setup]
+AppName=Status Light Checker
+AppVersion={#AppVersion}
+AppVerName=Status Light Checker {#AppVersion}
+AppPublisher=Jublin.xyz
+AppPublisherURL=https://github.com/jublin
+AppSupportURL=https://github.com/jublin/StatusLightCheckerWPF/issues
+AppUpdatesURL=https://github.com/jublin/StatusLightCheckerWPF/releases
+
+AppId={{E220399D-9D5E-4163-AB48-CABB103A67A1}}
+; AppId is the stable product GUID. Must never change between versions.
+
+DefaultDirName={autopf}\JublinXYZ\StatusLightChecker
+; {autopf} resolves to Program Files in admin mode and
+; {localappdata}\Programs in non-admin mode (Inno Setup 6.1+).
+
+DefaultGroupName=Status Light Checker
+DisableProgramGroupPage=yes
+
+OutputDir={#OutputDir}
+OutputBaseFilename=StatusLightCheckerSetup
+
+SetupIconFile=..\StatusLightChecker\icon.ico
+UninstallDisplayIcon={app}\Client\StatusLightChecker.exe
+
+Compression=lzma2/ultra64
+SolidCompression=yes
+WizardStyle=modern
+
+; Allow the user to choose per-user or per-machine at install time.
+; Non-admin (per-user) installs will NOT register the Windows Service — see [Code].
+PrivilegesRequired=lowest
+PrivilegesRequiredOverridesAllowed=dialog
+
+ArchitecturesAllowed=x64compatible
+ArchitecturesInstallIn64BitMode=x64compatible
+
+MinVersion=10.0
+
+; ---------------------------------------------------------------------------
+; [Languages]
+; ---------------------------------------------------------------------------
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+
+; ---------------------------------------------------------------------------
+; [Dirs]
+; ---------------------------------------------------------------------------
+
+[Dirs]
+; Writable data directory for logs and SQLite DB.
+; Per-machine → %ProgramData%\Jublin\StatusLightChecker
+; Per-user → %AppData%\Jublin\StatusLightChecker
+; uninsneveruninstall: leave behind on uninstall so user data isn't wiped.
+Name: "{code:GetDataDir}\Service"; Flags: uninsneveruninstall
+Name: "{code:GetDataDir}\Service\logs"; Flags: uninsneveruninstall
+Name: "{code:GetDataDir}\Client"; Flags: uninsneveruninstall
+Name: "{code:GetDataDir}\Client\logs"; Flags: uninsneveruninstall
+
+; ---------------------------------------------------------------------------
+; [Files]
+; ---------------------------------------------------------------------------
+
+[Files]
+; Client binaries — all self-contained publish output
+Source: "{#ClientPublishDir}\*"; \
+ DestDir: "{app}\Client"; \
+ Flags: ignoreversion recursesubdirs createallsubdirs
+
+; Service binaries — all self-contained publish output
+Source: "{#ServicePublishDir}\*"; \
+ DestDir: "{app}\Service"; \
+ Flags: ignoreversion recursesubdirs createallsubdirs
+
+; ---------------------------------------------------------------------------
+; [Icons]
+; ---------------------------------------------------------------------------
+
+[Icons]
+Name: "{group}\Status Light Checker"; \
+ Filename: "{app}\Client\StatusLightChecker.exe"; \
+ WorkingDir: "{app}\Client"
+Name: "{group}\Uninstall Status Light Checker"; \
+ Filename: "{uninstallexe}"
+
+; ---------------------------------------------------------------------------
+; [Run] — post-install actions
+; ---------------------------------------------------------------------------
+
+[Run]
+; Register the Windows Service. sc.exe requires a space after binPath=.
+; Only runs in admin (all-users) install mode.
+Filename: "{sys}\sc.exe"; \
+ Parameters: "create ""StatusLightCheckerService"" binPath= ""{app}\Service\StatusLightChecker.Service.exe"" type= own start= auto error= normal displayname= ""Status Light Checker Service"""; \
+ Flags: runhidden waituntilterminated; \
+ StatusMsg: "Registering Status Light Checker Service..."; \
+ Check: IsAdminInstallMode
+
+Filename: "{sys}\sc.exe"; \
+ Parameters: "description ""StatusLightCheckerService"" ""Monitors application status for LED light control"""; \
+ Flags: runhidden waituntilterminated; \
+ Check: IsAdminInstallMode
+
+; Start= wait but don't block — service startup may legitimately take a moment.
+Filename: "{sys}\sc.exe"; \
+ Parameters: "start ""StatusLightCheckerService"""; \
+ Flags: runhidden waituntilterminated; \
+ StatusMsg: "Starting Status Light Checker Service..."; \
+ Check: IsAdminInstallMode
+
+; Optionally launch the client after install (user can uncheck)
+Filename: "{app}\Client\StatusLightChecker.exe"; \
+ Description: "Launch Status Light Checker"; \
+ Flags: nowait postinstall skipifsilent
+
+; ---------------------------------------------------------------------------
+; [UninstallRun] — pre/post-uninstall actions
+; ---------------------------------------------------------------------------
+
+[UninstallRun]
+; Stop and delete the service before files are removed.
+Filename: "{sys}\sc.exe"; \
+ Parameters: "stop ""StatusLightCheckerService"""; \
+ Flags: runhidden waituntilterminated; \
+ Check: IsAdminInstallMode
+
+Filename: "{sys}\sc.exe"; \
+ Parameters: "delete ""StatusLightCheckerService"""; \
+ Flags: runhidden waituntilterminated; \
+ Check: IsAdminInstallMode
+
+; ---------------------------------------------------------------------------
+; [Code] — Pascal script
+; ---------------------------------------------------------------------------
+
+[Code]
+
+{ Returns the writable data directory appropriate for the install mode. }
+function GetDataDir(Param: String): String;
+begin
+ if IsAdminInstallMode then
+ Result := ExpandConstant('{commonappdata}\Jublin\StatusLightChecker')
+ else
+ Result := ExpandConstant('{userappdata}\Jublin\StatusLightChecker');
+end;
+
+{ Warn the user if they chose non-admin install: service won't run. }
+procedure CurStepChanged(CurStep: TSetupStep);
+begin
+ if (CurStep = ssInstall) and not IsAdminInstallMode then
+ begin
+ MsgBox(
+ 'You are installing Status Light Checker for the current user only.' + #13#10 + #13#10 +
+ 'The background Windows Service will NOT be installed.' + #13#10 +
+ 'The application requires the service to monitor Teams status.' + #13#10 + #13#10 +
+ 'To install with full functionality, cancel and re-run the installer' + #13#10 +
+ 'by right-clicking and selecting "Run as administrator".',
+ mbInformation,
+ MB_OK
+ );
+ end;
+end;
diff --git a/src/StatusLightChecker.Service/Program.cs b/src/StatusLightChecker.Service/Program.cs
new file mode 100644
index 0000000..4777fb1
--- /dev/null
+++ b/src/StatusLightChecker.Service/Program.cs
@@ -0,0 +1,115 @@
+using Serilog;
+using StatusLightChecker.Core.Services;
+using StatusLightChecker.Service.Services;
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.AspNetCore.Http;
+using StatusLightChecker.Core;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Resolve writable data directory. When running as a Windows Service, the process
+// working directory is System32 (LocalSystem account) so relative paths fail.
+// Environment.UserInteractive is false when hosted by the SCM.
+var isService = !Environment.UserInteractive;
+var dataDir = isService
+ ? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "Jublin", "StatusLightChecker", "Service")
+ : AppContext.BaseDirectory;
+
+Directory.CreateDirectory(Path.Combine(dataDir, "logs"));
+
+// File sink path is set programmatically so it always resolves to a writable
+// location. Console sink remains configured via appsettings.json.
+Log.Logger = new LoggerConfiguration()
+ .ReadFrom.Configuration(builder.Configuration)
+ .Enrich.FromLogContext()
+ .WriteTo.File(
+ Path.Combine(dataDir, "logs", "service-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 7,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+
+builder.Logging.ClearProviders();
+builder.Logging.AddSerilog();
+
+// Configure as Windows Service
+builder.Host.UseWindowsService(options =>
+{
+ options.ServiceName = builder.Configuration["ServiceConfiguration:ServiceName"] ?? "StatusLightCheckerService";
+});
+
+// DB path resolved to ProgramData when running as a service; relative otherwise (dev).
+var dbConnectionString = isService
+ ? $"Data Source={Path.Combine(dataDir, "StatusLightChecker.db")}"
+ : builder.Configuration.GetConnectionString("DefaultConnection")
+ ?? builder.Configuration["Database:ConnectionString"]
+ ?? "Data Source=StatusLightChecker.db";
+
+// Register Core Services
+builder.Services.AddSingleton(sp =>
+{
+ var logger = sp.GetRequiredService>();
+ return new ColorConfigurationService(logger, dbConnectionString);
+});
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp => sp.GetRequiredService());
+
+// Register gRPC services
+builder.Services.AddGrpc(options =>
+{
+ options.EnableDetailedErrors = true;
+ options.MaxReceiveMessageSize = 1024 * 1024;
+ options.MaxSendMessageSize = 1024 * 1024;
+});
+
+// Register Status Detectors
+builder.Services.AddSingleton();
+
+// Register Background Services
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+// Set initial service state
+var healthTracker = app.Services.GetRequiredService();
+healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Starting);
+
+// Configure gRPC endpoints
+app.MapGrpcService();
+app.MapGrpcService();
+
+// Health check endpoint
+app.MapGet("/health", () =>
+{
+ var health = healthTracker.GetCurrentHealth();
+ return Results.Ok(new { health.State, health.Version, health.UptimeSeconds });
+});
+
+// Configure shutdown
+app.Lifetime.ApplicationStarted.Register(() =>
+{
+ healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Running);
+ Log.Information("Status Light Checker Service started successfully");
+});
+
+app.Lifetime.ApplicationStopping.Register(() =>
+{
+ healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Stopping);
+ Log.Information("Status Light Checker Service is stopping...");
+});
+
+app.Lifetime.ApplicationStopped.Register(() =>
+{
+ healthTracker.SetState(StatusLightChecker.Core.Services.ServiceState.Stopped);
+ Log.Information("Status Light Checker Service stopped");
+ Log.CloseAndFlush();
+});
+
+await app.RunAsync();
diff --git a/src/StatusLightChecker.Service/Properties/launchSettings.json b/src/StatusLightChecker.Service/Properties/launchSettings.json
new file mode 100644
index 0000000..e5e747f
--- /dev/null
+++ b/src/StatusLightChecker.Service/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "StatusLightChecker.Service": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:59219;http://localhost:59220"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs b/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs
new file mode 100644
index 0000000..b5113ac
--- /dev/null
+++ b/src/StatusLightChecker.Service/Services/GrpcSettingsService.cs
@@ -0,0 +1,159 @@
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Contracts;
+using StatusLightChecker.Core.Services;
+using StatusLightChecker.Core.Models;
+using System.Collections.Concurrent;
+
+namespace StatusLightChecker.Service.Services;
+
+public class GrpcSettingsService : global::StatusLightChecker.Contracts.SettingsService.SettingsServiceBase
+{
+ private readonly ILogger _logger;
+ private readonly IColorConfigurationService _colorConfigService;
+ private readonly ConcurrentDictionary> _activeStreams = new();
+
+ public GrpcSettingsService(
+ ILogger logger,
+ IColorConfigurationService colorConfigService)
+ {
+ _logger = logger;
+ _colorConfigService = colorConfigService;
+
+ _colorConfigService.ConfigurationChanged += OnConfigurationChanged;
+ }
+
+ public override Task GetColorConfig(
+ ColorConfigRequest request,
+ ServerCallContext context)
+ {
+ _logger.LogDebug("GetColorConfig called by {ClientId}", request.ClientId);
+
+ var config = _colorConfigService.GetCurrentConfiguration();
+ return Task.FromResult(MapToResponse(config, true, null));
+ }
+
+ public override async Task UpdateColorConfig(
+ UpdateColorConfigRequest request,
+ ServerCallContext context)
+ {
+ _logger.LogInformation("UpdateColorConfig called by {ClientId}", request.ClientId);
+
+ try
+ {
+ var config = MapFromProto(request.Config);
+ await _colorConfigService.UpdateConfigurationAsync(config);
+
+ return MapToResponse(config, true, null);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to update color configuration");
+ return MapToResponse(_colorConfigService.GetCurrentConfiguration(), false, ex.Message);
+ }
+ }
+
+ public override async Task StreamColorConfigUpdates(
+ ColorConfigRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context)
+ {
+ _logger.LogInformation("Client {ClientId} connected to config stream", request.ClientId);
+ _activeStreams[request.ClientId] = responseStream;
+
+ // Send current config immediately
+ var currentConfig = _colorConfigService.GetCurrentConfiguration();
+ await responseStream.WriteAsync(MapToResponse(currentConfig, true, null));
+
+ // Keep stream open until cancelled
+ try
+ {
+ await Task.Delay(Timeout.Infinite, context.CancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Client {ClientId} disconnected from config stream", request.ClientId);
+ }
+ finally
+ {
+ _activeStreams.TryRemove(request.ClientId, out _);
+ }
+ }
+
+ private void OnConfigurationChanged(object? sender, ColorConfigurationChangedEventArgs e)
+ {
+ var response = MapToResponse(e.Configuration, true, null);
+
+ foreach (var (clientId, stream) in _activeStreams.ToArray())
+ {
+ try
+ {
+ stream.WriteAsync(response).Wait(TimeSpan.FromSeconds(5));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send config update to client {ClientId}", clientId);
+ _activeStreams.TryRemove(clientId, out _);
+ }
+ }
+ }
+
+ private static ColorConfigResponse MapToResponse(Core.Models.ColorConfiguration config, bool success, string? error)
+ {
+ return new ColorConfigResponse
+ {
+ Config = MapToProto(config),
+ LastUpdatedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Success = success,
+ ErrorMessage = error ?? string.Empty
+ };
+ }
+
+ private static Contracts.ColorConfiguration MapToProto(Core.Models.ColorConfiguration config)
+ {
+ return new Contracts.ColorConfiguration
+ {
+ Available = MapToProto(config.Available),
+ Busy = MapToProto(config.Busy),
+ DoNotDisturb = MapToProto(config.DoNotDisturb),
+ Away = MapToProto(config.Away),
+ Offline = MapToProto(config.Offline),
+ Unknown = MapToProto(config.Unknown)
+ };
+ }
+
+ private static Contracts.StatusColor MapToProto(Core.Models.StatusColor color)
+ {
+ return new Contracts.StatusColor
+ {
+ StatusLevel = (int)color.StatusLevel,
+ Name = color.Name,
+ HexColor = color.HexColor,
+ IsBlinking = color.IsBlinking
+ };
+ }
+
+ private static Core.Models.ColorConfiguration MapFromProto(Contracts.ColorConfiguration proto)
+ {
+ return new Core.Models.ColorConfiguration
+ {
+ Available = MapFromProto(proto.Available),
+ Busy = MapFromProto(proto.Busy),
+ DoNotDisturb = MapFromProto(proto.DoNotDisturb),
+ Away = MapFromProto(proto.Away),
+ Offline = MapFromProto(proto.Offline),
+ Unknown = MapFromProto(proto.Unknown)
+ };
+ }
+
+ private static Core.Models.StatusColor MapFromProto(Contracts.StatusColor proto)
+ {
+ return new Core.Models.StatusColor
+ {
+ StatusLevel = (Core.Models.StatusLevel)proto.StatusLevel,
+ Name = proto.Name,
+ HexColor = proto.HexColor,
+ IsBlinking = proto.IsBlinking
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Service/Services/GrpcStatusService.cs b/src/StatusLightChecker.Service/Services/GrpcStatusService.cs
new file mode 100644
index 0000000..a46b4f3
--- /dev/null
+++ b/src/StatusLightChecker.Service/Services/GrpcStatusService.cs
@@ -0,0 +1,131 @@
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Contracts;
+using StatusLightChecker.Core.Services;
+using StatusLightChecker.Core.Models;
+using System.Collections.Concurrent;
+
+namespace StatusLightChecker.Service.Services;
+
+public class GrpcStatusService : global::StatusLightChecker.Contracts.StatusService.StatusServiceBase
+{
+ private readonly ILogger _logger;
+ private readonly StatusMonitoringService _monitoringService;
+ private readonly IColorConfigurationService _colorConfigService;
+ private readonly ServiceHealthTracker _healthTracker;
+ private readonly ConcurrentDictionary> _activeStreams = new();
+
+ public GrpcStatusService(
+ ILogger logger,
+ StatusMonitoringService monitoringService,
+ IColorConfigurationService colorConfigService,
+ ServiceHealthTracker healthTracker)
+ {
+ _logger = logger;
+ _monitoringService = monitoringService;
+ _colorConfigService = colorConfigService;
+ _healthTracker = healthTracker;
+
+ _monitoringService.StatusChanged += OnStatusChanged;
+ }
+
+ public override async Task StreamStatusUpdates(
+ StatusRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context)
+ {
+ _logger.LogInformation("Client {ClientId} connected to status stream", request.ClientId);
+ _activeStreams[request.ClientId] = responseStream;
+
+ // Send current status immediately
+ var currentStatus = await _monitoringService.GetCurrentStatusAsync();
+ if (currentStatus != null)
+ {
+ await responseStream.WriteAsync(MapToStatusUpdate(currentStatus));
+ }
+
+ // Keep stream open until cancelled
+ try
+ {
+ await Task.Delay(Timeout.Infinite, context.CancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Client {ClientId} disconnected from status stream", request.ClientId);
+ }
+ finally
+ {
+ _activeStreams.TryRemove(request.ClientId, out _);
+ }
+ }
+
+ public override async Task GetCurrentStatus(
+ StatusRequest request,
+ ServerCallContext context)
+ {
+ _logger.LogDebug("GetCurrentStatus called by {ClientId}", request.ClientId);
+
+ var status = await _monitoringService.GetCurrentStatusAsync();
+ return status != null
+ ? MapToStatusUpdate(status)
+ : new StatusUpdate { StatusLevel = (int)Core.Models.StatusLevel.Unknown };
+ }
+
+ public override Task GetServiceHealth(
+ HealthRequest request,
+ ServerCallContext context)
+ {
+ var health = _healthTracker.GetCurrentHealth();
+ return Task.FromResult(new HealthResponse
+ {
+ State = (Contracts.ServiceState)health.State,
+ Version = health.Version,
+ UptimeSeconds = health.UptimeSeconds,
+ Message = health.Message
+ });
+ }
+
+ private void OnStatusChanged(object? sender, StatusChangedEventArgs e)
+ {
+ var update = MapToStatusUpdate(e.Status);
+
+ foreach (var (clientId, stream) in _activeStreams)
+ {
+ try
+ {
+ stream.WriteAsync(update).Wait(TimeSpan.FromSeconds(5));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send status update to client {ClientId}", clientId);
+ _activeStreams.TryRemove(clientId, out _);
+ }
+ }
+ }
+
+ private StatusUpdate MapToStatusUpdate(Core.Models.ApplicationStatus status)
+ {
+ var colorConfig = _colorConfigService.GetCurrentConfiguration();
+ var statusColor = status.StatusLevel switch
+ {
+ Core.Models.StatusLevel.Available => colorConfig.Available,
+ Core.Models.StatusLevel.Busy => colorConfig.Busy,
+ Core.Models.StatusLevel.DoNotDisturb => colorConfig.DoNotDisturb,
+ Core.Models.StatusLevel.Away => colorConfig.Away,
+ Core.Models.StatusLevel.Offline => colorConfig.Offline,
+ _ => colorConfig.Unknown
+ };
+
+ return new StatusUpdate
+ {
+ StatusId = Guid.NewGuid().ToString(),
+ ApplicationName = status.ApplicationName,
+ StatusValue = status.StatusValue,
+ StatusLabel = status.StatusLabel,
+ StatusLevel = (int)status.StatusLevel,
+ ColorHex = statusColor.HexColor,
+ TimestampUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Details = status.Details
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Service/Services/HealthHeartbeatService.cs b/src/StatusLightChecker.Service/Services/HealthHeartbeatService.cs
new file mode 100644
index 0000000..2a9e148
--- /dev/null
+++ b/src/StatusLightChecker.Service/Services/HealthHeartbeatService.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Core.Services;
+
+namespace StatusLightChecker.Service.Services;
+
+public class HealthHeartbeatService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly IServiceHealthTracker _healthTracker;
+ private readonly TimeSpan _heartbeatInterval = TimeSpan.FromSeconds(30);
+
+ public HealthHeartbeatService(
+ ILogger logger,
+ IServiceHealthTracker healthTracker)
+ {
+ _logger = logger;
+ _healthTracker = healthTracker;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogDebug("Health heartbeat service starting...");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ _healthTracker.RecordHeartbeat();
+ _logger.LogDebug("Health heartbeat recorded");
+
+ await Task.Delay(_heartbeatInterval, stoppingToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Service/Services/TeamsStatusDetector.cs b/src/StatusLightChecker.Service/Services/TeamsStatusDetector.cs
new file mode 100644
index 0000000..3d47e70
--- /dev/null
+++ b/src/StatusLightChecker.Service/Services/TeamsStatusDetector.cs
@@ -0,0 +1,135 @@
+using FlaUI.Core;
+using FlaUI.Core.AutomationElements;
+using FlaUI.UIA3;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Core.Models;
+using StatusLightChecker.Core.Services;
+using System.Diagnostics;
+
+namespace StatusLightChecker.Service.Services;
+
+public class TeamsStatusDetector : IStatusDetector
+{
+ private readonly ILogger _logger;
+ private const string TeamsProcessName = "Teams";
+ private const string TeamsProcessName2 = "ms-teams";
+
+ public string ApplicationName => "Microsoft Teams";
+
+ public TeamsStatusDetector(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task DetectStatusAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var process = FindTeamsProcess();
+ if (process == null)
+ {
+ return Task.FromResult(null);
+ }
+
+ var status = DetectStatusFromProcess(process);
+ return Task.FromResult(status);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error detecting Teams status");
+ return Task.FromResult(null);
+ }
+ }
+
+ private Process? FindTeamsProcess()
+ {
+ var process = Process.GetProcessesByName(TeamsProcessName).FirstOrDefault();
+ if (process != null) return process;
+
+ process = Process.GetProcessesByName(TeamsProcessName2).FirstOrDefault();
+ if (process != null) return process;
+
+ process = Process.GetProcessesByName(TeamsProcessName + ".exe").FirstOrDefault();
+ return process;
+ }
+
+ private ApplicationStatus DetectStatusFromProcess(Process process)
+ {
+ var statusLevel = StatusLevel.Away;
+ var statusLabel = "Away";
+ var details = $"Process ID: {process.Id}";
+
+ try
+ {
+ using var automation = new UIA3Automation();
+ var app = FlaUI.Core.Application.Attach(process);
+ var window = app.GetMainWindow(automation);
+
+ if (window != null)
+ {
+ var statusIndicator = FindStatusIndicator(window);
+ if (statusIndicator != null)
+ {
+ (statusLevel, statusLabel) = ParseStatusFromElement(statusIndicator);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not access Teams UI elements");
+ statusLevel = StatusLevel.Available;
+ statusLabel = "Available";
+ }
+
+ return new ApplicationStatus
+ {
+ ApplicationName = ApplicationName,
+ StatusValue = statusLevel.ToString().ToLowerInvariant(),
+ StatusLabel = statusLabel,
+ StatusLevel = statusLevel,
+ Details = details
+ };
+ }
+
+ private AutomationElement? FindStatusIndicator(AutomationElement window)
+ {
+ try
+ {
+ var buttons = window.FindAllDescendants(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button));
+
+ foreach (var button in buttons)
+ {
+ var name = button.Name?.ToLowerInvariant() ?? "";
+ if (name.Contains("status") || name.Contains("presence") ||
+ name.Contains("available") || name.Contains("busy") ||
+ name.Contains("away") || name.Contains("do not disturb"))
+ {
+ return button;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error finding status indicator");
+ }
+ return null;
+ }
+
+ private (StatusLevel Level, string Label) ParseStatusFromElement(AutomationElement element)
+ {
+ var name = element.Name?.ToLowerInvariant() ?? "";
+
+ if (name.Contains("available") || name.Contains("green"))
+ return (StatusLevel.Available, "Available");
+ if (name.Contains("busy") || name.Contains("red"))
+ return (StatusLevel.Busy, "Busy");
+ if (name.Contains("do not disturb") || name.Contains("dnd") || name.Contains("presenting"))
+ return (StatusLevel.DoNotDisturb, "Do Not Disturb");
+ if (name.Contains("away") || name.Contains("yellow"))
+ return (StatusLevel.Away, "Away");
+ if (name.Contains("offline") || name.Contains("gray"))
+ return (StatusLevel.Offline, "Offline");
+
+ return (StatusLevel.Unknown, "Unknown");
+ }
+}
diff --git a/src/StatusLightChecker.Service/StatusLightChecker.Service.csproj b/src/StatusLightChecker.Service/StatusLightChecker.Service.csproj
new file mode 100644
index 0000000..3cd8905
--- /dev/null
+++ b/src/StatusLightChecker.Service/StatusLightChecker.Service.csproj
@@ -0,0 +1,42 @@
+
+
+
+ Exe
+ net10.0-windows
+ win-x64
+ enable
+ enable
+ statuslightchecker-service-2025
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/src/StatusLightChecker.Service/appsettings.Development.json b/src/StatusLightChecker.Service/appsettings.Development.json
new file mode 100644
index 0000000..0b33292
--- /dev/null
+++ b/src/StatusLightChecker.Service/appsettings.Development.json
@@ -0,0 +1,17 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "Microsoft.AspNetCore": "Information",
+ "Grpc": "Debug"
+ }
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Debug"
+ }
+ },
+ "ServiceConfiguration": {
+ "GrpcEndpoint": "http://localhost:50052"
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker.Service/appsettings.json b/src/StatusLightChecker.Service/appsettings.json
new file mode 100644
index 0000000..c9c2c21
--- /dev/null
+++ b/src/StatusLightChecker.Service/appsettings.json
@@ -0,0 +1,48 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Grpc": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "Serilog": {
+ "Using": ["Serilog.Sinks.Console"],
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.AspNetCore": "Warning",
+ "System": "Warning"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ],
+ "Enrich": ["FromLogContext"]
+ },
+ "ServiceConfiguration": {
+ "ServiceName": "StatusLightCheckerService",
+ "DisplayName": "Status Light Checker Service",
+ "Description": "Monitors Microsoft Teams and other application statuses for LED control",
+ "GrpcEndpoint": "http://localhost:50051"
+ },
+ "Database": {
+ "ConnectionString": "Data Source=StatusLightChecker.db"
+ },
+ "Kestrel": {
+ "Endpoints": {
+ "Grpc": {
+ "Url": "http://localhost:50051",
+ "Protocols": "Http2"
+ }
+ }
+ }
+}
diff --git a/src/StatusLightChecker.sln b/src/StatusLightChecker.sln
index 9653209..0de851d 100644
--- a/src/StatusLightChecker.sln
+++ b/src/StatusLightChecker.sln
@@ -1,10 +1,17 @@
-
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35327.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker", "StatusLightChecker\StatusLightChecker.csproj", "{06F16C62-C908-4452-B052-CB57B5C6AC8C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Core", "StatusLightChecker.Core\StatusLightChecker.Core.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Service", "StatusLightChecker.Service\StatusLightChecker.Service.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Contracts", "StatusLightChecker.Contracts\StatusLightChecker.Contracts.csproj", "{C3D4E5F6-A7B8-9012-CDEF-345678901234}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusLightChecker.Installer", "StatusLightChecker.Installer\StatusLightChecker.Installer.csproj", "{9EC27587-AC73-405F-9A4D-BB402E13DC1E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +22,21 @@ Global
{06F16C62-C908-4452-B052-CB57B5C6AC8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{06F16C62-C908-4452-B052-CB57B5C6AC8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{06F16C62-C908-4452-B052-CB57B5C6AC8C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9EC27587-AC73-405F-9A4D-BB402E13DC1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9EC27587-AC73-405F-9A4D-BB402E13DC1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9EC27587-AC73-405F-9A4D-BB402E13DC1E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -22,4 +44,4 @@ Global
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5BAEB2C4-7651-4766-95DF-431D382E8895}
EndGlobalSection
-EndGlobal
+EndGlobal
\ No newline at end of file
diff --git a/src/StatusLightChecker/App.xaml b/src/StatusLightChecker/App.xaml
index c276127..cd171d3 100644
--- a/src/StatusLightChecker/App.xaml
+++ b/src/StatusLightChecker/App.xaml
@@ -1,16 +1,40 @@
-
+ xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
+ xmlns:converters="clr-namespace:StatusLightChecker.Converters"
+ xmlns:vm="clr-namespace:StatusLightChecker.ViewModels">
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
\ No newline at end of file
diff --git a/src/StatusLightChecker/App.xaml.cs b/src/StatusLightChecker/App.xaml.cs
index eea7b2c..5480af1 100644
--- a/src/StatusLightChecker/App.xaml.cs
+++ b/src/StatusLightChecker/App.xaml.cs
@@ -1,28 +1,93 @@
-using System.Windows;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using StatusLightChecker.Services;
using StatusLightChecker.ViewModels;
+using System.IO;
+using System.Windows;
+using System.Windows.Threading;
-namespace StatusLightChecker
+namespace StatusLightChecker;
+
+public partial class App : Application
{
- public delegate void StatusChangedEventHandler();
- ///
- /// Interaction logic for App.xaml
- ///
- public partial class App : Application
+ public static IServiceProvider ServiceProvider { get; private set; } = null!;
+
+ protected override void OnStartup(StartupEventArgs e)
{
+ base.OnStartup(e);
+
+ var services = ConfigureServices();
+ ServiceProvider = services.BuildServiceProvider();
- protected override void OnStartup(StartupEventArgs e)
+ // Log to AppData\Local so the client never tries to write into ProgramFiles.
+ var logDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Jublin", "StatusLightChecker", "Client", "logs");
+ Directory.CreateDirectory(logDir);
+
+ Log.Logger = new LoggerConfiguration()
+ .ReadFrom.Configuration(ServiceProvider.GetRequiredService())
+ .WriteTo.File(
+ Path.Combine(logDir, "client-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 7)
+ .CreateLogger();
+
+ var mainWindow = ServiceProvider.GetRequiredService();
+ mainWindow.Show();
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ if (ServiceProvider is IDisposable disposable)
{
-
+ disposable.Dispose();
}
-
- protected override async void OnExit(ExitEventArgs e)
+ Log.CloseAndFlush();
+ base.OnExit(e);
+ }
+
+ private static IServiceCollection ConfigureServices()
+ {
+ var services = new ServiceCollection();
+
+ // Load appsettings.json from the executable's directory, not the working
+ // directory. These diverge when launched via a Start Menu shortcut.
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .AddUserSecrets(optional: true)
+ .Build();
+
+ services.AddSingleton(configuration);
+
+ services.AddLogging(builder =>
{
- await MainViewModel.Instance?.SerialPort?.WriteAsync([0,0,0], 0, 3)!;
- await MainViewModel.Instance?.SerialPort?.FlushAsync()!;
- await Task.Delay(1000);
- MainViewModel.Instance?.SerialPort?.Close();
- base.OnExit(e);
- }
+ builder.AddSerilog();
+ builder.SetMinimumLevel(LogLevel.Information);
+ });
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
}
+ private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
+ {
+ Log.Error(e.Exception, "Unhandled exception occurred");
+
+ MessageBox.Show(
+ $"An unhandled exception occurred: {e.Exception.Message}",
+ "Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+
+ e.Handled = true;
+ }
}
diff --git a/src/StatusLightChecker/Converters/Converters.cs b/src/StatusLightChecker/Converters/Converters.cs
new file mode 100644
index 0000000..53bd3c8
--- /dev/null
+++ b/src/StatusLightChecker/Converters/Converters.cs
@@ -0,0 +1,111 @@
+using StatusLightChecker.Core.Models;
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace StatusLightChecker.Converters;
+
+public class StatusLevelToBrushConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is StatusLevel level)
+ {
+ var resources = App.Current.Resources;
+ return level switch
+ {
+ StatusLevel.Available => resources["StatusAvailableBrush"] ?? Brushes.Green,
+ StatusLevel.Busy => resources["StatusBusyBrush"] ?? Brushes.Red,
+ StatusLevel.DoNotDisturb => resources["StatusDoNotDisturbBrush"] ?? Brushes.DarkRed,
+ StatusLevel.Away => resources["StatusAwayBrush"] ?? Brushes.Orange,
+ StatusLevel.Offline => resources["StatusOfflineBrush"] ?? Brushes.Gray,
+ _ => resources["StatusUnknownBrush"] ?? Brushes.LightGray
+ };
+ }
+ return Brushes.Gray;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
+
+public class StatusLevelToIconConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is StatusLevel level)
+ {
+ return level switch
+ {
+ StatusLevel.Available => "CheckCircle",
+ StatusLevel.Busy => "Circle",
+ StatusLevel.DoNotDisturb => "MinusCircle",
+ StatusLevel.Away => "ClockOutline",
+ StatusLevel.Offline => "CloseCircle",
+ _ => "HelpCircle"
+ };
+ }
+ return "HelpCircle";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
+
+public class HexToColorConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ try
+ {
+ if (value is string hex && !string.IsNullOrWhiteSpace(hex))
+ {
+ return (Color)ColorConverter.ConvertFromString(hex)!;
+ }
+ }
+ catch { }
+ return Colors.Gray;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
+
+public class InverseBooleanConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value is bool b && !b;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value is bool b && !b;
+ }
+}
+
+public class ServiceStatusToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is string status && parameter is string targetStatus)
+ {
+ var visibility = status.Equals(targetStatus, StringComparison.OrdinalIgnoreCase)
+ ? System.Windows.Visibility.Visible
+ : System.Windows.Visibility.Collapsed;
+ return visibility;
+ }
+ return System.Windows.Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker/MainWindow.xaml b/src/StatusLightChecker/MainWindow.xaml
index 4143e82..5523789 100644
--- a/src/StatusLightChecker/MainWindow.xaml
+++ b/src/StatusLightChecker/MainWindow.xaml
@@ -1,102 +1,315 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/StatusLightChecker/MainWindow.xaml.cs b/src/StatusLightChecker/MainWindow.xaml.cs
index ab8c6a9..6f9d984 100644
--- a/src/StatusLightChecker/MainWindow.xaml.cs
+++ b/src/StatusLightChecker/MainWindow.xaml.cs
@@ -1,34 +1,24 @@
-using System.Windows;
-using System.Windows.Input;
+using Microsoft.Extensions.DependencyInjection;
using StatusLightChecker.ViewModels;
-using Wpf.Ui.Appearance;
-using Wpf.Ui.Controls;
+using System.Windows;
+using System.ComponentModel;
-namespace StatusLightChecker
+namespace StatusLightChecker;
+
+public partial class MainWindow : Window
{
- ///
- /// Interaction logic for MainWindow.xaml
- ///
- public partial class MainWindow : Window
+ public MainWindow()
{
- public MainWindow()
- {
- InitializeComponent();
- ApplicationThemeManager.Apply(
- ApplicationTheme.Dark, // Theme type
- WindowBackdropType.Acrylic, // Background type
- true // Whether to change accents automatically
- );
- DataContext = MainViewModel.Instance;
- }
+ InitializeComponent();
+ DataContext = App.ServiceProvider.GetRequiredService();
+ }
- private void LogBox_OnTextInput(object sender, TextCompositionEventArgs e)
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ if (DataContext is MainViewModel vm)
{
- LogBox.ScrollToEnd();
- if (LogBox.Document.Blocks.Count > 200)
- {
- LogBox.Document.Blocks.Clear();
- }
+ vm.Dispose();
}
+ base.OnClosing(e);
}
}
\ No newline at end of file
diff --git a/src/StatusLightChecker/Services/GrpcClientService.cs b/src/StatusLightChecker/Services/GrpcClientService.cs
new file mode 100644
index 0000000..97ea93d
--- /dev/null
+++ b/src/StatusLightChecker/Services/GrpcClientService.cs
@@ -0,0 +1,233 @@
+using Grpc.Core;
+using Grpc.Net.Client;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Contracts;
+using StatusLightChecker.Core.Models;
+using System.Collections.ObjectModel;
+
+namespace StatusLightChecker.Services;
+
+public class GrpcClientService : IDisposable
+{
+ private readonly ILogger _logger;
+ private readonly string _clientId;
+ private GrpcChannel? _channel;
+ private global::StatusLightChecker.Contracts.StatusService.StatusServiceClient? _statusClient;
+ private global::StatusLightChecker.Contracts.SettingsService.SettingsServiceClient? _settingsClient;
+ private CancellationTokenSource? _statusStreamCts;
+ private CancellationTokenSource? _configStreamCts;
+ private readonly ObservableCollection _statusUpdates;
+
+ public event EventHandler? StatusReceived;
+ public event EventHandler? ConfigurationReceived;
+ public event EventHandler? ServiceStateChanged;
+
+ public bool IsConnected => _channel?.State == ConnectivityState.Ready;
+ public ObservableCollection StatusUpdates => _statusUpdates;
+
+ public GrpcClientService(ILogger logger, IConfiguration configuration)
+ {
+ _logger = logger;
+ _clientId = configuration["ClientConfiguration:ClientId"] ?? $"WpfClient_{Guid.NewGuid():N}";
+ _statusUpdates = new ObservableCollection();
+
+ var endpoint = configuration["ClientConfiguration:GrpcEndpoint"] ?? "http://localhost:50051";
+ InitializeChannel(endpoint);
+ }
+
+ private void InitializeChannel(string endpoint)
+ {
+ try
+ {
+ _logger.LogInformation("Initializing gRPC channel to {Endpoint}", endpoint);
+
+ _channel = GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
+ {
+ MaxReceiveMessageSize = 1024 * 1024,
+ MaxSendMessageSize = 1024 * 1024
+ });
+
+ _statusClient = new global::StatusLightChecker.Contracts.StatusService.StatusServiceClient(_channel);
+ _settingsClient = new global::StatusLightChecker.Contracts.SettingsService.SettingsServiceClient(_channel);
+
+ _logger.LogInformation("gRPC channel initialized successfully");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize gRPC channel");
+ throw;
+ }
+ }
+
+ public async Task StartStatusStreamAsync()
+ {
+ if (_statusClient == null) return;
+
+ _statusStreamCts = new CancellationTokenSource();
+
+ try
+ {
+ using var call = _statusClient.StreamStatusUpdates(new StatusRequest { ClientId = _clientId });
+
+ await foreach (var update in call.ResponseStream.ReadAllAsync(_statusStreamCts.Token))
+ {
+ _statusUpdates.Insert(0, update);
+ if (_statusUpdates.Count > 100) _statusUpdates.RemoveAt(_statusUpdates.Count - 1);
+
+ StatusReceived?.Invoke(this, update);
+ ServiceStateChanged?.Invoke(this, (ServiceState)update.StatusLevel);
+ }
+ }
+ catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
+ {
+ _logger.LogInformation("Status stream cancelled");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in status stream");
+ }
+ }
+
+ public async Task StartConfigurationStreamAsync()
+ {
+ if (_settingsClient == null) return;
+
+ _configStreamCts = new CancellationTokenSource();
+
+ try
+ {
+ using var call = _settingsClient.StreamColorConfigUpdates(new ColorConfigRequest { ClientId = _clientId });
+
+ await foreach (var config in call.ResponseStream.ReadAllAsync(_configStreamCts.Token))
+ {
+ ConfigurationReceived?.Invoke(this, config);
+ }
+ }
+ catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
+ {
+ _logger.LogInformation("Config stream cancelled");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in config stream");
+ }
+ }
+
+ public async Task GetServiceHealthAsync()
+ {
+ if (_statusClient == null) return null;
+
+ try
+ {
+ return await _statusClient.GetServiceHealthAsync(new HealthRequest());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get service health");
+ return null;
+ }
+ }
+
+ public async Task GetColorConfigurationAsync()
+ {
+ if (_settingsClient == null) return null;
+
+ try
+ {
+ var response = await _settingsClient.GetColorConfigAsync(new ColorConfigRequest { ClientId = _clientId });
+ if (response.Success)
+ {
+ return MapFromProto(response.Config);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get color configuration");
+ }
+ return null;
+ }
+
+ 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
+ });
+ return response.Success;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to update color configuration");
+ return false;
+ }
+ }
+
+ public void StopStreams()
+ {
+ _statusStreamCts?.Cancel();
+ _configStreamCts?.Cancel();
+ }
+
+ public void Dispose()
+ {
+ StopStreams();
+ _statusStreamCts?.Dispose();
+ _configStreamCts?.Dispose();
+ _channel?.Dispose();
+ }
+
+ private static Contracts.ColorConfiguration MapToProto(StatusLightChecker.Core.Models.ColorConfiguration config)
+ {
+ return new Contracts.ColorConfiguration
+ {
+ Available = MapToProto(config.Available),
+ Busy = MapToProto(config.Busy),
+ DoNotDisturb = MapToProto(config.DoNotDisturb),
+ Away = MapToProto(config.Away),
+ Offline = MapToProto(config.Offline),
+ Unknown = MapToProto(config.Unknown)
+ };
+ }
+
+ private static Contracts.StatusColor MapToProto(StatusLightChecker.Core.Models.StatusColor color)
+ {
+ return new Contracts.StatusColor
+ {
+ StatusLevel = (int)color.StatusLevel,
+ Name = color.Name,
+ HexColor = color.HexColor,
+ IsBlinking = color.IsBlinking
+ };
+ }
+
+ private static StatusLightChecker.Core.Models.ColorConfiguration MapFromProto(Contracts.ColorConfiguration proto)
+ {
+ return new StatusLightChecker.Core.Models.ColorConfiguration
+ {
+ Available = MapFromProto(proto.Available),
+ Busy = MapFromProto(proto.Busy),
+ DoNotDisturb = MapFromProto(proto.DoNotDisturb),
+ Away = MapFromProto(proto.Away),
+ Offline = MapFromProto(proto.Offline),
+ Unknown = MapFromProto(proto.Unknown)
+ };
+ }
+
+ private static StatusLightChecker.Core.Models.StatusColor MapFromProto(Contracts.StatusColor proto)
+ {
+ return new StatusLightChecker.Core.Models.StatusColor
+ {
+ StatusLevel = (StatusLightChecker.Core.Models.StatusLevel)proto.StatusLevel,
+ Name = proto.Name,
+ HexColor = proto.HexColor,
+ IsBlinking = proto.IsBlinking
+ };
+ }
+}
diff --git a/src/StatusLightChecker/Services/ServiceControllerWrapper.cs b/src/StatusLightChecker/Services/ServiceControllerWrapper.cs
new file mode 100644
index 0000000..023d97b
--- /dev/null
+++ b/src/StatusLightChecker/Services/ServiceControllerWrapper.cs
@@ -0,0 +1,117 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using System.ServiceProcess;
+
+namespace StatusLightChecker.Services;
+
+public class ServiceControllerWrapper : IDisposable
+{
+ private readonly ILogger _logger;
+ private readonly string _serviceName;
+ private ServiceController? _serviceController;
+
+ public event EventHandler? StatusChanged;
+
+ public ServiceControllerWrapper(ILogger logger, IConfiguration configuration)
+ {
+ _logger = logger;
+ _serviceName = configuration["ClientConfiguration:ServiceName"] ?? "StatusLightCheckerService";
+ RefreshServiceController();
+ }
+
+ public ServiceControllerStatus? CurrentStatus => _serviceController?.Status;
+
+ public bool IsInstalled
+ {
+ get
+ {
+ try
+ {
+ RefreshServiceController();
+ return _serviceController != null;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+
+ public bool CanStart => _serviceController != null;
+ public bool CanStop => _serviceController?.CanStop ?? false;
+ public bool CanPause => false;
+
+ public async Task StartAsync()
+ {
+ if (_serviceController == null) return;
+
+ try
+ {
+ if (_serviceController.Status == ServiceControllerStatus.Stopped)
+ {
+ _logger.LogInformation("Starting service {ServiceName}", _serviceName);
+ _serviceController.Start();
+ await Task.Run(() => _serviceController.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30)));
+ StatusChanged?.Invoke(this, _serviceController.Status);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to start service");
+ throw;
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ if (_serviceController == null) return;
+
+ try
+ {
+ if (_serviceController.CanStop)
+ {
+ _logger.LogInformation("Stopping service {ServiceName}", _serviceName);
+ _serviceController.Stop();
+ await Task.Run(() => _serviceController.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(30)));
+ StatusChanged?.Invoke(this, _serviceController.Status);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to stop service");
+ throw;
+ }
+ }
+
+ public async Task RestartAsync()
+ {
+ await StopAsync();
+ await Task.Delay(1000);
+ await StartAsync();
+ }
+
+ public void Refresh()
+ {
+ RefreshServiceController();
+ }
+
+ private void RefreshServiceController()
+ {
+ try
+ {
+ _serviceController?.Dispose();
+ _serviceController = new ServiceController(_serviceName);
+ _serviceController.Refresh();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Service {ServiceName} not found or not accessible", _serviceName);
+ _serviceController = null;
+ }
+ }
+
+ public void Dispose()
+ {
+ _serviceController?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs b/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs
index eb7b5ef..7c1db78 100644
--- a/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs
+++ b/src/StatusLightChecker/StatusCheckers/StatusCheckerBase.cs
@@ -1,9 +1,14 @@
-using System.Windows.Media;
+using System;
+using System.Windows.Media;
using System.Windows.Threading;
+using System;
+using StatusLightChecker.Core.Models;
+using StatusLightChecker.Core.StatusCheckers;
using FlaUI.Core.AutomationElements;
using FlaUI.UIA3;
using Serilog;
using StatusLightChecker.Enumerations;
+using StatusLightChecker.ViewModels;
namespace StatusLightChecker.StatusCheckers;
@@ -21,7 +26,7 @@ public abstract class StatusCheckerBase(
protected AutomationElement? StoredWindow;
public abstract Task GetCurrentStatus();
- private DispatcherTimer statusTimer;
+ private DispatcherTimer? statusTimer;
public StatusChangedEventHandler StatusChanged { get; set; } = statusChangedEventHandler;
@@ -45,12 +50,12 @@ public abstract class StatusCheckerBase(
public async void StartChecking()
{
InitializeTimer();
- statusTimer.Start();
+ statusTimer?.Start();
}
public async void StopChecking()
{
- statusTimer.Stop();
+ statusTimer?.Stop();
await CancellationTokenSource.CancelAsync();
}
@@ -62,7 +67,7 @@ public void InitializeTimer()
private async void StatusTimerCallback(object? sender, EventArgs e)
{
await GetCurrentStatus();
- statusTimer.Start();
+ statusTimer?.Start();
}
private List? FindWindows()
diff --git a/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs b/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs
index b05ed62..7ce343f 100644
--- a/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs
+++ b/src/StatusLightChecker/StatusCheckers/TeamsApplicationStatusChecker.cs
@@ -1,9 +1,12 @@
-using System.Drawing;
+using System;
+using System.Drawing;
using System.Windows.Media;
+using StatusLightChecker.Core.StatusCheckers;
using FlaUI.Core.AutomationElements;
using FlaUI.UIA3;
using Serilog;
using StatusLightChecker.Enumerations;
+using StatusLightChecker.ViewModels;
using Brushes = System.Windows.Media.Brushes;
namespace StatusLightChecker.StatusCheckers;
diff --git a/src/StatusLightChecker/StatusLightChecker.csproj b/src/StatusLightChecker/StatusLightChecker.csproj
index 65d84e5..b5bd822 100644
--- a/src/StatusLightChecker/StatusLightChecker.csproj
+++ b/src/StatusLightChecker/StatusLightChecker.csproj
@@ -1,31 +1,53 @@
-
+
WinExe
- net8.0-windows
+ net10.0-windows
enable
enable
true
- Jublin.xyz
- 1.0.2
- 1.0.2
+ false
icon.ico
+ win-x64
+ true
+ statuslightchecker-wpf-2025
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
diff --git a/src/StatusLightChecker/StatusLightCheckerInstaller.cs b/src/StatusLightChecker/StatusLightCheckerInstaller.cs
new file mode 100644
index 0000000..5f56a48
--- /dev/null
+++ b/src/StatusLightChecker/StatusLightCheckerInstaller.cs
@@ -0,0 +1,24 @@
+using System.ServiceProcess;
+
+namespace StatusLightChecker
+{
+ // TODO: Service installer needs to be updated for .NET 10 compatibility
+ // [System.Configuration.Install.RunInstaller(true)]
+ // public class StatusLightCheckerInstaller : System.Configuration.Install.Installer
+ // {
+ // public StatusLightCheckerInstaller()
+ // {
+ // var processInstaller = new System.ServiceProcess.ServiceProcessInstaller();
+ // var serviceInstaller = new System.ServiceProcess.ServiceInstaller();
+
+ // processInstaller.Account = System.ServiceProcess.ServiceAccount.LocalSystem;
+ // serviceInstaller.ServiceName = "StatusLightCheckerService";
+ // serviceInstaller.DisplayName = "Status Light Checker Service";
+ // serviceInstaller.Description = "Monitors application status and updates lights accordingly";
+ // serviceInstaller.StartType = System.ServiceProcess.ServiceStartMode.Automatic;
+
+ // Installers.Add(processInstaller);
+ // Installers.Add(serviceInstaller);
+ // }
+ // }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker/ViewModels/MainViewModel.cs b/src/StatusLightChecker/ViewModels/MainViewModel.cs
index 7f7917e..5310977 100644
--- a/src/StatusLightChecker/ViewModels/MainViewModel.cs
+++ b/src/StatusLightChecker/ViewModels/MainViewModel.cs
@@ -1,164 +1,289 @@
-using System.Windows;
-using System.Windows.Controls;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using MaterialDesignThemes.Wpf;
+using Microsoft.Extensions.Logging;
+using StatusLightChecker.Core.Models;
+using StatusLightChecker.Services;
+using System.Collections.ObjectModel;
+using System.ServiceProcess;
using System.Windows.Media;
-using System.Windows.Threading;
-using CommunityToolkit.Mvvm.ComponentModel;
-using Microsoft.Xaml.Behaviors.Core;
-using RJCP.IO.Ports;
-using Serilog;
-using StatusLightChecker.Enumerations;
-using StatusLightChecker.StatusCheckers;
namespace StatusLightChecker.ViewModels;
-internal class MainViewModel : ObservableObject
+public partial class MainViewModel : ObservableObject, IDisposable
{
- public static MainViewModel Instance { get; } = new();
+ private readonly ILogger _logger;
+ private readonly GrpcClientService _grpcClient;
+ private readonly ServiceControllerWrapper _serviceController;
+ private System.Timers.Timer? _refreshTimer;
+ private readonly System.Threading.CancellationTokenSource _cts = new();
- public SerialPortStream? SerialPort { get; private set; }
+ [ObservableProperty]
+ private string _serviceStatus = "Unknown";
- private TeamsApplicationStatusChecker? teamsApplicationStatusChecker;
-
- private readonly StatusChangedEventHandler statusChangedEventHandler;
+ [ObservableProperty]
+ private Brush _serviceStatusColor = Brushes.Gray;
- private MainViewModel()
- {
- Log.Logger = new LoggerConfiguration()
- .WriteTo.RichTextBox(Application.Current.MainWindow?.FindName("LogBox") as RichTextBox)
- .CreateLogger();
- Log.Information("Hello, world!");
- statusChangedEventHandler = StatusChangedEvent;
- }
+ [ObservableProperty]
+ private ApplicationStatus? _currentStatus;
+
+ [ObservableProperty]
+ private ObservableCollection _statusHistory = new();
+
+ [ObservableProperty]
+ private ColorConfigurationViewModel _colorConfiguration = new();
+
+ [ObservableProperty]
+ private bool _isConnected;
+
+ [ObservableProperty]
+ private string _connectionStatus = "Disconnected";
+
+ [ObservableProperty]
+ private bool _serviceInstalled;
- private string comPort = string.Empty;
- public string ComPort
+ [ObservableProperty]
+ private bool _canControlService;
+
+ public MainViewModel(
+ ILogger logger,
+ GrpcClientService grpcClient,
+ ServiceControllerWrapper serviceController)
{
- get => comPort;
- set => SetProperty(ref comPort, value);
+ _logger = logger;
+ _grpcClient = grpcClient;
+ _serviceController = serviceController;
+
+ _serviceController.StatusChanged += OnServiceStatusChanged;
+ _grpcClient.StatusReceived += OnStatusReceived;
+ _grpcClient.ConfigurationReceived += OnConfigurationReceived;
+
+ // Setup refresh timer using System.Timers.Timer instead of DispatcherTimer
+ _refreshTimer = new System.Timers.Timer(5000);
+ _refreshTimer.Elapsed += async (s, e) => await RefreshServiceStatusAsync();
+ _refreshTimer.AutoReset = true;
+ _refreshTimer.Start();
+
+ _ = InitializeAsync();
}
- private int baudRate = 115200;
- public int BaudRate
+ private async Task InitializeAsync()
{
- get => baudRate;
- set => SetProperty(ref baudRate, value);
+ try
+ {
+ ServiceInstalled = _serviceController.IsInstalled;
+ CanControlService = ServiceInstalled;
+
+ await RefreshServiceStatusAsync();
+ _ = StartGrpcStreamsAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during initialization");
+ }
}
-
- private ApplicationCheck selectedApp = ApplicationCheck.MicrosoftTeams;
- public ApplicationCheck SelectedApp
+ private async Task StartGrpcStreamsAsync()
{
- get => selectedApp;
- set
+ try
{
- SetProperty(ref selectedApp, value);
- SetupChecker();
- GetChecker()?.GetCurrentStatus();
+ _ = Task.Run(async () => await _grpcClient.StartStatusStreamAsync());
+ _ = Task.Run(async () => await _grpcClient.StartConfigurationStreamAsync());
+
+ IsConnected = true;
+ ConnectionStatus = "Connected";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to start gRPC streams");
+ IsConnected = false;
+ ConnectionStatus = "Disconnected";
}
}
-
- private IStatusChecker? GetChecker()
+
+ private void OnStatusReceived(object? sender, StatusLightChecker.Contracts.StatusUpdate update)
{
- return SelectedApp switch
+ var viewModel = new StatusUpdateViewModel
{
- ApplicationCheck.MicrosoftTeams => teamsApplicationStatusChecker,
- // ApplicationCheck.Slack => slackStatusChecker,
- _ => null
+ ApplicationName = update.ApplicationName,
+ StatusLabel = update.StatusLabel,
+ StatusLevel = (StatusLevel)update.StatusLevel,
+ ColorHex = update.ColorHex,
+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(update.TimestampUnixMs).LocalDateTime,
+ Details = update.Details
};
+
+ // Use App.Current.Dispatcher for UI thread marshaling
+ _ = App.Current.Dispatcher.InvokeAsync(() =>
+ {
+ StatusHistory.Insert(0, viewModel);
+ if (StatusHistory.Count > 50) StatusHistory.RemoveAt(StatusHistory.Count - 1);
+
+ CurrentStatus = new ApplicationStatus
+ {
+ ApplicationName = update.ApplicationName,
+ StatusLabel = update.StatusLabel,
+ StatusLevel = (StatusLevel)update.StatusLevel
+ };
+ });
}
- private void SetupChecker()
+ private void OnConfigurationReceived(object? sender, StatusLightChecker.Contracts.ColorConfigResponse config)
{
- var currChecker = GetChecker();
- currChecker?.Dispose();
- switch (SelectedApp)
- {
- case ApplicationCheck.MicrosoftTeams:
- teamsApplicationStatusChecker = new TeamsApplicationStatusChecker(Log.Logger, statusChangedEventHandler);
- break;
- // case ApplicationCheck.Slack:
- // slackStatusChecker = new SlackStatusChecker(Log.Logger, statusChangedEventHandler);
- // break;
- default:
- throw new ArgumentOutOfRangeException();
- }
+ _ = App.Current.Dispatcher.InvokeAsync(() =>
+ {
+ if (config.Config != null)
+ {
+ ColorConfiguration = new ColorConfigurationViewModel(config.Config);
+ }
+ });
}
- private bool connected;
+ private void OnServiceStatusChanged(object? sender, ServiceControllerStatus status)
+ {
+ _ = App.Current.Dispatcher.InvokeAsync(async () => await RefreshServiceStatusAsync());
+ }
- private bool Connected
+ private async Task RefreshServiceStatusAsync()
{
- get => connected;
- set
+ try
+ {
+ _serviceController.Refresh();
+
+ var status = _serviceController.CurrentStatus;
+ if (status.HasValue)
+ {
+ // Update on UI thread
+ await App.Current.Dispatcher.InvokeAsync(() =>
+ {
+ ServiceStatus = status.Value.ToString();
+ ServiceStatusColor = status.Value switch
+ {
+ ServiceControllerStatus.Running => Brushes.Green,
+ ServiceControllerStatus.Stopped => Brushes.Red,
+ ServiceControllerStatus.Paused => Brushes.Orange,
+ ServiceControllerStatus.StartPending or
+ ServiceControllerStatus.StopPending or
+ ServiceControllerStatus.PausePending or
+ ServiceControllerStatus.ContinuePending => Brushes.Yellow,
+ _ => Brushes.Gray
+ };
+ });
+ }
+ else
+ {
+ await App.Current.Dispatcher.InvokeAsync(() =>
+ {
+ ServiceStatus = "Not Installed";
+ ServiceStatusColor = Brushes.Gray;
+ });
+ }
+
+ // Also check gRPC health
+ var health = await _grpcClient.GetServiceHealthAsync();
+ await App.Current.Dispatcher.InvokeAsync(() =>
+ {
+ if (health != null)
+ {
+ IsConnected = true;
+ ConnectionStatus = $"Connected (v{health.Version})";
+ }
+ else
+ {
+ IsConnected = false;
+ ConnectionStatus = "Disconnected";
+ }
+ });
+ }
+ catch (Exception ex)
{
- SetProperty(ref connected, value);
- OnPropertyChanged(nameof(ComportButtonText));
- OnPropertyChanged(nameof(ComPortButtonCommand));
+ _logger.LogDebug(ex, "Error refreshing service status");
}
}
- private bool showLog;
-
- public bool ShowLog
+ [RelayCommand]
+ private async Task StartServiceAsync()
{
- get => showLog;
- set
+ try
+ {
+ await _serviceController.StartAsync();
+ }
+ catch (Exception ex)
{
- SetProperty(ref showLog, value);
- OnPropertyChanged(nameof(ShowLogText));
+ _logger.LogError(ex, "Failed to start service");
+ await ShowErrorDialogAsync($"Failed to start service: {ex.Message}");
}
}
- public string ShowLogText => ShowLog ? "Hide Log" : "Show Log";
-
-
- public ActionCommand ComPortButtonCommand => Connected ? DisconnectCommand : ConnectCommand;
-
- private ActionCommand ConnectCommand => new(ConnectSerialPort);
- private ActionCommand DisconnectCommand => new(DisconnectSerialPort);
-
- private void DisconnectSerialPort()
+ [RelayCommand]
+ private async Task StopServiceAsync()
{
- GetChecker()?.StopChecking();
- SerialPort?.Close();
+ try
+ {
+ await _serviceController.StopAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to stop service");
+ await ShowErrorDialogAsync($"Failed to stop service: {ex.Message}");
+ }
}
- private void ConnectSerialPort()
+ [RelayCommand]
+ private async Task RestartServiceAsync()
{
- if (GetChecker() == null)
- {
- SetupChecker();
- }
- SerialPort = new SerialPortStream(ComPort, BaudRate);
try
{
- SerialPort.Open();
+ await _serviceController.RestartAsync();
}
- catch (Exception)
+ catch (Exception ex)
{
- Connected = false;
- return;
+ _logger.LogError(ex, "Failed to restart service");
+ await ShowErrorDialogAsync($"Failed to restart service: {ex.Message}");
}
- Connected = true;
- GetChecker()?.StartChecking();
}
- public string ComportButtonText => Connected ? "Disconnect" : "Connect";
+ [RelayCommand]
+ private async Task RefreshStatusAsync()
+ {
+ await RefreshServiceStatusAsync();
+ }
- public SolidColorBrush? StatusColor => GetColorFromSelectedChecker();
+ [RelayCommand]
+ private async Task OpenSettingsAsync()
+ {
+ var dialog = new Views.SettingsDialog
+ {
+ DataContext = new SettingsViewModel(_grpcClient, ColorConfiguration)
+ };
+
+ await DialogHost.Show(dialog, "RootDialog");
+ }
- private SolidColorBrush? GetColorFromSelectedChecker()
+ private async Task ShowErrorDialogAsync(string message)
{
- var checker = GetChecker();
- return checker == null ? new SolidColorBrush(Colors.Black) : checker?.GetColorFromStatus();
+ var dialog = new Views.ErrorDialog { Message = message };
+ await DialogHost.Show(dialog, "RootDialog");
}
-
- private async void StatusChangedEvent()
+
+ public void Dispose()
{
- OnPropertyChanged(nameof(StatusColor));
- SerialPort?.DiscardOutBuffer();
- byte[] colorBytes = StatusColor == null ? [0, 0, 0] : [StatusColor.Color.R, StatusColor.Color.G, StatusColor.Color.B];
- await SerialPort?.WriteAsync(colorBytes, 0, colorBytes.Length)!;
- await SerialPort?.FlushAsync()!;
+ _refreshTimer?.Stop();
+ _refreshTimer?.Dispose();
+ _cts?.Cancel();
+ _cts?.Dispose();
+ _grpcClient?.Dispose();
+ _serviceController?.Dispose();
}
-}
\ No newline at end of file
+}
+
+public class StatusUpdateViewModel
+{
+ public string ApplicationName { get; set; } = string.Empty;
+ public string StatusLabel { get; set; } = string.Empty;
+ public StatusLevel StatusLevel { get; set; }
+ public string ColorHex { get; set; } = "#808080";
+ public DateTime Timestamp { get; set; }
+ public string Details { get; set; } = string.Empty;
+ public Brush StatusBrush => (SolidColorBrush)new BrushConverter().ConvertFromString(ColorHex)! ?? Brushes.Gray;
+}
diff --git a/src/StatusLightChecker/ViewModels/SettingsViewModel.cs b/src/StatusLightChecker/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..034d179
--- /dev/null
+++ b/src/StatusLightChecker/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,179 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using MaterialDesignThemes.Wpf;
+using StatusLightChecker.Core.Models;
+using StatusLightChecker.Services;
+using System.Windows.Media;
+
+namespace StatusLightChecker.ViewModels;
+
+public partial class ColorConfigurationViewModel : ObservableObject
+{
+ [ObservableProperty]
+ private StatusColorViewModel _available = new();
+
+ [ObservableProperty]
+ private StatusColorViewModel _busy = new();
+
+ [ObservableProperty]
+ private StatusColorViewModel _doNotDisturb = new();
+
+ [ObservableProperty]
+ private StatusColorViewModel _away = new();
+
+ [ObservableProperty]
+ private StatusColorViewModel _offline = new();
+
+ [ObservableProperty]
+ private StatusColorViewModel _unknown = new();
+
+ public ColorConfigurationViewModel()
+ {
+ _available = new StatusColorViewModel { Name = "Available", HexColor = "#00CC6A", StatusLevel = StatusLevel.Available };
+ _busy = new StatusColorViewModel { Name = "Busy", HexColor = "#FF0000", StatusLevel = StatusLevel.Busy };
+ _doNotDisturb = new StatusColorViewModel { Name = "Do Not Disturb", HexColor = "#B30000", StatusLevel = StatusLevel.DoNotDisturb, IsBlinking = true };
+ _away = new StatusColorViewModel { Name = "Away", HexColor = "#FFCC00", StatusLevel = StatusLevel.Away };
+ _offline = new StatusColorViewModel { Name = "Offline", HexColor = "#808080", StatusLevel = StatusLevel.Offline };
+ _unknown = new StatusColorViewModel { Name = "Unknown", HexColor = "#CCCCCC", StatusLevel = StatusLevel.Unknown };
+ }
+
+ public ColorConfigurationViewModel(Contracts.ColorConfiguration config)
+ {
+ _available = new StatusColorViewModel(config.Available);
+ _busy = new StatusColorViewModel(config.Busy);
+ _doNotDisturb = new StatusColorViewModel(config.DoNotDisturb);
+ _away = new StatusColorViewModel(config.Away);
+ _offline = new StatusColorViewModel(config.Offline);
+ _unknown = new StatusColorViewModel(config.Unknown);
+ }
+
+ public ColorConfiguration ToConfiguration()
+ {
+ return new ColorConfiguration
+ {
+ Available = Available.ToStatusColor(),
+ Busy = Busy.ToStatusColor(),
+ DoNotDisturb = DoNotDisturb.ToStatusColor(),
+ Away = Away.ToStatusColor(),
+ Offline = Offline.ToStatusColor(),
+ Unknown = Unknown.ToStatusColor()
+ };
+ }
+}
+
+public partial class StatusColorViewModel : ObservableObject
+{
+ [ObservableProperty]
+ private StatusLevel _statusLevel;
+
+ [ObservableProperty]
+ private string _name = string.Empty;
+
+ [ObservableProperty]
+ private string _hexColor = "#808080";
+
+ [ObservableProperty]
+ private bool _isBlinking;
+
+ public Brush ColorBrush => (SolidColorBrush)new BrushConverter().ConvertFromString(HexColor)! ?? Brushes.Gray;
+
+ public StatusColorViewModel() { }
+
+ public StatusColorViewModel(Contracts.StatusColor color)
+ {
+ StatusLevel = (StatusLevel)color.StatusLevel;
+ Name = color.Name;
+ HexColor = color.HexColor;
+ IsBlinking = color.IsBlinking;
+ }
+
+ public StatusColor ToStatusColor()
+ {
+ return new StatusColor
+ {
+ StatusLevel = StatusLevel,
+ Name = Name,
+ HexColor = HexColor,
+ IsBlinking = IsBlinking
+ };
+ }
+}
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly GrpcClientService _grpcClient;
+
+ [ObservableProperty]
+ private ColorConfigurationViewModel _configuration;
+
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ [ObservableProperty]
+ private bool _isSaving;
+
+ public SettingsViewModel(GrpcClientService grpcClient, ColorConfigurationViewModel initialConfig)
+ {
+ _grpcClient = grpcClient;
+ Configuration = initialConfig;
+ }
+
+ [RelayCommand]
+ private async Task SaveConfigurationAsync()
+ {
+ try
+ {
+ IsSaving = true;
+ StatusMessage = "Saving...";
+
+ var config = Configuration.ToConfiguration();
+ var success = await _grpcClient.UpdateColorConfigurationAsync(config);
+
+ if (success)
+ {
+ StatusMessage = "Settings saved successfully!";
+ // Apply the colors dynamically
+ await App.Current.Dispatcher.InvokeAsync(() => ApplyColorsToTheme(config));
+ }
+ else
+ {
+ StatusMessage = "Failed to save settings.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+ }
+
+ [RelayCommand]
+ private void ResetToDefaults()
+ {
+ Configuration = new ColorConfigurationViewModel();
+ StatusMessage = "Reset to defaults. Click Save to apply.";
+ }
+
+ [RelayCommand]
+ private void CloseDialog()
+ {
+ // Close dialog via command
+ DialogHost.CloseDialogCommand.Execute(null, null);
+ }
+
+ private static void ApplyColorsToTheme(ColorConfiguration config)
+ {
+ // Apply colors to application resources for dynamic theming
+ var resources = App.Current.Resources;
+
+ resources["StatusAvailableBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Available.HexColor)!;
+ resources["StatusBusyBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Busy.HexColor)!;
+ resources["StatusDoNotDisturbBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.DoNotDisturb.HexColor)!;
+ resources["StatusAwayBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Away.HexColor)!;
+ resources["StatusOfflineBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Offline.HexColor)!;
+ resources["StatusUnknownBrush"] = (SolidColorBrush)new BrushConverter().ConvertFromString(config.Unknown.HexColor)!;
+ }
+}
diff --git a/src/StatusLightChecker/Views/ErrorDialog.xaml b/src/StatusLightChecker/Views/ErrorDialog.xaml
new file mode 100644
index 0000000..90b22eb
--- /dev/null
+++ b/src/StatusLightChecker/Views/ErrorDialog.xaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/StatusLightChecker/Views/ErrorDialog.xaml.cs b/src/StatusLightChecker/Views/ErrorDialog.xaml.cs
new file mode 100644
index 0000000..d405be7
--- /dev/null
+++ b/src/StatusLightChecker/Views/ErrorDialog.xaml.cs
@@ -0,0 +1,22 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace StatusLightChecker.Views;
+
+public partial class ErrorDialog : UserControl
+{
+ public static readonly DependencyProperty MessageProperty =
+ DependencyProperty.Register(nameof(Message), typeof(string), typeof(ErrorDialog),
+ new PropertyMetadata(string.Empty));
+
+ public string Message
+ {
+ get => (string)GetValue(MessageProperty);
+ set => SetValue(MessageProperty, value);
+ }
+
+ public ErrorDialog()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker/Views/SettingsDialog.xaml b/src/StatusLightChecker/Views/SettingsDialog.xaml
new file mode 100644
index 0000000..5e5fcd8
--- /dev/null
+++ b/src/StatusLightChecker/Views/SettingsDialog.xaml
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/StatusLightChecker/Views/SettingsDialog.xaml.cs b/src/StatusLightChecker/Views/SettingsDialog.xaml.cs
new file mode 100644
index 0000000..c568050
--- /dev/null
+++ b/src/StatusLightChecker/Views/SettingsDialog.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace StatusLightChecker.Views;
+
+public partial class SettingsDialog : UserControl
+{
+ public SettingsDialog()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/StatusLightChecker/appsettings.json b/src/StatusLightChecker/appsettings.json
new file mode 100644
index 0000000..cd321ce
--- /dev/null
+++ b/src/StatusLightChecker/appsettings.json
@@ -0,0 +1,30 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "System": "Warning"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ]
+ },
+ "ClientConfiguration": {
+ "ServiceName": "StatusLightCheckerService",
+ "GrpcEndpoint": "http://localhost:50051",
+ "ClientId": "WpfClient"
+ }
+}