diff --git a/AppConfig.cs b/AppConfig.cs new file mode 100644 index 0000000..b7c759d --- /dev/null +++ b/AppConfig.cs @@ -0,0 +1,96 @@ +#nullable enable +using Microsoft.Extensions.Configuration; + +namespace InstDotNet; + +/// +/// Application configuration model loaded from appsettings.json +/// +public class AppConfig +{ + public MqttConfig MQTT { get; set; } = new(); + public ApplicationConfig Application { get; set; } = new(); + public AlgorithmConfig Algorithm { get; set; } = new(); + public List Beacons { get; set; } = new(); + + /// + /// Load configuration from appsettings.json and environment variables + /// + public static AppConfig Load() + { + var builder = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(); + + var configuration = builder.Build(); + var config = new AppConfig(); + configuration.Bind(config); + return config; + } + + /// + /// Resolves placeholders in configuration strings (e.g., {$BoardID}) + /// + public void ResolvePlaceholders(string boardId) + { + MQTT.ReceiveTopic = ResolvePlaceholder(MQTT.ReceiveTopic, boardId); + MQTT.SendTopic = ResolvePlaceholder(MQTT.SendTopic, boardId); + MQTT.ClientId = ResolvePlaceholder(MQTT.ClientId, boardId); + } + + private static string ResolvePlaceholder(string value, string boardId) + { + if (string.IsNullOrEmpty(value)) + return value; + + return value.Replace("{$BoardID}", boardId, StringComparison.OrdinalIgnoreCase) + .Replace("{$BOARD_ID}", boardId, StringComparison.OrdinalIgnoreCase); + } +} + +public class MqttConfig +{ + public string ServerAddress { get; set; } = string.Empty; + public int Port { get; set; } = 1883; + public string ClientId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string ReceiveTopic { get; set; } = string.Empty; + public string SendTopic { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 10; + public int RetryAttempts { get; set; } = 5; + public int RetryDelaySeconds { get; set; } = 2; + public double RetryBackoffMultiplier { get; set; } = 2.0; + public bool AutoReconnect { get; set; } = true; + public int ReconnectDelaySeconds { get; set; } = 5; + public int KeepAlivePeriodSeconds { get; set; } = 60; + public bool UseTls { get; set; } = false; + public bool AllowUntrustedCertificates { get; set; } = false; + public string? CertificatePath { get; set; } + public string? CertificatePassword { get; set; } +} + +public class ApplicationConfig +{ + public int UpdateIntervalMs { get; set; } = 10; + public string LogLevel { get; set; } = "Information"; + public int HealthCheckPort { get; set; } = 8080; +} + +public class AlgorithmConfig +{ + public int MaxIterations { get; set; } = 10; + public float LearningRate { get; set; } = 0.1f; + public bool RefinementEnabled { get; set; } = true; +} + +public class BeaconConfig +{ + public string Id { get; set; } = string.Empty; + public double Latitude { get; set; } + public double Longitude { get; set; } + public double Altitude { get; set; } +} + diff --git a/HardwareId.cs b/HardwareId.cs new file mode 100644 index 0000000..47f31a0 --- /dev/null +++ b/HardwareId.cs @@ -0,0 +1,150 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Net.NetworkInformation; + +namespace InstDotNet; + +/// +/// Provides unique hardware identifiers for the system. +/// +public static class HardwareId +{ + private static string? _cachedId; + + /// + /// Gets a unique hardware identifier for this system. + /// Tries multiple methods in order of preference: + /// 1. Systemd machine-id (/etc/machine-id) + /// 2. DMI system UUID (/sys/class/dmi/id/product_uuid) + /// 3. First MAC address + /// 4. Hostname (fallback) + /// + public static string GetUniqueId() + { + if (_cachedId != null) + { + return _cachedId; + } + + // Try systemd machine-id (most reliable on modern Linux) + try + { + var machineIdPath = "/etc/machine-id"; + if (File.Exists(machineIdPath)) + { + var machineId = File.ReadAllText(machineIdPath).Trim(); + if (!string.IsNullOrEmpty(machineId)) + { + _cachedId = $"machine-{machineId}"; + return _cachedId; + } + } + } + catch (Exception) + { + // Ignore errors + } + + // Try DMI system UUID + try + { + var dmiUuidPath = "/sys/class/dmi/id/product_uuid"; + if (File.Exists(dmiUuidPath)) + { + var dmiUuid = File.ReadAllText(dmiUuidPath).Trim(); + if (!string.IsNullOrEmpty(dmiUuid)) + { + _cachedId = $"dmi-{dmiUuid}"; + return _cachedId; + } + } + } + catch (Exception) + { + // Ignore errors + } + + // Try first MAC address + try + { + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces() + .Where(nic => nic.OperationalStatus == OperationalStatus.Up && + nic.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .OrderBy(nic => nic.NetworkInterfaceType) + .ToList(); + + foreach (var nic in networkInterfaces) + { + var macAddress = nic.GetPhysicalAddress().ToString(); + if (!string.IsNullOrEmpty(macAddress) && macAddress != "000000000000") + { + _cachedId = $"mac-{macAddress}"; + return _cachedId; + } + } + } + catch (Exception) + { + // Ignore errors + } + + // Fallback to hostname + try + { + var hostname = Environment.MachineName; + if (!string.IsNullOrEmpty(hostname)) + { + _cachedId = $"host-{hostname}"; + return _cachedId; + } + } + catch (Exception) + { + // Ignore errors + } + + // Last resort: generate a random ID (not ideal, but better than nothing) + _cachedId = $"random-{Guid.NewGuid():N}"; + return _cachedId; + } + + /// + /// Gets a sanitized version of the hardware ID suitable for use as an MQTT client ID. + /// MQTT client IDs must be alphanumeric and can contain hyphens and underscores. + /// + /// Optional prefix to prepend to the hardware ID (e.g., "UwbManager") + /// A sanitized MQTT client ID string, truncated to 128 characters if necessary + public static string GetMqttClientId(string? prefix = null) + { + var hardwareId = GetUniqueId(); + var clientId = string.IsNullOrEmpty(prefix) ? hardwareId : $"{prefix}-{hardwareId}"; + + // Sanitize for MQTT: only alphanumeric, hyphens, and underscores allowed + // Replace any invalid characters with hyphens + var sanitized = new System.Text.StringBuilder(); + foreach (var c in clientId) + { + if (char.IsLetterOrDigit(c) || c == '-' || c == '_') + { + sanitized.Append(c); + } + else + { + sanitized.Append('-'); + } + } + + // Ensure it's not too long (MQTT spec recommends max 23 characters for client IDs) + // But we'll allow up to 128 characters as per MQTT 3.1.1 spec + var result = sanitized.ToString(); + if (result.Length > 128) + { + result = result.Substring(0, 128); + } + + return result; + } +} + diff --git a/HealthCheck.cs b/HealthCheck.cs new file mode 100644 index 0000000..153e6b7 --- /dev/null +++ b/HealthCheck.cs @@ -0,0 +1,102 @@ +#nullable enable +using System; +using System.Text.Json; + +namespace InstDotNet; + +/// +/// Health check service that tracks application health status +/// +public static class HealthCheck +{ + private static DateTime _lastUpdateTime = DateTime.MinValue; + private static int _beaconCount = 0; + private static int _totalNodesProcessed = 0; + private static DateTime _startTime = DateTime.UtcNow; + + /// + /// Initialize the health check service + /// + public static void Initialize() + { + _startTime = DateTime.UtcNow; + } + + /// + /// Update the last processing time + /// + public static void UpdateLastProcessTime() + { + _lastUpdateTime = DateTime.UtcNow; + } + + /// + /// Update beacon count + /// + public static void UpdateBeaconCount(int count) + { + _beaconCount = count; + } + + /// + /// Increment total nodes processed + /// + public static void IncrementNodesProcessed(int count = 1) + { + _totalNodesProcessed += count; + } + + /// + /// Get current health status + /// + public static HealthStatus GetStatus() + { + var mqttConnected = MQTTControl.IsConnected(); + var uptime = DateTime.UtcNow - _startTime; + var timeSinceLastUpdate = _lastUpdateTime == DateTime.MinValue + ? TimeSpan.Zero + : DateTime.UtcNow - _lastUpdateTime; + + return new HealthStatus + { + Status = mqttConnected && timeSinceLastUpdate.TotalSeconds < 60 ? "healthy" : "degraded", + MqttConnected = mqttConnected, + LastUpdateTime = _lastUpdateTime == DateTime.MinValue ? null : _lastUpdateTime, + TimeSinceLastUpdate = timeSinceLastUpdate.TotalSeconds, + BeaconCount = _beaconCount, + TotalNodesProcessed = _totalNodesProcessed, + UptimeSeconds = uptime.TotalSeconds, + Version = VersionInfo.FullVersion + }; + } + + /// + /// Get health status as JSON string + /// + public static string GetStatusJson() + { + var status = GetStatus(); + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + return JsonSerializer.Serialize(status, options); + } +} + +/// +/// Health status model +/// +public class HealthStatus +{ + public string Status { get; set; } = "unknown"; + public bool MqttConnected { get; set; } + public DateTime? LastUpdateTime { get; set; } + public double TimeSinceLastUpdate { get; set; } + public int BeaconCount { get; set; } + public int TotalNodesProcessed { get; set; } + public double UptimeSeconds { get; set; } + public string Version { get; set; } = string.Empty; +} + diff --git a/HealthCheckServer.cs b/HealthCheckServer.cs new file mode 100644 index 0000000..f73ad2f --- /dev/null +++ b/HealthCheckServer.cs @@ -0,0 +1,128 @@ +#nullable enable +using System; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace InstDotNet; + +/// +/// HTTP server for health check endpoint +/// +public static class HealthCheckServer +{ + private static HttpListener? _listener; + private static Task? _serverTask; + private static CancellationTokenSource? _cts; + private static int _port = 8080; + + /// + /// Start the health check HTTP server + /// + public static void Start(int port = 8080) + { + _port = port; + _cts = new CancellationTokenSource(); + + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://+:{port}/"); + + try + { + _listener.Start(); + Console.WriteLine($"Health check server started on port {port}"); + + _serverTask = Task.Run(async () => await ListenAsync(_cts.Token), _cts.Token); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start health check server on port {port}: {ex.Message}"); + throw; + } + } + + /// + /// Stop the health check server + /// + public static void Stop() + { + _cts?.Cancel(); + _listener?.Stop(); + _listener?.Close(); + Console.WriteLine("Health check server stopped"); + } + + private static async Task ListenAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && _listener != null && _listener.IsListening) + { + try + { + var context = await _listener.GetContextAsync().ConfigureAwait(false); + _ = Task.Run(() => HandleRequest(context), cancellationToken); + } + catch (ObjectDisposedException) + { + // Listener was closed, exit gracefully + break; + } + catch (HttpListenerException ex) + { + Console.WriteLine($"Health check server error: {ex.Message}"); + if (!_listener.IsListening) + break; + } + catch (Exception ex) + { + Console.WriteLine($"Unexpected error in health check server: {ex.Message}"); + } + } + } + + private static void HandleRequest(HttpListenerContext context) + { + try + { + var request = context.Request; + var response = context.Response; + + // Only handle GET requests to /health + if (request.HttpMethod == "GET" && request.Url?.AbsolutePath == "/health") + { + var status = HealthCheck.GetStatus(); + var json = HealthCheck.GetStatusJson(); + + // Set status code based on health + response.StatusCode = status.Status == "healthy" ? 200 : 503; + response.ContentType = "application/json"; + response.ContentEncoding = Encoding.UTF8; + + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + else + { + // 404 for other paths + response.StatusCode = 404; + response.Close(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error handling health check request: {ex.Message}"); + try + { + context.Response.StatusCode = 500; + context.Response.Close(); + } + catch + { + // Ignore errors when closing response + } + } + } +} + diff --git a/InstDotNet.csproj b/InstDotNet.csproj index dd1c0d2..67486da 100644 --- a/InstDotNet.csproj +++ b/InstDotNet.csproj @@ -51,6 +51,17 @@ + + + + + + + + + + PreserveNewest + diff --git a/MQTTControl.cs b/MQTTControl.cs index 84d5e16..c3a19e5 100644 --- a/MQTTControl.cs +++ b/MQTTControl.cs @@ -6,17 +6,11 @@ using MQTTnet; using MQTTnet.Packets; // for v5 subscribe options if needed using MQTTnet.Protocol; // QoS enums +using InstDotNet; public class MQTTControl { public const string DEFAULT_CLIENT_ID = "clientId-UwbManager-001"; - public const string DEFAULT_SERVER_ADDRESS = "mqtt.dynamicdevices.co.uk"; - public const string DEFAULT_USERNAME = ""; - public const string DEFAULT_PASSWORD = ""; - public const int DEFAULT_SERVER_PORT = 1883; - public const string DEFAULT_RECEIVE_MESSAGE_TOPIC = "DotnetMQTT/Test/in"; - public const string DEFAULT_SEND_MESSAGE_TOPIC = "DotnetMQTT/Test/out"; - public const int DEFAULT_TIMEOUT_IN_SECONDS = 10; //not currently being used - need to for safety? private static string _clientId; private static string _serverAddress; private static string _usernname; @@ -25,7 +19,7 @@ public class MQTTControl private static string _receiveMessageTopic; private static string _sendMessageTopic; private static int _timeoutInSeconds; - + private static int _keepAlivePeriodSeconds; public static System.Action OnMessageReceived; @@ -33,25 +27,49 @@ public class MQTTControl private static CancellationTokenSource _cts; // Return Task so callers can await completion and observe exceptions - public static async Task Initialise(CancellationTokenSource cts, - string clientId = DEFAULT_CLIENT_ID, - string serverAddress = DEFAULT_SERVER_ADDRESS, - int port = DEFAULT_SERVER_PORT, - string username = DEFAULT_USERNAME, - string password = DEFAULT_PASSWORD, - string receiveMessageTopic = DEFAULT_RECEIVE_MESSAGE_TOPIC, - string sendMessageTopic = DEFAULT_SEND_MESSAGE_TOPIC, - int timeoutSeconds = DEFAULT_TIMEOUT_IN_SECONDS) + public static async Task Initialise(CancellationTokenSource cts, AppConfig? config = null) { _cts = cts ?? new CancellationTokenSource(); - _clientId = clientId; - _serverAddress = serverAddress; - _port = port; - _usernname = username; - _password = password; - _receiveMessageTopic = receiveMessageTopic; - _sendMessageTopic = sendMessageTopic; - _timeoutInSeconds = timeoutSeconds; + + if (config != null) + { + _serverAddress = config.MQTT.ServerAddress; + _port = config.MQTT.Port; + _usernname = config.MQTT.Username; + _password = config.MQTT.Password; + _receiveMessageTopic = config.MQTT.ReceiveTopic; + _sendMessageTopic = config.MQTT.SendTopic; + _timeoutInSeconds = config.MQTT.TimeoutSeconds; + _keepAlivePeriodSeconds = config.MQTT.KeepAlivePeriodSeconds; + + // Use configured client ID, or generate from hardware ID if empty + if (string.IsNullOrWhiteSpace(config.MQTT.ClientId)) + { + var baseClientId = HardwareId.GetMqttClientId("UwbManager"); + var processId = System.Diagnostics.Process.GetCurrentProcess().Id; + _clientId = $"{baseClientId}-pid{processId}"; + Console.WriteLine($"Using hardware-based MQTT client ID: {_clientId} (PID: {processId})"); + } + else + { + _clientId = config.MQTT.ClientId; + } + } + else + { + // Fallback to defaults if no config provided + _serverAddress = "mqtt.dynamicdevices.co.uk"; + _port = 1883; + _usernname = ""; + _password = ""; + _receiveMessageTopic = "DotnetMQTT/Test/in"; + _sendMessageTopic = "DotnetMQTT/Test/out"; + _timeoutInSeconds = 10; + _keepAlivePeriodSeconds = 60; + var baseClientId = HardwareId.GetMqttClientId("UwbManager"); + var processId = System.Diagnostics.Process.GetCurrentProcess().Id; + _clientId = $"{baseClientId}-pid{processId}"; + } var factory = new MqttClientFactory(); client = factory.CreateMqttClient(); @@ -90,7 +108,9 @@ public static async Task Initialise(CancellationTokenSource cts, var builder = new MqttClientOptionsBuilder() .WithClientId(_clientId) .WithTcpServer(_serverAddress, _port) - .WithCleanSession(); + .WithCleanSession() + .WithTimeout(TimeSpan.FromSeconds(_timeoutInSeconds)) + .WithKeepAlivePeriod(TimeSpan.FromSeconds(_keepAlivePeriodSeconds)); // Add credentials only if provided if (!string.IsNullOrWhiteSpace(_usernname) && !string.IsNullOrWhiteSpace(_password)) @@ -182,5 +202,13 @@ public static void ReceiveMessage(string message) OnMessageReceived?.Invoke(message); } + /// + /// Check if MQTT client is connected + /// + public static bool IsConnected() + { + return client != null && client.IsConnected; + } + } \ No newline at end of file diff --git a/Program.cs b/Program.cs index 8fb45d1..ea7c4a3 100644 --- a/Program.cs +++ b/Program.cs @@ -1,9 +1,10 @@ // Program.cs (net8.0, MQTTnet 5.x) using System.Text.Json; +using InstDotNet; class Program { - + static AppConfig? _config; static async Task Main() { @@ -11,9 +12,46 @@ static async Task Main() Console.WriteLine($"CGA Coordinate Mapping - Version {VersionInfo.FullVersion}"); Console.WriteLine(); + // Load configuration + try + { + _config = AppConfig.Load(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to load configuration: {ex.Message}"); + Console.Error.WriteLine("Using default configuration values"); + _config = new AppConfig(); + } + + // Get board ID and resolve placeholders + var boardId = HardwareId.GetMqttClientId("UwbManager"); + _config.ResolvePlaceholders(boardId); + + Console.WriteLine($"Board ID: {boardId}"); + Console.WriteLine($"MQTT Receive Topic: {_config.MQTT.ReceiveTopic}"); + Console.WriteLine($"MQTT Send Topic: {_config.MQTT.SendTopic}"); + Console.WriteLine(); + using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; - await MQTTControl.Initialise(cts); + + // Initialize health check + HealthCheck.Initialize(); + + // Start health check server + int healthCheckPort = _config?.Application.HealthCheckPort ?? 8080; + try + { + HealthCheckServer.Start(healthCheckPort); + Console.WriteLine($"Health check endpoint available at http://localhost:{healthCheckPort}/health"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start health check server on port {healthCheckPort}, continuing without health endpoint: {ex.Message}"); + } + + await MQTTControl.Initialise(cts, _config); UWBManager.Initialise(); //Try loading from Python-generated file if it exists @@ -39,8 +77,8 @@ static async Task Main() // Run one immediate update, then start a background loop to update repeatedly UWBManager.Update(); - // Interval between updates in milliseconds (few ms as requested) - const int updateIntervalMs = 10; + // Interval between updates from configuration + int updateIntervalMs = _config?.Application.UpdateIntervalMs ?? 10; // Start background loop that will run until Ctrl+C cancels the token _ = Task.Run(async () => @@ -66,6 +104,8 @@ static async Task Main() Console.WriteLine("Press Ctrl+C to exit…"); try { await Task.Delay(Timeout.Infinite, cts.Token); } catch { } + Console.WriteLine("Shutting down..."); + HealthCheckServer.Stop(); await MQTTControl.DisconnectAsync(); } } diff --git a/UWBManager.cs b/UWBManager.cs index 567a0bf..e0b45b0 100644 --- a/UWBManager.cs +++ b/UWBManager.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Numerics; using System.Text.Json; - +using InstDotNet; public class UWBManager { @@ -60,6 +60,7 @@ private static void UpdateUwbs() Console.WriteLine("UpdateUwbs: already updating — skipping re-entrant call."); return; } + isUpdating = true; UWB2GPSConverter.ConvertUWBToPositions(network, true); @@ -81,6 +82,11 @@ private static void UpdateUwbs() } sendNetwork = new UWB2GPSConverter.Network(sendUwbsList.ToArray()); + // Update health check + HealthCheck.UpdateLastProcessTime(); + HealthCheck.UpdateBeaconCount(sendNetwork.uwbs.Length); + HealthCheck.IncrementNodesProcessed(network.uwbs.Length); + SendNetwork(sendNetwork); isUpdating = false; } diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..de96fc4 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,38 @@ +{ + "MQTT": { + "ServerAddress": "mqtt.dynamicdevices.co.uk", + "Port": 1883, + "ClientId": "", + "Username": "", + "Password": "", + // To use board-specific topics, replace {$BoardID} with the actual board identifier + // Example: "uwb/{$BoardID}/network/in" becomes "uwb/UwbManager-machine-xxx/network/in" + // The {$BoardID} placeholder will be automatically replaced with the hardware-based MQTT client ID + // (without the process ID suffix). This allows each board to have its own topic namespace. + "ReceiveTopic": "DotnetMQTT/Test/in", + "SendTopic": "DotnetMQTT/Test/out", + "TimeoutSeconds": 10, + "RetryAttempts": 5, + "RetryDelaySeconds": 2, + "RetryBackoffMultiplier": 2.0, + "AutoReconnect": true, + "ReconnectDelaySeconds": 5, + "KeepAlivePeriodSeconds": 60, + "UseTls": false, + "AllowUntrustedCertificates": false, + "CertificatePath": null, + "CertificatePassword": null + }, + "Application": { + "UpdateIntervalMs": 10, + "LogLevel": "Information", + "HealthCheckPort": 8080 + }, + "Algorithm": { + "MaxIterations": 10, + "LearningRate": 0.1, + "RefinementEnabled": true + }, + "Beacons": [] +} +