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
+
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 @@
-
-
-