diff --git a/PowerControlHub/ConfigController.cpp b/PowerControlHub/ConfigController.cpp index e670d45..04392bd 100644 --- a/PowerControlHub/ConfigController.cpp +++ b/PowerControlHub/ConfigController.cpp @@ -21,6 +21,7 @@ #include "IBluetoothRadio.h" #include "IWifiController.h" #include "SystemFunctions.h" +#include "DateTimeManager.h" // from NextionIds.h constexpr uint8_t ImageButtonColorBlue = 2; @@ -252,6 +253,7 @@ ConfigResult ConfigController::setTimezoneOffset(const int8_t offset) return ConfigResult::InvalidParameter; _config->system.timezoneOffset = offset; + DateTimeManager::setTimezoneOffset(offset); return ConfigResult::Success; } diff --git a/PowerControlHub/PowerControlHubApp.cpp b/PowerControlHub/PowerControlHubApp.cpp index a72357a..c195d1a 100644 --- a/PowerControlHub/PowerControlHubApp.cpp +++ b/PowerControlHub/PowerControlHubApp.cpp @@ -165,6 +165,7 @@ void PowerControlHubApp::setup(RemoteSensor** remoteSensors, uint8_t remoteSenso Config* config = ConfigManager::getConfigPtr(); DateTimeManager::begin(config->rtc); + DateTimeManager::setTimezoneOffset(config->system.timezoneOffset); // Build remote sensors from RemoteSensorsConfig (populated by ConfigManager::load()). // Sensors are created dynamically here; ownership stays with PowerControlHubApp (_remoteSensors). diff --git a/PowerControlHubApp/AppShell.xaml.cs b/PowerControlHubApp/AppShell.xaml.cs index c189338..f7e286c 100644 --- a/PowerControlHubApp/AppShell.xaml.cs +++ b/PowerControlHubApp/AppShell.xaml.cs @@ -10,6 +10,7 @@ public AppShell() Routing.RegisterRoute(nameof(RelayDetailPage), typeof(RelayDetailPage)); Routing.RegisterRoute(nameof(ExternalSensorDetailPage), typeof(ExternalSensorDetailPage)); Routing.RegisterRoute(nameof(LocalSensorDetailPage), typeof(LocalSensorDetailPage)); + Routing.RegisterRoute(nameof(TimeSettingsPage), typeof(TimeSettingsPage)); } } } diff --git a/PowerControlHubApp/Internal/Constants.cs b/PowerControlHubApp/Internal/Constants.cs index 71ed955..953fc46 100644 --- a/PowerControlHubApp/Internal/Constants.cs +++ b/PowerControlHubApp/Internal/Constants.cs @@ -1,5 +1,8 @@ namespace PowerControlHubApp.Internal { + +#pragma warning disable CC0003 + internal static class Constants { #if WINDOWS @@ -108,15 +111,16 @@ internal static class Constants public const int PortMax = 65535; public const string MsgIpRequired = "IP address is required."; public const string MsgInvalidPort = "Enter a valid port number (1–65535)."; + public const string MessageNotConnected = "Not connected"; // Relay UI public const string NoneString = "None"; public const string SavedOk = "✓ Saved successfully"; public const string SavedFailed = "⚠ One or more commands failed"; public const int ColorOptionNoneIndex = 6; - public static readonly string[] ColorOptionNames = new[] { ColorName_Blue, ColorName_Green, ColorName_Grey, ColorName_Orange, ColorName_Red, ColorName_Yellow, NoneString }; - public static readonly string[] ActionOptionNames = new[] { ActionName_Default, ActionName_Horn, ActionName_NightRelay }; - public static readonly string[] DefaultStateOptionNames = new[] { DefaultState_Off, DefaultState_On }; + public static readonly string[] ColorOptionNames = [ColorName_Blue, ColorName_Green, ColorName_Grey, ColorName_Orange, ColorName_Red, ColorName_Yellow, NoneString]; + public static readonly string[] ActionOptionNames = [ActionName_Default, ActionName_Horn, ActionName_NightRelay]; + public static readonly string[] DefaultStateOptionNames = [DefaultState_Off, DefaultState_On]; public const string NavBack = ".."; public const string RelayPageTitle = "Relay"; public const string CheckMark = "✓"; @@ -191,12 +195,14 @@ internal static class Constants public const string RouteSystemPins = "api/system/F15"; public const string RouteSystemGetDateTime = "api/system/F7"; public const string RouteSystemSetDateTime = "api/system/F6"; + public const string RouteConfigTimezoneOffset = "api/config/C20"; public const string RouteWarnings = "api/warning/W5"; public const string ForwardSlash = "/"; public const string ResultSuccess = "success"; public const string RouteDashboardPage = "//DashboardPage"; public const string RouteSettingsPage = "//SettingsPage"; public const string RouteSystemPage = "//SystemPage"; + public const string RouteTimeSettingsPage = "TimeSettingsPage"; public const string ConnectionTypeKey = "X-Connection-Type"; public const string ConnectionTypePersistent = "persistent"; public const int MaximumPermanentConnections = 2; @@ -204,6 +210,10 @@ internal static class Constants public const int SecondsTen = 10; public const int SecondsFive = 5; public const int SecondsThree = 3; + private const int TZOffsetRawMinValue = 12; + private const int TZOffsetRawMaxValue = 14; + public const int TimezoneOffsetMin = -TZOffsetRawMinValue; + public const int TimezoneOffsetMax = TZOffsetRawMaxValue; public const int SecondsTwo = 2; public const string UserAgentKey = "User-Agent"; public const string UserAgentValue = "PowerControlHub/1.0"; @@ -225,7 +235,7 @@ internal static class Constants public const string JsonSensorIndex = "i"; public const string JsonSensorId = "id"; public const string JsonSensorName = "n"; - public const string JsonSensorMqttName = "mn"; + public const string JsonSensorMqttName = "mn"; public const string JsonSensorMqttSlug = "ms"; public const string JsonSensorMqttType = "mt"; public const string JsonSensorMqttDeviceClass = "md"; @@ -408,4 +418,6 @@ internal static class Constants public const string NullByte = "0x00"; public const string NibbleZero = "0x0"; } + +#pragma warning restore CC0003 } diff --git a/PowerControlHubApp/MauiProgram.cs b/PowerControlHubApp/MauiProgram.cs index e6d471f..bba363c 100644 --- a/PowerControlHubApp/MauiProgram.cs +++ b/PowerControlHubApp/MauiProgram.cs @@ -94,6 +94,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddTransient(); + builder.Services.AddTransient(); // Pages builder.Services.AddSingleton(); @@ -105,6 +106,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddTransient(); + builder.Services.AddTransient(); #if DEBUG builder.Logging.AddDebug(); diff --git a/PowerControlHubApp/PowerControlHubApp.csproj b/PowerControlHubApp/PowerControlHubApp.csproj index ccfc371..690f0fc 100644 --- a/PowerControlHubApp/PowerControlHubApp.csproj +++ b/PowerControlHubApp/PowerControlHubApp.csproj @@ -100,6 +100,9 @@ MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile @@ -139,6 +142,9 @@ MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile diff --git a/PowerControlHubApp/Services/ConfigConnection.cs b/PowerControlHubApp/Services/ConfigConnection.cs index 01a09d6..f66eaf2 100644 --- a/PowerControlHubApp/Services/ConfigConnection.cs +++ b/PowerControlHubApp/Services/ConfigConnection.cs @@ -437,6 +437,45 @@ public async Task SetDateTimeAsync(long unixTimestamp, CancellationToken c } } + public async Task GetTimezoneOffsetAsync(CancellationToken ct = default) + { + try + { + string json = await _client.GetStringAsync(RouteConfigTimezoneOffset, ct); + using JsonDocument doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty(ResultSuccess, out var success) && + success.GetBoolean() && + doc.RootElement.TryGetProperty(JsonValueKey, out var v)) + { + if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out int val)) + return val; + + if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out val)) + return val; + } + } + catch + { + // fall through + } + return null; + } + + public async Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = default) + { + try + { + string url = $"{RouteConfigTimezoneOffset}?{JsonValueKey}={offsetHours}"; + 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 eebb43e..043c320 100644 --- a/PowerControlHubApp/Services/ConfigPoller.cs +++ b/PowerControlHubApp/Services/ConfigPoller.cs @@ -173,6 +173,10 @@ private async Task RunLoopAsync(CancellationToken stoppingToken) public Task SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default) => _connection.SetDateTimeAsync(unixTimestamp, ct); + public Task GetTimezoneOffsetAsync(CancellationToken ct = default) => _connection.GetTimezoneOffsetAsync(ct); + + public Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = default) => _connection.SetTimezoneOffsetAsync(offsetHours, ct); + public void Dispose() { if (_cts != null) diff --git a/PowerControlHubApp/Services/IConfigConnection.cs b/PowerControlHubApp/Services/IConfigConnection.cs index 1a133d1..0e2fe30 100644 --- a/PowerControlHubApp/Services/IConfigConnection.cs +++ b/PowerControlHubApp/Services/IConfigConnection.cs @@ -32,5 +32,7 @@ public interface IConfigConnection Task TriggerOtaInstallAsync(CancellationToken ct = default); Task GetDateTimeAsync(CancellationToken ct = default); Task SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default); + Task GetTimezoneOffsetAsync(CancellationToken ct = default); + Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = default); } } diff --git a/PowerControlHubApp/Services/PowerHubService.cs b/PowerControlHubApp/Services/PowerHubService.cs index be04a56..ca0191d 100644 --- a/PowerControlHubApp/Services/PowerHubService.cs +++ b/PowerControlHubApp/Services/PowerHubService.cs @@ -173,4 +173,24 @@ public Task TriggerOtaInstallAsync(CancellationToken ct = default) { return _configConnection.TriggerOtaInstallAsync(ct); } + + public Task GetDateTimeAsync(CancellationToken ct = default) + { + return _configConnection.GetDateTimeAsync(ct); + } + + public Task SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default) + { + return _configConnection.SetDateTimeAsync(unixTimestamp, ct); + } + + public Task GetTimezoneOffsetAsync(CancellationToken ct = default) + { + return _configConnection.GetTimezoneOffsetAsync(ct); + } + + public Task SetTimezoneOffsetAsync(int offsetHours, CancellationToken ct = default) + { + return _configConnection.SetTimezoneOffsetAsync(offsetHours, ct); + } } diff --git a/PowerControlHubApp/Services/TimeSyncService.cs b/PowerControlHubApp/Services/TimeSyncService.cs index 7ccfc3f..cbe354e 100644 --- a/PowerControlHubApp/Services/TimeSyncService.cs +++ b/PowerControlHubApp/Services/TimeSyncService.cs @@ -23,7 +23,8 @@ public class TimeSyncService : IDisposable public TimeSyncService(IConfigConnection configConnection, ILogger log) : this(configConnection, log, TimeSpan.FromMinutes(TimeSyncIntervalMinutes), - TimeSyncDriftThresholdSeconds) { } + TimeSyncDriftThresholdSeconds) + { } public TimeSyncService(IConfigConnection configConnection, ILogger log, TimeSpan interval, int driftThresholdSeconds) @@ -110,13 +111,7 @@ private async Task SyncTimeAsync(CancellationToken ct) DateTimeOffset? deviceTime = await _configConnection.GetDateTimeAsync(ct); if (deviceTime == null) - { - _log.LogInformation(LogTimeSyncNotSet); - long nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - await _configConnection.SetDateTimeAsync(nowUnix, ct); - _log.LogInformation(LogTimeSyncSetting, 0, DateTimeOffset.UtcNow); return; - } DateTimeOffset localTime = DateTimeOffset.UtcNow; double deltaSec = Math.Abs((localTime - deviceTime.Value).TotalSeconds); diff --git a/PowerControlHubApp/ViewModels/SystemViewModel.cs b/PowerControlHubApp/ViewModels/SystemViewModel.cs index 1d81982..f08ce30 100644 --- a/PowerControlHubApp/ViewModels/SystemViewModel.cs +++ b/PowerControlHubApp/ViewModels/SystemViewModel.cs @@ -64,12 +64,15 @@ public bool IsPinsRefreshing public ICommand InstallFirmwareCommand { get; } + public ICommand NavigateToTimeSettingsCommand { get; } + public SystemViewModel(PowerHubService service, LogService log) : base(service, log) { CheckForUpdateCommand = new Command(async () => await CheckForUpdateAsync()); RefreshPinsCommand = new Command(async () => await RefreshPinsAsync()); InstallFirmwareCommand = new Command(async () => await InstallFirmwareAsync(), () => CanInstallFirmware); + NavigateToTimeSettingsCommand = new Command(async () => await Shell.Current.GoToAsync(RouteTimeSettingsPage)); } protected override void OnDataFetched(IndexModel index) diff --git a/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs b/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs new file mode 100644 index 0000000..7e4107d --- /dev/null +++ b/PowerControlHubApp/ViewModels/TimeSettingsViewModel.cs @@ -0,0 +1,321 @@ +using PowerControlHubApp.Models.Json; +using PowerControlHubApp.Services; +using System.Collections.ObjectModel; +using System.Windows.Input; +using static PowerControlHubApp.Internal.Constants; + +namespace PowerControlHubApp.ViewModels; + +public sealed class TimeSettingsViewModel : BaseViewModel +{ + private const int DstCheckIntervalMinutes = 30; + private const string PlusSign = "+"; + private const string MsgTimeSyncedToDevice = "Time synced to device"; + private const string MsgSyncFailedDeviceUnreachable = "Sync failed — device unreachable"; + private const string MsgTimezoneSaved = "Timezone saved (UTC"; + private const string MsgSaveFailedDeviceUnreachable = "Save failed — device unreachable"; + private const string MsgTimezoneNotAvailable = "Timezone not available on this device"; + private const string MsgDstAdjusted = "DST adjusted (UTC"; + + private string _deviceTime = DoubleDash; + private int _selectedTimezoneIndex = -1; + private bool _isRefreshing; + private PeriodicTimer _dstTimer; + private CancellationTokenSource _dstCts; + + public sealed class TimeZoneOption + { + public string DisplayName { get; init; } + public string TimeZoneId { get; init; } + public override string ToString() => DisplayName; + } + +#pragma warning disable CC0009 + public static readonly ReadOnlyCollection TimeZoneOptionsList = new( + [ + new() { DisplayName = "UTC-12 (Baker Island)", TimeZoneId = "Etc/GMT+12" }, + new() { DisplayName = "UTC-11 (Midway)", TimeZoneId = "Etc/GMT+11" }, + new() { DisplayName = "UTC-10 (Hawaii)", TimeZoneId = "Pacific/Honolulu" }, + new() { DisplayName = "UTC-9 (Alaska)", TimeZoneId = "America/Anchorage" }, + new() { DisplayName = "UTC-8 (Pacific)", TimeZoneId = "America/Los_Angeles" }, + new() { DisplayName = "UTC-7 (Mountain)", TimeZoneId = "America/Denver" }, + new() { DisplayName = "UTC-6 (Central)", TimeZoneId = "America/Chicago" }, + new() { DisplayName = "UTC-5 (Eastern)", TimeZoneId = "America/New_York" }, + new() { DisplayName = "UTC-4 (Atlantic)", TimeZoneId = "America/Halifax" }, + new() { DisplayName = "UTC-3 (Sao Paulo)", TimeZoneId = "America/Sao_Paulo" }, + new() { DisplayName = "UTC-2 (Fernando de Noronha)", TimeZoneId = "Etc/GMT+2" }, + new() { DisplayName = "UTC-1 (Azores)", TimeZoneId = "Atlantic/Azores" }, + new() { DisplayName = "UTC+0 (London)", TimeZoneId = "Europe/London" }, + new() { DisplayName = "UTC+1 (Paris / Copenhagen)", TimeZoneId = "Europe/Paris" }, + new() { DisplayName = "UTC+2 (Helsinki / Athens)", TimeZoneId = "Europe/Helsinki" }, + new() { DisplayName = "UTC+3 (Moscow)", TimeZoneId = "Europe/Moscow" }, + new() { DisplayName = "UTC+4 (Dubai)", TimeZoneId = "Asia/Dubai" }, + new() { DisplayName = "UTC+5 (Karachi)", TimeZoneId = "Asia/Karachi" }, + new() { DisplayName = "UTC+6 (Dhaka)", TimeZoneId = "Asia/Dhaka" }, + new() { DisplayName = "UTC+7 (Bangkok)", TimeZoneId = "Asia/Bangkok" }, + new() { DisplayName = "UTC+8 (Hong Kong / Singapore)", TimeZoneId = "Asia/Singapore" }, + new() { DisplayName = "UTC+9 (Tokyo)", TimeZoneId = "Asia/Tokyo" }, + new() { DisplayName = "UTC+10 (Sydney)", TimeZoneId = "Australia/Sydney" }, + new() { DisplayName = "UTC+11 (Solomon Islands)", TimeZoneId = "Pacific/Guadalcanal" }, + new() { DisplayName = "UTC+12 (Auckland)", TimeZoneId = "Pacific/Auckland" }, + new() { DisplayName = "UTC+13 (Apia)", TimeZoneId = "Pacific/Apia" }, + new() { DisplayName = "UTC+14 (Kiritimati)", TimeZoneId = "Pacific/Kiritimati" }, + ]); +#pragma warning restore CC0009 + + private static readonly TimeSpan DstCheckInterval = TimeSpan.FromMinutes(DstCheckIntervalMinutes); + + public TimeSettingsViewModel(PowerHubService service, LogService log) + : base(service, log) + { + RefreshCommand = new Command(async () => await RefreshAsync()); + SyncTimeCommand = new Command(async () => await SyncTimeAsync()); + SaveTimezoneCommand = new Command(async () => await SaveTimezoneAsync()); + } + + public ICommand RefreshCommand { get; } + public ICommand SyncTimeCommand { get; } + public ICommand SaveTimezoneCommand { get; } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Exposed to XAML bindings as instance property")] + public ReadOnlyCollection TimeZoneOptions => TimeZoneOptionsList; + + public string DeviceTime + { + get => _deviceTime; + set + { + _deviceTime = value; + OnPropertyChanged(); + } + } + + public int SelectedTimezoneIndex + { + get => _selectedTimezoneIndex; + set + { + _selectedTimezoneIndex = value; + OnPropertyChanged(); + } + } + + public bool IsRefreshing + { + get => _isRefreshing; + set + { + _isRefreshing = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsNotRefreshing)); + } + } + + public bool IsNotRefreshing => !_isRefreshing; + + public bool HasStatusMessage => !string.IsNullOrEmpty(StatusMessage); + + public async Task RefreshAsync() + { + if (!Service.IsConfigured || _isRefreshing) + return; + + IsRefreshing = true; + + try + { + var index = await Service.GetDashboardDataAsync(); + + MainThread.BeginInvokeOnMainThread(() => + { + if (index?.System?.Time.Year >= MinimumValidDateTimeYear) + DeviceTime = index.System.Time.ToString(DeviceTimeFormat); + else + DeviceTime = DoubleDash; + + if (index?.Config != null) + { + int offset = index.Config.TimezoneOffset; + MatchTimezoneIndexByOffset(offset); + } + + StatusMessage = $"Updated {DateTime.Now:HH:mm:ss}"; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + catch + { + MainThread.BeginInvokeOnMainThread(() => + { + DeviceTime = DoubleDash; + StatusMessage = MessageDeviceUnreachable; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + finally + { + IsRefreshing = false; + } + } + + private void MatchTimezoneIndexByOffset(int offsetHours) + { + for (int i = 0; i < TimeZoneOptionsList.Count; i++) + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneOptionsList[i].TimeZoneId); + int currentOffset = (int)tz.GetUtcOffset(new DateTime(DateTime.UtcNow.Ticks, DateTimeKind.Utc)).TotalHours; + + if (currentOffset == offsetHours) + { + SelectedTimezoneIndex = i; + return; + } + } + catch + { + // timezone not on this platform + } + } + } + + public async Task SyncTimeAsync() + { + if (!Service.IsConfigured) + return; + + long nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + bool ok = await Service.SetDateTimeAsync(nowUnix); + + MainThread.BeginInvokeOnMainThread(() => + { + if (ok) + { + DeviceTime = DateTimeOffset.UtcNow.ToString(DeviceTimeFormat); + StatusMessage = MsgTimeSyncedToDevice; + } + else + { + StatusMessage = MsgSyncFailedDeviceUnreachable; + } + + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + + public async Task SaveTimezoneAsync() + { + if (!Service.IsConfigured || SelectedTimezoneIndex < 0 || SelectedTimezoneIndex >= TimeZoneOptionsList.Count) + return; + + var option = TimeZoneOptionsList[SelectedTimezoneIndex]; + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(option.TimeZoneId); + var offset = tz.GetUtcOffset(new DateTime(DateTime.UtcNow.Ticks, DateTimeKind.Utc)); + int offsetHours = (int)offset.TotalHours; + + bool ok = await Service.SetTimezoneOffsetAsync(offsetHours); + + MainThread.BeginInvokeOnMainThread(() => + { + if (ok) + { + StatusMessage = $"{MsgTimezoneSaved}{(offsetHours >= 0 ? PlusSign : string.Empty)}{offsetHours})"; + StartDstAutoAdjust(option.TimeZoneId); + } + else + { + StatusMessage = MsgSaveFailedDeviceUnreachable; + } + + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + catch (TimeZoneNotFoundException) + { + StatusMessage = MsgTimezoneNotAvailable; + OnPropertyChanged(nameof(HasStatusMessage)); + } + } + + private void StartDstAutoAdjust(string timeZoneId) + { + StopDstAutoAdjust(); + + _dstCts = new CancellationTokenSource(); + var ct = _dstCts.Token; + _dstTimer = new PeriodicTimer(DstCheckInterval); + + _ = RunDstLoopAsync(timeZoneId, ct); + } + + private async Task RunDstLoopAsync(string timeZoneId, CancellationToken ct) + { + int lastOffset = int.MinValue; + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + lastOffset = (int)tz.GetUtcOffset(new DateTime(DateTime.UtcNow.Ticks, DateTimeKind.Utc)).TotalHours; + } + catch + { + return; + } + + while (await _dstTimer.WaitForNextTickAsync(ct)) + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + int currentOffset = (int)tz.GetUtcOffset(new DateTime(DateTime.UtcNow.Ticks, DateTimeKind.Utc)).TotalHours; + + if (currentOffset != lastOffset) + { + bool ok = await Service.SetTimezoneOffsetAsync(currentOffset, ct); + + if (ok) + { + lastOffset = currentOffset; + int displayOffset = currentOffset; + MainThread.BeginInvokeOnMainThread(() => + { + StatusMessage = $"{MsgDstAdjusted}{(displayOffset >= 0 ? PlusSign : string.Empty)}{displayOffset})"; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch + { + // silently retry next tick + } + } + } + + private void StopDstAutoAdjust() + { + _dstCts?.Cancel(); + _dstCts?.Dispose(); + _dstCts = null; + _dstTimer?.Dispose(); + _dstTimer = null; + } + + public void Cleanup() + { + StopDstAutoAdjust(); + } + + protected override void OnDataFetched(Models.Json.IndexModel index) + { + // Not used — this page does its own refresh + } +} diff --git a/PowerControlHubApp/Views/DashboardPage.xaml b/PowerControlHubApp/Views/DashboardPage.xaml index 0be5085..24ef234 100644 --- a/PowerControlHubApp/Views/DashboardPage.xaml +++ b/PowerControlHubApp/Views/DashboardPage.xaml @@ -159,6 +159,9 @@ + + +