From 9a3c8e5be96475883c3a56003e331f0db2b03ab7 Mon Sep 17 00:00:00 2001 From: k3ldar Date: Sun, 26 Jul 2026 18:36:49 +0200 Subject: [PATCH 1/2] Configure MQTT from app and add new device mqtt network handler --- PowerControlHub/MQTTNetworkHandler.cpp | 320 ++++++++++++++++++ PowerControlHub/MQTTNetworkHandler.h | 44 +++ PowerControlHub/PowerControlHub.vcxproj | 2 + .../PowerControlHub.vcxproj.filters | 6 + PowerControlHub/PowerControlHubApp.cpp | 16 +- PowerControlHub/PowerControlHubApp.h | 2 + PowerControlHubApp/AppShell.xaml.cs | 1 + PowerControlHubApp/Internal/Constants.cs | 19 ++ PowerControlHubApp/MauiProgram.cs | 2 + PowerControlHubApp/PowerControlHubApp.csproj | 3 + .../Services/ConfigConnection.cs | 146 ++++++++ PowerControlHubApp/Services/ConfigPoller.cs | 36 ++ .../Services/IConfigConnection.cs | 18 + .../Services/PowerHubService.cs | 90 +++++ .../ViewModels/BaseViewModel.cs | 2 +- .../ViewModels/MqttSettingsViewModel.cs | 302 +++++++++++++++++ .../ViewModels/SystemViewModel.cs | 3 + .../ViewModels/TimeSettingsViewModel.cs | 88 ++++- .../Views/MqttSettingsPage.xaml | 232 +++++++++++++ .../Views/MqttSettingsPage.xaml.cs | 27 ++ PowerControlHubApp/Views/SystemPage.xaml | 32 +- 21 files changed, 1374 insertions(+), 17 deletions(-) create mode 100644 PowerControlHub/MQTTNetworkHandler.cpp create mode 100644 PowerControlHub/MQTTNetworkHandler.h create mode 100644 PowerControlHubApp/ViewModels/MqttSettingsViewModel.cs create mode 100644 PowerControlHubApp/Views/MqttSettingsPage.xaml create mode 100644 PowerControlHubApp/Views/MqttSettingsPage.xaml.cs diff --git a/PowerControlHub/MQTTNetworkHandler.cpp b/PowerControlHub/MQTTNetworkHandler.cpp new file mode 100644 index 0000000..b2a1c2e --- /dev/null +++ b/PowerControlHub/MQTTNetworkHandler.cpp @@ -0,0 +1,320 @@ +/* + * PowerControlHub + * Copyright (C) 2025 Simon Carter (s1cart3r@gmail.com) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "Local.h" +#include "MQTTNetworkHandler.h" +#include "ConfigManager.h" +#include "SystemDefinitions.h" +#include "SystemFunctions.h" + +#if defined(MQTT_SUPPORT) +#include "MQTTController.h" +#endif + +MQTTNetworkHandler::MQTTNetworkHandler(MQTTController* mqttController) + : _mqttController(mqttController) +{ +} + +static const char* getParamValue(const StringKeyValue params[], uint8_t paramCount, const char* key) +{ + for (uint8_t i = 0; i < paramCount; ++i) + { + if (strcmp(params[i].key, key) == 0) + return params[i].value; + } + + return nullptr; +} + +static void writeSuccess(char* buffer, size_t size, const char* command, const char* value) +{ + if (value) + { + snprintf(buffer, size, "\"success\":true,\"command\":\"%s\",\"v\":\"%s\"", command, value); + } + else + { + snprintf(buffer, size, "\"success\":true,\"command\":\"%s\"", command); + } +} + +static void writeError(char* buffer, size_t size, const char* message) +{ + snprintf(buffer, size, "\"success\":false,\"error\":\"%s\"", message); +} + +void MQTTNetworkHandler::writeStatusJson(IWifiClient* client) +{ + Config* config = ConfigManager::getConfigPtr(); + + if (!config) + { + client->print("\"mqtt\":{\"error\":\"Config not available\"}"); + return; + } + + client->print("\"mqtt\":{"); + + client->print("\"enabled\":"); + client->print(config->mqtt.enabled ? "true" : "false"); + client->print(","); + + client->print("\"broker\":\""); + client->print(config->mqtt.broker); + client->print("\","); + + client->print("\"port\":"); + client->print(config->mqtt.port); + client->print(","); + + client->print("\"username\":\""); + client->print(config->mqtt.username); + client->print("\","); + + client->print("\"password\":\"***\","); + + client->print("\"deviceId\":\""); + client->print(config->mqtt.deviceId); + client->print("\","); + + client->print("\"useHomeAssistantDiscovery\":"); + client->print(config->mqtt.useHomeAssistantDiscovery ? "true" : "false"); + client->print(","); + + client->print("\"keepAliveInterval\":"); + client->print(config->mqtt.keepAliveInterval); + client->print(","); + + client->print("\"discoveryPrefix\":\""); + client->print(config->mqtt.discoveryPrefix); + client->print("\"}"); +} + +void MQTTNetworkHandler::formatWifiStatusJson(IWifiClient* client) +{ + writeStatusJson(client); +} + +CommandResult MQTTNetworkHandler::handleRequest(const char* method, + const char* command, + StringKeyValue* params, + uint8_t paramCount, + char* responseBuffer, + size_t bufferSize) +{ + (void)method; + + Config* config = ConfigManager::getConfigPtr(); + + if (config == nullptr) + { + writeError(responseBuffer, bufferSize, "Config not available"); + return CommandResult::ok(); + } + + const char* v = getParamValue(params, paramCount, "v"); + +#if defined(MQTT_SUPPORT) + if (command[0] == '\0') + { + snprintf(responseBuffer, bufferSize, + "\"success\":true," + "\"enabled\":%s," + "\"broker\":\"%s\"," + "\"port\":%u," + "\"username\":\"%s\"," + "\"password\":\"***\"," + "\"deviceId\":\"%s\"," + "\"useHomeAssistantDiscovery\":%s," + "\"keepAliveInterval\":%u," + "\"discoveryPrefix\":\"%s\"", + config->mqtt.enabled ? "true" : "false", + config->mqtt.broker, + config->mqtt.port, + config->mqtt.username, + config->mqtt.deviceId, + config->mqtt.useHomeAssistantDiscovery ? "true" : "false", + config->mqtt.keepAliveInterval, + config->mqtt.discoveryPrefix); + return CommandResult::ok(); + } + + if (SystemFunctions::commandMatches(command, MqttConfigEnable)) + { + if (v) + { + config->mqtt.enabled = (v[0] == '1'); + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + char buf[2]; + snprintf(buf, sizeof(buf), "%d", config->mqtt.enabled ? 1 : 0); + writeSuccess(responseBuffer, bufferSize, command, buf); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigBroker)) + { + if (v) + { + strncpy(config->mqtt.broker, v, ConfigMqttBrokerLength - 1); + config->mqtt.broker[ConfigMqttBrokerLength - 1] = '\0'; + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + writeSuccess(responseBuffer, bufferSize, command, config->mqtt.broker); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigPort)) + { + if (v) + { + uint16_t port = static_cast(strtoul(v, nullptr, 10)); + + if (port > 0) + config->mqtt.port = port; + + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + char buf[8]; + snprintf(buf, sizeof(buf), "%u", config->mqtt.port); + writeSuccess(responseBuffer, bufferSize, command, buf); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigUsername)) + { + if (v) + { + strncpy(config->mqtt.username, v, ConfigMqttUsernameLength - 1); + config->mqtt.username[ConfigMqttUsernameLength - 1] = '\0'; + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + writeSuccess(responseBuffer, bufferSize, command, config->mqtt.username); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigPassword)) + { + if (v) + { + strncpy(config->mqtt.password, v, ConfigMqttPasswordLength - 1); + config->mqtt.password[ConfigMqttPasswordLength - 1] = '\0'; + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + writeSuccess(responseBuffer, bufferSize, command, "***"); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigDeviceId)) + { + if (v) + { + strncpy(config->mqtt.deviceId, v, ConfigMqttDeviceIdLength - 1); + config->mqtt.deviceId[ConfigMqttDeviceIdLength - 1] = '\0'; + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + writeSuccess(responseBuffer, bufferSize, command, config->mqtt.deviceId); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigHADiscovery)) + { + if (v) + { + config->mqtt.useHomeAssistantDiscovery = (v[0] == '1'); + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + char buf[2]; + snprintf(buf, sizeof(buf), "%d", config->mqtt.useHomeAssistantDiscovery ? 1 : 0); + writeSuccess(responseBuffer, bufferSize, command, buf); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigKeepAlive)) + { + if (v) + { + uint16_t keepAlive = static_cast(strtoul(v, nullptr, 10)); + + if (keepAlive > 0) + config->mqtt.keepAliveInterval = keepAlive; + + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + char buf[8]; + snprintf(buf, sizeof(buf), "%u", config->mqtt.keepAliveInterval); + writeSuccess(responseBuffer, bufferSize, command, buf); + } + + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigState)) + { + bool isConnected = false; + + if (_mqttController != nullptr) + isConnected = _mqttController->isConnected(); + + char buf[2]; + snprintf(buf, sizeof(buf), "%d", isConnected ? 1 : 0); + writeSuccess(responseBuffer, bufferSize, command, buf); + return CommandResult::ok(); + } + else if (SystemFunctions::commandMatches(command, MqttConfigDiscoveryPrefix)) + { + if (v) + { + strncpy(config->mqtt.discoveryPrefix, v, ConfigMqttDiscoveryPrefixLength - 1); + config->mqtt.discoveryPrefix[ConfigMqttDiscoveryPrefixLength - 1] = '\0'; + writeSuccess(responseBuffer, bufferSize, command, nullptr); + } + else + { + writeSuccess(responseBuffer, bufferSize, command, config->mqtt.discoveryPrefix); + } + + return CommandResult::ok(); + } +#endif + + writeError(responseBuffer, bufferSize, "Invalid command"); + return CommandResult::ok(); +} diff --git a/PowerControlHub/MQTTNetworkHandler.h b/PowerControlHub/MQTTNetworkHandler.h new file mode 100644 index 0000000..8a3106f --- /dev/null +++ b/PowerControlHub/MQTTNetworkHandler.h @@ -0,0 +1,44 @@ +/* + * PowerControlHub + * Copyright (C) 2025 Simon Carter (s1cart3r@gmail.com) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "INetworkCommandHandler.h" + +class MQTTController; + +class MQTTNetworkHandler : public INetworkCommandHandler +{ +private: + MQTTController* _mqttController; + + void writeStatusJson(IWifiClient* client); + +public: + explicit MQTTNetworkHandler(MQTTController* mqttController); + + const char* getRoute() const override { return "/api/mqtt"; } + + void formatWifiStatusJson(IWifiClient* client) override; + + CommandResult handleRequest(const char* method, + const char* cmd, + StringKeyValue* params, + uint8_t paramCount, + char* responseBuffer, + size_t bufferSize) override; +}; diff --git a/PowerControlHub/PowerControlHub.vcxproj b/PowerControlHub/PowerControlHub.vcxproj index ba6997c..68d5d60 100644 --- a/PowerControlHub/PowerControlHub.vcxproj +++ b/PowerControlHub/PowerControlHub.vcxproj @@ -78,6 +78,7 @@ + @@ -179,6 +180,7 @@ + diff --git a/PowerControlHub/PowerControlHub.vcxproj.filters b/PowerControlHub/PowerControlHub.vcxproj.filters index cebd5b2..5ba78ec 100644 --- a/PowerControlHub/PowerControlHub.vcxproj.filters +++ b/PowerControlHub/PowerControlHub.vcxproj.filters @@ -325,6 +325,9 @@ Source Files\NetworkCommandHandlers + + Source Files\NetworkCommandHandlers + @@ -711,5 +714,8 @@ Header Files + + Header Files\NetworkCommandHandlers + \ No newline at end of file diff --git a/PowerControlHub/PowerControlHubApp.cpp b/PowerControlHub/PowerControlHubApp.cpp index c195d1a..7ffaa9a 100644 --- a/PowerControlHub/PowerControlHubApp.cpp +++ b/PowerControlHub/PowerControlHubApp.cpp @@ -96,7 +96,8 @@ PowerControlHubApp::PowerControlHubApp(SerialCommandManager* commandMgrComputer) , _mqttConfigHandler(&_configController, &_mqttController, commandMgrComputer) , _mqttRelayHandler(&_mqttController, &_messageBus, &_relayController, commandMgrComputer) , _mqttSensorHandler(nullptr) - , _mqttSystemHandler(&_mqttController, &_messageBus, commandMgrComputer) + , _mqttSystemHandler(&_mqttController, &_messageBus, commandMgrComputer) + , _mqttNetworkHandler(&_mqttController) , _nextRunMqttMs(0) #endif @@ -469,7 +470,15 @@ void PowerControlHubApp::loop() void PowerControlHubApp::configureWifiSupport(Config* config) { - // network command handlers + INetworkCommandHandler* networkHandlers[] = { &_relayNetworkHandler, &_soundNetworkHandler, &_warningNetworkHandler, + &_systemNetworkHandler, _sensorNetworkHandler, &_configNetworkHandler, &_schedulerNetworkHandler, + &_externalSensorNetworkHandler, &_sensorConfigNetworkHandler, _webIndexNetworkHandler, + &_wifiCommandBridge +#if defined(MQTT_SUPPORT) + , &_mqttNetworkHandler +#endif + }; + INetworkCommandHandler* networkHandlers[] = { &_relayNetworkHandler, &_soundNetworkHandler, &_warningNetworkHandler, &_systemNetworkHandler, _sensorNetworkHandler, &_configNetworkHandler, &_schedulerNetworkHandler, &_externalSensorNetworkHandler, &_sensorConfigNetworkHandler, _webIndexNetworkHandler, @@ -489,6 +498,9 @@ void PowerControlHubApp::configureWifiSupport(Config* config) &_schedulerNetworkHandler, &_externalSensorNetworkHandler, &_sensorConfigNetworkHandler, +#if defined(MQTT_SUPPORT) + &_mqttNetworkHandler, +#endif }; uint8_t jsonVisitorCount = sizeof(jsonVisitors) / sizeof(jsonVisitors[0]); _wifiController.registerJsonVisitors(jsonVisitors, jsonVisitorCount); diff --git a/PowerControlHub/PowerControlHubApp.h b/PowerControlHub/PowerControlHubApp.h index e7c7729..cb7f20e 100644 --- a/PowerControlHub/PowerControlHubApp.h +++ b/PowerControlHub/PowerControlHubApp.h @@ -71,6 +71,7 @@ #include "MQTTRelayHandler.h" #include "MQTTSensorHandler.h" #include "MQTTSystemHandler.h" +#include "MQTTNetworkHandler.h" #endif #include "SchedulerCommandHandler.h" @@ -151,6 +152,7 @@ class PowerControlHubApp MQTTRelayHandler _mqttRelayHandler; MQTTSensorHandler* _mqttSensorHandler; MQTTSystemHandler _mqttSystemHandler; + MQTTNetworkHandler _mqttNetworkHandler; uint64_t _nextRunMqttMs; #endif diff --git a/PowerControlHubApp/AppShell.xaml.cs b/PowerControlHubApp/AppShell.xaml.cs index f7e286c..cd56121 100644 --- a/PowerControlHubApp/AppShell.xaml.cs +++ b/PowerControlHubApp/AppShell.xaml.cs @@ -11,6 +11,7 @@ public AppShell() Routing.RegisterRoute(nameof(ExternalSensorDetailPage), typeof(ExternalSensorDetailPage)); Routing.RegisterRoute(nameof(LocalSensorDetailPage), typeof(LocalSensorDetailPage)); Routing.RegisterRoute(nameof(TimeSettingsPage), typeof(TimeSettingsPage)); + Routing.RegisterRoute(nameof(MqttSettingsPage), typeof(MqttSettingsPage)); } } } diff --git a/PowerControlHubApp/Internal/Constants.cs b/PowerControlHubApp/Internal/Constants.cs index 953fc46..59519ae 100644 --- a/PowerControlHubApp/Internal/Constants.cs +++ b/PowerControlHubApp/Internal/Constants.cs @@ -196,6 +196,22 @@ internal static class Constants public const string RouteSystemGetDateTime = "api/system/F7"; public const string RouteSystemSetDateTime = "api/system/F6"; public const string RouteConfigTimezoneOffset = "api/config/C20"; + public const string RouteConfigMqttGet = "api/mqtt/{0}"; + public const string RouteConfigMqttSet = "api/mqtt/{0}?v={1}"; + public const string MqttConfigEnabled = "M0"; + public const string MqttConfigBroker = "M1"; + public const string MqttConfigPort = "M2"; + public const string MqttConfigUsername = "M3"; + public const string MqttConfigPassword = "M4"; + public const string MqttConfigDeviceId = "M5"; + public const string MqttConfigHADiscovery = "M6"; + public const string MqttConfigKeepAlive = "M7"; + public const string MqttConfigState = "M8"; + public const string MqttConfigDiscoveryPrefix = "M9"; + public const string MqttConnectedLabel = "Connected"; + public const string MqttDisconnectedLabel = "Disconnected"; + public const string MqttMsgSaveFailed = "Save failed — device unreachable"; + public const string MqttMsgSaved = "MQTT settings saved"; public const string RouteWarnings = "api/warning/W5"; public const string ForwardSlash = "/"; public const string ResultSuccess = "success"; @@ -203,6 +219,7 @@ internal static class Constants public const string RouteSettingsPage = "//SettingsPage"; public const string RouteSystemPage = "//SystemPage"; public const string RouteTimeSettingsPage = "TimeSettingsPage"; + public const string RouteMqttSettingsPage = "MqttSettingsPage"; public const string ConnectionTypeKey = "X-Connection-Type"; public const string ConnectionTypePersistent = "persistent"; public const int MaximumPermanentConnections = 2; @@ -223,6 +240,8 @@ internal static class Constants public const string PreferenceKey = "app_theme"; public const string ThemeLight = "Light"; public const string ThemeDark = "Dark"; + public const string BoolFalseString = "0"; + public const string BoolTrueString = "1"; public const int KilobyteBytes = 1024; diff --git a/PowerControlHubApp/MauiProgram.cs b/PowerControlHubApp/MauiProgram.cs index bba363c..93b9261 100644 --- a/PowerControlHubApp/MauiProgram.cs +++ b/PowerControlHubApp/MauiProgram.cs @@ -95,6 +95,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); // Pages builder.Services.AddSingleton(); @@ -107,6 +108,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); #if DEBUG builder.Logging.AddDebug(); diff --git a/PowerControlHubApp/PowerControlHubApp.csproj b/PowerControlHubApp/PowerControlHubApp.csproj index 690f0fc..5bb8f90 100644 --- a/PowerControlHubApp/PowerControlHubApp.csproj +++ b/PowerControlHubApp/PowerControlHubApp.csproj @@ -100,6 +100,9 @@ MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile diff --git a/PowerControlHubApp/Services/ConfigConnection.cs b/PowerControlHubApp/Services/ConfigConnection.cs index f66eaf2..5b167ac 100644 --- a/PowerControlHubApp/Services/ConfigConnection.cs +++ b/PowerControlHubApp/Services/ConfigConnection.cs @@ -476,6 +476,152 @@ public async Task SetTimezoneOffsetAsync(int offsetHours, CancellationToke } } + public async Task GetMqttEnabledAsync(CancellationToken ct = default) + { + return await GetMqttBoolAsync(MqttConfigEnabled, ct); + } + + public async Task SetMqttEnabledAsync(bool enabled, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigEnabled, enabled ? BoolTrueString : BoolFalseString, ct); + } + + public async Task GetMqttBrokerAsync(CancellationToken ct = default) + { + return await GetMqttStringAsync(MqttConfigBroker, ct) ?? string.Empty; + } + + public async Task SetMqttBrokerAsync(string broker, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigBroker, broker, ct); + } + + public async Task GetMqttPortAsync(CancellationToken ct = default) + { + return await GetMqttIntAsync(MqttConfigPort, ct); + } + + public async Task SetMqttPortAsync(int port, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigPort, port.ToString(), ct); + } + + public async Task GetMqttUsernameAsync(CancellationToken ct = default) + { + return await GetMqttStringAsync(MqttConfigUsername, ct) ?? string.Empty; + } + + public async Task SetMqttUsernameAsync(string username, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigUsername, username, ct); + } + + public async Task SetMqttPasswordAsync(string password, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigPassword, password, ct); + } + + public async Task GetMqttDeviceIdAsync(CancellationToken ct = default) + { + return await GetMqttStringAsync(MqttConfigDeviceId, ct) ?? string.Empty; + } + + public async Task SetMqttDeviceIdAsync(string deviceId, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigDeviceId, deviceId, ct); + } + + public async Task GetMqttHADiscoveryAsync(CancellationToken ct = default) + { + return await GetMqttBoolAsync(MqttConfigHADiscovery, ct); + } + + public async Task SetMqttHADiscoveryAsync(bool enabled, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigHADiscovery, enabled ? BoolTrueString : BoolFalseString, ct); + } + + public async Task GetMqttKeepAliveAsync(CancellationToken ct = default) + { + return await GetMqttIntAsync(MqttConfigKeepAlive, ct); + } + + public async Task SetMqttKeepAliveAsync(int seconds, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigKeepAlive, seconds.ToString(), ct); + } + + public async Task GetMqttConnectionStateAsync(CancellationToken ct = default) + { + return await GetMqttBoolAsync(MqttConfigState, ct); + } + + public async Task GetMqttDiscoveryPrefixAsync(CancellationToken ct = default) + { + return await GetMqttStringAsync(MqttConfigDiscoveryPrefix, ct) ?? string.Empty; + } + + public async Task SetMqttDiscoveryPrefixAsync(string prefix, CancellationToken ct = default) + { + return await SetMqttStringAsync(MqttConfigDiscoveryPrefix, prefix, ct); + } + + private async Task GetMqttStringAsync(string command, CancellationToken ct) + { + try + { + string url = string.Format(RouteConfigMqttGet, command); + string json = await _client.GetStringAsync(url, ct); + using JsonDocument doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty(ResultSuccess, out var success) && + success.GetBoolean() && + doc.RootElement.TryGetProperty(JsonValueKey, out var v)) + { + return v.GetString(); + } + } + catch + { + // fall through + } + return null; + } + + private async Task GetMqttIntAsync(string command, CancellationToken ct) + { + string raw = await GetMqttStringAsync(command, ct); + + if (raw != null && int.TryParse(raw, out int val)) + return val; + + return null; + } + + private async Task GetMqttBoolAsync(string command, CancellationToken ct) + { + string raw = await GetMqttStringAsync(command, ct); + + if (raw == null) + return null; + + return raw == BoolTrueString; + } + + private async Task SetMqttStringAsync(string command, string value, CancellationToken ct) + { + try + { + string url = string.Format(RouteConfigMqttSet, command, Uri.EscapeDataString(value)); + HttpResponseMessage response = await _client.PostAsync(url, null, ct); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + private static async Task IsSuccessResponseAsync(HttpResponseMessage response, CancellationToken ct) { if (!response.IsSuccessStatusCode) diff --git a/PowerControlHubApp/Services/ConfigPoller.cs b/PowerControlHubApp/Services/ConfigPoller.cs index 043c320..bbb4168 100644 --- a/PowerControlHubApp/Services/ConfigPoller.cs +++ b/PowerControlHubApp/Services/ConfigPoller.cs @@ -177,6 +177,42 @@ private async Task RunLoopAsync(CancellationToken stoppingToken) public Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = default) => _connection.SetTimezoneOffsetAsync(offsetHours, ct); + public Task GetMqttEnabledAsync(CancellationToken ct = default) => _connection.GetMqttEnabledAsync(ct); + + public Task SetMqttEnabledAsync(bool enabled, CancellationToken ct = default) => _connection.SetMqttEnabledAsync(enabled, ct); + + public Task GetMqttBrokerAsync(CancellationToken ct = default) => _connection.GetMqttBrokerAsync(ct); + + public Task SetMqttBrokerAsync(string broker, CancellationToken ct = default) => _connection.SetMqttBrokerAsync(broker, ct); + + public Task GetMqttPortAsync(CancellationToken ct = default) => _connection.GetMqttPortAsync(ct); + + public Task SetMqttPortAsync(int port, CancellationToken ct = default) => _connection.SetMqttPortAsync(port, ct); + + public Task GetMqttUsernameAsync(CancellationToken ct = default) => _connection.GetMqttUsernameAsync(ct); + + public Task SetMqttUsernameAsync(string username, CancellationToken ct = default) => _connection.SetMqttUsernameAsync(username, ct); + + public Task SetMqttPasswordAsync(string password, CancellationToken ct = default) => _connection.SetMqttPasswordAsync(password, ct); + + public Task GetMqttDeviceIdAsync(CancellationToken ct = default) => _connection.GetMqttDeviceIdAsync(ct); + + public Task SetMqttDeviceIdAsync(string deviceId, CancellationToken ct = default) => _connection.SetMqttDeviceIdAsync(deviceId, ct); + + public Task GetMqttHADiscoveryAsync(CancellationToken ct = default) => _connection.GetMqttHADiscoveryAsync(ct); + + public Task SetMqttHADiscoveryAsync(bool enabled, CancellationToken ct = default) => _connection.SetMqttHADiscoveryAsync(enabled, ct); + + public Task GetMqttKeepAliveAsync(CancellationToken ct = default) => _connection.GetMqttKeepAliveAsync(ct); + + public Task SetMqttKeepAliveAsync(int seconds, CancellationToken ct = default) => _connection.SetMqttKeepAliveAsync(seconds, ct); + + public Task GetMqttConnectionStateAsync(CancellationToken ct = default) => _connection.GetMqttConnectionStateAsync(ct); + + public Task GetMqttDiscoveryPrefixAsync(CancellationToken ct = default) => _connection.GetMqttDiscoveryPrefixAsync(ct); + + public Task SetMqttDiscoveryPrefixAsync(string prefix, CancellationToken ct = default) => _connection.SetMqttDiscoveryPrefixAsync(prefix, ct); + public void Dispose() { if (_cts != null) diff --git a/PowerControlHubApp/Services/IConfigConnection.cs b/PowerControlHubApp/Services/IConfigConnection.cs index 0e2fe30..ce12fb3 100644 --- a/PowerControlHubApp/Services/IConfigConnection.cs +++ b/PowerControlHubApp/Services/IConfigConnection.cs @@ -34,5 +34,23 @@ public interface IConfigConnection Task SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default); Task GetTimezoneOffsetAsync(CancellationToken ct = default); Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = default); + Task GetMqttEnabledAsync(CancellationToken ct = default); + Task SetMqttEnabledAsync(bool enabled, CancellationToken ct = default); + Task GetMqttBrokerAsync(CancellationToken ct = default); + Task SetMqttBrokerAsync(string broker, CancellationToken ct = default); + Task GetMqttPortAsync(CancellationToken ct = default); + Task SetMqttPortAsync(int port, CancellationToken ct = default); + Task GetMqttUsernameAsync(CancellationToken ct = default); + Task SetMqttUsernameAsync(string username, CancellationToken ct = default); + Task SetMqttPasswordAsync(string password, CancellationToken ct = default); + Task GetMqttDeviceIdAsync(CancellationToken ct = default); + Task SetMqttDeviceIdAsync(string deviceId, CancellationToken ct = default); + Task GetMqttHADiscoveryAsync(CancellationToken ct = default); + Task SetMqttHADiscoveryAsync(bool enabled, CancellationToken ct = default); + Task GetMqttKeepAliveAsync(CancellationToken ct = default); + Task SetMqttKeepAliveAsync(int seconds, CancellationToken ct = default); + Task GetMqttConnectionStateAsync(CancellationToken ct = default); + Task GetMqttDiscoveryPrefixAsync(CancellationToken ct = default); + Task SetMqttDiscoveryPrefixAsync(string prefix, CancellationToken ct = default); } } diff --git a/PowerControlHubApp/Services/PowerHubService.cs b/PowerControlHubApp/Services/PowerHubService.cs index ca0191d..ba9ee84 100644 --- a/PowerControlHubApp/Services/PowerHubService.cs +++ b/PowerControlHubApp/Services/PowerHubService.cs @@ -193,4 +193,94 @@ public Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = { return _configConnection.SetTimezoneOffsetAsync(offsetHours, ct); } + + public Task GetMqttEnabledAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttEnabledAsync(ct); + } + + public Task SetMqttEnabledAsync(bool enabled, CancellationToken ct = default) + { + return _configConnection.SetMqttEnabledAsync(enabled, ct); + } + + public Task GetMqttBrokerAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttBrokerAsync(ct); + } + + public Task SetMqttBrokerAsync(string broker, CancellationToken ct = default) + { + return _configConnection.SetMqttBrokerAsync(broker, ct); + } + + public Task GetMqttPortAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttPortAsync(ct); + } + + public Task SetMqttPortAsync(int port, CancellationToken ct = default) + { + return _configConnection.SetMqttPortAsync(port, ct); + } + + public Task GetMqttUsernameAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttUsernameAsync(ct); + } + + public Task SetMqttUsernameAsync(string username, CancellationToken ct = default) + { + return _configConnection.SetMqttUsernameAsync(username, ct); + } + + public Task SetMqttPasswordAsync(string password, CancellationToken ct = default) + { + return _configConnection.SetMqttPasswordAsync(password, ct); + } + + public Task GetMqttDeviceIdAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttDeviceIdAsync(ct); + } + + public Task SetMqttDeviceIdAsync(string deviceId, CancellationToken ct = default) + { + return _configConnection.SetMqttDeviceIdAsync(deviceId, ct); + } + + public Task GetMqttHADiscoveryAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttHADiscoveryAsync(ct); + } + + public Task SetMqttHADiscoveryAsync(bool enabled, CancellationToken ct = default) + { + return _configConnection.SetMqttHADiscoveryAsync(enabled, ct); + } + + public Task GetMqttKeepAliveAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttKeepAliveAsync(ct); + } + + public Task SetMqttKeepAliveAsync(int seconds, CancellationToken ct = default) + { + return _configConnection.SetMqttKeepAliveAsync(seconds, ct); + } + + public Task GetMqttConnectionStateAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttConnectionStateAsync(ct); + } + + public Task GetMqttDiscoveryPrefixAsync(CancellationToken ct = default) + { + return _configConnection.GetMqttDiscoveryPrefixAsync(ct); + } + + public Task SetMqttDiscoveryPrefixAsync(string prefix, CancellationToken ct = default) + { + return _configConnection.SetMqttDiscoveryPrefixAsync(prefix, ct); + } } diff --git a/PowerControlHubApp/ViewModels/BaseViewModel.cs b/PowerControlHubApp/ViewModels/BaseViewModel.cs index 0c36cb6..2f81a99 100644 --- a/PowerControlHubApp/ViewModels/BaseViewModel.cs +++ b/PowerControlHubApp/ViewModels/BaseViewModel.cs @@ -42,7 +42,7 @@ protected BaseViewModel(PowerHubService service, LogService log) public ObservableCollection LogEntries => _log.Entries; - public ICommand RefreshCommand { get; } + public virtual ICommand RefreshCommand { get; protected set; } public ICommand SystemLabelTappedCommand { get; } public ICommand FirmwareLabelTappedCommand { get; } diff --git a/PowerControlHubApp/ViewModels/MqttSettingsViewModel.cs b/PowerControlHubApp/ViewModels/MqttSettingsViewModel.cs new file mode 100644 index 0000000..071f73f --- /dev/null +++ b/PowerControlHubApp/ViewModels/MqttSettingsViewModel.cs @@ -0,0 +1,302 @@ +using PowerControlHubApp.Models.Json; +using PowerControlHubApp.Services; +using System.Windows.Input; +using static PowerControlHubApp.Internal.Constants; + +namespace PowerControlHubApp.ViewModels; + +public sealed class MqttSettingsViewModel : BaseViewModel +{ + private const string MsgMqttSettingsSaved = "MQTT settings saved"; + private const string MsgSaveFailedDeviceUnreachable = "Save failed — device unreachable"; + private const string MsgRefreshed = "Refreshed"; + + private bool _mqttEnabled; + private string _mqttBroker = string.Empty; + private string _mqttPort = string.Empty; + private string _mqttUsername = string.Empty; + private string _mqttPassword = string.Empty; + private string _mqttDeviceId = string.Empty; + private bool _mqttHADiscovery; + private string _mqttKeepAlive = string.Empty; + private bool _mqttConnectionState; + private string _mqttDiscoveryPrefix = string.Empty; + private bool _isRefreshing; + private bool _isSaving; + + public MqttSettingsViewModel(PowerHubService service, LogService log) + : base(service, log) + { + RefreshCommand = new Command(async () => await RefreshAsync()); + SaveAllCommand = new Command(async () => await SaveAllAsync()); + } + + public ICommand SaveAllCommand { get; } + + public bool MqttEnabled + { + get => _mqttEnabled; + set + { + _mqttEnabled = value; + OnPropertyChanged(); + } + } + + public string MqttBroker + { + get => _mqttBroker; + set + { + _mqttBroker = value; + OnPropertyChanged(); + } + } + + public string MqttPort + { + get => _mqttPort; + set + { + _mqttPort = value; + OnPropertyChanged(); + } + } + + public string MqttUsername + { + get => _mqttUsername; + set + { + _mqttUsername = value; + OnPropertyChanged(); + } + } + + public string MqttPassword + { + get => _mqttPassword; + set + { + _mqttPassword = value; + OnPropertyChanged(); + } + } + + public string MqttDeviceId + { + get => _mqttDeviceId; + set + { + _mqttDeviceId = value; + OnPropertyChanged(); + } + } + + public bool MqttHADiscovery + { + get => _mqttHADiscovery; + set + { + _mqttHADiscovery = value; + OnPropertyChanged(); + } + } + + public string MqttKeepAlive + { + get => _mqttKeepAlive; + set + { + _mqttKeepAlive = value; + OnPropertyChanged(); + } + } + + public bool MqttConnectionState + { + get => _mqttConnectionState; + set + { + _mqttConnectionState = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(MqttConnectionStateText)); + } + } + + public string MqttConnectionStateText => _mqttConnectionState ? MqttConnectedLabel : MqttDisconnectedLabel; + + public string MqttDiscoveryPrefix + { + get => _mqttDiscoveryPrefix; + set + { + _mqttDiscoveryPrefix = value; + OnPropertyChanged(); + } + } + + public bool IsRefreshing + { + get => _isRefreshing; + set + { + _isRefreshing = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsNotRefreshing)); + } + } + + public bool IsNotRefreshing => !_isRefreshing; + + public bool IsSaving + { + get => _isSaving; + set + { + _isSaving = value; + OnPropertyChanged(); + } + } + + public bool HasStatusMessage => !string.IsNullOrEmpty(StatusMessage); + + public async Task RefreshAsync() + { + if (!Service.IsConfigured || _isRefreshing) + return; + + IsRefreshing = true; + + try + { + var enabled = await Service.GetMqttEnabledAsync(); + var broker = await Service.GetMqttBrokerAsync(); + var port = await Service.GetMqttPortAsync(); + var username = await Service.GetMqttUsernameAsync(); + var deviceId = await Service.GetMqttDeviceIdAsync(); + var haDiscovery = await Service.GetMqttHADiscoveryAsync(); + var keepAlive = await Service.GetMqttKeepAliveAsync(); + var connectionState = await Service.GetMqttConnectionStateAsync(); + var discoveryPrefix = await Service.GetMqttDiscoveryPrefixAsync(); + + MainThread.BeginInvokeOnMainThread(() => + { + MqttEnabled = enabled ?? false; + MqttBroker = broker ?? string.Empty; + MqttPort = port?.ToString() ?? string.Empty; + MqttUsername = username ?? string.Empty; + MqttPassword = string.Empty; + MqttDeviceId = deviceId ?? string.Empty; + MqttHADiscovery = haDiscovery ?? false; + MqttKeepAlive = keepAlive?.ToString() ?? string.Empty; + MqttConnectionState = connectionState ?? false; + MqttDiscoveryPrefix = discoveryPrefix ?? string.Empty; + + IsConnected = true; + StatusMessage = $"{MsgRefreshed} {DateTime.Now:HH:mm:ss}"; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + catch + { + MainThread.BeginInvokeOnMainThread(() => + { + IsConnected = false; + StatusMessage = MessageDeviceUnreachable; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + finally + { + IsRefreshing = false; + } + } + + public async Task SaveAllAsync() + { + if (!Service.IsConfigured || _isSaving) + return; + + IsSaving = true; + bool anyFailed = false; + + try + { + var enabledOk = await Service.SetMqttEnabledAsync(MqttEnabled); + anyFailed |= !enabledOk; + + var brokerOk = await Service.SetMqttBrokerAsync(MqttBroker); + anyFailed |= !brokerOk; + + if (int.TryParse(MqttPort, out int port) && port >= PortMin && port <= PortMax) + { + var portOk = await Service.SetMqttPortAsync(port); + anyFailed |= !portOk; + } + + var usernameOk = await Service.SetMqttUsernameAsync(MqttUsername); + anyFailed |= !usernameOk; + + if (!string.IsNullOrEmpty(MqttPassword)) + { + var passwordOk = await Service.SetMqttPasswordAsync(MqttPassword); + anyFailed |= !passwordOk; + } + + var deviceIdOk = await Service.SetMqttDeviceIdAsync(MqttDeviceId); + anyFailed |= !deviceIdOk; + + var haOk = await Service.SetMqttHADiscoveryAsync(MqttHADiscovery); + anyFailed |= !haOk; + + if (int.TryParse(MqttKeepAlive, out int keepAlive) && keepAlive > 0) + { + var kaOk = await Service.SetMqttKeepAliveAsync(keepAlive); + anyFailed |= !kaOk; + } + + var prefixOk = await Service.SetMqttDiscoveryPrefixAsync(MqttDiscoveryPrefix); + anyFailed |= !prefixOk; + + await Service.SaveSettingsAsync(); + + MainThread.BeginInvokeOnMainThread(() => + { + MqttPassword = string.Empty; + + if (anyFailed) + { + StatusMessage = MqttMsgSaveFailed; + } + else + { + StatusMessage = MsgMqttSettingsSaved; + } + + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + catch + { + MainThread.BeginInvokeOnMainThread(() => + { + StatusMessage = MqttMsgSaveFailed; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + finally + { + IsSaving = false; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Called polymorphically from OnDisappearing")] + public void Cleanup() + { + } + + protected override void OnDataFetched(IndexModel index) + { + } +} diff --git a/PowerControlHubApp/ViewModels/SystemViewModel.cs b/PowerControlHubApp/ViewModels/SystemViewModel.cs index f08ce30..1ee8aba 100644 --- a/PowerControlHubApp/ViewModels/SystemViewModel.cs +++ b/PowerControlHubApp/ViewModels/SystemViewModel.cs @@ -66,6 +66,8 @@ public bool IsPinsRefreshing public ICommand NavigateToTimeSettingsCommand { get; } + public ICommand NavigateToMqttSettingsCommand { get; } + public SystemViewModel(PowerHubService service, LogService log) : base(service, log) { @@ -73,6 +75,7 @@ public SystemViewModel(PowerHubService service, LogService log) RefreshPinsCommand = new Command(async () => await RefreshPinsAsync()); InstallFirmwareCommand = new Command(async () => await InstallFirmwareAsync(), () => CanInstallFirmware); NavigateToTimeSettingsCommand = new Command(async () => await Shell.Current.GoToAsync(RouteTimeSettingsPage)); + NavigateToMqttSettingsCommand = new Command(async () => await Shell.Current.GoToAsync(RouteMqttSettingsPage)); } protected override void OnDataFetched(IndexModel index) diff --git a/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs b/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs index 7e4107d..cc8ad33 100644 --- a/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs +++ b/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs @@ -22,6 +22,11 @@ public sealed class TimeSettingsViewModel : BaseViewModel private bool _isRefreshing; private PeriodicTimer _dstTimer; private CancellationTokenSource _dstCts; + private PeriodicTimer _clockTimer; + private CancellationTokenSource _clockCts; + private DateTime _deviceTimeAtCapture; + private long _captureUtcTicks; + private const int ClockTickIntervalSeconds = 1; public sealed class TimeZoneOption { @@ -64,6 +69,7 @@ public sealed class TimeZoneOption #pragma warning restore CC0009 private static readonly TimeSpan DstCheckInterval = TimeSpan.FromMinutes(DstCheckIntervalMinutes); + private static readonly TimeSpan ClockTickInterval = TimeSpan.FromSeconds(ClockTickIntervalSeconds); public TimeSettingsViewModel(PowerHubService service, LogService log) : base(service, log) @@ -73,7 +79,6 @@ public TimeSettingsViewModel(PowerHubService service, LogService log) SaveTimezoneCommand = new Command(async () => await SaveTimezoneAsync()); } - public ICommand RefreshCommand { get; } public ICommand SyncTimeCommand { get; } public ICommand SaveTimezoneCommand { get; } @@ -129,9 +134,15 @@ public async Task RefreshAsync() MainThread.BeginInvokeOnMainThread(() => { if (index?.System?.Time.Year >= MinimumValidDateTimeYear) - DeviceTime = index.System.Time.ToString(DeviceTimeFormat); + { + _deviceTimeAtCapture = index.System.Time; + _captureUtcTicks = DateTime.UtcNow.Ticks; + DeviceTime = _deviceTimeAtCapture.ToString(DeviceTimeFormat); + } else + { DeviceTime = DoubleDash; + } if (index?.Config != null) { @@ -142,6 +153,8 @@ public async Task RefreshAsync() StatusMessage = $"Updated {DateTime.Now:HH:mm:ss}"; OnPropertyChanged(nameof(HasStatusMessage)); }); + + StartClock(); } catch { @@ -192,7 +205,11 @@ public async Task SyncTimeAsync() { if (ok) { - DeviceTime = DateTimeOffset.UtcNow.ToString(DeviceTimeFormat); + DateTime utcNow = DateTime.UtcNow; + int offsetHours = GetSelectedTimezoneOffset(); + _deviceTimeAtCapture = utcNow.AddHours(offsetHours); + _captureUtcTicks = utcNow.Ticks; + DeviceTime = _deviceTimeAtCapture.ToString(DeviceTimeFormat); StatusMessage = MsgTimeSyncedToDevice; } else @@ -202,6 +219,25 @@ public async Task SyncTimeAsync() OnPropertyChanged(nameof(HasStatusMessage)); }); + + if (ok) + StartClock(); + } + + private int GetSelectedTimezoneOffset() + { + if (SelectedTimezoneIndex < 0 || SelectedTimezoneIndex >= TimeZoneOptionsList.Count) + return TimezoneOffsetMin; + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneOptionsList[SelectedTimezoneIndex].TimeZoneId); + return (int)tz.GetUtcOffset(new DateTime(DateTime.UtcNow.Ticks, DateTimeKind.Utc)).TotalHours; + } + catch + { + return TimezoneOffsetMin; + } } public async Task SaveTimezoneAsync() @@ -241,6 +277,51 @@ public async Task SaveTimezoneAsync() } } + private void StartClock() + { + StopClock(); + + _clockCts = new CancellationTokenSource(); + var ct = _clockCts.Token; + _clockTimer = new PeriodicTimer(ClockTickInterval); + + _ = RunClockLoopAsync(ct); + } + + private async Task RunClockLoopAsync(CancellationToken ct) + { + while (await _clockTimer.WaitForNextTickAsync(ct)) + { + try + { + long elapsedTicks = DateTime.UtcNow.Ticks - _captureUtcTicks; + DateTime now = _deviceTimeAtCapture.AddTicks(elapsedTicks); + + MainThread.BeginInvokeOnMainThread(() => + { + DeviceTime = now.ToString(DeviceTimeFormat); + }); + } + catch (OperationCanceledException) + { + break; + } + catch + { + // silently retry + } + } + } + + private void StopClock() + { + _clockCts?.Cancel(); + _clockCts?.Dispose(); + _clockCts = null; + _clockTimer?.Dispose(); + _clockTimer = null; + } + private void StartDstAutoAdjust(string timeZoneId) { StopDstAutoAdjust(); @@ -311,6 +392,7 @@ private void StopDstAutoAdjust() public void Cleanup() { + StopClock(); StopDstAutoAdjust(); } diff --git a/PowerControlHubApp/Views/MqttSettingsPage.xaml b/PowerControlHubApp/Views/MqttSettingsPage.xaml new file mode 100644 index 0000000..5b2f6a7 --- /dev/null +++ b/PowerControlHubApp/Views/MqttSettingsPage.xaml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +