diff --git a/.editorconfig b/.editorconfig index ef67e0b..56b2f89 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,13 +9,13 @@ dotnet_diagnostic.CC0009.severity = error dotnet_diagnostic.CC0010.severity = error -dotnet_diagnostic.IDE0028.severity = error -dotnet_diagnostic.IDE0290.severity = none -dotnet_diagnostic.IDE0305.severity = error +dotnet_diagnostic.IDE0028.severity = error # Use collection initializer +dotnet_diagnostic.IDE0290.severity = none # Use 'var' when variable type is apparent +dotnet_diagnostic.IDE0305.severity = error # Use 'var' when variable type is apparent -dotnet_diagnostic.CS0144.severity = error -dotnet_diagnostic.CS8632.severity = error +dotnet_diagnostic.CS0144.severity = error # The body of 'method' cannot be an iterator block because 'type' does not implement 'IEnumerable' or 'IEnumerator' +dotnet_diagnostic.CS8632.severity = error # nullable reference types -dotnet_diagnostic.CA1822.severity = error +dotnet_diagnostic.CA1822.severity = error # mark members as static diff --git a/.gitignore b/.gitignore index 821bceb..b536965 100644 --- a/.gitignore +++ b/.gitignore @@ -338,3 +338,6 @@ StaticElectrics/__vm/ /SmartFuseBox/__vm/.SmartFuseBox.vsarduino.h /LocalOnly /.github/copilot-instructions.md +/PowerControlHubApp/pch-release-key.keystore +/directory.props.build +/BuildPlayStoreApp.txt diff --git a/PowerControlHubApp/AppShell.xaml b/PowerControlHubApp/AppShell.xaml index a0393de..587328a 100644 --- a/PowerControlHubApp/AppShell.xaml +++ b/PowerControlHubApp/AppShell.xaml @@ -6,42 +6,31 @@ xmlns:views="clr-namespace:PowerControlHubApp.Views" xmlns:localization="clr-namespace:PowerControlHubApp.Resources.Localization" Title="PowerControlHub" + FlyoutBehavior="Flyout" Shell.NavBarIsVisible="False"> - + + + - + + + - + + + - + + + - + + + - + + + - + \ No newline at end of file diff --git a/PowerControlHubApp/Internal/Constants.cs b/PowerControlHubApp/Internal/Constants.cs index f76ea0f..35c95f2 100644 --- a/PowerControlHubApp/Internal/Constants.cs +++ b/PowerControlHubApp/Internal/Constants.cs @@ -12,6 +12,8 @@ internal static class Constants #endif public const string ApplicationNameWithIcon = IconWarning + SingleSpace + ApplicationName; public const string ApplicationName = "Power Control Hub"; + public const string AndroidLogTag = "PowerControlHub"; + public const string UnknownExceptionMessage = "Unknown exception"; public const string SummarySeparator = ";"; public const string IconTimePrefix = "🕐"; public const string IconExternalSensor = "📡"; @@ -28,7 +30,7 @@ internal static class Constants public const string IconLocalSensor = "🔌"; public const string IconRelaySymbol = "⚡"; public const string IconRemove = "🗑"; - public const string NavBack = "..."; + public const string NavBack = ".."; public const string FailSeparator = ";"; public const string ColorAsHex1 = "#44cc44"; public const string ColorError = "#cc4444"; diff --git a/PowerControlHubApp/MauiProgram.cs b/PowerControlHubApp/MauiProgram.cs index 951eb92..c63c84f 100644 --- a/PowerControlHubApp/MauiProgram.cs +++ b/PowerControlHubApp/MauiProgram.cs @@ -81,7 +81,8 @@ public static MauiApp CreateMauiApp() PowerHubService service = new PowerHubService( sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService()); + sp.GetRequiredService(), + sp.GetRequiredService()); return service; }); diff --git a/PowerControlHubApp/Platforms/Android/AndroidManifest.xml b/PowerControlHubApp/Platforms/Android/AndroidManifest.xml index e9937ad..d4f487a 100644 --- a/PowerControlHubApp/Platforms/Android/AndroidManifest.xml +++ b/PowerControlHubApp/Platforms/Android/AndroidManifest.xml @@ -1,6 +1,6 @@  - + \ No newline at end of file diff --git a/PowerControlHubApp/Platforms/Android/MainApplication.cs b/PowerControlHubApp/Platforms/Android/MainApplication.cs index 25dd32a..916a03b 100644 --- a/PowerControlHubApp/Platforms/Android/MainApplication.cs +++ b/PowerControlHubApp/Platforms/Android/MainApplication.cs @@ -1,5 +1,8 @@ using Android.App; using Android.Runtime; +using PowerControlHubApp.Internal; +using System; +using System.Threading.Tasks; namespace PowerControlHubApp { @@ -11,6 +14,34 @@ public MainApplication(IntPtr handle, JniHandleOwnership ownership) { } - protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + protected override MauiApp CreateMauiApp() + { + AndroidEnvironment.UnhandledExceptionRaiser += OnAndroidUnhandledException; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + + return MauiProgram.CreateMauiApp(); + } + + private static void OnAndroidUnhandledException(object sender, RaiseThrowableEventArgs e) + { + LogException(e.Exception); + } + + private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + LogException(e.ExceptionObject as Exception); + } + + private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) + { + LogException(e.Exception); + e.SetObserved(); + } + + private static void LogException(Exception exception) + { + Android.Util.Log.Error(Constants.AndroidLogTag, exception?.ToString() ?? Constants.UnknownExceptionMessage); + } } -} +} \ No newline at end of file diff --git a/PowerControlHubApp/PowerControlHubApp.csproj b/PowerControlHubApp/PowerControlHubApp.csproj index e9d10e9..3028cc8 100644 --- a/PowerControlHubApp/PowerControlHubApp.csproj +++ b/PowerControlHubApp/PowerControlHubApp.csproj @@ -2,7 +2,7 @@ net10.0-android - $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-maccatalyst $(TargetFrameworks);net10.0-windows10.0.19041.0 - com.companyname.powercontrolhubapp + pch.si.carter.powercontrolhubapp 1.0 @@ -47,6 +47,23 @@ 21.0 10.0.17763.0 10.0.17763.0 + False + pch-release-key.keystore + + + + True + True + --v2-signing-enabled true + + True + + + + True + True + --v2-signing-enabled true + True @@ -54,7 +71,7 @@ - + diff --git a/PowerControlHubApp/Resources/Splash/splash.jpg b/PowerControlHubApp/Resources/Splash/splash.jpg new file mode 100644 index 0000000..3085f75 Binary files /dev/null and b/PowerControlHubApp/Resources/Splash/splash.jpg differ diff --git a/PowerControlHubApp/Resources/Splash/splash.svg b/PowerControlHubApp/Resources/Splash/splash.svg index 21dfb25..384b30f 100644 --- a/PowerControlHubApp/Resources/Splash/splash.svg +++ b/PowerControlHubApp/Resources/Splash/splash.svg @@ -1,8 +1,99 @@ - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + System Initializing… + + diff --git a/PowerControlHubApp/Services/ConfigConnection.cs b/PowerControlHubApp/Services/ConfigConnection.cs index 20a650f..82fe1b3 100644 --- a/PowerControlHubApp/Services/ConfigConnection.cs +++ b/PowerControlHubApp/Services/ConfigConnection.cs @@ -152,9 +152,15 @@ private static SocketsHttpHandler CreateHandler() NoDelay = true }; socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, SecondsTen); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, SecondsFive); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, SecondsThree); + try + { + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, SecondsTen); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, SecondsFive); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, SecondsThree); + } + catch + { + } await socket.ConnectAsync(context.DnsEndPoint, ct); return new NetworkStream(socket, ownsSocket: true); } diff --git a/PowerControlHubApp/Services/DashboardConnection.cs b/PowerControlHubApp/Services/DashboardConnection.cs index c3982de..bc8c254 100644 --- a/PowerControlHubApp/Services/DashboardConnection.cs +++ b/PowerControlHubApp/Services/DashboardConnection.cs @@ -141,9 +141,15 @@ private static SocketsHttpHandler CreateHandler() NoDelay = true }; socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, SecondsTen); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, SecondsFive); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, SecondsThree); + try + { + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, SecondsTen); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, SecondsFive); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, SecondsThree); + } + catch + { + } await socket.ConnectAsync(context.DnsEndPoint, ct); return new NetworkStream(socket, ownsSocket: true); } diff --git a/PowerControlHubApp/Services/PowerHubService.cs b/PowerControlHubApp/Services/PowerHubService.cs index 1c4e2a9..def8f65 100644 --- a/PowerControlHubApp/Services/PowerHubService.cs +++ b/PowerControlHubApp/Services/PowerHubService.cs @@ -25,13 +25,16 @@ public class PowerHubService { private readonly IDashboardConnection _dashboardConnection; private readonly IConfigConnection _configConnection; + private readonly IDashboardProvider _dashboardProvider; public PowerHubService(IDashboardConnection dashboardConnection, IConfigConnection configConnection, - IMessageBus messageBus) + IMessageBus messageBus, + IDashboardProvider dashboardProvider) { _dashboardConnection = dashboardConnection ?? throw new ArgumentNullException(nameof(dashboardConnection)); _configConnection = configConnection ?? throw new ArgumentNullException(nameof(configConnection)); + _dashboardProvider = dashboardProvider ?? throw new ArgumentNullException(nameof(dashboardProvider)); messageBus.Subscribe(OnAuthConfigChanged); } @@ -40,6 +43,8 @@ public PowerHubService(IDashboardConnection dashboardConnection, public string BaseUrl => _dashboardConnection is DashboardConnection dc ? dc.BaseUrl : string.Empty; + public IndexModel CurrentIndex => _dashboardProvider?.CurrentIndex; + public void Configure(string ipAddress, int port, string apiKey = "", string hmacKey = "") { if (_dashboardConnection is DashboardConnection dc) diff --git a/PowerControlHubApp/ViewModels/BaseViewModel.cs b/PowerControlHubApp/ViewModels/BaseViewModel.cs index 6eaff20..85dbee5 100644 --- a/PowerControlHubApp/ViewModels/BaseViewModel.cs +++ b/PowerControlHubApp/ViewModels/BaseViewModel.cs @@ -28,6 +28,7 @@ public abstract class BaseViewModel : INotifyPropertyChanged private string _systemUptime = DoubleDash; private bool _hasWarnings; private bool _isApplyingRemoteState = true; + private bool _hasData; protected BaseViewModel(PowerHubService service, LogService log) { @@ -40,6 +41,7 @@ protected BaseViewModel(PowerHubService service, LogService log) protected LogService Log => _log; protected PowerHubService Service => _service; + protected IndexModel LastKnownIndex => _service.CurrentIndex; public ObservableCollection LogEntries => _log.Entries; @@ -242,6 +244,8 @@ protected virtual async Task ExecuteRefreshAsync(CancellationToken ct) if (IsBusy) return; + ApplyCachedIfNeeded(); + IsBusy = true; DeviceUrl = _service.BaseUrl; @@ -254,9 +258,8 @@ protected virtual async Task ExecuteRefreshAsync(CancellationToken ct) IsApplyingRemoteState = true; try { - UpdateSystem(index.System); - UpdateHasWarnings(index.Warning); - OnDataFetched(index); + ApplyIndex(index); + _hasData = true; IsConnected = true; StatusMessage = $"Updated {DateTime.Now:HH:mm:ss}"; } @@ -291,6 +294,39 @@ protected virtual async Task ExecuteRefreshAsync(CancellationToken ct) } } + private void ApplyIndex(IndexModel index) + { + UpdateSystem(index.System); + UpdateHasWarnings(index.Warning); + OnDataFetched(index); + } + + private void ApplyCachedIfNeeded() + { + if (_hasData) + return; + + IndexModel cached = _service.CurrentIndex; + + if (cached is null) + return; + + _hasData = true; + + MainThread.BeginInvokeOnMainThread(() => + { + IsApplyingRemoteState = true; + try + { + ApplyIndex(cached); + } + finally + { + IsApplyingRemoteState = false; + } + }); + } + /// /// Override to apply view-model-specific updates after the common data /// has been fetched and system status has been written. @@ -325,6 +361,31 @@ private void UpdateHasWarnings(WarningModel warning) !warning.Active.Equals(NibbleZero, StringComparison.OrdinalIgnoreCase); } + protected static Task ConfirmAsync(string title, string message, string accept, string cancel) + { + Page page = CurrentPage(); + + if (page is null) + return Task.FromResult(false); + + return page.DisplayAlertAsync(title, message, accept, cancel); + } + + protected static Task AlertAsync(string title, string message, string cancel) + { + Page page = CurrentPage(); + + if (page is null) + return Task.CompletedTask; + + return page.DisplayAlertAsync(title, message, cancel); + } + + private static Page CurrentPage() + { + return Application.Current?.Windows.FirstOrDefault()?.Page ?? Shell.Current?.CurrentPage; + } + public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string name = null) diff --git a/PowerControlHubApp/ViewModels/ExternalSensorDetailViewModel.cs b/PowerControlHubApp/ViewModels/ExternalSensorDetailViewModel.cs index ff03c41..e7bc484 100644 --- a/PowerControlHubApp/ViewModels/ExternalSensorDetailViewModel.cs +++ b/PowerControlHubApp/ViewModels/ExternalSensorDetailViewModel.cs @@ -246,7 +246,7 @@ private async Task RemoveAsync() if (!Service.IsConfigured || _sensorIndex < 0) return; - bool confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync( + bool confirmed = await ConfirmAsync( RemoveSensor, $"Remove sensor at index {_sensorIndex}?\n\nThis cannot be undone.", Remove, diff --git a/PowerControlHubApp/ViewModels/LocalSensorDetailViewModel.cs b/PowerControlHubApp/ViewModels/LocalSensorDetailViewModel.cs index 82251ee..f09b290 100644 --- a/PowerControlHubApp/ViewModels/LocalSensorDetailViewModel.cs +++ b/PowerControlHubApp/ViewModels/LocalSensorDetailViewModel.cs @@ -819,7 +819,7 @@ private async Task RemoveAsync() if (!Service.IsConfigured || _sensorIndex < 0) return; - bool confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync( + bool confirmed = await ConfirmAsync( RemoveSensor, $"Remove local sensor at index {_sensorIndex}?\n\nThis cannot be undone.", Remove, diff --git a/PowerControlHubApp/ViewModels/NextionSettingsViewModel.cs b/PowerControlHubApp/ViewModels/NextionSettingsViewModel.cs index 0f5fad2..8d13f62 100644 --- a/PowerControlHubApp/ViewModels/NextionSettingsViewModel.cs +++ b/PowerControlHubApp/ViewModels/NextionSettingsViewModel.cs @@ -269,7 +269,7 @@ public async Task SaveAllAsync() public async Task RebootAsync() { - bool confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync(NextionRebootTitle, NextionRebootMessage, Reboot, Cancel); + bool confirmed = await ConfirmAsync(NextionRebootTitle, NextionRebootMessage, Reboot, Cancel); if (!confirmed) return; @@ -277,11 +277,11 @@ public async Task RebootAsync() try { await Service.SaveSettingsAsync(); - await Application.Current.Windows[0].Page.DisplayAlertAsync(Reboot, NextionRebootSavedMessage, OK); + await AlertAsync(Reboot, NextionRebootSavedMessage, OK); } catch { - await Application.Current.Windows[0].Page.DisplayAlertAsync(Reboot, NextionRebootFailed, OK); + await AlertAsync(Reboot, NextionRebootFailed, OK); } } diff --git a/PowerControlHubApp/ViewModels/SystemViewModel.cs b/PowerControlHubApp/ViewModels/SystemViewModel.cs index bf38401..93ae50a 100644 --- a/PowerControlHubApp/ViewModels/SystemViewModel.cs +++ b/PowerControlHubApp/ViewModels/SystemViewModel.cs @@ -318,11 +318,7 @@ public async Task InstallFirmwareAsync() return; string message = string.Format(OtaDialogMessage, AvailableFirmwareVersion); - bool confirmed = await Application.Current.Windows[0].Page.DisplayAlertAsync( - OtaDialogTitle, - message, - OtaDialogAccept, - MsgCancel); + bool confirmed = await ConfirmAsync(OtaDialogTitle, message, OtaDialogAccept, MsgCancel); if (!confirmed) return; diff --git a/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs b/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs index fdf4868..53760f2 100644 --- a/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs +++ b/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs @@ -126,6 +126,11 @@ public async Task RefreshAsync() if (!Service.IsConfigured || _isRefreshing) return; + IndexModel cached = LastKnownIndex; + + if (cached is not null) + ApplyTimeAndTimezone(cached); + IsRefreshing = true; try @@ -134,23 +139,7 @@ public async Task RefreshAsync() MainThread.BeginInvokeOnMainThread(() => { - if (index?.System?.Time.Year >= MinimumValidDateTimeYear) - { - _deviceTimeAtCapture = index.System.Time; - _captureUtcTicks = DateTime.UtcNow.Ticks; - DeviceTime = _deviceTimeAtCapture.ToString(DeviceTimeFormat); - } - else - { - DeviceTime = DoubleDash; - } - - if (index?.Config != null) - { - int offset = index.Config.TimezoneOffset; - MatchTimezoneIndexByOffset(offset); - } - + ApplyTimeAndTimezone(index); StatusMessage = $"Updated {DateTime.Now:HH:mm:ss}"; OnPropertyChanged(nameof(HasStatusMessage)); }); @@ -172,6 +161,23 @@ public async Task RefreshAsync() } } + private void ApplyTimeAndTimezone(IndexModel index) + { + if (index?.System?.Time.Year >= MinimumValidDateTimeYear) + { + _deviceTimeAtCapture = index.System.Time; + _captureUtcTicks = DateTime.UtcNow.Ticks; + DeviceTime = _deviceTimeAtCapture.ToString(DeviceTimeFormat); + } + else + { + DeviceTime = DoubleDash; + } + + if (index?.Config != null) + MatchTimezoneIndexByOffset(index.Config.TimezoneOffset); + } + private void MatchTimezoneIndexByOffset(int offsetHours) { for (int i = 0; i < TimeZoneOptionsList.Count; i++) diff --git a/PowerControlHubApp/Views/ExternalSensorConfigPage.xaml b/PowerControlHubApp/Views/ExternalSensorConfigPage.xaml index acd7451..5c7ceb7 100644 --- a/PowerControlHubApp/Views/ExternalSensorConfigPage.xaml +++ b/PowerControlHubApp/Views/ExternalSensorConfigPage.xaml @@ -55,13 +55,6 @@ - - -