Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PowerControlHub/PowerControlHub.vcxproj

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions PowerControlHub/SystemNetworkHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,59 @@ CommandResult SystemNetworkHandler::handleRequest(const char* method,
return CommandResult::ok();
}
#endif // OTA_AUTO_UPDATE
else if (SystemFunctions::commandMatches(command, SystemGetDateTime))
{
char dateTimeStr[DateTimeBufferLength];
if (DateTimeManager::formatDateTime(dateTimeStr, sizeof(dateTimeStr)))
{
snprintf(responseBuffer, bufferSize,
"\"success\":true,\"command\":\"%s\",\"v\":\"%s\"",
command, dateTimeStr);
}
else
{
snprintf(responseBuffer, bufferSize,
"\"success\":false,\"error\":\"Date/time not set\"");
}
return CommandResult::ok();
}
else if (SystemFunctions::commandMatches(command, SystemSetDateTime))
{
const char* tsStr = nullptr;
for (uint8_t i = 0; i < paramCount; ++i)
{
if (strcmp(params[i].key, ValueParamName) == 0)
{
tsStr = params[i].value;
break;
}
}

if (tsStr)
{
uint64_t timestamp = static_cast<uint64_t>(strtoull(tsStr, nullptr, 0));
if (timestamp > 0)
{
DateTimeManager::setDateTime(timestamp);
char dateTimeStr[DateTimeBufferLength];
DateTimeManager::formatDateTime(dateTimeStr, sizeof(dateTimeStr));
snprintf(responseBuffer, bufferSize,
"\"success\":true,\"command\":\"%s\",\"v\":\"%s\"",
command, dateTimeStr);
}
else
{
snprintf(responseBuffer, bufferSize,
"\"success\":false,\"error\":\"Invalid timestamp\"");
}
}
else
{
snprintf(responseBuffer, bufferSize,
"\"success\":false,\"error\":\"Missing v parameter\"");
}
return CommandResult::ok();
}
else
{
return CommandResult::error(InvalidCommandParameters);
Expand Down
8 changes: 7 additions & 1 deletion PowerControlHubApp/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public partial class App : Application
private readonly ConfigPoller _configPoller;
private readonly SensorMetaCache _sensorMetaCache;
private readonly IConfigConnection _configConnection;
private readonly TimeSyncService _timeSyncService;
private readonly ILogger<App> _log;

public App(
Expand All @@ -25,13 +26,15 @@ public App(
ConfigPoller configPoller,
SensorMetaCache sensorMetaCache,
IConfigConnection configConnection,
TimeSyncService timeSyncService,
ILogger<App> log)
{
InitializeComponent();
_dashboardPoller = dashboardPoller;
_configPoller = configPoller;
_sensorMetaCache = sensorMetaCache;
_configConnection = configConnection;
_timeSyncService = timeSyncService;
_log = log;

// Apply after InitializeComponent so Application.Resources is populated.
Expand All @@ -56,6 +59,7 @@ protected override void OnStart()
// Start background pollers
_dashboardPoller.Start();
_configPoller.Start();
_timeSyncService.Start();

// Startup orchestration: after first successful dashboard poll,
// fetch sensor meta data over the config connection so config pages are ready.
Expand All @@ -75,13 +79,15 @@ protected override void OnResume()
// Restart pollers when the app comes back to foreground
_dashboardPoller.Start();
_configPoller.Start();
_timeSyncService.Start();
}

private async Task StopPollersAsync()
{
await Task.WhenAll(
_dashboardPoller.StopAsync(),
_configPoller.StopAsync());
_configPoller.StopAsync(),
_timeSyncService.StopAsync());
}

private void AttachStartupOrchestration()
Expand Down
20 changes: 19 additions & 1 deletion PowerControlHubApp/Internal/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,17 @@ internal static class Constants
public const string OtaAuto_Off = "0";

public const int DefaultIntervalMs = 750;
public const int TimeSyncIntervalMinutes = 60;
public const int TimeSyncDriftThresholdSeconds = 120;
public const int MinimumValidDateTimeYear = 2000;
public const string DeviceTimeFormat = "yyyy-MM-dd HH:mm:ss";
public const string RouteApiIndex = "api/index";
public const string RouteSaveConfig = "api/config/C0";
public const string RouteOtaUpdate = "api/system/F13";
public const string RouteUpdateOta = "api/system/F12?apply=1";
public const string RouteSystemPins = "api/system/F15";
public const string RouteSystemGetDateTime = "api/system/F7";
public const string RouteSystemSetDateTime = "api/system/F6";
public const string RouteWarnings = "api/warning/W5";
public const string ForwardSlash = "/";
public const string ResultSuccess = "success";
Expand Down Expand Up @@ -219,12 +225,15 @@ 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";
public const string JsonSensorMqttUnit = "mu";
public const string JsonSensorMqttBinary = "bin";
public const string JsonValueKey = "v";
public const char JsonObjectOpen = '{';
public const char JsonObjectClose = '}';

public const string ErrRemoveCommandFailed = "⚠ Remove command failed";

Expand Down Expand Up @@ -382,6 +391,15 @@ internal static class Constants
public const string LogConfigMetaRefreshed = "ConfigPoller: SensorMetaCache refreshed after connection.";
public const string LogConfigHealthCheckFailed = "ConfigPoller: Connection health check failed.";

// TimeSyncService log messages
public const string LogTimeSyncStarted = "TimeSyncService started with interval {IntervalMin}min, drift threshold {DriftSec}s";
public const string LogTimeSyncDeviceTime = "TimeSyncService: device time is {DeviceTime}, delta {DeltaSec}s";
public const string LogTimeSyncSetting = "TimeSyncService: drift {DeltaSec}s exceeds threshold, setting device time to {LocalTime}";
public const string LogTimeSyncInSync = "TimeSyncService: device time is in sync (delta {DeltaSec}s)";
public const string LogTimeSyncNotSet = "TimeSyncService: device time not set, synchronizing";
public const string LogTimeSyncFailed = "TimeSyncService: time sync attempt failed";
public const string LogTimeSyncStopping = "TimeSyncService stopping";

// MauiProgram startup orchestration
public const string LogStartupMetaFetch = "Startup: first dashboard data received, fetching sensor meta on connection 2.";
public const string LogStartupMetaPopulated = "Startup: sensor meta cache populated.";
Expand Down
1 change: 1 addition & 0 deletions PowerControlHubApp/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public static MauiApp CreateMauiApp()
builder.Services.AddSingleton<LogService>();
builder.Services.AddSingleton<ThemeService>();
builder.Services.AddSingleton<RelayStore>();
builder.Services.AddSingleton<TimeSyncService>();

// ViewModels
builder.Services.AddSingleton<DashboardViewModel>();
Expand Down
38 changes: 38 additions & 0 deletions PowerControlHubApp/Services/ConfigConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,44 @@ public async Task<bool> TriggerOtaInstallAsync(CancellationToken ct = default)
}
}

public async Task<DateTimeOffset?> GetDateTimeAsync(CancellationToken ct = default)
{
try
{
string json = await _client.GetStringAsync(RouteSystemGetDateTime, ct);
using JsonDocument doc = JsonDocument.Parse(json);

if (doc.RootElement.TryGetProperty(ResultSuccess, out var success) &&
success.GetBoolean() &&
doc.RootElement.TryGetProperty(JsonValueKey, out var v))
{
string dtStr = v.GetString();

if (DateTimeOffset.TryParse(dtStr, out var dt))
return dt;
}
}
catch
{
// fall through
}
return null;
}

public async Task<bool> SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default)
{
try
{
string url = $"{RouteSystemSetDateTime}?{JsonValueKey}={unixTimestamp}";
HttpResponseMessage response = await _client.PostAsync(url, null, ct);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}

private static async Task<bool> IsSuccessResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
if (!response.IsSuccessStatusCode)
Expand Down
4 changes: 4 additions & 0 deletions PowerControlHubApp/Services/ConfigPoller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ private async Task RunLoopAsync(CancellationToken stoppingToken)

public Task<bool> TriggerOtaInstallAsync(CancellationToken ct = default) => _connection.TriggerOtaInstallAsync(ct);

public Task<DateTimeOffset?> GetDateTimeAsync(CancellationToken ct = default) => _connection.GetDateTimeAsync(ct);

public Task<bool> SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default) => _connection.SetDateTimeAsync(unixTimestamp, ct);

public void Dispose()
{
if (_cts != null)
Expand Down
2 changes: 2 additions & 0 deletions PowerControlHubApp/Services/IConfigConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ public interface IConfigConnection
Task<bool> SaveSettingsAsync(CancellationToken ct = default);
Task<OtaStatusModel> GetOtaStatusAsync(CancellationToken ct = default);
Task<bool> TriggerOtaInstallAsync(CancellationToken ct = default);
Task<DateTimeOffset?> GetDateTimeAsync(CancellationToken ct = default);
Task<bool> SetDateTimeAsync(long unixTimestamp, CancellationToken ct = default);
}
}
150 changes: 150 additions & 0 deletions PowerControlHubApp/Services/TimeSyncService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using Microsoft.Extensions.Logging;
using static PowerControlHubApp.Internal.Constants;

namespace PowerControlHubApp.Services;

/// <summary>
/// Background service that periodically synchronizes the device clock with the local system time.
/// On each tick (immediately on start, then every configured interval):
/// 1. Reads the device time via GET /api/system/F7.
/// 2. If the device time is not set or drifts beyond the threshold, sets it via POST /api/system/F6.
/// Runs independently of the active page.
/// </summary>
public class TimeSyncService : IDisposable
{
private readonly IConfigConnection _configConnection;
private readonly ILogger<TimeSyncService> _log;
private readonly TimeSpan _interval;
private readonly int _driftThresholdSeconds;
private CancellationTokenSource _cts;
private Task _loopTask;
private readonly object _lock = new();

public TimeSyncService(IConfigConnection configConnection, ILogger<TimeSyncService> log)
: this(configConnection, log,
TimeSpan.FromMinutes(TimeSyncIntervalMinutes),
TimeSyncDriftThresholdSeconds) { }

public TimeSyncService(IConfigConnection configConnection, ILogger<TimeSyncService> log,
TimeSpan interval, int driftThresholdSeconds)
{
_configConnection = configConnection ?? throw new ArgumentNullException(nameof(configConnection));
_log = log ?? throw new ArgumentNullException(nameof(log));
_interval = interval;
_driftThresholdSeconds = driftThresholdSeconds;
}

public bool IsRunning => _loopTask is { IsCompleted: false };

/// <summary>
/// Starts the background time-sync loop. Safe to call multiple times; subsequent calls are no-ops.
/// </summary>
public void Start()
{
lock (_lock)
{
if (IsRunning)
return;

_cts = new CancellationTokenSource();
_loopTask = RunLoopAsync(_cts.Token);
}

_log.LogDebug(LogTimeSyncStarted, _interval.TotalMinutes, _driftThresholdSeconds);
}

/// <summary>
/// Signals the loop to stop and awaits completion.
/// </summary>
public async Task StopAsync()
{
Task task;

lock (_lock)
{
if (_cts == null)
return;

_cts.Cancel();
task = _loopTask ?? Task.CompletedTask;
_cts.Dispose();
_cts = null;
_loopTask = null;
}

await task;
_log.LogDebug(LogTimeSyncStopping);
}

private async Task RunLoopAsync(CancellationToken ct)
{
// First sync runs immediately on start
while (!ct.IsCancellationRequested)
{
try
{
await SyncTimeAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_log.LogWarning(ex, LogTimeSyncFailed);
}

try
{
await Task.Delay(_interval, ct);
}
catch (TaskCanceledException)
{
break;
}
}
}

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);

_log.LogDebug(LogTimeSyncDeviceTime, deviceTime.Value, deltaSec);

if (deltaSec > _driftThresholdSeconds)
{
long nowUnix = localTime.ToUnixTimeSeconds();
await _configConnection.SetDateTimeAsync(nowUnix, ct);
_log.LogInformation(LogTimeSyncSetting, deltaSec, localTime);
}
else
{
_log.LogDebug(LogTimeSyncInSync, deltaSec);
}
}

public void Dispose()
{
if (_cts != null)
{
_cts.Cancel();
_cts.Dispose();
_cts = null;
}

_loopTask = null;
GC.SuppressFinalize(this);
}
}
Loading
Loading