diff --git a/PowerControlHub.sln b/PowerControlHub.sln index 50f23c4..36c2a65 100644 --- a/PowerControlHub.sln +++ b/PowerControlHub.sln @@ -45,6 +45,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerControlHubApp", "PowerControlHubApp\PowerControlHubApp.csproj", "{9A5A950B-B42B-40DE-BDA7-299184BB23BA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -85,6 +87,18 @@ Global {31F2CD6A-B9E7-9FD4-3808-399C52A59619}.Release|ARM.Build.0 = Release|Any CPU {31F2CD6A-B9E7-9FD4-3808-399C52A59619}.Release|ARM64.ActiveCfg = Release|Any CPU {31F2CD6A-B9E7-9FD4-3808-399C52A59619}.Release|ARM64.Build.0 = Release|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Debug|ARM.ActiveCfg = Debug|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Debug|ARM.Build.0 = Debug|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Debug|ARM64.Build.0 = Debug|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Release|Any CPU.Build.0 = Release|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Release|ARM.ActiveCfg = Release|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Release|ARM.Build.0 = Release|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Release|ARM64.ActiveCfg = Release|Any CPU + {9A5A950B-B42B-40DE-BDA7-299184BB23BA}.Release|ARM64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PowerControlHub/Dht11SensorHandler.h b/PowerControlHub/Dht11SensorHandler.h index c1bd0c8..781655a 100644 --- a/PowerControlHub/Dht11SensorHandler.h +++ b/PowerControlHub/Dht11SensorHandler.h @@ -250,24 +250,24 @@ class Dht11SensorHandler : public BaseSensor, public BroadcastLoggerSupport void formatStatusJson(char* buffer, size_t size) override { - // Validate output buffer + // Validate output buffer if (!buffer || size == 0) { return; } - char celsius[8]; - char humidity[8]; - char celsiusOffset[8]; - char humidityOffset[8]; - dtostrf(_celsius, 1, 1, celsius); - dtostrf(_humidity, 1, 1, humidity); - dtostrf(_temperatureOffset, 1, 1, celsiusOffset); - dtostrf(_humidityOffset, 1, 1, humidityOffset); + char celsius[16]; + char humidity[16]; + char celsiusOffset[16]; + char humidityOffset[16]; + SystemFunctions::safeJsonFloat(_celsius, celsius, sizeof(celsius), 1); + SystemFunctions::safeJsonFloat(_humidity, humidity, sizeof(humidity), 1); + SystemFunctions::safeJsonFloat(_temperatureOffset, celsiusOffset, sizeof(celsiusOffset), 1); + SystemFunctions::safeJsonFloat(_humidityOffset, humidityOffset,sizeof(humidityOffset), 1); double dewPt = Environment::dewPoint(_celsius, _humidity); - char dewPointStr[8]; - dtostrf(dewPt, 1, 1, dewPointStr); + char dewPointStr[16]; + SystemFunctions::safeJsonFloat(static_cast(dewPt), dewPointStr, sizeof(dewPointStr), 1); char comfortBuf[24]; strncpy_P(comfortBuf, Environment::getComfortDescription(_celsius, _humidity, dewPt), sizeof(comfortBuf)); diff --git a/PowerControlHub/GpsSensorHandler.h b/PowerControlHub/GpsSensorHandler.h index bd09967..b782d4f 100644 --- a/PowerControlHub/GpsSensorHandler.h +++ b/PowerControlHub/GpsSensorHandler.h @@ -28,6 +28,7 @@ #include "BaseSensor.h" #include "DateTimeManager.h" #include "SensorCommandHandler.h" +#include "SystemFunctions.h" constexpr unsigned long GpsCheckMs = 10; constexpr unsigned long StatusUpdateMs = 1000UL; @@ -422,17 +423,17 @@ class GpsSensorHandler : public BaseSensor, public BroadcastLoggerSupport { char lat[16]; char lon[16]; - char alt[12]; - char speed[12]; - char course[12]; - - dtostrf(_latitude, 1, 6, lat); - dtostrf(_longitude, 1, 6, lon); - dtostrf(_altitude, 1, 2, alt); - dtostrf(_speedKmh, 1, 2, speed); - dtostrf(_courseDeg, 1, 2, course); - - snprintf_P(buffer, size, + char alt[16]; + char speed[16]; + char course[16]; + + SystemFunctions::safeJsonFloat(static_cast(_latitude), lat, sizeof(lat), 6); + SystemFunctions::safeJsonFloat(static_cast(_longitude), lon, sizeof(lon), 6); + SystemFunctions::safeJsonFloat(static_cast(_altitude), alt, sizeof(alt), 2); + SystemFunctions::safeJsonFloat(static_cast(_speedKmh), speed, sizeof(speed), 2); + SystemFunctions::safeJsonFloat(static_cast(_courseDeg), course, sizeof(course), 2); + + snprintf_P(buffer, size, PSTR("\"gps\":{\"lat\":%s,\"lon\":%s,\"alt\":%s,\"speed\":%s,\"course\":%s,\"sats\":%lu,\"valid\":%s}"), lat, lon, alt, speed, course, _satellites, _hasValidFix ? "true" : "false"); } diff --git a/PowerControlHub/SystemFunctions.cpp b/PowerControlHub/SystemFunctions.cpp index 4b59f2e..c8fb417 100644 --- a/PowerControlHub/SystemFunctions.cpp +++ b/PowerControlHub/SystemFunctions.cpp @@ -607,4 +607,19 @@ void SystemFunctions::formatTimeParts(char* buffer, size_t bufferSize, const Tim (unsigned)timeparts.hours, (unsigned)timeparts.minutes, (unsigned)timeparts.seconds); +} + +void SystemFunctions::safeJsonFloat(float value, char* output, size_t outputSize, uint8_t decimals) +{ + if (!output || outputSize == 0) + return; + + if (isnan(value) || isinf(value)) + { + strncpy(output, "null", outputSize); + output[outputSize - 1] = '\0'; + return; + } + + dtostrf(value, 1, decimals, output); } \ No newline at end of file diff --git a/PowerControlHub/SystemFunctions.h b/PowerControlHub/SystemFunctions.h index 0da3865..7bfa50e 100644 --- a/PowerControlHub/SystemFunctions.h +++ b/PowerControlHub/SystemFunctions.h @@ -353,6 +353,20 @@ class SystemFunctions */ static void sanitizeJsonString(const char* input, char* output, size_t outputSize); + /** + * @brief Format a float as a JSON number, emitting null for non-finite values. + * + * IEEE 754 special values (NaN, +Inf, -Inf) are not valid JSON numbers. + * This helper emits the literal `null` for any non-finite value so that + * the output is always valid JSON. + * + * @param value The float value to format. + * @param output Destination buffer. + * @param outputSize Size of destination buffer including null terminator. + * @param decimals Number of decimal places (passed to dtostrf). + */ + static void safeJsonFloat(float value, char* output, size_t outputSize, uint8_t decimals = 2); + /** * @brief Escape a string for safe embedding in HTML content. * diff --git a/PowerControlHubApp/App.xaml b/PowerControlHubApp/App.xaml new file mode 100644 index 0000000..a96558e --- /dev/null +++ b/PowerControlHubApp/App.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/PowerControlHubApp/App.xaml.cs b/PowerControlHubApp/App.xaml.cs new file mode 100644 index 0000000..aab35de --- /dev/null +++ b/PowerControlHubApp/App.xaml.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using PowerControlHubApp.Services; + +namespace PowerControlHubApp +{ + public partial class App : Application + { + public App(ThemeService themeService) + { + InitializeComponent(); + // Apply after InitializeComponent so Application.Resources is populated. + themeService.ApplySaved(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + var window = new Window(new AppShell()); + +#if WINDOWS + window.HandlerChanged += OnWindowHandlerChanged; +#endif + + return window; + } + +#if WINDOWS + private static void OnWindowHandlerChanged(object? sender, EventArgs e) + { + if (sender is not Window mauiWindow) + return; + + // Detach — only needs to run once + mauiWindow.HandlerChanged -= OnWindowHandlerChanged; + + if (mauiWindow.Handler?.PlatformView is not Microsoft.UI.Xaml.Window nativeWindow) + return; + + var appWindow = nativeWindow.AppWindow; + + // Restore saved position and size (stored in physical pixels) + int savedW = Preferences.Get("win_w", 0); + int savedH = Preferences.Get("win_h", 0); + int savedX = Preferences.Get("win_x", int.MinValue); + int savedY = Preferences.Get("win_y", int.MinValue); + + if (savedW > 0 && savedH > 0) + appWindow.Resize(new Windows.Graphics.SizeInt32(savedW, savedH)); + + if (savedX != int.MinValue && savedY != int.MinValue) + appWindow.Move(new Windows.Graphics.PointInt32(savedX, savedY)); + + // Persist position/size whenever the window moves or is resized + appWindow.Changed += (aw, args) => + { + if (!args.DidPositionChange && !args.DidSizeChange) + return; + + Preferences.Set("win_x", aw.Position.X); + Preferences.Set("win_y", aw.Position.Y); + Preferences.Set("win_w", aw.Size.Width); + Preferences.Set("win_h", aw.Size.Height); + }; + } +#endif + } +} diff --git a/PowerControlHubApp/AppShell.xaml b/PowerControlHubApp/AppShell.xaml new file mode 100644 index 0000000..2ecf608 --- /dev/null +++ b/PowerControlHubApp/AppShell.xaml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/PowerControlHubApp/AppShell.xaml.cs b/PowerControlHubApp/AppShell.xaml.cs new file mode 100644 index 0000000..c54287f --- /dev/null +++ b/PowerControlHubApp/AppShell.xaml.cs @@ -0,0 +1,10 @@ +namespace PowerControlHubApp +{ + public partial class AppShell : Shell + { + public AppShell() + { + InitializeComponent(); + } + } +} diff --git a/PowerControlHubApp/Converters/ValueConverters.cs b/PowerControlHubApp/Converters/ValueConverters.cs new file mode 100644 index 0000000..082a30e --- /dev/null +++ b/PowerControlHubApp/Converters/ValueConverters.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace PowerControlHubApp.Converters; + +/// Returns true when the integer value is greater than zero. +public class IntToBoolConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is int i && i > 0; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Returns true when the integer value equals zero (inverse of IntToBoolConverter). +public class IntToInverseBoolConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is not int i || i == 0; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Returns true when the string is non-empty. +public class StringToBoolConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string s && !string.IsNullOrEmpty(s); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// Returns a green colour for true (connected) and a red colour for false (disconnected). +public class BoolToStatusColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Color.FromArgb("#44cc44") : Color.FromArgb("#cc4444"); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/PowerControlHubApp/MainPage.xaml b/PowerControlHubApp/MainPage.xaml new file mode 100644 index 0000000..0fd8e0e --- /dev/null +++ b/PowerControlHubApp/MainPage.xaml @@ -0,0 +1,36 @@ + + + + + + + +