From fdd7b3277b2d41841ba10a5549302991009ba55a Mon Sep 17 00:00:00 2001
From: Mattie <566406+Mattie@users.noreply.github.com>
Date: Mon, 27 Jul 2026 20:35:03 -0500
Subject: [PATCH 1/5] feat: add experimental ESPHome panel host support
---
Joydex.sln | 21 +
scripts/Publish-Joydex.ps1 | 7 +
src/Joydex.App/Joydex.App.csproj | 1 +
src/Joydex.App/TrayApplicationContext.cs | 92 +-
.../WirelessPanel/EspHomePanelAdapter.cs | 467 ++++++++++
.../WirelessPanel/EspHomePanelModels.cs | 20 +
.../WirelessPanel/EspHomePanelTransport.cs | 488 +++++++++++
.../WirelessPanel/EspHomeSseParser.cs | 326 +++++++
.../Joydex.WirelessPanel.csproj | 11 +
.../WirelessPanelConfiguration.cs | 149 ++++
.../WirelessPanelConfigurationStore.cs | 364 ++++++++
.../Joydex.Tests/EspHomePanelAdapterTests.cs | 657 ++++++++++++++
.../EspHomePanelTransportTests.cs | 811 ++++++++++++++++++
.../Joydex.WirelessPanel.Tests.csproj | 27 +
.../ProvisioningToolProcessTests.cs | 66 ++
.../WirelessPanelConfigurationStoreTests.cs | 264 ++++++
.../WirelessPanelConfigurationTests.cs | 87 ++
.../Joydex.WirelessPanel.Configure.csproj | 12 +
.../Joydex.WirelessPanel.Configure/Program.cs | 199 +++++
19 files changed, 4063 insertions(+), 6 deletions(-)
create mode 100644 src/Joydex.Windows/WirelessPanel/EspHomePanelAdapter.cs
create mode 100644 src/Joydex.Windows/WirelessPanel/EspHomePanelModels.cs
create mode 100644 src/Joydex.Windows/WirelessPanel/EspHomePanelTransport.cs
create mode 100644 src/Joydex.Windows/WirelessPanel/EspHomeSseParser.cs
create mode 100644 src/Joydex.WirelessPanel/Joydex.WirelessPanel.csproj
create mode 100644 src/Joydex.WirelessPanel/WirelessPanelConfiguration.cs
create mode 100644 src/Joydex.WirelessPanel/WirelessPanelConfigurationStore.cs
create mode 100644 tests/Joydex.Tests/EspHomePanelAdapterTests.cs
create mode 100644 tests/Joydex.Tests/EspHomePanelTransportTests.cs
create mode 100644 tests/Joydex.WirelessPanel.Tests/Joydex.WirelessPanel.Tests.csproj
create mode 100644 tests/Joydex.WirelessPanel.Tests/ProvisioningToolProcessTests.cs
create mode 100644 tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationStoreTests.cs
create mode 100644 tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationTests.cs
create mode 100644 tools/Joydex.WirelessPanel.Configure/Joydex.WirelessPanel.Configure.csproj
create mode 100644 tools/Joydex.WirelessPanel.Configure/Program.cs
diff --git a/Joydex.sln b/Joydex.sln
index 901801f..b4f015c 100644
--- a/Joydex.sln
+++ b/Joydex.sln
@@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E2410E01-290
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.Core", "src\Joydex.Core\Joydex.Core.csproj", "{3164D466-1B0D-496E-A00C-FCED33739C9D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.WirelessPanel", "src\Joydex.WirelessPanel\Joydex.WirelessPanel.csproj", "{7E364531-9197-4856-A674-1F0E3F926E02}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.Windows", "src\Joydex.Windows\Joydex.Windows.csproj", "{828482D5-6A56-4401-8FF3-7EE416F246A0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.App", "src\Joydex.App\Joydex.App.csproj", "{18911FBD-4F2A-404A-A60C-F6CC51C9A626}"
@@ -19,10 +21,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{DEFBDF7A
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.Trace", "tools\Joydex.Trace\Joydex.Trace.csproj", "{DA7922AD-9900-4E7B-8479-190C57ED72DD}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.WirelessPanel.Configure", "tools\Joydex.WirelessPanel.Configure\Joydex.WirelessPanel.Configure.csproj", "{1E88D793-7289-42AB-ADDC-358E25BC5E5A}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{450F8F5B-BB91-4471-AB6C-5FDD608C58C2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.Tests", "tests\Joydex.Tests\Joydex.Tests.csproj", "{3B99BEE9-29C8-4704-9873-3D2E6D521BC3}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Joydex.WirelessPanel.Tests", "tests\Joydex.WirelessPanel.Tests\Joydex.WirelessPanel.Tests.csproj", "{1028506F-A877-4F06-80C8-BD8BE73D71D4}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -36,6 +42,10 @@ Global
{3164D466-1B0D-496E-A00C-FCED33739C9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3164D466-1B0D-496E-A00C-FCED33739C9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3164D466-1B0D-496E-A00C-FCED33739C9D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7E364531-9197-4856-A674-1F0E3F926E02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E364531-9197-4856-A674-1F0E3F926E02}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7E364531-9197-4856-A674-1F0E3F926E02}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7E364531-9197-4856-A674-1F0E3F926E02}.Release|Any CPU.Build.0 = Release|Any CPU
{828482D5-6A56-4401-8FF3-7EE416F246A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{828482D5-6A56-4401-8FF3-7EE416F246A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{828482D5-6A56-4401-8FF3-7EE416F246A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -56,18 +66,29 @@ Global
{DA7922AD-9900-4E7B-8479-190C57ED72DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA7922AD-9900-4E7B-8479-190C57ED72DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA7922AD-9900-4E7B-8479-190C57ED72DD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1E88D793-7289-42AB-ADDC-358E25BC5E5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1E88D793-7289-42AB-ADDC-358E25BC5E5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1E88D793-7289-42AB-ADDC-358E25BC5E5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1E88D793-7289-42AB-ADDC-358E25BC5E5A}.Release|Any CPU.Build.0 = Release|Any CPU
{3B99BEE9-29C8-4704-9873-3D2E6D521BC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B99BEE9-29C8-4704-9873-3D2E6D521BC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B99BEE9-29C8-4704-9873-3D2E6D521BC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B99BEE9-29C8-4704-9873-3D2E6D521BC3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1028506F-A877-4F06-80C8-BD8BE73D71D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1028506F-A877-4F06-80C8-BD8BE73D71D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1028506F-A877-4F06-80C8-BD8BE73D71D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1028506F-A877-4F06-80C8-BD8BE73D71D4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3164D466-1B0D-496E-A00C-FCED33739C9D} = {E2410E01-2903-45FB-98C8-91DD1E194599}
+ {7E364531-9197-4856-A674-1F0E3F926E02} = {E2410E01-2903-45FB-98C8-91DD1E194599}
{828482D5-6A56-4401-8FF3-7EE416F246A0} = {E2410E01-2903-45FB-98C8-91DD1E194599}
{18911FBD-4F2A-404A-A60C-F6CC51C9A626} = {E2410E01-2903-45FB-98C8-91DD1E194599}
{5F62D9E7-D5D8-4E02-85FB-892A5E8C4A14} = {E2410E01-2903-45FB-98C8-91DD1E194599}
{6A7A958D-2D87-4E96-A2C4-214B593AE4C2} = {E2410E01-2903-45FB-98C8-91DD1E194599}
{DA7922AD-9900-4E7B-8479-190C57ED72DD} = {DEFBDF7A-8DC4-4D45-95B1-C55E7DDB342F}
+ {1E88D793-7289-42AB-ADDC-358E25BC5E5A} = {DEFBDF7A-8DC4-4D45-95B1-C55E7DDB342F}
{3B99BEE9-29C8-4704-9873-3D2E6D521BC3} = {450F8F5B-BB91-4471-AB6C-5FDD608C58C2}
+ {1028506F-A877-4F06-80C8-BD8BE73D71D4} = {450F8F5B-BB91-4471-AB6C-5FDD608C58C2}
EndGlobalSection
EndGlobal
diff --git a/scripts/Publish-Joydex.ps1 b/scripts/Publish-Joydex.ps1
index 2f8f095..4814c17 100644
--- a/scripts/Publish-Joydex.ps1
+++ b/scripts/Publish-Joydex.ps1
@@ -30,4 +30,11 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
--configuration Release `
--runtime win-x64 `
--output $output
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+& $dotnet publish (Join-Path $repositoryRoot 'tools\Joydex.WirelessPanel.Configure\Joydex.WirelessPanel.Configure.csproj') `
+ --configuration Release `
+ --runtime win-x64 `
+ --self-contained false `
+ --output $output
exit $LASTEXITCODE
diff --git a/src/Joydex.App/Joydex.App.csproj b/src/Joydex.App/Joydex.App.csproj
index 6f41c9c..93dbeca 100644
--- a/src/Joydex.App/Joydex.App.csproj
+++ b/src/Joydex.App/Joydex.App.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/Joydex.App/TrayApplicationContext.cs b/src/Joydex.App/TrayApplicationContext.cs
index 59c0f1c..a803ccd 100644
--- a/src/Joydex.App/TrayApplicationContext.cs
+++ b/src/Joydex.App/TrayApplicationContext.cs
@@ -3,11 +3,13 @@
using Joydex.Core.Mapping;
using Joydex.Core.Runtime;
using Joydex.Core.TaskAlerts;
+using Joydex.WirelessPanel;
using Joydex.Windows.Actions;
using Joydex.Windows.Input;
using Joydex.Windows.Interop;
using Joydex.Windows.Runtime;
using Joydex.Windows.TaskAlerts;
+using Joydex.Windows.WirelessPanel;
using Microsoft.Win32;
namespace Joydex.App;
@@ -45,6 +47,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
private readonly Dictionary _deviceStatuses = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _controllerItems = new(StringComparer.OrdinalIgnoreCase);
private CompanionConfig? _activeConfig;
+ private EspHomePanelAdapter? _wirelessPanelAdapter;
private DryRunActivityForm? _activityForm;
private readonly Dictionary _buttonMapForms = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _buttonMapItems = new(StringComparer.OrdinalIgnoreCase);
@@ -218,11 +221,7 @@ protected override void ExitThreadCore()
_promptOverlay?.Dispose();
_promptOverlay = null;
- foreach (var worker in _workers.Values)
- {
- worker.DisposeAsync().AsTask().GetAwaiter().GetResult();
- }
- _workers.Clear();
+ StopWorkersAsync().GetAwaiter().GetResult();
_shiftModeMonitor.DisposeAsync().AsTask().GetAwaiter().GetResult();
_taskAlertPipe.DisposeAsync().AsTask().GetAwaiter().GetResult();
@@ -453,6 +452,8 @@ await promptSubmitExecutor.ExecuteAsync(
worker.Start();
}
+ StartWirelessPanel(config);
+
if (showFirstRunNotice)
{
_notifyIcon.ShowBalloonTip(
@@ -766,13 +767,91 @@ private async void OnPromptPickers(object? sender, EventArgs eventArgs)
private async Task StopWorkersAsync()
{
+ var wirelessPanelAdapter = Interlocked.Exchange(ref _wirelessPanelAdapter, null);
+ if (wirelessPanelAdapter is not null)
+ {
+ try
+ {
+ await wirelessPanelAdapter.DisposeAsync().ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ _log.Write($"Could not stop the ESPHome panel adapter: {exception.Message}");
+ }
+ }
+
foreach (var worker in _workers.Values)
{
- await worker.DisposeAsync();
+ await worker.DisposeAsync().ConfigureAwait(false);
}
_workers.Clear();
}
+ private void StartWirelessPanel(CompanionConfig config)
+ {
+ EspHomePanelAdapter? adapter = null;
+ try
+ {
+ var panelConfiguration = new WirelessPanelConfigurationStore().Load();
+ if (panelConfiguration is null)
+ {
+ _log.Write("ESPHome panel is not configured.");
+ return;
+ }
+
+ if (!panelConfiguration.Enabled)
+ {
+ _log.Write("ESPHome panel is configured and disabled.");
+ return;
+ }
+
+ var navigator = new TaskDeepLinkNavigator(config.Safety, WriteActivity);
+ var executor = new CodexActionExecutor(
+ config.Safety,
+ WriteActivity,
+ _keybindingService,
+ config.OpenWorkingDirectory,
+ internalAction: OnInternalAction);
+ var transport = new EspHomePanelTransport(
+ panelConfiguration.Endpoint,
+ panelConfiguration.Username,
+ panelConfiguration.Password,
+ _log.Write);
+ var initialSnapshot = _taskAlerts.GetSnapshot();
+ adapter = new EspHomePanelAdapter(
+ transport,
+ initialSnapshot,
+ _taskAlerts.GetSnapshot,
+ navigator,
+ _taskAlerts.AcknowledgeTerminal,
+ executor.ExecuteAsync,
+ _log.Write);
+ Volatile.Write(ref _wirelessPanelAdapter, adapter);
+ adapter.Start();
+ _log.Write(
+ $"ESPHome panel adapter started for {panelConfiguration.Endpoint.Host}:" +
+ $"{panelConfiguration.Endpoint.Port}.");
+ }
+ catch (Exception exception)
+ {
+ Interlocked.Exchange(ref _wirelessPanelAdapter, null);
+ if (adapter is not null)
+ {
+ try
+ {
+ adapter.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+ catch (Exception disposeException)
+ {
+ _log.Write(
+ $"Could not clean up the ESPHome panel adapter: {disposeException.Message}");
+ }
+ }
+
+ _log.Write($"ESPHome panel is unavailable: {exception.Message}");
+ }
+ }
+
private void WriteActivity(string message)
{
_log.Write(message);
@@ -1020,6 +1099,7 @@ private void OnTaskAlertsChanged(object? sender, TaskAlertSnapshot snapshot)
}
_ledService.Apply(snapshot);
+ Volatile.Read(ref _wirelessPanelAdapter)?.Apply(snapshot);
_uiContext.Post(_ =>
{
_taskAlertsItem.Checked = snapshot.Enabled;
diff --git a/src/Joydex.Windows/WirelessPanel/EspHomePanelAdapter.cs b/src/Joydex.Windows/WirelessPanel/EspHomePanelAdapter.cs
new file mode 100644
index 0000000..55dac7d
--- /dev/null
+++ b/src/Joydex.Windows/WirelessPanel/EspHomePanelAdapter.cs
@@ -0,0 +1,467 @@
+using Joydex.Core.Config;
+using Joydex.Core.Mapping;
+using Joydex.Core.TaskAlerts;
+using Joydex.Windows.Actions;
+using Joydex.Windows.TaskAlerts;
+
+namespace Joydex.Windows.WirelessPanel;
+
+///
+/// Projects Joydex task alerts onto the fixed ESPHome screen and routes its five touch targets
+/// through the existing task navigator and semantic action executor.
+///
+public sealed class EspHomePanelAdapter : IAsyncDisposable
+{
+ private static readonly TimeSpan DefaultStateRetryDelay = TimeSpan.FromSeconds(2);
+
+ private readonly IEspHomePanelTransport _transport;
+ private readonly Func _getSnapshot;
+ private readonly ITaskAlertNavigator _navigator;
+ private readonly Func _acknowledgeTerminal;
+ private readonly Func> _executeAction;
+ private readonly Action _log;
+ private readonly TimeSpan _stateRetryDelay;
+ private readonly CancellationTokenSource _cancellation = new();
+ private readonly SemaphoreSlim _publishSignal = new(0, 1);
+ private readonly object _lifecycleGate = new();
+ private TaskAlertSnapshot _latestSnapshot;
+ private Task? _eventLoopObserverTask;
+ private Task? _publisherTask;
+ private EspHomePanelSnapshot? _lastPublishedSnapshot;
+ private long _requestedReconnectGeneration;
+ private long _completedReconnectGeneration;
+ private bool _started;
+ private bool _disposed;
+
+ /// Creates the host-side policy adapter for one ESPHome panel.
+ public EspHomePanelAdapter(
+ IEspHomePanelTransport transport,
+ TaskAlertSnapshot initialSnapshot,
+ Func getSnapshot,
+ ITaskAlertNavigator navigator,
+ Func acknowledgeTerminal,
+ Func> executeAction,
+ Action log)
+ : this(
+ transport,
+ initialSnapshot,
+ getSnapshot,
+ navigator,
+ acknowledgeTerminal,
+ executeAction,
+ log,
+ DefaultStateRetryDelay)
+ {
+ }
+
+ internal EspHomePanelAdapter(
+ IEspHomePanelTransport transport,
+ TaskAlertSnapshot initialSnapshot,
+ Func getSnapshot,
+ ITaskAlertNavigator navigator,
+ Func acknowledgeTerminal,
+ Func> executeAction,
+ Action log,
+ TimeSpan stateRetryDelay)
+ {
+ _transport = transport ?? throw new ArgumentNullException(nameof(transport));
+ _latestSnapshot = initialSnapshot ?? throw new ArgumentNullException(nameof(initialSnapshot));
+ _getSnapshot = getSnapshot ?? throw new ArgumentNullException(nameof(getSnapshot));
+ _navigator = navigator ?? throw new ArgumentNullException(nameof(navigator));
+ _acknowledgeTerminal = acknowledgeTerminal ?? throw new ArgumentNullException(nameof(acknowledgeTerminal));
+ _executeAction = executeAction ?? throw new ArgumentNullException(nameof(executeAction));
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+ if (stateRetryDelay < TimeSpan.Zero || stateRetryDelay > TimeSpan.FromSeconds(30))
+ {
+ throw new ArgumentOutOfRangeException(nameof(stateRetryDelay));
+ }
+
+ _stateRetryDelay = stateRetryDelay;
+ }
+
+ /// Starts the outbound event loop and latest-state publisher.
+ public void Start()
+ {
+ lock (_lifecycleGate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_started)
+ {
+ throw new InvalidOperationException("The ESPHome panel adapter is already running.");
+ }
+
+ _started = true;
+ _publisherTask = PublishLoopAsync(_cancellation.Token);
+ _eventLoopObserverTask = ObserveEventLoopAsync(
+ _transport.RunAsync(
+ HandlePressedAsync,
+ QueueReconnectSnapshotAsync,
+ _cancellation.Token));
+ }
+
+ SignalPublisher();
+ }
+
+ ///
+ /// Replaces the latest desired host state. This method is synchronous and never performs
+ /// network I/O, so it is safe to call from .
+ ///
+ public void Apply(TaskAlertSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ lock (_lifecycleGate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _latestSnapshot = snapshot;
+ }
+
+ SignalPublisher();
+ }
+
+ /// Stops all panel work and releases the transport.
+ public async ValueTask DisposeAsync()
+ {
+ Task? publisherTask;
+ Task? eventLoopObserverTask;
+ lock (_lifecycleGate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _cancellation.Cancel();
+ publisherTask = _publisherTask;
+ eventLoopObserverTask = _eventLoopObserverTask;
+ }
+
+ SignalPublisher();
+ if (publisherTask is not null)
+ {
+ await publisherTask.ConfigureAwait(false);
+ }
+
+ if (eventLoopObserverTask is not null)
+ {
+ await eventLoopObserverTask.ConfigureAwait(false);
+ }
+
+ await _transport.DisposeAsync().ConfigureAwait(false);
+ _publishSignal.Dispose();
+ _cancellation.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ internal static EspHomePanelSnapshot Project(TaskAlertSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (!snapshot.Enabled)
+ {
+ return EspHomePanelSnapshot.Empty;
+ }
+
+ return new EspHomePanelSnapshot(
+ ProjectSlot(snapshot, 1),
+ ProjectSlot(snapshot, 2),
+ ProjectSlot(snapshot, 3),
+ ProjectSlot(snapshot, 4));
+ }
+
+ private static EspHomeTaskState ProjectSlot(TaskAlertSnapshot snapshot, int slot)
+ {
+ var assignment = snapshot.Assignments.FirstOrDefault(candidate => candidate.Slot == slot);
+ return assignment?.State switch
+ {
+ null => EspHomeTaskState.Empty,
+ TaskAlertState.Running => EspHomeTaskState.Running,
+ TaskAlertState.Approval => EspHomeTaskState.Attention,
+ TaskAlertState.Completed => EspHomeTaskState.Complete,
+ TaskAlertState.Fault => EspHomeTaskState.Attention,
+ _ => throw new ArgumentOutOfRangeException(nameof(snapshot), assignment.State, null),
+ };
+ }
+
+ private async Task PublishLoopAsync(CancellationToken cancellationToken)
+ {
+ var consecutiveFailures = 0;
+ var failureReported = false;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await _publishSignal.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ while (_publishSignal.Wait(0))
+ {
+ }
+
+ try
+ {
+ await PublishLatestSnapshotAsync(cancellationToken).ConfigureAwait(false);
+ consecutiveFailures = 0;
+ if (failureReported)
+ {
+ _log("ESPHome panel state updates resumed.");
+ failureReported = false;
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception exception)
+ {
+ consecutiveFailures++;
+ if (!failureReported)
+ {
+ _log($"ESPHome panel state update failed: {exception.Message}");
+ failureReported = true;
+ }
+
+ var multiplier = 1 << Math.Min(consecutiveFailures - 1, 4);
+ var retryDelay = TimeSpan.FromMilliseconds(
+ Math.Min(
+ TimeSpan.FromSeconds(30).TotalMilliseconds,
+ _stateRetryDelay.TotalMilliseconds * multiplier));
+ try
+ {
+ await Task.Delay(retryDelay, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ // A multi-select REST update is serialized but cannot be atomic. Retry against the
+ // last confirmed snapshot so a partial write converges without new task data.
+ SignalPublisher();
+ }
+ }
+ }
+
+ private async Task PublishLatestSnapshotAsync(CancellationToken cancellationToken)
+ {
+ TaskAlertSnapshot snapshot;
+ long reconnectGeneration;
+ lock (_lifecycleGate)
+ {
+ snapshot = _latestSnapshot;
+ reconnectGeneration = _requestedReconnectGeneration;
+ }
+
+ await PublishSnapshotAsync(
+ snapshot,
+ force: reconnectGeneration != _completedReconnectGeneration,
+ cancellationToken)
+ .ConfigureAwait(false);
+ _completedReconnectGeneration = reconnectGeneration;
+ }
+
+ private ValueTask QueueReconnectSnapshotAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var snapshot = _getSnapshot();
+ lock (_lifecycleGate)
+ {
+ if (_disposed)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ _latestSnapshot = snapshot;
+ _requestedReconnectGeneration++;
+ }
+
+ SignalPublisher();
+ return ValueTask.CompletedTask;
+ }
+
+ private async Task PublishSnapshotAsync(
+ TaskAlertSnapshot snapshot,
+ bool force,
+ CancellationToken cancellationToken)
+ {
+ var projected = Project(snapshot);
+ if (!force && _lastPublishedSnapshot == projected)
+ {
+ return;
+ }
+
+ if (force || _lastPublishedSnapshot is not { } previous)
+ {
+ await _transport.SetTaskStatesAsync(
+ projected.Task1,
+ projected.Task2,
+ projected.Task3,
+ projected.Task4,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ var updates = GetChangedTaskStates(previous, projected);
+ await _transport
+ .SetTaskStateUpdatesAsync(updates, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ _lastPublishedSnapshot = projected;
+ }
+
+ private static IReadOnlyList GetChangedTaskStates(
+ EspHomePanelSnapshot previous,
+ EspHomePanelSnapshot current)
+ {
+ var updates = new List(4);
+ AddIfChanged(1, previous.Task1, current.Task1);
+ AddIfChanged(2, previous.Task2, current.Task2);
+ AddIfChanged(3, previous.Task3, current.Task3);
+ AddIfChanged(4, previous.Task4, current.Task4);
+ return updates;
+
+ void AddIfChanged(int slot, EspHomeTaskState oldState, EspHomeTaskState newState)
+ {
+ if (oldState != newState)
+ {
+ updates.Add(new EspHomeTaskStateUpdate(slot, newState));
+ }
+ }
+ }
+
+ private async ValueTask HandlePressedAsync(
+ EspHomePanelButton button,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ switch (button)
+ {
+ case EspHomePanelButton.Task1:
+ await OpenCurrentSlotAsync(1, cancellationToken).ConfigureAwait(false);
+ break;
+ case EspHomePanelButton.Task2:
+ await OpenCurrentSlotAsync(2, cancellationToken).ConfigureAwait(false);
+ break;
+ case EspHomePanelButton.Task3:
+ await OpenCurrentSlotAsync(3, cancellationToken).ConfigureAwait(false);
+ break;
+ case EspHomePanelButton.Task4:
+ await OpenCurrentSlotAsync(4, cancellationToken).ConfigureAwait(false);
+ break;
+ case EspHomePanelButton.PlanMode:
+ await TogglePlanModeAsync(cancellationToken).ConfigureAwait(false);
+ break;
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception exception)
+ {
+ _log($"ESPHome panel action failed: {exception.Message}");
+ }
+ }
+
+ private async Task OpenCurrentSlotAsync(
+ int slot,
+ CancellationToken cancellationToken)
+ {
+ var snapshot = _getSnapshot();
+ if (!snapshot.Enabled)
+ {
+ return;
+ }
+
+ var assignment = snapshot.Assignments.FirstOrDefault(candidate => candidate.Slot == slot);
+ if (assignment is null)
+ {
+ return;
+ }
+
+ var banks = TaskAlertSlots.Banks(slot);
+ var bank = banks.Contains(snapshot.Bank) ? snapshot.Bank : banks[0];
+ var navigation = new TaskAlertNavigationRequest(
+ slot,
+ bank,
+ TaskAlertSlots.Button(slot),
+ assignment.SessionId);
+ if (!await _navigator.NavigateAsync(navigation, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ if (assignment.State is TaskAlertState.Completed or TaskAlertState.Fault
+ && !_acknowledgeTerminal(slot, assignment.SessionId))
+ {
+ _log($"ESPHome panel opened slot {slot}; its terminal assignment changed before acknowledgement.");
+ }
+
+ }
+
+ private async Task TogglePlanModeAsync(CancellationToken cancellationToken)
+ {
+ var request = new ActionRequest(
+ "ESPHome panel Plan Mode",
+ CompanionConfig.AlwaysBank,
+ (int)EspHomePanelButton.PlanMode,
+ "press",
+ CodexAction.TogglePlanMode,
+ DateTimeOffset.UtcNow,
+ DeviceId: "esphome-panel");
+ await _executeAction(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ObserveEventLoopAsync(Task eventLoopTask)
+ {
+ try
+ {
+ await eventLoopTask.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
+ {
+ }
+ catch (Exception exception)
+ {
+ _log($"ESPHome panel event loop stopped: {exception.Message}");
+ }
+ }
+
+ private void SignalPublisher()
+ {
+ try
+ {
+ _publishSignal.Release();
+ }
+ catch (SemaphoreFullException)
+ {
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+}
+
+internal readonly record struct EspHomePanelSnapshot(
+ EspHomeTaskState Task1,
+ EspHomeTaskState Task2,
+ EspHomeTaskState Task3,
+ EspHomeTaskState Task4)
+{
+ public static EspHomePanelSnapshot Empty { get; } = new(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty);
+}
diff --git a/src/Joydex.Windows/WirelessPanel/EspHomePanelModels.cs b/src/Joydex.Windows/WirelessPanel/EspHomePanelModels.cs
new file mode 100644
index 0000000..b165f34
--- /dev/null
+++ b/src/Joydex.Windows/WirelessPanel/EspHomePanelModels.cs
@@ -0,0 +1,20 @@
+namespace Joydex.Windows.WirelessPanel;
+
+/// Identifies one of the fixed touch targets exposed by the ESPHome panel.
+public enum EspHomePanelButton
+{
+ Task1 = 1,
+ Task2 = 2,
+ Task3 = 3,
+ Task4 = 4,
+ PlanMode = 5,
+}
+
+/// Represents the coarse task state rendered by one panel card.
+public enum EspHomeTaskState
+{
+ Empty,
+ Running,
+ Attention,
+ Complete,
+}
diff --git a/src/Joydex.Windows/WirelessPanel/EspHomePanelTransport.cs b/src/Joydex.Windows/WirelessPanel/EspHomePanelTransport.cs
new file mode 100644
index 0000000..8616d1e
--- /dev/null
+++ b/src/Joydex.Windows/WirelessPanel/EspHomePanelTransport.cs
@@ -0,0 +1,488 @@
+using System.Net;
+using System.Net.Http.Headers;
+
+namespace Joydex.Windows.WirelessPanel;
+
+///
+/// Defines the small panel I/O surface consumed by Joydex's higher-level task/action controller.
+///
+public interface IEspHomePanelTransport : IAsyncDisposable
+{
+ ///
+ Task RunAsync(
+ Func onPressed,
+ Func? onConnected = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ Task SetTaskStatesAsync(
+ EspHomeTaskState task1,
+ EspHomeTaskState task2,
+ EspHomeTaskState task3,
+ EspHomeTaskState task4,
+ CancellationToken cancellationToken = default);
+
+ ///
+ Task SetTaskStateUpdatesAsync(
+ IReadOnlyList updates,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Maintains the outbound ESPHome Web Server connection used for panel touch events and state.
+/// The transport owns no Joydex task identity or action policy.
+///
+public sealed class EspHomePanelTransport : IEspHomePanelTransport
+{
+ private static readonly TimeSpan DefaultReconnectDelay = TimeSpan.FromSeconds(2);
+ private static readonly TimeSpan DefaultPostTimeout = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan DefaultSseIdleTimeout = TimeSpan.FromSeconds(45);
+
+ private readonly HttpClient _httpClient;
+ private readonly Uri _baseUri;
+ private readonly bool _ownsHttpClient;
+ private readonly Action? _log;
+ private readonly TimeSpan _reconnectDelay;
+ private readonly TimeSpan _postTimeout;
+ private readonly TimeSpan _sseIdleTimeout;
+ private readonly SemaphoreSlim _postGate = new(1, 1);
+ private readonly CancellationTokenSource _disposeCancellation = new();
+ private readonly object _lifecycleGate = new();
+ private Task? _runTask;
+ private bool _disposed;
+
+ ///
+ /// Creates a transport whose HTTP client responds to ESPHome Digest authentication challenges.
+ ///
+ public EspHomePanelTransport(
+ Uri baseUri,
+ string username,
+ string password,
+ Action? log = null)
+ : this(
+ CreateDigestClient(username, password),
+ baseUri,
+ ownsHttpClient: true,
+ log,
+ DefaultReconnectDelay,
+ DefaultPostTimeout,
+ DefaultSseIdleTimeout)
+ {
+ }
+
+ internal EspHomePanelTransport(
+ HttpClient httpClient,
+ Uri baseUri,
+ Action? log = null,
+ TimeSpan? reconnectDelay = null,
+ TimeSpan? postTimeout = null,
+ TimeSpan? sseIdleTimeout = null)
+ : this(
+ httpClient,
+ baseUri,
+ ownsHttpClient: false,
+ log,
+ reconnectDelay ?? DefaultReconnectDelay,
+ postTimeout ?? DefaultPostTimeout,
+ sseIdleTimeout ?? DefaultSseIdleTimeout)
+ {
+ }
+
+ private EspHomePanelTransport(
+ HttpClient httpClient,
+ Uri baseUri,
+ bool ownsHttpClient,
+ Action? log,
+ TimeSpan reconnectDelay,
+ TimeSpan postTimeout,
+ TimeSpan sseIdleTimeout)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _baseUri = NormalizeBaseUri(baseUri);
+ _ownsHttpClient = ownsHttpClient;
+ _log = log;
+ _reconnectDelay = reconnectDelay >= TimeSpan.Zero
+ ? reconnectDelay
+ : throw new ArgumentOutOfRangeException(nameof(reconnectDelay));
+ _postTimeout = postTimeout > TimeSpan.Zero
+ ? postTimeout
+ : throw new ArgumentOutOfRangeException(nameof(postTimeout));
+ _sseIdleTimeout = sseIdleTimeout > TimeSpan.Zero
+ ? sseIdleTimeout
+ : throw new ArgumentOutOfRangeException(nameof(sseIdleTimeout));
+ }
+
+ ///
+ /// Connects to /events, reports live allowlisted press edges, and reconnects after
+ /// network failure. Cancellation ends the loop normally.
+ ///
+ ///
+ /// Called once for each live OFF-to-ON transition. A callback that wants to stop the owner
+ /// should cancel, return, and let the owner dispose after completes.
+ ///
+ ///
+ /// Called after each successful SSE connection, before the initial state catch-up is read.
+ /// This is the appropriate place to push a complete current task-state replacement. As with
+ /// , cancel and return before the owner disposes the transport.
+ ///
+ /// Stops the stream and any reconnect delay.
+ public Task RunAsync(
+ Func onPressed,
+ Func? onConnected = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(onPressed);
+ lock (_lifecycleGate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_runTask is { IsCompleted: false })
+ {
+ throw new InvalidOperationException("The ESPHome panel event loop is already running.");
+ }
+
+ _runTask = RunCoreAsync(onPressed, onConnected, cancellationToken);
+ return _runTask;
+ }
+ }
+
+ ///
+ /// Replaces all four task-card states as one serialized group of ESPHome select requests. HTTP
+ /// has no transaction, so the caller should retry its latest complete snapshot after a failure.
+ ///
+ public Task SetTaskStatesAsync(
+ EspHomeTaskState task1,
+ EspHomeTaskState task2,
+ EspHomeTaskState task3,
+ EspHomeTaskState task4,
+ CancellationToken cancellationToken = default) =>
+ PostSelectsAsync(
+ [
+ new("Task 1 State", ToOption(task1)),
+ new("Task 2 State", ToOption(task2)),
+ new("Task 3 State", ToOption(task3)),
+ new("Task 4 State", ToOption(task4)),
+ ],
+ cancellationToken);
+
+ ///
+ /// Updates only the task-card selects whose projected state changed. Keeping ordinary
+ /// publications narrow avoids unnecessary full-card redraws on the single-framebuffer panel.
+ ///
+ public Task SetTaskStateUpdatesAsync(
+ IReadOnlyList updates,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(updates);
+ var selects = new SelectUpdate[updates.Count];
+ for (var index = 0; index < updates.Count; index++)
+ {
+ var update = updates[index];
+ var entityName = update.Slot switch
+ {
+ 1 => "Task 1 State",
+ 2 => "Task 2 State",
+ 3 => "Task 3 State",
+ 4 => "Task 4 State",
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(updates),
+ update.Slot,
+ "ESPHome task slots must be between 1 and 4."),
+ };
+ selects[index] = new SelectUpdate(entityName, ToOption(update.State));
+ }
+
+ return PostSelectsAsync(selects, cancellationToken);
+ }
+
+ /// Cancels the active stream and waits for in-flight transport work to stop.
+ public async ValueTask DisposeAsync()
+ {
+ Task? runTask;
+ lock (_lifecycleGate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _disposeCancellation.Cancel();
+ runTask = _runTask;
+ }
+
+ try
+ {
+ if (runTask is not null)
+ {
+ try
+ {
+ await runTask.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
+ {
+ }
+ }
+ }
+ finally
+ {
+ await _postGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ if (_ownsHttpClient)
+ {
+ _httpClient.Dispose();
+ }
+ }
+ finally
+ {
+ _postGate.Release();
+ }
+
+ _postGate.Dispose();
+ _disposeCancellation.Dispose();
+ GC.SuppressFinalize(this);
+ }
+ }
+
+ private async Task RunCoreAsync(
+ Func onPressed,
+ Func? onConnected,
+ CancellationToken callerCancellation)
+ {
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ callerCancellation,
+ _disposeCancellation.Token);
+ var cancellationToken = linkedCancellation.Token;
+ var consecutiveFailures = 0;
+ var failureReported = false;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ReadOneConnectionAsync(
+ onPressed,
+ onConnected,
+ MarkConnectionHealthy,
+ cancellationToken)
+ .ConfigureAwait(false);
+ ReportFailure("ESPHome panel event stream ended; reconnecting.");
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (HttpRequestException exception)
+ {
+ ReportFailure($"ESPHome panel connection failed: {exception.Message}");
+ }
+ catch (IOException exception)
+ {
+ ReportFailure($"ESPHome panel event stream failed: {exception.Message}");
+ }
+ catch (TimeoutException exception)
+ {
+ ReportFailure($"ESPHome panel request timed out: {exception.Message}");
+ }
+
+ var multiplier = 1 << Math.Min(consecutiveFailures - 1, 4);
+ var reconnectDelay = TimeSpan.FromMilliseconds(
+ Math.Min(
+ TimeSpan.FromSeconds(30).TotalMilliseconds,
+ _reconnectDelay.TotalMilliseconds * multiplier));
+ try
+ {
+ await Task.Delay(reconnectDelay, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ }
+
+ void MarkConnectionHealthy()
+ {
+ consecutiveFailures = 0;
+ if (failureReported)
+ {
+ _log?.Invoke("ESPHome panel event stream resumed.");
+ failureReported = false;
+ }
+ }
+
+ void ReportFailure(string message)
+ {
+ consecutiveFailures++;
+ if (!failureReported)
+ {
+ _log?.Invoke(message);
+ failureReported = true;
+ }
+ }
+ }
+
+ private async Task ReadOneConnectionAsync(
+ Func onPressed,
+ Func? onConnected,
+ Action onHealthy,
+ CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri("events"));
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
+ request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
+ using var response = await _httpClient.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ var mediaType = response.Content.Headers.ContentType?.MediaType;
+ if (!string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new HttpRequestException(
+ $"ESPHome /events returned unexpected content type '{mediaType ?? ""}'.");
+ }
+
+ if (onConnected is not null)
+ {
+ await onConnected(cancellationToken).ConfigureAwait(false);
+ }
+
+ var tracker = new EspHomePressTracker();
+ var healthy = false;
+ await using var stream = await response.Content
+ .ReadAsStreamAsync(cancellationToken)
+ .ConfigureAwait(false);
+ await EspHomeSseParser.ReadAsync(
+ stream,
+ async (sseEvent, token) =>
+ {
+ if (!healthy)
+ {
+ healthy = true;
+ onHealthy();
+ }
+
+ if (!string.Equals(sseEvent.EventType, "state", StringComparison.Ordinal) ||
+ !EspHomeStateEventParser.TryParse(sseEvent.Data, out var stateEvent) ||
+ !tracker.TryObserve(stateEvent, out var pressed))
+ {
+ return;
+ }
+
+ await onPressed(pressed, token).ConfigureAwait(false);
+ },
+ _sseIdleTimeout,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task PostSelectsAsync(
+ IReadOnlyList updates,
+ CancellationToken callerCancellation)
+ {
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ callerCancellation,
+ _disposeCancellation.Token);
+ var cancellationToken = linkedCancellation.Token;
+ await _postGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ foreach (var update in updates)
+ {
+ using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ requestTimeout.CancelAfter(_postTimeout);
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post,
+ BuildUri(
+ $"select/{Uri.EscapeDataString(update.EntityName)}/set" +
+ $"?option={Uri.EscapeDataString(update.Option)}"));
+ HttpResponseMessage response;
+ try
+ {
+ response = await _httpClient
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestTimeout.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException exception)
+ when (!cancellationToken.IsCancellationRequested && requestTimeout.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"Setting ESPHome select '{update.EntityName}' exceeded {_postTimeout.TotalSeconds:g} seconds.",
+ exception);
+ }
+
+ using (response)
+ {
+ response.EnsureSuccessStatusCode();
+ }
+ }
+ }
+ finally
+ {
+ _postGate.Release();
+ }
+ }
+
+ private Uri BuildUri(string relativePath) => new(_baseUri, relativePath);
+
+ private static HttpClient CreateDigestClient(string username, string password)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(username);
+ ArgumentException.ThrowIfNullOrWhiteSpace(password);
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ Credentials = new NetworkCredential(username, password),
+ PreAuthenticate = true,
+ UseProxy = false,
+ };
+ return new HttpClient(handler)
+ {
+ Timeout = Timeout.InfiniteTimeSpan,
+ };
+ }
+
+ private static Uri NormalizeBaseUri(Uri baseUri)
+ {
+ ArgumentNullException.ThrowIfNull(baseUri);
+ if (!baseUri.IsAbsoluteUri ||
+ (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps) ||
+ string.IsNullOrWhiteSpace(baseUri.Host))
+ {
+ throw new ArgumentException("The ESPHome panel endpoint must be an absolute HTTP or HTTPS URI.", nameof(baseUri));
+ }
+
+ if (!string.IsNullOrEmpty(baseUri.Query) ||
+ !string.IsNullOrEmpty(baseUri.Fragment) ||
+ !string.IsNullOrEmpty(baseUri.UserInfo))
+ {
+ throw new ArgumentException(
+ "The ESPHome panel endpoint cannot contain credentials, a query, or a fragment.",
+ nameof(baseUri));
+ }
+
+ var builder = new UriBuilder(baseUri);
+ if (!builder.Path.EndsWith('/'))
+ {
+ builder.Path += "/";
+ }
+
+ return builder.Uri;
+ }
+
+ private static string ToOption(EspHomeTaskState state) => state switch
+ {
+ EspHomeTaskState.Empty => "EMPTY",
+ EspHomeTaskState.Running => "RUNNING",
+ EspHomeTaskState.Attention => "ATTENTION",
+ EspHomeTaskState.Complete => "COMPLETE",
+ _ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
+ };
+
+ private sealed record SelectUpdate(string EntityName, string Option);
+}
+
+/// One changed ESPHome task-card projection.
+public readonly record struct EspHomeTaskStateUpdate(int Slot, EspHomeTaskState State);
diff --git a/src/Joydex.Windows/WirelessPanel/EspHomeSseParser.cs b/src/Joydex.Windows/WirelessPanel/EspHomeSseParser.cs
new file mode 100644
index 0000000..07a1ff0
--- /dev/null
+++ b/src/Joydex.Windows/WirelessPanel/EspHomeSseParser.cs
@@ -0,0 +1,326 @@
+using System.Text;
+using System.Text.Json;
+
+namespace Joydex.Windows.WirelessPanel;
+
+internal readonly record struct ServerSentEvent(string EventType, string Data);
+
+internal static class EspHomeSseParser
+{
+ private const int MaximumLineCharacters = 16 * 1024;
+ private const int MaximumEventDataCharacters = 64 * 1024;
+
+ public static Task ReadAsync(
+ Stream stream,
+ Func onEvent,
+ CancellationToken cancellationToken) =>
+ ReadAsync(
+ stream,
+ onEvent,
+ Timeout.InfiniteTimeSpan,
+ cancellationToken);
+
+ public static async Task ReadAsync(
+ Stream stream,
+ Func onEvent,
+ TimeSpan idleTimeout,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(onEvent);
+ if (idleTimeout <= TimeSpan.Zero && idleTimeout != Timeout.InfiniteTimeSpan)
+ {
+ throw new ArgumentOutOfRangeException(nameof(idleTimeout));
+ }
+
+ using var reader = new BoundedSseLineReader(stream, MaximumLineCharacters);
+ var eventType = "message";
+ StringBuilder? data = null;
+
+ while (await reader.ReadLineAsync(idleTimeout, cancellationToken).ConfigureAwait(false) is { } line)
+ {
+ if (line.Length == 0)
+ {
+ if (data is not null)
+ {
+ if (data.Length > 0)
+ {
+ data.Length--;
+ }
+
+ await onEvent(
+ new ServerSentEvent(eventType, data.ToString()),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ eventType = "message";
+ data?.Clear();
+ data = null;
+ continue;
+ }
+
+ if (line[0] == ':')
+ {
+ continue;
+ }
+
+ var separator = line.IndexOf(':');
+ var field = separator < 0 ? line : line[..separator];
+ var value = separator < 0 ? string.Empty : line[(separator + 1)..];
+ if (value.StartsWith(' '))
+ {
+ value = value[1..];
+ }
+
+ switch (field)
+ {
+ case "event":
+ eventType = value;
+ break;
+ case "data":
+ data ??= new StringBuilder();
+ if (data.Length + value.Length + 1 > MaximumEventDataCharacters)
+ {
+ throw new IOException(
+ $"The ESPHome event exceeded {MaximumEventDataCharacters} characters.");
+ }
+
+ data.Append(value);
+ data.Append('\n');
+ break;
+ }
+ }
+ }
+
+ private sealed class BoundedSseLineReader : IDisposable
+ {
+ private readonly StreamReader _reader;
+ private readonly int _maximumLineCharacters;
+ private readonly char[] _buffer = new char[1024];
+ private int _position;
+ private int _count;
+ private bool _skipLeadingLineFeed;
+
+ public BoundedSseLineReader(Stream stream, int maximumLineCharacters)
+ {
+ _reader = new StreamReader(
+ stream,
+ Encoding.UTF8,
+ detectEncodingFromByteOrderMarks: true,
+ bufferSize: 1024,
+ leaveOpen: true);
+ _maximumLineCharacters = maximumLineCharacters;
+ }
+
+ public async ValueTask ReadLineAsync(
+ TimeSpan idleTimeout,
+ CancellationToken cancellationToken)
+ {
+ if (idleTimeout == Timeout.InfiniteTimeSpan)
+ {
+ return await ReadLineCoreAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ using var idleCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ idleCancellation.CancelAfter(idleTimeout);
+ try
+ {
+ return await ReadLineCoreAsync(idleCancellation.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException exception)
+ when (!cancellationToken.IsCancellationRequested
+ && idleCancellation.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"The ESPHome event stream was idle for more than " +
+ $"{idleTimeout.TotalSeconds:g} seconds.",
+ exception);
+ }
+ }
+
+ public void Dispose() => _reader.Dispose();
+
+ private async ValueTask ReadLineCoreAsync(
+ CancellationToken cancellationToken)
+ {
+ var line = new StringBuilder();
+ while (true)
+ {
+ if (_position >= _count)
+ {
+ _count = await _reader
+ .ReadAsync(_buffer.AsMemory(), cancellationToken)
+ .ConfigureAwait(false);
+ _position = 0;
+ if (_count == 0)
+ {
+ return line.Length == 0 ? null : line.ToString();
+ }
+ }
+
+ var character = _buffer[_position++];
+ if (_skipLeadingLineFeed)
+ {
+ _skipLeadingLineFeed = false;
+ if (character == '\n')
+ {
+ continue;
+ }
+ }
+
+ if (character == '\r')
+ {
+ _skipLeadingLineFeed = true;
+ return line.ToString();
+ }
+
+ if (character == '\n')
+ {
+ return line.ToString();
+ }
+
+ if (line.Length >= _maximumLineCharacters)
+ {
+ throw new IOException(
+ $"The ESPHome event stream line exceeded " +
+ $"{_maximumLineCharacters} characters.");
+ }
+
+ line.Append(character);
+ }
+ }
+ }
+}
+
+internal readonly record struct EspHomeStateEvent(string Identifier, bool IsOn);
+
+internal static class EspHomeStateEventParser
+{
+ public static bool TryParse(string json, out EspHomeStateEvent stateEvent)
+ {
+ stateEvent = default;
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ var identifier = ReadIdentifier(root);
+ if (identifier is null || !TryReadState(root, out var isOn))
+ {
+ return false;
+ }
+
+ stateEvent = new EspHomeStateEvent(identifier, isOn);
+ return true;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ private static string? ReadIdentifier(JsonElement root)
+ {
+ if (root.TryGetProperty("name_id", out var nameId) &&
+ nameId.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(nameId.GetString()))
+ {
+ return nameId.GetString();
+ }
+
+ if (root.TryGetProperty("id", out var id) &&
+ id.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(id.GetString()))
+ {
+ return id.GetString();
+ }
+
+ return null;
+ }
+
+ private static bool TryReadState(JsonElement root, out bool isOn)
+ {
+ isOn = false;
+ if (root.TryGetProperty("state", out var state) && state.ValueKind == JsonValueKind.String)
+ {
+ if (string.Equals(state.GetString(), "ON", StringComparison.OrdinalIgnoreCase))
+ {
+ isOn = true;
+ return true;
+ }
+
+ if (string.Equals(state.GetString(), "OFF", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ if (root.TryGetProperty("value", out var value))
+ {
+ if (value.ValueKind == JsonValueKind.True)
+ {
+ isOn = true;
+ return true;
+ }
+
+ if (value.ValueKind == JsonValueKind.False)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+
+internal sealed class EspHomePressTracker
+{
+ private static readonly IReadOnlyDictionary Buttons =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["binary_sensor/Task 1"] = EspHomePanelButton.Task1,
+ ["binary_sensor/Task 2"] = EspHomePanelButton.Task2,
+ ["binary_sensor/Task 3"] = EspHomePanelButton.Task3,
+ ["binary_sensor/Task 4"] = EspHomePanelButton.Task4,
+ ["binary_sensor/Sidebar"] = EspHomePanelButton.PlanMode,
+ ["binary_sensor-task_1"] = EspHomePanelButton.Task1,
+ ["binary_sensor-task_2"] = EspHomePanelButton.Task2,
+ ["binary_sensor-task_3"] = EspHomePanelButton.Task3,
+ ["binary_sensor-task_4"] = EspHomePanelButton.Task4,
+ ["binary_sensor-sidebar"] = EspHomePanelButton.PlanMode,
+ };
+
+ private readonly Dictionary _lastStates = [];
+
+ public bool TryObserve(EspHomeStateEvent stateEvent, out EspHomePanelButton pressed)
+ {
+ pressed = default;
+ if (!Buttons.TryGetValue(stateEvent.Identifier, out var button))
+ {
+ return false;
+ }
+
+ if (!_lastStates.TryGetValue(button, out var previous))
+ {
+ // ESPHome sends every current state after an SSE connection. The first value for each
+ // allowlisted entity seeds the edge detector and cannot dispatch a stale held press.
+ _lastStates[button] = stateEvent.IsOn;
+ return false;
+ }
+
+ _lastStates[button] = stateEvent.IsOn;
+ if (previous || !stateEvent.IsOn)
+ {
+ return false;
+ }
+
+ pressed = button;
+ return true;
+ }
+}
diff --git a/src/Joydex.WirelessPanel/Joydex.WirelessPanel.csproj b/src/Joydex.WirelessPanel/Joydex.WirelessPanel.csproj
new file mode 100644
index 0000000..d3f3623
--- /dev/null
+++ b/src/Joydex.WirelessPanel/Joydex.WirelessPanel.csproj
@@ -0,0 +1,11 @@
+
+
+
+ net8.0-windows
+
+
+
+
+
+
+
diff --git a/src/Joydex.WirelessPanel/WirelessPanelConfiguration.cs b/src/Joydex.WirelessPanel/WirelessPanelConfiguration.cs
new file mode 100644
index 0000000..308d683
--- /dev/null
+++ b/src/Joydex.WirelessPanel/WirelessPanelConfiguration.cs
@@ -0,0 +1,149 @@
+using System.Text.Json.Serialization;
+
+namespace Joydex.WirelessPanel;
+
+///
+/// Holds the validated connection settings Joydex needs for the attended ESPHome panel.
+///
+public sealed class WirelessPanelConfiguration
+{
+ private const int MaximumEndpointLength = 2048;
+ private const int MaximumUsernameLength = 256;
+ private const int MaximumPasswordLength = 4096;
+
+ private WirelessPanelConfiguration(
+ Uri endpoint,
+ string username,
+ string password,
+ bool enabled)
+ {
+ Endpoint = endpoint;
+ Username = username;
+ Password = password;
+ Enabled = enabled;
+ }
+
+ /// Gets the absolute HTTP base address of the ESPHome panel.
+ public Uri Endpoint { get; }
+
+ /// Gets the ESPHome Web Server Digest username.
+ public string Username { get; }
+
+ ///
+ /// Gets the ESPHome Web Server Digest password after DPAPI decryption.
+ /// The property is excluded from incidental JSON serialization.
+ ///
+ [JsonIgnore]
+ public string Password { get; }
+
+ /// Gets whether Joydex should connect to the configured panel.
+ public bool Enabled { get; }
+
+ ///
+ /// Parses and validates settings for ESPHome's attended, HTTP-only direct connection.
+ ///
+ /// An absolute HTTP endpoint without credentials, query, or fragment.
+ /// The nonempty Digest username.
+ /// The nonempty Digest password.
+ /// Whether Joydex should connect to the panel.
+ /// An immutable, validated configuration.
+ /// A setting is absent or outside the supported boundary.
+ public static WirelessPanelConfiguration Create(
+ string endpoint,
+ string username,
+ string password,
+ bool enabled = true)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(endpoint);
+ ArgumentException.ThrowIfNullOrWhiteSpace(username);
+ ArgumentException.ThrowIfNullOrWhiteSpace(password);
+
+ endpoint = endpoint.Trim();
+ username = username.Trim();
+
+ if (endpoint.Length > MaximumEndpointLength)
+ {
+ throw new ArgumentException(
+ $"Panel endpoint must be {MaximumEndpointLength} characters or fewer.",
+ nameof(endpoint));
+ }
+
+ if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri))
+ {
+ throw new ArgumentException("Panel endpoint must be an absolute URI.", nameof(endpoint));
+ }
+
+ if (!string.Equals(endpointUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException(
+ "Attended ESPHome direct mode requires an http:// endpoint.",
+ nameof(endpoint));
+ }
+
+ if (string.IsNullOrWhiteSpace(endpointUri.Host))
+ {
+ throw new ArgumentException("Panel endpoint must include a host.", nameof(endpoint));
+ }
+
+ if (!string.IsNullOrEmpty(endpointUri.UserInfo)
+ || endpointUri.GetLeftPart(UriPartial.Authority).Contains('@', StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "Panel endpoint must not contain embedded credentials.",
+ nameof(endpoint));
+ }
+
+ if (!string.IsNullOrEmpty(endpointUri.Query))
+ {
+ throw new ArgumentException("Panel endpoint must not contain a query.", nameof(endpoint));
+ }
+
+ if (!string.IsNullOrEmpty(endpointUri.Fragment))
+ {
+ throw new ArgumentException("Panel endpoint must not contain a fragment.", nameof(endpoint));
+ }
+
+ if (endpointUri.Port <= 0 || endpointUri.Port > 65_535)
+ {
+ throw new ArgumentException(
+ "Panel endpoint port must be between 1 and 65535.",
+ nameof(endpoint));
+ }
+
+ if (username.Length > MaximumUsernameLength)
+ {
+ throw new ArgumentException(
+ $"Panel username must be {MaximumUsernameLength} characters or fewer.",
+ nameof(username));
+ }
+
+ if (username.Any(char.IsControl))
+ {
+ throw new ArgumentException(
+ "Panel username must not contain control characters.",
+ nameof(username));
+ }
+
+ if (password.Length > MaximumPasswordLength)
+ {
+ throw new ArgumentException(
+ $"Panel password must be {MaximumPasswordLength} characters or fewer.",
+ nameof(password));
+ }
+
+ if (password.Any(char.IsControl))
+ {
+ throw new ArgumentException(
+ "Panel password must not contain control characters.",
+ nameof(password));
+ }
+
+ return new WirelessPanelConfiguration(endpointUri, username, password, enabled);
+ }
+
+ ///
+ /// Returns a diagnostic description that deliberately excludes the password.
+ ///
+ public override string ToString() =>
+ $"Wireless panel {Endpoint.AbsoluteUri} (username: {Username}, enabled: {Enabled})";
+}
diff --git a/src/Joydex.WirelessPanel/WirelessPanelConfigurationStore.cs b/src/Joydex.WirelessPanel/WirelessPanelConfigurationStore.cs
new file mode 100644
index 0000000..98c659e
--- /dev/null
+++ b/src/Joydex.WirelessPanel/WirelessPanelConfigurationStore.cs
@@ -0,0 +1,364 @@
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Joydex.WirelessPanel;
+
+///
+/// Loads and atomically saves wireless-panel settings with a CurrentUser DPAPI password.
+///
+public sealed class WirelessPanelConfigurationStore
+{
+ /// The only on-disk schema currently understood by Joydex.
+ public const int CurrentSchemaVersion = 1;
+
+ private const int MaximumDocumentBytes = 64 * 1024;
+ private const int MaximumProtectedPasswordCharacters = 32 * 1024;
+
+ private static readonly byte[] DpapiEntropy =
+ Encoding.UTF8.GetBytes("Joydex.WirelessPanel.Password.v1");
+
+ private static readonly UTF8Encoding StrictUtf8 = new(
+ encoderShouldEmitUTF8Identifier: false,
+ throwOnInvalidBytes: true);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = false,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ WriteIndented = true,
+ };
+
+ /// Creates a store at the standard per-user Joydex location.
+ public WirelessPanelConfigurationStore()
+ : this(GetDefaultPath())
+ {
+ }
+
+ /// Creates a store at an explicit path, primarily for integration and tests.
+ /// Path to the protected JSON document.
+ public WirelessPanelConfigurationStore(string configurationPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(configurationPath);
+ ConfigurationPath = Path.GetFullPath(configurationPath);
+ }
+
+ /// Gets the full path this store reads and writes.
+ public string ConfigurationPath { get; }
+
+ ///
+ /// Resolves %LOCALAPPDATA%\Joydex\WirelessPanel\panel.json for the current user.
+ ///
+ public static string GetDefaultPath()
+ {
+ var localApplicationData = Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData);
+ if (string.IsNullOrWhiteSpace(localApplicationData))
+ {
+ throw new InvalidOperationException(
+ "The current user's LocalApplicationData folder is unavailable.");
+ }
+
+ return Path.Combine(localApplicationData, "Joydex", "WirelessPanel", "panel.json");
+ }
+
+ ///
+ /// Loads and validates the configuration, or returns when none exists.
+ ///
+ ///
+ /// The document is malformed, unsupported, too large, or cannot be decrypted by this user.
+ ///
+ public WirelessPanelConfiguration? Load()
+ {
+ if (!File.Exists(ConfigurationPath))
+ {
+ return null;
+ }
+
+ var fileInfo = new FileInfo(ConfigurationPath);
+ if (fileInfo.Length is <= 0 or > MaximumDocumentBytes)
+ {
+ throw new InvalidDataException(
+ $"The wireless-panel settings file must contain 1 to {MaximumDocumentBytes} bytes.");
+ }
+
+ WirelessPanelConfigurationDocument? document;
+ try
+ {
+ using var stream = new FileStream(
+ ConfigurationPath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read | FileShare.Delete,
+ bufferSize: 4096,
+ FileOptions.SequentialScan);
+ document = JsonSerializer.Deserialize(
+ stream,
+ JsonOptions);
+ }
+ catch (JsonException exception)
+ {
+ throw new InvalidDataException(
+ "The wireless-panel settings file is not valid schema-v1 JSON.",
+ exception);
+ }
+
+ if (document is null)
+ {
+ throw new InvalidDataException("The wireless-panel settings file was empty.");
+ }
+
+ if (document.SchemaVersion != CurrentSchemaVersion)
+ {
+ throw new InvalidDataException(
+ $"Unsupported wireless-panel settings schema version {document.SchemaVersion}.");
+ }
+
+ if (document.Enabled is null)
+ {
+ throw new InvalidDataException(
+ "The wireless-panel settings do not declare whether the panel is enabled.");
+ }
+
+ if (string.IsNullOrWhiteSpace(document.ProtectedPassword)
+ || document.ProtectedPassword.Length > MaximumProtectedPasswordCharacters)
+ {
+ throw new InvalidDataException(
+ "The wireless-panel settings contain an invalid protected password.");
+ }
+
+ byte[] protectedPassword;
+ try
+ {
+ protectedPassword = Convert.FromBase64String(document.ProtectedPassword);
+ }
+ catch (FormatException exception)
+ {
+ throw new InvalidDataException(
+ "The wireless-panel protected password is not valid base64.",
+ exception);
+ }
+
+ byte[]? clearPassword = null;
+ try
+ {
+ try
+ {
+ clearPassword = ProtectedData.Unprotect(
+ protectedPassword,
+ DpapiEntropy,
+ DataProtectionScope.CurrentUser);
+ }
+ catch (CryptographicException exception)
+ {
+ throw new InvalidDataException(
+ "The wireless-panel password cannot be decrypted by the current Windows user.",
+ exception);
+ }
+
+ string password;
+ try
+ {
+ password = StrictUtf8.GetString(clearPassword);
+ }
+ catch (DecoderFallbackException exception)
+ {
+ throw new InvalidDataException(
+ "The decrypted wireless-panel password is not valid UTF-8.",
+ exception);
+ }
+
+ try
+ {
+ return WirelessPanelConfiguration.Create(
+ document.Endpoint ?? string.Empty,
+ document.Username ?? string.Empty,
+ password,
+ document.Enabled.Value);
+ }
+ catch (ArgumentException exception)
+ {
+ throw new InvalidDataException(
+ "The wireless-panel settings failed validation.",
+ exception);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(protectedPassword);
+ if (clearPassword is not null)
+ {
+ CryptographicOperations.ZeroMemory(clearPassword);
+ }
+ }
+ }
+
+ ///
+ /// Protects the password with CurrentUser DPAPI and atomically replaces the JSON document.
+ ///
+ /// A validated configuration to persist.
+ public void Save(WirelessPanelConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ var directory = Path.GetDirectoryName(ConfigurationPath)
+ ?? throw new InvalidOperationException(
+ "The wireless-panel settings path has no parent directory.");
+ Directory.CreateDirectory(directory);
+ TryRestrictDirectory(directory);
+
+ var clearPassword = StrictUtf8.GetBytes(configuration.Password);
+ byte[]? protectedPassword = null;
+ string? temporaryPath = null;
+ try
+ {
+ protectedPassword = ProtectedData.Protect(
+ clearPassword,
+ DpapiEntropy,
+ DataProtectionScope.CurrentUser);
+
+ var document = new WirelessPanelConfigurationDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ Enabled = configuration.Enabled,
+ Endpoint = configuration.Endpoint.AbsoluteUri,
+ Username = configuration.Username,
+ ProtectedPassword = Convert.ToBase64String(protectedPassword),
+ };
+
+ temporaryPath = Path.Combine(
+ directory,
+ $".{Path.GetFileName(ConfigurationPath)}.{Guid.NewGuid():N}.tmp");
+ using (var stream = new FileStream(
+ temporaryPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, document, JsonOptions);
+ stream.WriteByte((byte)'\n');
+ stream.Flush(flushToDisk: true);
+ }
+
+ TryRestrictFile(temporaryPath);
+ File.Move(temporaryPath, ConfigurationPath, overwrite: true);
+ temporaryPath = null;
+ TryRestrictFile(ConfigurationPath);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(clearPassword);
+ if (protectedPassword is not null)
+ {
+ CryptographicOperations.ZeroMemory(protectedPassword);
+ }
+
+ if (temporaryPath is not null)
+ {
+ TryDeleteTemporaryFile(temporaryPath);
+ }
+ }
+ }
+
+ private static void TryDeleteTemporaryFile(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ private static void TryRestrictDirectory(string path)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ try
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var user = identity.User;
+ if (user is null)
+ {
+ return;
+ }
+
+ var security = new DirectorySecurity();
+ security.SetOwner(user);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(
+ new FileSystemAccessRule(
+ user,
+ FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
+ PropagationFlags.None,
+ AccessControlType.Allow));
+ new DirectoryInfo(path).SetAccessControl(security);
+ }
+ catch (Exception exception) when (IsBestEffortAccessFailure(exception))
+ {
+ }
+ }
+
+ private static void TryRestrictFile(string path)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ try
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var user = identity.User;
+ if (user is null)
+ {
+ return;
+ }
+
+ var security = new FileSecurity();
+ security.SetOwner(user);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(
+ new FileSystemAccessRule(
+ user,
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+ new FileInfo(path).SetAccessControl(security);
+ }
+ catch (Exception exception) when (IsBestEffortAccessFailure(exception))
+ {
+ }
+ }
+
+ private static bool IsBestEffortAccessFailure(Exception exception) =>
+ exception is IOException
+ or UnauthorizedAccessException
+ or PlatformNotSupportedException
+ or NotSupportedException
+ or System.Security.SecurityException
+ or IdentityNotMappedException;
+
+ private sealed class WirelessPanelConfigurationDocument
+ {
+ public int SchemaVersion { get; init; }
+
+ public bool? Enabled { get; init; }
+
+ public string? Endpoint { get; init; }
+
+ public string? Username { get; init; }
+
+ public string? ProtectedPassword { get; init; }
+ }
+}
diff --git a/tests/Joydex.Tests/EspHomePanelAdapterTests.cs b/tests/Joydex.Tests/EspHomePanelAdapterTests.cs
new file mode 100644
index 0000000..d66bf9a
--- /dev/null
+++ b/tests/Joydex.Tests/EspHomePanelAdapterTests.cs
@@ -0,0 +1,657 @@
+using Joydex.Core.Mapping;
+using Joydex.Core.TaskAlerts;
+using Joydex.Windows.Actions;
+using Joydex.Windows.TaskAlerts;
+using Joydex.Windows.WirelessPanel;
+
+namespace Joydex.Tests;
+
+public sealed class EspHomePanelAdapterTests
+{
+ [Fact]
+ public void ProjectsEveryHostStateAndClearsDisabledSnapshots()
+ {
+ var projected = EspHomePanelAdapter.Project(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "running"),
+ Assignment(2, TaskAlertState.Approval, "approval"),
+ Assignment(3, TaskAlertState.Completed, "complete"),
+ Assignment(4, TaskAlertState.Fault, "fault")));
+
+ Assert.Equal(EspHomeTaskState.Running, projected.Task1);
+ Assert.Equal(EspHomeTaskState.Attention, projected.Task2);
+ Assert.Equal(EspHomeTaskState.Complete, projected.Task3);
+ Assert.Equal(EspHomeTaskState.Attention, projected.Task4);
+ Assert.Equal(
+ EspHomePanelSnapshot.Empty,
+ EspHomePanelAdapter.Project(Snapshot(
+ enabled: false,
+ Assignment(1, TaskAlertState.Running, "hidden"))));
+ }
+
+ [Fact]
+ public async Task CardPressReadsCurrentAssignmentAndAcknowledgesSuccessfulTerminalNavigation()
+ {
+ var initial = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "old-session"));
+ var current = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Completed, "current-session"));
+ var navigator = new RecordingNavigator(result: true);
+ var acknowledgements = new List<(int Slot, string SessionId)>();
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ initial,
+ () => current,
+ navigator,
+ (slot, sessionId) =>
+ {
+ acknowledgements.Add((slot, sessionId));
+ return true;
+ });
+ adapter.Start();
+
+ await transport.PressAsync(EspHomePanelButton.Task1);
+
+ var request = Assert.Single(navigator.Requests);
+ Assert.Equal(1, request.Slot);
+ Assert.Equal(2, request.Bank);
+ Assert.Equal(1, request.Button);
+ Assert.Equal("current-session", request.SessionId);
+ Assert.Equal([(1, "current-session")], acknowledgements);
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public async Task DisabledOrEmptySlotDoesNotNavigate(bool enabled, bool includeAssignment)
+ {
+ var assignments = includeAssignment
+ ? new[] { Assignment(1, TaskAlertState.Running, "session") }
+ : [];
+ var snapshot = Snapshot(enabled, assignments);
+ var navigator = new RecordingNavigator(result: true);
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ snapshot,
+ () => snapshot,
+ navigator);
+ adapter.Start();
+
+ await transport.PressAsync(EspHomePanelButton.Task1);
+
+ Assert.Empty(navigator.Requests);
+ }
+
+ [Fact]
+ public async Task FailedNavigationDoesNotAcknowledge()
+ {
+ var snapshot = Snapshot(
+ enabled: true,
+ Assignment(2, TaskAlertState.Approval, "approval-session"));
+ var navigator = new RecordingNavigator(result: false);
+ var acknowledged = false;
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ snapshot,
+ () => snapshot,
+ navigator,
+ (_, _) =>
+ {
+ acknowledged = true;
+ return true;
+ });
+ adapter.Start();
+
+ await transport.PressAsync(EspHomePanelButton.Task2);
+
+ Assert.False(acknowledged);
+ }
+
+ [Theory]
+ [InlineData(TaskAlertState.Running)]
+ [InlineData(TaskAlertState.Approval)]
+ public async Task SuccessfulNonterminalNavigationDoesNotAcknowledge(
+ TaskAlertState state)
+ {
+ var snapshot = Snapshot(
+ enabled: true,
+ Assignment(1, state, "active-session"));
+ var acknowledgementCount = 0;
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ snapshot,
+ () => snapshot,
+ acknowledge: (_, _) =>
+ {
+ acknowledgementCount++;
+ return true;
+ });
+ adapter.Start();
+
+ await transport.PressAsync(EspHomePanelButton.Task1);
+
+ Assert.Equal(0, acknowledgementCount);
+ }
+
+ [Fact]
+ public async Task PlanModeUsesTheExistingSemanticAction()
+ {
+ var snapshot = Snapshot(enabled: true);
+ var requests = new List();
+ var results = new Queue(
+ [
+ ActionExecutionResult.Success("sent"),
+ ActionExecutionResult.Simulated("dry run"),
+ ActionExecutionResult.Blocked("blocked"),
+ ]);
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ snapshot,
+ () => snapshot,
+ executeAction: (request, _) =>
+ {
+ requests.Add(request);
+ return Task.FromResult(results.Dequeue());
+ });
+ adapter.Start();
+
+ await transport.PressAsync(EspHomePanelButton.PlanMode);
+ await transport.PressAsync(EspHomePanelButton.PlanMode);
+ await transport.PressAsync(EspHomePanelButton.PlanMode);
+
+ Assert.All(requests, request =>
+ {
+ Assert.Equal("ESPHome panel Plan Mode", request.BindingName);
+ Assert.Equal(CodexAction.TogglePlanMode, request.Action);
+ Assert.Equal("press", request.Trigger);
+ Assert.Equal("always", request.Bank);
+ Assert.Equal("esphome-panel", request.DeviceId);
+ });
+ }
+
+ [Fact]
+ public async Task ActionExceptionIsLogged()
+ {
+ const string Sentinel = "PRIVATE_EXCEPTION_SENTINEL";
+ var snapshot = Snapshot(enabled: true);
+ var logs = new List();
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ snapshot,
+ () => snapshot,
+ executeAction: (_, _) => throw new InvalidOperationException(Sentinel),
+ log: logs.Add);
+ adapter.Start();
+
+ await transport.PressAsync(EspHomePanelButton.PlanMode);
+
+ Assert.Contains(logs, value => value.Contains(Sentinel, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ReconnectReadsFreshSnapshot()
+ {
+ var current = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one"));
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ current,
+ () => current);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+ current = Snapshot(
+ enabled: true,
+ Assignment(4, TaskAlertState.Fault, "four"));
+
+ await transport.ConnectAsync();
+ await transport.WaitForStateCountAsync(2);
+
+ Assert.Equal(
+ new EspHomePanelSnapshot(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Attention),
+ transport.States.Last());
+ }
+
+ [Fact]
+ public async Task ReconnectForcesAFullRefreshForTheSameProjection()
+ {
+ var current = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one"));
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ current,
+ () => current);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+
+ await transport.ConnectAsync();
+ await transport.WaitForStateCountAsync(2);
+
+ Assert.Equal(2, transport.States.Count);
+ Assert.Equal(transport.States[0], transport.States[1]);
+ }
+
+ [Fact]
+ public async Task ReconnectQueuedDuringAPublishConvergesToTheLatestSnapshot()
+ {
+ var current = Snapshot(enabled: true);
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ current,
+ () => current);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+ var blocked = transport.BlockNextState();
+
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one")));
+ await blocked.Started.Task.WaitAsync(TimeSpan.FromSeconds(1));
+ current = Snapshot(
+ enabled: true,
+ Assignment(2, TaskAlertState.Approval, "two"));
+
+ await transport.ConnectAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1));
+ blocked.Release.SetResult();
+ await transport.WaitForStateCountAsync(3);
+
+ Assert.Equal(
+ new EspHomePanelSnapshot(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Attention,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty),
+ transport.States.Last());
+ }
+
+ [Fact]
+ public async Task ApplyCoalescesPendingUpdatesToTheLatestSnapshot()
+ {
+ var initial = Snapshot(enabled: true);
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ initial,
+ () => initial);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+ var blocked = transport.BlockNextState();
+
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one")));
+ await blocked.Started.Task.WaitAsync(TimeSpan.FromSeconds(1));
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(2, TaskAlertState.Approval, "two")));
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(4, TaskAlertState.Completed, "four")));
+ blocked.Release.SetResult();
+ await transport.WaitForStateCountAsync(3);
+
+ Assert.DoesNotContain(
+ new EspHomePanelSnapshot(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Attention,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty),
+ transport.States);
+ Assert.Equal(
+ new EspHomePanelSnapshot(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Complete),
+ transport.States.Last());
+ }
+
+ [Fact]
+ public async Task ApplySkipsSnapshotsWithTheSamePanelProjection()
+ {
+ var initial = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "first-session"));
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ initial,
+ () => initial);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "different-session")));
+ await Task.Delay(100);
+
+ Assert.Single(transport.States);
+
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Approval, "different-session")));
+ await transport.WaitForStateCountAsync(2);
+ Assert.Equal(EspHomeTaskState.Attention, transport.States.Last().Task1);
+ }
+
+ [Fact]
+ public async Task ApplyPublishesOnlyChangedTaskSlots()
+ {
+ var initial = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one"),
+ Assignment(2, TaskAlertState.Approval, "two"),
+ Assignment(3, TaskAlertState.Completed, "three"));
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ initial,
+ () => initial);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one"),
+ Assignment(2, TaskAlertState.Completed, "two"),
+ Assignment(3, TaskAlertState.Completed, "three")));
+ await transport.WaitForStateCountAsync(2);
+
+ var update = Assert.Single(Assert.Single(transport.StateUpdateBatches));
+ Assert.Equal(new EspHomeTaskStateUpdate(2, EspHomeTaskState.Complete), update);
+ }
+
+ [Fact]
+ public async Task FailedStateUpdateRetriesTheLatestCompleteSnapshot()
+ {
+ var snapshot = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one"),
+ Assignment(3, TaskAlertState.Approval, "three"));
+ var logs = new List();
+ await using var transport = new RecordingTransport();
+ transport.FailNextState();
+ await using var adapter = CreateAdapter(
+ transport,
+ snapshot,
+ () => snapshot,
+ log: logs.Add);
+ adapter.Start();
+
+ await transport.WaitForStateCountAsync(2, TimeSpan.FromSeconds(4));
+
+ Assert.Equal(transport.States[0], transport.States[1]);
+ Assert.Contains(
+ logs,
+ message => message.Contains("state update failed", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task FailedDeltaRetriesTheSameChangedSlots()
+ {
+ var initial = Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Running, "one"));
+ var logs = new List();
+ await using var transport = new RecordingTransport();
+ await using var adapter = CreateAdapter(
+ transport,
+ initial,
+ () => initial,
+ log: logs.Add);
+ adapter.Start();
+ await transport.WaitForStateCountAsync(1);
+ transport.FailNextState();
+
+ adapter.Apply(Snapshot(
+ enabled: true,
+ Assignment(1, TaskAlertState.Approval, "one"),
+ Assignment(4, TaskAlertState.Completed, "four")));
+ await transport.WaitForStateCountAsync(3, TimeSpan.FromSeconds(4));
+
+ Assert.Equal(2, transport.StateUpdateBatches.Count);
+ Assert.Equal(transport.StateUpdateBatches[0], transport.StateUpdateBatches[1]);
+ Assert.Equal(
+ [
+ new EspHomeTaskStateUpdate(1, EspHomeTaskState.Attention),
+ new EspHomeTaskStateUpdate(4, EspHomeTaskState.Complete),
+ ],
+ transport.StateUpdateBatches[1]);
+ Assert.Contains(
+ logs,
+ message => message.Contains("state update failed", StringComparison.Ordinal));
+ }
+
+ private static EspHomePanelAdapter CreateAdapter(
+ IEspHomePanelTransport transport,
+ TaskAlertSnapshot initial,
+ Func getSnapshot,
+ ITaskAlertNavigator? navigator = null,
+ Func? acknowledge = null,
+ Func>? executeAction = null,
+ Action? log = null) =>
+ new(
+ transport,
+ initial,
+ getSnapshot,
+ navigator ?? new RecordingNavigator(result: true),
+ acknowledge ?? ((_, _) => true),
+ executeAction ?? ((_, _) => Task.FromResult(ActionExecutionResult.Success("sent"))),
+ log ?? (_ => { }),
+ TimeSpan.Zero);
+
+ private static TaskAlertSnapshot Snapshot(
+ bool enabled,
+ params TaskAlertAssignment[] assignments) =>
+ new(enabled, assignments, DroppedEventCount: 0, Bank: 2);
+
+ private static TaskAlertAssignment Assignment(
+ int slot,
+ TaskAlertState state,
+ string sessionId) =>
+ new(slot, sessionId, TurnId: null, state, DateTimeOffset.UtcNow);
+
+ private sealed class RecordingNavigator(bool result) : ITaskAlertNavigator
+ {
+ public List Requests { get; } = [];
+
+ public Task NavigateAsync(
+ TaskAlertNavigationRequest request,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Requests.Add(request);
+ return Task.FromResult(result);
+ }
+ }
+
+ private sealed class RecordingTransport : IEspHomePanelTransport
+ {
+ private readonly object _sync = new();
+ private readonly TaskCompletionSource _disposed =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private Func? _onPressed;
+ private Func? _onConnected;
+ private CancellationToken _runCancellation;
+ private StateBlock? _nextStateBlock;
+ private bool _failNextState;
+ private EspHomePanelSnapshot _currentState = EspHomePanelSnapshot.Empty;
+
+ public List States { get; } = [];
+
+ public List> StateUpdateBatches { get; } = [];
+
+ public Task RunAsync(
+ Func onPressed,
+ Func? onConnected = null,
+ CancellationToken cancellationToken = default)
+ {
+ _onPressed = onPressed;
+ _onConnected = onConnected;
+ _runCancellation = cancellationToken;
+ return WaitForDisposalAsync(cancellationToken);
+ }
+
+ public async Task SetTaskStatesAsync(
+ EspHomeTaskState task1,
+ EspHomeTaskState task2,
+ EspHomeTaskState task3,
+ EspHomeTaskState task4,
+ CancellationToken cancellationToken = default)
+ {
+ await RecordStateAsync(
+ new EspHomePanelSnapshot(task1, task2, task3, task4),
+ updates: null,
+ cancellationToken);
+ }
+
+ public async Task SetTaskStateUpdatesAsync(
+ IReadOnlyList updates,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(updates);
+ await RecordStateAsync(
+ completeState: null,
+ updates,
+ cancellationToken);
+ }
+
+ private async Task RecordStateAsync(
+ EspHomePanelSnapshot? completeState,
+ IReadOnlyList? updates,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ StateBlock? stateBlock;
+ lock (_sync)
+ {
+ var next = completeState ?? _currentState;
+ if (updates is not null)
+ {
+ foreach (var update in updates)
+ {
+ next = update.Slot switch
+ {
+ 1 => next with { Task1 = update.State },
+ 2 => next with { Task2 = update.State },
+ 3 => next with { Task3 = update.State },
+ 4 => next with { Task4 = update.State },
+ _ => throw new ArgumentOutOfRangeException(nameof(updates)),
+ };
+ }
+
+ StateUpdateBatches.Add(updates.ToArray());
+ }
+
+ _currentState = next;
+ States.Add(next);
+ stateBlock = _nextStateBlock;
+ _nextStateBlock = null;
+ if (_failNextState)
+ {
+ _failNextState = false;
+ throw new HttpRequestException("Simulated partial state update.");
+ }
+ }
+
+ if (stateBlock is not null)
+ {
+ stateBlock.Started.SetResult();
+ await stateBlock.Release.Task.WaitAsync(cancellationToken);
+ }
+ }
+
+ public async ValueTask PressAsync(EspHomePanelButton button)
+ {
+ var callback = _onPressed
+ ?? throw new InvalidOperationException("The transport has not started.");
+ await callback(button, _runCancellation);
+ }
+
+ public async ValueTask ConnectAsync()
+ {
+ var callback = _onConnected
+ ?? throw new InvalidOperationException("The transport has not started.");
+ await callback(_runCancellation);
+ }
+
+ public StateBlock BlockNextState()
+ {
+ lock (_sync)
+ {
+ return _nextStateBlock = new StateBlock();
+ }
+ }
+
+ public void FailNextState()
+ {
+ lock (_sync)
+ {
+ _failNextState = true;
+ }
+ }
+
+ public async Task WaitForStateCountAsync(
+ int count,
+ TimeSpan? timeout = null)
+ {
+ var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ lock (_sync)
+ {
+ if (States.Count >= count)
+ {
+ return;
+ }
+ }
+
+ await Task.Delay(10);
+ }
+
+ throw new TimeoutException($"Expected {count} panel state updates.");
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _disposed.TrySetResult();
+ return ValueTask.CompletedTask;
+ }
+
+ private async Task WaitForDisposalAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _disposed.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ }
+ }
+
+ private sealed class StateBlock
+ {
+ public TaskCompletionSource Started { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public TaskCompletionSource Release { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+}
diff --git a/tests/Joydex.Tests/EspHomePanelTransportTests.cs b/tests/Joydex.Tests/EspHomePanelTransportTests.cs
new file mode 100644
index 0000000..e1a6927
--- /dev/null
+++ b/tests/Joydex.Tests/EspHomePanelTransportTests.cs
@@ -0,0 +1,811 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using Joydex.Windows.WirelessPanel;
+
+namespace Joydex.Tests;
+
+public sealed class EspHomePanelTransportTests
+{
+ [Fact]
+ public async Task SseParserDispatchesCompleteEventBeforeReadingTheNextChunk()
+ {
+ var text =
+ "event: state\r\n" +
+ "data: first\r\n" +
+ "data: second\r\n\r\n" +
+ "event: ping\n" +
+ "data: {}\n\n";
+ var checkpoint = text.IndexOf("event: ping", StringComparison.Ordinal);
+ var firstDispatched = false;
+ await using var stream = new CheckpointStream(
+ Encoding.UTF8.GetBytes(text),
+ checkpoint,
+ () => firstDispatched);
+ var events = new List();
+
+ await EspHomeSseParser.ReadAsync(
+ stream,
+ (sseEvent, _) =>
+ {
+ events.Add(sseEvent);
+ firstDispatched = true;
+ return ValueTask.CompletedTask;
+ },
+ CancellationToken.None);
+
+ Assert.Equal(
+ [
+ new ServerSentEvent("state", "first\nsecond"),
+ new ServerSentEvent("ping", "{}"),
+ ],
+ events);
+ }
+
+ [Fact]
+ public async Task SseParserRejectsAnOversizedLineBeforeDispatch()
+ {
+ await using var stream = new MemoryStream(
+ Encoding.UTF8.GetBytes("data: " + new string('x', 16 * 1024 + 1)));
+
+ await Assert.ThrowsAsync(
+ () => EspHomeSseParser.ReadAsync(
+ stream,
+ (_, _) => ValueTask.CompletedTask,
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SseParserRejectsOversizedAccumulatedEventData()
+ {
+ var line = "data: " + new string('x', 15 * 1024) + "\n";
+ await using var stream = new MemoryStream(
+ Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat(line, 5))));
+
+ await Assert.ThrowsAsync(
+ () => EspHomeSseParser.ReadAsync(
+ stream,
+ (_, _) => ValueTask.CompletedTask,
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public void StateParserPrefersNameIdAndAcceptsThe2026_8IdFormat()
+ {
+ Assert.True(EspHomeStateEventParser.TryParse(
+ """
+ {"name_id":"binary_sensor/Task 1","id":"binary_sensor-task_2","state":"OFF","value":false}
+ """,
+ out var transitional));
+ Assert.Equal("binary_sensor/Task 1", transitional.Identifier);
+ Assert.False(transitional.IsOn);
+
+ Assert.True(EspHomeStateEventParser.TryParse(
+ """
+ {"id":"binary_sensor/Task 2","state":"ON","value":true}
+ """,
+ out var current));
+ Assert.Equal("binary_sensor/Task 2", current.Identifier);
+ Assert.True(current.IsOn);
+ }
+
+ [Fact]
+ public void PressTrackerIgnoresCatchUpAndEmitsOnlyAllowlistedOffToOnEdges()
+ {
+ var tracker = new EspHomePressTracker();
+
+ Assert.False(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor/Task 1", true),
+ out _));
+ Assert.False(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor/Task 1", true),
+ out _));
+ Assert.False(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor/Task 1", false),
+ out _));
+ Assert.True(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor/Task 1", true),
+ out var pressed));
+ Assert.Equal(EspHomePanelButton.Task1, pressed);
+ Assert.False(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor-task_1", false),
+ out _));
+ Assert.True(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor-task_1", true),
+ out pressed));
+ Assert.Equal(EspHomePanelButton.Task1, pressed);
+ Assert.False(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor/Anything Else", false),
+ out _));
+ Assert.False(tracker.TryObserve(
+ new EspHomeStateEvent("binary_sensor/Anything Else", true),
+ out _));
+ }
+
+ [Theory]
+ [InlineData("binary_sensor/Task 1", EspHomePanelButton.Task1)]
+ [InlineData("binary_sensor/Task 2", EspHomePanelButton.Task2)]
+ [InlineData("binary_sensor/Task 3", EspHomePanelButton.Task3)]
+ [InlineData("binary_sensor/Task 4", EspHomePanelButton.Task4)]
+ [InlineData("binary_sensor/Sidebar", EspHomePanelButton.PlanMode)]
+ [InlineData("binary_sensor-task_1", EspHomePanelButton.Task1)]
+ [InlineData("binary_sensor-task_2", EspHomePanelButton.Task2)]
+ [InlineData("binary_sensor-task_3", EspHomePanelButton.Task3)]
+ [InlineData("binary_sensor-task_4", EspHomePanelButton.Task4)]
+ [InlineData("binary_sensor-sidebar", EspHomePanelButton.PlanMode)]
+ public void PressTrackerMapsEverySupportedEntity(
+ string identifier,
+ EspHomePanelButton expected)
+ {
+ var tracker = new EspHomePressTracker();
+
+ Assert.False(tracker.TryObserve(new EspHomeStateEvent(identifier, false), out _));
+ Assert.True(tracker.TryObserve(new EspHomeStateEvent(identifier, true), out var pressed));
+ Assert.Equal(expected, pressed);
+ }
+
+ [Fact]
+ public async Task SelectPostsUseExactEntitiesAndRemainSerialized()
+ {
+ var handler = new SerializedRecordingHandler();
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local"));
+
+ var states = transport.SetTaskStatesAsync(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Running,
+ EspHomeTaskState.Attention,
+ EspHomeTaskState.Complete);
+ await handler.FirstRequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+ var update = transport.SetTaskStateUpdatesAsync(
+ [new EspHomeTaskStateUpdate(2, EspHomeTaskState.Attention)]);
+ handler.ReleaseFirstRequest();
+
+ await Task.WhenAll(states, update);
+
+ Assert.Equal(1, handler.MaxActiveRequests);
+ Assert.Equal(
+ [
+ "POST /select/Task%201%20State/set?option=EMPTY",
+ "POST /select/Task%202%20State/set?option=RUNNING",
+ "POST /select/Task%203%20State/set?option=ATTENTION",
+ "POST /select/Task%204%20State/set?option=COMPLETE",
+ "POST /select/Task%202%20State/set?option=ATTENTION",
+ ],
+ handler.Requests);
+ }
+
+ [Fact]
+ public async Task TaskStateUpdatesPostOnlyChangedEntities()
+ {
+ var handler = new SerializedRecordingHandler();
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local"));
+
+ var updates = transport.SetTaskStateUpdatesAsync(
+ [
+ new EspHomeTaskStateUpdate(2, EspHomeTaskState.Attention),
+ new EspHomeTaskStateUpdate(4, EspHomeTaskState.Empty),
+ ]);
+ await handler.FirstRequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+ handler.ReleaseFirstRequest();
+ await updates;
+
+ Assert.Equal(
+ [
+ "POST /select/Task%202%20State/set?option=ATTENTION",
+ "POST /select/Task%204%20State/set?option=EMPTY",
+ ],
+ handler.Requests);
+ }
+
+ [Fact]
+ public async Task TaskStateUpdatesRejectSlotsOutsideThePanel()
+ {
+ using var httpClient = new HttpClient(new SerializedRecordingHandler());
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local"));
+
+ await Assert.ThrowsAsync(
+ () => transport.SetTaskStateUpdatesAsync(
+ [new EspHomeTaskStateUpdate(5, EspHomeTaskState.Running)]));
+ }
+
+ [Fact]
+ public async Task TimedOutPostReleasesTheSerializationGate()
+ {
+ var handler = new TimeoutThenSuccessHandler();
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local"),
+ postTimeout: TimeSpan.FromMilliseconds(50));
+
+ await Assert.ThrowsAsync(
+ () => transport.SetTaskStateUpdatesAsync(
+ [new EspHomeTaskStateUpdate(1, EspHomeTaskState.Attention)]));
+ await transport
+ .SetTaskStateUpdatesAsync(
+ [new EspHomeTaskStateUpdate(1, EspHomeTaskState.Running)])
+ .WaitAsync(TimeSpan.FromSeconds(1));
+
+ Assert.Equal(2, handler.RequestCount);
+ }
+
+ [Fact]
+ public async Task PartialTaskBatchFailureAllowsACompleteReplacement()
+ {
+ var handler = new PartialBatchFailureHandler();
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local"));
+
+ await Assert.ThrowsAsync(
+ () => transport.SetTaskStatesAsync(
+ EspHomeTaskState.Running,
+ EspHomeTaskState.Attention,
+ EspHomeTaskState.Complete,
+ EspHomeTaskState.Empty));
+ await transport.SetTaskStatesAsync(
+ EspHomeTaskState.Empty,
+ EspHomeTaskState.Running,
+ EspHomeTaskState.Attention,
+ EspHomeTaskState.Complete);
+
+ Assert.Equal(
+ [
+ "/select/Task%201%20State/set?option=RUNNING",
+ "/select/Task%202%20State/set?option=ATTENTION",
+ "/select/Task%203%20State/set?option=COMPLETE",
+ "/select/Task%201%20State/set?option=EMPTY",
+ "/select/Task%202%20State/set?option=RUNNING",
+ "/select/Task%203%20State/set?option=ATTENTION",
+ "/select/Task%204%20State/set?option=COMPLETE",
+ ],
+ handler.Paths);
+ }
+
+ [Fact]
+ public async Task EventLoopUsesPreferredAndCurrentIdsWithoutReplayingCatchUp()
+ {
+ var handler = new EventSequenceHandler(
+ """
+ event: ping
+ data: {"uptime":1}
+
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","id":"binary_sensor-task_2","state":"OFF"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","id":"binary_sensor-task_2","state":"ON"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","id":"binary_sensor-task_2","state":"ON"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Unknown","state":"OFF"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Unknown","state":"ON"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Sidebar","state":"ON"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Sidebar","state":"OFF"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Sidebar","state":"ON"}
+
+ event: state
+ data: {"id":"binary_sensor/Task 2","state":"OFF"}
+
+ event: state
+ data: {"id":"binary_sensor/Task 2","state":"ON"}
+ """ + "\n\n");
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local/"),
+ reconnectDelay: TimeSpan.Zero);
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ var presses = new List();
+
+ await transport.RunAsync(
+ (button, _) =>
+ {
+ presses.Add(button);
+ if (presses.Count == 3)
+ {
+ cancellation.Cancel();
+ }
+
+ return ValueTask.CompletedTask;
+ },
+ cancellationToken: cancellation.Token);
+
+ Assert.Equal(
+ [EspHomePanelButton.Task1, EspHomePanelButton.PlanMode, EspHomePanelButton.Task2],
+ presses);
+ var request = Assert.Single(handler.Requests);
+ Assert.Equal("GET /events", request.MethodAndPath);
+ Assert.Contains("text/event-stream", request.Accept);
+ Assert.True(request.NoCache);
+ }
+
+ [Fact]
+ public async Task ReconnectStartsANewCatchUpBaseline()
+ {
+ var handler = new EventSequenceHandler(
+ """
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","state":"OFF"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","state":"ON"}
+
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","state":"OFF"}
+ """ + "\n\n",
+ """
+ event: state
+ data: {"id":"binary_sensor/Task 1","state":"ON"}
+
+ event: state
+ data: {"id":"binary_sensor/Task 1","state":"OFF"}
+
+ event: state
+ data: {"id":"binary_sensor/Task 2","state":"OFF"}
+
+ event: state
+ data: {"id":"binary_sensor/Task 2","state":"ON"}
+ """ + "\n\n");
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local/"),
+ reconnectDelay: TimeSpan.Zero);
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ var presses = new List();
+ var connected = 0;
+
+ await transport.RunAsync(
+ (button, _) =>
+ {
+ presses.Add(button);
+ if (presses.Count == 2)
+ {
+ cancellation.Cancel();
+ }
+
+ return ValueTask.CompletedTask;
+ },
+ _ =>
+ {
+ connected++;
+ return ValueTask.CompletedTask;
+ },
+ cancellation.Token);
+
+ Assert.Equal(2, connected);
+ Assert.Equal([EspHomePanelButton.Task1, EspHomePanelButton.Task2], presses);
+ Assert.Equal(2, handler.Requests.Count);
+ }
+
+ [Fact]
+ public async Task EventLoopReconnectsAfterTheStreamMissesItsIdleDeadline()
+ {
+ var handler = new IdleThenEventHandler();
+ using var httpClient = new HttpClient(handler);
+ await using var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local/"),
+ reconnectDelay: TimeSpan.Zero,
+ sseIdleTimeout: TimeSpan.FromMilliseconds(50));
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ var presses = new List();
+
+ await transport.RunAsync(
+ (button, _) =>
+ {
+ presses.Add(button);
+ cancellation.Cancel();
+ return ValueTask.CompletedTask;
+ },
+ cancellationToken: cancellation.Token);
+
+ Assert.True(handler.FirstStream.ReadWasCanceled);
+ Assert.Equal(2, handler.RequestCount);
+ Assert.Equal([EspHomePanelButton.Task1], presses);
+ }
+
+ [Fact]
+ public async Task DisposeCancelsAStreamReadAndWaitsForTheLoop()
+ {
+ var stream = new CancellationAwareStream();
+ var handler = new StreamHandler(stream);
+ using var httpClient = new HttpClient(handler);
+ var transport = new EspHomePanelTransport(
+ httpClient,
+ new Uri("http://panel.local/"));
+ var runTask = transport.RunAsync((_, _) => ValueTask.CompletedTask);
+ await stream.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+
+ await transport.DisposeAsync();
+
+ await runTask.WaitAsync(TimeSpan.FromSeconds(1));
+ Assert.True(stream.ReadWasCanceled);
+ }
+
+ private sealed class CheckpointStream(
+ byte[] bytes,
+ int checkpoint,
+ Func canReadPastCheckpoint) : MemoryStream(bytes)
+ {
+ public override ValueTask ReadAsync(
+ Memory buffer,
+ CancellationToken cancellationToken = default)
+ {
+ AssertCanRead();
+ var count = LimitCount(buffer.Length);
+ return base.ReadAsync(buffer[..count], cancellationToken);
+ }
+
+ public override Task ReadAsync(
+ byte[] buffer,
+ int offset,
+ int count,
+ CancellationToken cancellationToken)
+ {
+ AssertCanRead();
+ return base.ReadAsync(buffer, offset, LimitCount(count), cancellationToken);
+ }
+
+ private int LimitCount(int requested)
+ {
+ var remainingBeforeCheckpoint = checkpoint - (int)Position;
+ return remainingBeforeCheckpoint > 0
+ ? Math.Min(requested, remainingBeforeCheckpoint)
+ : requested;
+ }
+
+ private void AssertCanRead()
+ {
+ if (Position >= checkpoint && !canReadPastCheckpoint())
+ {
+ throw new InvalidOperationException("The parser read the next chunk before dispatching the first event.");
+ }
+ }
+ }
+
+ private sealed class SerializedRecordingHandler : HttpMessageHandler
+ {
+ private readonly TaskCompletionSource _releaseFirstRequest = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _activeRequests;
+ private int _requestCount;
+
+ public TaskCompletionSource FirstRequestStarted { get; } = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public ConcurrentQueue Requests { get; } = [];
+
+ public int MaxActiveRequests => Volatile.Read(ref _maxActiveRequests);
+
+ private int _maxActiveRequests;
+
+ public void ReleaseFirstRequest() => _releaseFirstRequest.TrySetResult();
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var active = Interlocked.Increment(ref _activeRequests);
+ UpdateMaximum(active);
+ var requestNumber = Interlocked.Increment(ref _requestCount);
+ Requests.Enqueue($"{request.Method} {request.RequestUri!.PathAndQuery}");
+ try
+ {
+ if (requestNumber == 1)
+ {
+ FirstRequestStarted.TrySetResult();
+ await _releaseFirstRequest.Task.WaitAsync(cancellationToken);
+ }
+
+ await Task.Yield();
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+ finally
+ {
+ Interlocked.Decrement(ref _activeRequests);
+ }
+ }
+
+ private void UpdateMaximum(int candidate)
+ {
+ var current = Volatile.Read(ref _maxActiveRequests);
+ while (candidate > current)
+ {
+ var observed = Interlocked.CompareExchange(
+ ref _maxActiveRequests,
+ candidate,
+ current);
+ if (observed == current)
+ {
+ return;
+ }
+
+ current = observed;
+ }
+ }
+ }
+
+ private sealed class EventSequenceHandler(params string[] eventStreams) : HttpMessageHandler
+ {
+ private readonly ConcurrentQueue _eventStreams = new(eventStreams);
+
+ public List Requests { get; } = [];
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ Requests.Add(new RecordedRequest(
+ request.Method.Method,
+ request.RequestUri!.PathAndQuery,
+ string.Join(",", request.Headers.Accept.Select(value => value.MediaType)),
+ request.Headers.CacheControl?.NoCache == true));
+ if (!_eventStreams.TryDequeue(out var stream))
+ {
+ throw new HttpRequestException("No canned ESPHome event stream remains.");
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(stream, Encoding.UTF8, "text/event-stream"),
+ });
+ }
+ }
+
+ private sealed class TimeoutThenSuccessHandler : HttpMessageHandler
+ {
+ public int RequestCount { get; private set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ RequestCount++;
+ if (RequestCount == 1)
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+ }
+
+ private sealed class PartialBatchFailureHandler : HttpMessageHandler
+ {
+ private int _requestCount;
+
+ public List Paths { get; } = [];
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Paths.Add(request.RequestUri!.PathAndQuery);
+ var requestNumber = Interlocked.Increment(ref _requestCount);
+ return Task.FromResult(new HttpResponseMessage(
+ requestNumber == 3
+ ? HttpStatusCode.InternalServerError
+ : HttpStatusCode.OK));
+ }
+ }
+
+ private sealed class IdleThenEventHandler : HttpMessageHandler
+ {
+ private int _requestCount;
+
+ public PrefixThenBlockingStream FirstStream { get; } = new(
+ """
+ event: state
+ data: {"name_id":"binary_sensor/Task 1","state":"OFF"}
+ """ + "\n\n");
+
+ public int RequestCount => Volatile.Read(ref _requestCount);
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var requestNumber = Interlocked.Increment(ref _requestCount);
+ if (requestNumber == 1)
+ {
+ var content = new StreamContent(FirstStream);
+ content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream");
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = content,
+ });
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """
+ event: state
+ data: {"id":"binary_sensor/Task 1","state":"OFF"}
+
+ event: state
+ data: {"id":"binary_sensor/Task 1","state":"ON"}
+ """ + "\n\n",
+ Encoding.UTF8,
+ "text/event-stream"),
+ });
+ }
+ }
+
+ private sealed record RecordedRequest(
+ string Method,
+ string Path,
+ string Accept,
+ bool NoCache)
+ {
+ public string MethodAndPath => $"{Method} {Path}";
+ }
+
+ private sealed class StreamHandler(Stream stream) : HttpMessageHandler
+ {
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var content = new StreamContent(stream);
+ content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream");
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = content,
+ });
+ }
+ }
+
+ private sealed class PrefixThenBlockingStream(string prefix) : Stream
+ {
+ private readonly MemoryStream _prefix = new(Encoding.UTF8.GetBytes(prefix));
+
+ public bool ReadWasCanceled { get; private set; }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+
+ public override async ValueTask ReadAsync(
+ Memory buffer,
+ CancellationToken cancellationToken = default)
+ {
+ if (_prefix.Position < _prefix.Length)
+ {
+ return await _prefix.ReadAsync(buffer, cancellationToken);
+ }
+
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return 0;
+ }
+ catch (OperationCanceledException)
+ {
+ ReadWasCanceled = true;
+ throw;
+ }
+ }
+
+ public override Task ReadAsync(
+ byte[] buffer,
+ int offset,
+ int count,
+ CancellationToken cancellationToken) =>
+ ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+
+ public override void SetLength(long value) =>
+ throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _prefix.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+
+ private sealed class CancellationAwareStream : Stream
+ {
+ public TaskCompletionSource ReadStarted { get; } = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public bool ReadWasCanceled { get; private set; }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+
+ public override async ValueTask ReadAsync(
+ Memory buffer,
+ CancellationToken cancellationToken = default)
+ {
+ ReadStarted.TrySetResult();
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return 0;
+ }
+ catch (OperationCanceledException)
+ {
+ ReadWasCanceled = true;
+ throw;
+ }
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+
+ public override void SetLength(long value) =>
+ throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/tests/Joydex.WirelessPanel.Tests/Joydex.WirelessPanel.Tests.csproj b/tests/Joydex.WirelessPanel.Tests/Joydex.WirelessPanel.Tests.csproj
new file mode 100644
index 0000000..cc9f9ae
--- /dev/null
+++ b/tests/Joydex.WirelessPanel.Tests/Joydex.WirelessPanel.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0-windows
+ false
+ true
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Joydex.WirelessPanel.Tests/ProvisioningToolProcessTests.cs b/tests/Joydex.WirelessPanel.Tests/ProvisioningToolProcessTests.cs
new file mode 100644
index 0000000..2553580
--- /dev/null
+++ b/tests/Joydex.WirelessPanel.Tests/ProvisioningToolProcessTests.cs
@@ -0,0 +1,66 @@
+using System.Diagnostics;
+
+namespace Joydex.WirelessPanel.Tests;
+
+public sealed class ProvisioningToolProcessTests
+{
+ [Fact]
+ public async Task Tool_rejects_all_arguments_without_echoing_them()
+ {
+ const string sentinel = "secret-sentinel-argument-ecfa13";
+
+ var result = await RunToolAsync([sentinel]);
+
+ Assert.Equal(64, result.ExitCode);
+ Assert.DoesNotContain(sentinel, result.StandardOutput);
+ Assert.DoesNotContain(sentinel, result.StandardError);
+ Assert.Contains("accepts no command-line options", result.StandardError);
+ }
+
+ [Fact]
+ public async Task Tool_rejects_redirected_input_before_prompting_for_password()
+ {
+ var result = await RunToolAsync([]);
+
+ Assert.Equal(64, result.ExitCode);
+ Assert.Contains("interactive console", result.StandardError);
+ Assert.DoesNotContain("Digest password", result.StandardOutput);
+ Assert.DoesNotContain("Digest password", result.StandardError);
+ }
+
+ private static async Task RunToolAsync(IReadOnlyList arguments)
+ {
+ var toolAssembly = typeof(Joydex.WirelessPanel.Configure.Program).Assembly.Location;
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet.exe",
+ RedirectStandardError = true,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+ startInfo.ArgumentList.Add(toolAssembly);
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using var process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("The provisioning tool process did not start.");
+ process.StandardInput.Close();
+ var standardOutputTask = process.StandardOutput.ReadToEndAsync();
+ var standardErrorTask = process.StandardError.ReadToEndAsync();
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await process.WaitForExitAsync(timeout.Token);
+
+ return new ProcessResult(
+ process.ExitCode,
+ await standardOutputTask,
+ await standardErrorTask);
+ }
+
+ private sealed record ProcessResult(
+ int ExitCode,
+ string StandardOutput,
+ string StandardError);
+}
diff --git a/tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationStoreTests.cs b/tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationStoreTests.cs
new file mode 100644
index 0000000..a19c968
--- /dev/null
+++ b/tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationStoreTests.cs
@@ -0,0 +1,264 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Joydex.WirelessPanel;
+
+namespace Joydex.WirelessPanel.Tests;
+
+public sealed class WirelessPanelConfigurationStoreTests
+{
+ [Fact]
+ public void Load_returns_null_when_file_does_not_exist()
+ {
+ using var directory = new TemporaryDirectory();
+ var store = new WirelessPanelConfigurationStore(
+ Path.Combine(directory.Path, "panel.json"));
+
+ Assert.Null(store.Load());
+ }
+
+ [Fact]
+ public void Save_and_load_round_trip_through_current_user_dpapi()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ var store = new WirelessPanelConfigurationStore(path);
+ var expected = WirelessPanelConfiguration.Create(
+ "http://panel.local:6052/",
+ "panel-user",
+ "unique round-trip password",
+ enabled: false);
+
+ store.Save(expected);
+ var actual = store.Load();
+
+ Assert.NotNull(actual);
+ Assert.Equal(expected.Endpoint, actual.Endpoint);
+ Assert.Equal(expected.Username, actual.Username);
+ Assert.Equal(expected.Password, actual.Password);
+ Assert.Equal(expected.Enabled, actual.Enabled);
+ }
+
+ [Fact]
+ public void Save_keeps_plaintext_password_out_of_json()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ var store = new WirelessPanelConfigurationStore(path);
+ const string password = "plain-text-marker-836ab56d";
+
+ store.Save(
+ WirelessPanelConfiguration.Create(
+ "http://panel.local/",
+ "user",
+ password));
+
+ var json = File.ReadAllText(path);
+ using var document = JsonDocument.Parse(json);
+ Assert.DoesNotContain(password, json);
+ Assert.Equal(
+ WirelessPanelConfigurationStore.CurrentSchemaVersion,
+ document.RootElement.GetProperty("schemaVersion").GetInt32());
+ Assert.False(
+ string.IsNullOrWhiteSpace(
+ document.RootElement.GetProperty("protectedPassword").GetString()));
+ }
+
+ [Fact]
+ public void Save_protects_password_for_the_current_windows_user()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ var store = new WirelessPanelConfigurationStore(path);
+ const string password = "current-user-scope-marker";
+ store.Save(
+ WirelessPanelConfiguration.Create(
+ "http://panel.local/",
+ "user",
+ password));
+
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ var protectedPassword = Convert.FromBase64String(
+ document.RootElement.GetProperty("protectedPassword").GetString()!);
+ var entropy = Encoding.UTF8.GetBytes("Joydex.WirelessPanel.Password.v1");
+ byte[]? clearPassword = null;
+ try
+ {
+ clearPassword = ProtectedData.Unprotect(
+ protectedPassword,
+ entropy,
+ DataProtectionScope.CurrentUser);
+
+ Assert.Equal(password, Encoding.UTF8.GetString(clearPassword));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(protectedPassword);
+ CryptographicOperations.ZeroMemory(entropy);
+ if (clearPassword is not null)
+ {
+ CryptographicOperations.ZeroMemory(clearPassword);
+ }
+ }
+ }
+
+ [Fact]
+ public void Save_replaces_existing_document_and_cleans_temporary_file()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ var store = new WirelessPanelConfigurationStore(path);
+ store.Save(
+ WirelessPanelConfiguration.Create(
+ "http://first-panel.local/",
+ "first-user",
+ "first-password"));
+
+ store.Save(
+ WirelessPanelConfiguration.Create(
+ "http://second-panel.local/",
+ "second-user",
+ "second-password",
+ enabled: false));
+
+ var configuration = store.Load();
+ Assert.NotNull(configuration);
+ Assert.Equal("http://second-panel.local/", configuration.Endpoint.AbsoluteUri);
+ Assert.Equal("second-user", configuration.Username);
+ Assert.Equal("second-password", configuration.Password);
+ Assert.False(configuration.Enabled);
+ Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp"));
+ }
+
+ [Fact]
+ public void Load_rejects_invalid_protected_password()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ File.WriteAllText(
+ path,
+ """
+ {
+ "schemaVersion": 1,
+ "enabled": true,
+ "endpoint": "http://panel.local/",
+ "username": "user",
+ "protectedPassword": "not base64!"
+ }
+ """);
+ var store = new WirelessPanelConfigurationStore(path);
+
+ Assert.Throws(() => store.Load());
+ }
+
+ [Fact]
+ public void Load_rejects_dpapi_ciphertext_protected_with_different_entropy()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ var clearPassword = Encoding.UTF8.GetBytes("wrong-entropy-password");
+ var wrongEntropy = Encoding.UTF8.GetBytes("Joydex.WirelessPanel.WrongEntropy");
+ var protectedPassword = ProtectedData.Protect(
+ clearPassword,
+ wrongEntropy,
+ DataProtectionScope.CurrentUser);
+ try
+ {
+ File.WriteAllText(
+ path,
+ $$"""
+ {
+ "schemaVersion": 1,
+ "enabled": true,
+ "endpoint": "http://panel.local/",
+ "username": "user",
+ "protectedPassword": "{{Convert.ToBase64String(protectedPassword)}}"
+ }
+ """);
+ var store = new WirelessPanelConfigurationStore(path);
+
+ Assert.Throws(() => store.Load());
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(clearPassword);
+ CryptographicOperations.ZeroMemory(wrongEntropy);
+ CryptographicOperations.ZeroMemory(protectedPassword);
+ }
+ }
+
+ [Fact]
+ public void Load_rejects_unknown_properties()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ File.WriteAllText(
+ path,
+ """
+ {
+ "schemaVersion": 1,
+ "enabled": true,
+ "endpoint": "http://panel.local/",
+ "username": "user",
+ "protectedPassword": "AA==",
+ "surprise": true
+ }
+ """);
+ var store = new WirelessPanelConfigurationStore(path);
+
+ Assert.Throws(() => store.Load());
+ }
+
+ [Fact]
+ public void Load_rejects_missing_enabled_property()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "panel.json");
+ File.WriteAllText(
+ path,
+ """
+ {
+ "schemaVersion": 1,
+ "endpoint": "http://panel.local/",
+ "username": "user",
+ "protectedPassword": "AA=="
+ }
+ """);
+ var store = new WirelessPanelConfigurationStore(path);
+
+ Assert.Throws(() => store.Load());
+ }
+
+ [Fact]
+ public void GetDefaultPath_uses_local_app_data()
+ {
+ var expected = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Joydex",
+ "WirelessPanel",
+ "panel.json");
+
+ Assert.Equal(expected, WirelessPanelConfigurationStore.GetDefaultPath());
+ }
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ public TemporaryDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"joydex-wireless-panel-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(Path);
+ }
+
+ public string Path { get; }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationTests.cs b/tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationTests.cs
new file mode 100644
index 0000000..d0ae0c8
--- /dev/null
+++ b/tests/Joydex.WirelessPanel.Tests/WirelessPanelConfigurationTests.cs
@@ -0,0 +1,87 @@
+using System.Text.Json;
+using Joydex.WirelessPanel;
+
+namespace Joydex.WirelessPanel.Tests;
+
+public sealed class WirelessPanelConfigurationTests
+{
+ [Fact]
+ public void Create_accepts_http_endpoint_with_port_and_path()
+ {
+ var configuration = WirelessPanelConfiguration.Create(
+ " http://panel.local:6052/esphome/ ",
+ " panel-user ",
+ "correct horse battery staple",
+ enabled: true);
+
+ Assert.Equal("http://panel.local:6052/esphome/", configuration.Endpoint.AbsoluteUri);
+ Assert.Equal("panel-user", configuration.Username);
+ Assert.Equal("correct horse battery staple", configuration.Password);
+ Assert.True(configuration.Enabled);
+ }
+
+ [Theory]
+ [InlineData("https://panel.local/")]
+ [InlineData("panel.local")]
+ [InlineData("http://user:password@panel.local/")]
+ [InlineData("http://@panel.local/")]
+ [InlineData("http://panel.local/?mode=touch")]
+ [InlineData("http://panel.local/#touch")]
+ [InlineData("http://panel.local:0/")]
+ [InlineData("ftp://panel.local/")]
+ public void Create_rejects_endpoint_outside_attended_direct_boundary(string endpoint)
+ {
+ Assert.Throws(
+ () => WirelessPanelConfiguration.Create(endpoint, "user", "password"));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("line\nbreak")]
+ public void Create_rejects_invalid_username(string username)
+ {
+ Assert.Throws(
+ () => WirelessPanelConfiguration.Create("http://panel.local/", username, "password"));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("line\nbreak")]
+ public void Create_rejects_invalid_password(string password)
+ {
+ Assert.Throws(
+ () => WirelessPanelConfiguration.Create("http://panel.local/", "user", password));
+ }
+
+ [Fact]
+ public void ToString_excludes_password()
+ {
+ const string password = "marker-password-that-must-stay-secret";
+ var configuration = WirelessPanelConfiguration.Create(
+ "http://panel.local/",
+ "user",
+ password);
+
+ var diagnosticText = configuration.ToString();
+
+ Assert.DoesNotContain(password, diagnosticText);
+ Assert.Contains("http://panel.local/", diagnosticText);
+ }
+
+ [Fact]
+ public void Incidental_json_serialization_excludes_password()
+ {
+ const string password = "json-marker-that-must-stay-secret";
+ var configuration = WirelessPanelConfiguration.Create(
+ "http://panel.local/",
+ "user",
+ password);
+
+ var json = JsonSerializer.Serialize(configuration);
+
+ Assert.DoesNotContain(password, json);
+ Assert.DoesNotContain("Password", json);
+ }
+}
diff --git a/tools/Joydex.WirelessPanel.Configure/Joydex.WirelessPanel.Configure.csproj b/tools/Joydex.WirelessPanel.Configure/Joydex.WirelessPanel.Configure.csproj
new file mode 100644
index 0000000..66e84c5
--- /dev/null
+++ b/tools/Joydex.WirelessPanel.Configure/Joydex.WirelessPanel.Configure.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Exe
+ net8.0-windows
+
+
+
+
+
+
+
diff --git a/tools/Joydex.WirelessPanel.Configure/Program.cs b/tools/Joydex.WirelessPanel.Configure/Program.cs
new file mode 100644
index 0000000..b46d14f
--- /dev/null
+++ b/tools/Joydex.WirelessPanel.Configure/Program.cs
@@ -0,0 +1,199 @@
+using System.Text;
+using Joydex.WirelessPanel;
+
+namespace Joydex.WirelessPanel.Configure;
+
+public static class Program
+{
+ private const string SuggestedEndpoint = "http://joydex-panel.local/";
+ private const string SuggestedUsername = "joydex";
+
+ public static int Main(string[] args)
+ {
+ if (args.Length > 0)
+ {
+ Console.Error.WriteLine(
+ "This provisioning tool accepts no command-line options. "
+ + "Run it interactively so credentials stay out of arguments.");
+ return 64;
+ }
+
+ if (Console.IsInputRedirected)
+ {
+ Console.Error.WriteLine(
+ "Provisioning requires an interactive console so the password can stay hidden.");
+ return 64;
+ }
+
+ var store = new WirelessPanelConfigurationStore();
+ WirelessPanelConfiguration? existing;
+ try
+ {
+ existing = store.Load();
+ }
+ catch (InvalidDataException exception)
+ {
+ Console.Error.WriteLine(
+ $"The existing panel settings are unusable and will be replaced: {exception.Message}");
+ existing = null;
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException)
+ {
+ Console.Error.WriteLine($"Unable to load the existing panel settings: {exception.Message}");
+ return 2;
+ }
+
+ Console.WriteLine("Joydex wireless-panel provisioning");
+ Console.WriteLine($"Settings file: {store.ConfigurationPath}");
+ if (existing is not null)
+ {
+ Console.WriteLine($"Current endpoint: {existing.Endpoint.AbsoluteUri}");
+ Console.WriteLine($"Current username: {existing.Username}");
+ Console.WriteLine($"Currently enabled: {existing.Enabled}");
+ }
+
+ try
+ {
+ while (true)
+ {
+ var endpoint = PromptWithDefault(
+ "Panel endpoint",
+ existing?.Endpoint.AbsoluteUri ?? SuggestedEndpoint);
+ var username = PromptWithDefault(
+ "Digest username",
+ existing?.Username ?? SuggestedUsername);
+ var enabled = PromptBoolean(
+ "Enable wireless panel",
+ existing?.Enabled ?? true);
+ var enteredPassword = ReadHiddenPassword(existing is not null);
+ var password = enteredPassword.Length == 0 && existing is not null
+ ? existing.Password
+ : enteredPassword;
+
+ try
+ {
+ var configuration = WirelessPanelConfiguration.Create(
+ endpoint,
+ username,
+ password,
+ enabled);
+ store.Save(configuration);
+
+ Console.WriteLine();
+ Console.WriteLine("Wireless-panel settings saved.");
+ Console.WriteLine($"Endpoint: {configuration.Endpoint.AbsoluteUri}");
+ Console.WriteLine($"Username: {configuration.Username}");
+ Console.WriteLine($"Enabled: {configuration.Enabled}");
+ Console.WriteLine($"File: {store.ConfigurationPath}");
+ return 0;
+ }
+ catch (ArgumentException exception)
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Settings were not saved: {exception.Message}");
+ Console.Error.WriteLine("Please enter the settings again.");
+ }
+ catch (Exception exception) when (
+ exception is IOException
+ or UnauthorizedAccessException
+ or System.Security.Cryptography.CryptographicException)
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Unable to save the panel settings: {exception.Message}");
+ return 2;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Provisioning cancelled.");
+ return 130;
+ }
+ }
+
+ private static string PromptWithDefault(string label, string defaultValue)
+ {
+ Console.Write($"{label} [{defaultValue}]: ");
+ var value = Console.ReadLine();
+ if (value is null)
+ {
+ throw new OperationCanceledException();
+ }
+
+ return string.IsNullOrWhiteSpace(value) ? defaultValue : value;
+ }
+
+ private static bool PromptBoolean(string label, bool defaultValue)
+ {
+ while (true)
+ {
+ Console.Write($"{label} [{(defaultValue ? "Y/n" : "y/N")}]: ");
+ var value = Console.ReadLine();
+ if (value is null)
+ {
+ throw new OperationCanceledException();
+ }
+
+ value = value.Trim();
+ if (value.Length == 0)
+ {
+ return defaultValue;
+ }
+
+ if (value.Equals("y", StringComparison.OrdinalIgnoreCase)
+ || value.Equals("yes", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (value.Equals("n", StringComparison.OrdinalIgnoreCase)
+ || value.Equals("no", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ Console.Error.WriteLine("Enter yes or no.");
+ }
+ }
+
+ private static string ReadHiddenPassword(bool canKeepExisting)
+ {
+ Console.Write(
+ canKeepExisting
+ ? "Digest password (hidden; Enter keeps current): "
+ : "Digest password (hidden): ");
+
+ var password = new StringBuilder();
+ while (true)
+ {
+ var key = Console.ReadKey(intercept: true);
+ if (key.Key == ConsoleKey.Enter)
+ {
+ Console.WriteLine();
+ return password.ToString();
+ }
+
+ if (key.Key == ConsoleKey.Backspace)
+ {
+ if (password.Length > 0)
+ {
+ password.Length--;
+ }
+
+ continue;
+ }
+
+ if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.C)
+ {
+ throw new OperationCanceledException();
+ }
+
+ if (!char.IsControl(key.KeyChar))
+ {
+ password.Append(key.KeyChar);
+ }
+ }
+ }
+}
From d866c3dd1c2b4418a011be470f63720e02d606cf Mon Sep 17 00:00:00 2001
From: Mattie <566406+Mattie@users.noreply.github.com>
Date: Mon, 27 Jul 2026 20:35:22 -0500
Subject: [PATCH 2/5] feat: add ESPHome touchscreen firmware examples
---
.gitignore | 3 +
firmware/esphome/.gitignore | 8 +
firmware/esphome/README.md | 176 +++++++
firmware/esphome/joydex-panel-bridge.yaml | 576 ++++++++++++++++++++++
firmware/esphome/joydex-panel.yaml | 510 +++++++++++++++++++
firmware/esphome/requirements.txt | 1 +
firmware/esphome/secrets.example.yaml | 10 +
7 files changed, 1284 insertions(+)
create mode 100644 firmware/esphome/.gitignore
create mode 100644 firmware/esphome/README.md
create mode 100644 firmware/esphome/joydex-panel-bridge.yaml
create mode 100644 firmware/esphome/joydex-panel.yaml
create mode 100644 firmware/esphome/requirements.txt
create mode 100644 firmware/esphome/secrets.example.yaml
diff --git a/.gitignore b/.gitignore
index 2dd89d9..7dc7d61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,9 @@ AGENTS.override.md
# Machine-specific configuration
/config/joydex.json
/config/config.json
+/firmware/esphome/secrets.yaml
+/firmware/esphome/.esphome/
+/firmware/esphome/.venv/
# Local reference material retained outside the public source set
/Virpil Mongoose T-50CM2+3 Fillable.pdf
diff --git a/firmware/esphome/.gitignore b/firmware/esphome/.gitignore
new file mode 100644
index 0000000..af6313c
--- /dev/null
+++ b/firmware/esphome/.gitignore
@@ -0,0 +1,8 @@
+# Gitignore settings for ESPHome
+# This is an example and may include too much for your use-case.
+# You can modify this file to suit your needs.
+/.esphome/
+/secrets.yaml
+/*.bin
+/*.elf
+/*.map
diff --git a/firmware/esphome/README.md b/firmware/esphome/README.md
new file mode 100644
index 0000000..0435e07
--- /dev/null
+++ b/firmware/esphome/README.md
@@ -0,0 +1,176 @@
+# Experimental Joydex ESPHome touchscreen
+
+This directory contains source examples for the
+ESP32-4848S040C_I / GUITION-4848S040 480×480 capacitive touchscreen. The
+panel joins a normal 2.4 GHz Wi-Fi network and talks directly to the Joydex
+Windows app. Home Assistant, MQTT, the ESPHome native API, a captive portal,
+and a separate runtime access point are not required.
+
+This is experimental support tested on one physical panel. Seller listings and
+board revisions can change, so verify the exact model and pinout before
+flashing:
+
+- [Tested AliExpress listing](https://www.aliexpress.us/item/3256808028364930.html)
+ (purchased as `ESP32-4848S040C_I`; no affiliation).
+- [GUITION specification](https://www.guition.com/ku/icms/upload/fb081940d6fc11f09850077a33e1404f/FTPData/UEditor/file/2026121/1768961092477/ESP32-4848S040%20Specifications-EN.pdf).
+- [Device-specific findings and recovery notes](../../docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md).
+
+## Choose a skin
+
+- `joydex-panel.yaml` is the neutral white baseline.
+- `joydex-panel-bridge.yaml` is the dark retro-futuristic bridge-console skin.
+
+Both expose the same host contract:
+
+- `Task 1 State` through `Task 4 State` accept `EMPTY`, `RUNNING`,
+ `ATTENTION`, and `COMPLETE`.
+- `Task 1` through `Task 4` are momentary touch controls.
+- The visible `PLAN MODE` control retains the ESPHome entity name `Sidebar`
+ for compatibility with the first deployed firmware.
+- `/events` supplies authenticated Server-Sent Events to Joydex.
+
+Empty task positions are blank gray. Running tasks are white with gray borders
+and text. Attention tasks are yellow with gray borders and text. Completed
+tasks are green with white text. Pressed controls contract slightly and gain a
+cyan border until release.
+
+## Prerequisites
+
+- Python 3.12
+- A data-capable USB cable for the first flash
+- A trusted 2.4 GHz LAN
+- A private backup of the panel's original flash
+- Joydex built from this repository
+
+ESPHome is pinned in `requirements.txt` so the example does not silently adopt
+new display defaults.
+
+## Prepare secrets
+
+From `firmware/esphome`:
+
+```powershell
+Copy-Item -LiteralPath .\secrets.example.yaml -Destination .\secrets.yaml
+```
+
+Replace every placeholder in `secrets.yaml`. Use distinct, long random values
+for the Web Server and OTA passwords. The real file is ignored by Git.
+
+The generated `.esphome` directory can contain expanded secrets, and compiled
+firmware contains the credentials it needs at runtime. Both remain private and
+ignored.
+
+## Install the pinned ESPHome CLI
+
+```powershell
+py.exe -3.12 -m venv .venv
+.\.venv\Scripts\python.exe -m pip install --requirement .\requirements.txt
+```
+
+## Validate and compile
+
+```powershell
+.\.venv\Scripts\esphome.exe config .\joydex-panel.yaml
+.\.venv\Scripts\esphome.exe config .\joydex-panel-bridge.yaml
+
+.\.venv\Scripts\esphome.exe compile .\joydex-panel.yaml
+.\.venv\Scripts\esphome.exe compile .\joydex-panel-bridge.yaml
+```
+
+Warnings about GPIO19 and GPIO20 being unavailable to native
+USB-Serial-JTAG are expected on this board: those pins are used by touch and
+RGB display data. The tested panel exposes a CH340 UART bridge for flashing and
+logging.
+
+## First flash
+
+Before writing a panel:
+
+1. Confirm the exact model and current serial port.
+2. Read and hash a complete 16 MiB factory backup for that physical unit.
+3. Validate and compile the chosen YAML.
+4. Hash the exact generated factory image.
+5. Keep every backup and compiled image outside the repository.
+
+Use `esptool` against the verified port and exact factory image:
+
+```powershell
+.\.venv\Scripts\python.exe -m esptool `
+ --chip esp32s3 `
+ --port `
+ --baud 460800 `
+ --after hard-reset `
+ write-flash 0x0
+```
+
+Do not restore one panel's whole-flash backup to another panel.
+
+## Configure Joydex
+
+From the repository root:
+
+```powershell
+dotnet run `
+ --project .\tools\Joydex.WirelessPanel.Configure\Joydex.WirelessPanel.Configure.csproj
+```
+
+Enter the panel endpoint, Web Server username, and matching password. Start
+with `http://joydex-panel.local/`. If `.local` resolution is unreliable on the
+Windows host, reserve the panel's DHCP lease and use that stable LAN address.
+
+The password is hidden while typed and saved with Windows CurrentUser DPAPI at:
+
+```text
+%LOCALAPPDATA%\Joydex\WirelessPanel\panel.json
+```
+
+Restart Joydex or choose **Reload configuration** after changing the panel
+settings. Published Joydex builds include
+`Joydex.WirelessPanel.Configure.exe`.
+
+## Updates
+
+After the initial USB flash, use password-protected ESPHome OTA with the
+panel's hostname or reserved LAN address:
+
+```powershell
+.\.venv\Scripts\esphome.exe upload .\joydex-panel-bridge.yaml `
+ --device
+```
+
+Keep the neutral skin and the unit-specific factory backup available as
+rollback paths.
+
+## Security boundary
+
+ESPHome's Web Server uses Digest authentication and disables web-based OTA and
+log handlers. Native password-protected ESPHome OTA remains enabled.
+
+The REST and SSE traffic is still HTTP without TLS. Use the example only on a
+trusted LAN:
+
+- never port-forward the panel;
+- do not expose it to the public internet;
+- use unique Web Server and OTA passwords;
+- reserve its DHCP lease if hostname resolution is unreliable;
+- keep backups, build caches, and firmware binaries private.
+
+## Transport behavior
+
+Joydex opens authenticated `GET /events`. ESPHome sends current-state catch-up
+events without a separate marker, so Joydex suppresses the first state
+observed for each expected touch entity and then reacts to live `OFF` to `ON`
+edges.
+
+Normal task changes post only the slots whose projected state changed, which
+limits display redraws. Every SSE reconnect forces a complete four-slot
+replacement so the panel converges after a network or host interruption.
+
+Example state update:
+
+```text
+POST /select/Task%201%20State/set?option=RUNNING
+```
+
+See the [research record](../../docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md) for
+the architecture decision and the physical findings behind this example.
diff --git a/firmware/esphome/joydex-panel-bridge.yaml b/firmware/esphome/joydex-panel-bridge.yaml
new file mode 100644
index 0000000..85307cd
--- /dev/null
+++ b/firmware/esphome/joydex-panel-bridge.yaml
@@ -0,0 +1,576 @@
+substitutions:
+ device_name: joydex-panel
+ friendly_name: Joydex Panel
+
+esphome:
+ name: ${device_name}
+ friendly_name: ${friendly_name}
+ min_version: 2026.7.2
+ project:
+ name: joydex.wireless_touchscreen
+ version: 0.2.1-bridge
+
+esp32:
+ variant: esp32s3
+ flash_size: 16MB
+ framework:
+ type: esp-idf
+
+psram:
+ mode: octal
+ speed: 80MHz
+
+logger:
+ level: INFO
+ hardware_uart: UART0
+
+wifi:
+ ssid: !secret wifi_ssid
+ password: !secret wifi_password
+ power_save_mode: none
+
+web_server:
+ port: 80
+ version: 3
+ auth:
+ username: !secret web_server_username
+ password: !secret web_server_password
+ type: digest
+ log: false
+ ota: false
+
+ota:
+ - platform: esphome
+ password: !secret ota_password
+
+output:
+ - platform: ledc
+ id: backlight_output
+ pin: GPIO38
+ frequency: 150Hz
+ min_power: 0.01
+ zero_means_zero: true
+
+light:
+ - platform: monochromatic
+ id: display_backlight
+ name: Backlight
+ output: backlight_output
+ restore_mode: ALWAYS_ON
+ default_transition_length: 250ms
+
+spi:
+ - id: lcd_spi
+ clk_pin: GPIO48
+ mosi_pin: GPIO47
+
+i2c:
+ id: touchscreen_bus
+ sda: GPIO19
+ scl:
+ number: GPIO45
+ ignore_strapping_warning: true
+
+display:
+ - platform: mipi_rgb
+ id: tft_display
+ model: GUITION-4848S040
+ spi_id: lcd_spi
+ # Keep the exact C_I timings explicit until ESPHome issue #17810 is fixed.
+ spi_mode: MODE3
+ hsync_pulse_width: 8
+ hsync_front_porch: 10
+ hsync_back_porch: 20
+ vsync_pulse_width: 8
+ vsync_front_porch: 10
+ vsync_back_porch: 10
+ update_interval: never
+ auto_clear_enabled: false
+
+touchscreen:
+ - platform: gt911
+ id: tft_touch
+ i2c_id: touchscreen_bus
+ display: tft_display
+ transform:
+ mirror_x: true
+ mirror_y: true
+
+select:
+ - platform: template
+ id: task_1_state
+ name: Task 1 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_1_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ - lvgl.label.update:
+ id: task_1_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 1");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_1_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+ - platform: template
+ id: task_2_state
+ name: Task 2 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_2_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ - lvgl.label.update:
+ id: task_2_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 2");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_2_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+ - platform: template
+ id: task_3_state
+ name: Task 3 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_3_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ - lvgl.label.update:
+ id: task_3_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 3");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_3_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+ - platform: template
+ id: task_4_state
+ name: Task 4 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_4_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ - lvgl.label.update:
+ id: task_4_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 4");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_4_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+binary_sensor:
+ - platform: lvgl
+ id: task_1_pressed
+ name: Task 1
+ widget: task_1_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: task_2_pressed
+ name: Task 2
+ widget: task_2_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: task_3_pressed
+ name: Task 3
+ widget: task_3_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: task_4_pressed
+ name: Task 4
+ widget: task_4_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: sidebar_pressed
+ name: Sidebar
+ widget: sidebar_button
+ state: PRESSED
+
+lvgl:
+ displays:
+ - tft_display
+ touchscreens:
+ - touchscreen_id: tft_touch
+ bottom_layer:
+ bg_color: 0x09060D
+ bg_opa: COVER
+ pages:
+ - id: main_page
+ bg_color: 0x09060D
+ bg_opa: COVER
+ border_width: 0
+ pad_all: 0
+ scrollbar_mode: "OFF"
+ widgets:
+ # Static retro-futuristic bridge chrome. These objects never change after boot,
+ # keeping button and status redraws local to the affected control.
+ - obj:
+ x: 8
+ y: 8
+ width: 464
+ height: 56
+ radius: 28
+ border_width: 0
+ bg_color: 0xF1A15F
+ - obj:
+ x: 8
+ y: 8
+ width: 56
+ height: 342
+ radius: 28
+ border_width: 0
+ bg_color: 0xF1A15F
+ - obj:
+ x: 8
+ y: 76
+ width: 56
+ height: 86
+ radius: 10
+ border_width: 0
+ bg_color: 0xE98175
+ - obj:
+ x: 8
+ y: 170
+ width: 56
+ height: 118
+ radius: 10
+ border_width: 0
+ bg_color: 0xB79AD8
+ - obj:
+ x: 8
+ y: 296
+ width: 56
+ height: 54
+ radius: 10
+ border_width: 0
+ bg_color: 0x66D9EF
+
+ - label:
+ x: 72
+ y: 18
+ width: 392
+ height: 36
+ text: JOYDEX PAD
+ text_font: montserrat_28
+ text_color: 0x09060D
+ text_align: CENTER
+
+ - button:
+ id: task_1_card
+ x: 72
+ y: 82
+ width: 190
+ height: 116
+ radius: 22
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ widgets:
+ - label:
+ id: task_1_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_1_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: task_2_card
+ x: 274
+ y: 82
+ width: 190
+ height: 116
+ radius: 22
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ widgets:
+ - label:
+ id: task_2_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_2_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: task_3_card
+ x: 72
+ y: 210
+ width: 190
+ height: 116
+ radius: 22
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ widgets:
+ - label:
+ id: task_3_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_3_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: task_4_card
+ x: 274
+ y: 210
+ width: 190
+ height: 116
+ radius: 22
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ widgets:
+ - label:
+ id: task_4_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_4_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: sidebar_button
+ x: 72
+ y: 364
+ width: 392
+ height: 92
+ radius: 46
+ border_width: 3
+ border_color: 0xF1A15F
+ bg_color: 0xE98175
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ transform_width: -6
+ transform_height: -6
+ widgets:
+ - label:
+ align: CENTER
+ text: PLAN MODE
+ text_font: montserrat_28
+ text_color: 0x09060D
diff --git a/firmware/esphome/joydex-panel.yaml b/firmware/esphome/joydex-panel.yaml
new file mode 100644
index 0000000..77ff70b
--- /dev/null
+++ b/firmware/esphome/joydex-panel.yaml
@@ -0,0 +1,510 @@
+substitutions:
+ device_name: joydex-panel
+ friendly_name: Joydex Panel
+
+esphome:
+ name: ${device_name}
+ friendly_name: ${friendly_name}
+ min_version: 2026.7.2
+ project:
+ name: joydex.wireless_touchscreen
+ version: 0.1.0
+
+esp32:
+ variant: esp32s3
+ flash_size: 16MB
+ framework:
+ type: esp-idf
+
+psram:
+ mode: octal
+ speed: 80MHz
+
+logger:
+ level: INFO
+ hardware_uart: UART0
+
+wifi:
+ ssid: !secret wifi_ssid
+ password: !secret wifi_password
+ power_save_mode: none
+
+web_server:
+ port: 80
+ version: 3
+ auth:
+ username: !secret web_server_username
+ password: !secret web_server_password
+ type: digest
+ log: false
+ ota: false
+
+ota:
+ - platform: esphome
+ password: !secret ota_password
+
+output:
+ - platform: ledc
+ id: backlight_output
+ pin: GPIO38
+ frequency: 150Hz
+ min_power: 0.01
+ zero_means_zero: true
+
+light:
+ - platform: monochromatic
+ id: display_backlight
+ name: Backlight
+ output: backlight_output
+ restore_mode: ALWAYS_ON
+ default_transition_length: 250ms
+
+spi:
+ - id: lcd_spi
+ clk_pin: GPIO48
+ mosi_pin: GPIO47
+
+i2c:
+ id: touchscreen_bus
+ sda: GPIO19
+ scl:
+ number: GPIO45
+ ignore_strapping_warning: true
+
+display:
+ - platform: mipi_rgb
+ id: tft_display
+ model: GUITION-4848S040
+ spi_id: lcd_spi
+ # Keep the exact C_I timings explicit until ESPHome issue #17810 is fixed.
+ spi_mode: MODE3
+ hsync_pulse_width: 8
+ hsync_front_porch: 10
+ hsync_back_porch: 20
+ vsync_pulse_width: 8
+ vsync_front_porch: 10
+ vsync_back_porch: 10
+ update_interval: never
+ auto_clear_enabled: false
+
+touchscreen:
+ - platform: gt911
+ id: tft_touch
+ i2c_id: touchscreen_bus
+ display: tft_display
+ transform:
+ mirror_x: true
+ mirror_y: true
+
+select:
+ - platform: template
+ id: task_1_state
+ name: Task 1 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_1_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ - lvgl.label.update:
+ id: task_1_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 1");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_1_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+ - platform: template
+ id: task_2_state
+ name: Task 2 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_2_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ - lvgl.label.update:
+ id: task_2_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 2");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_2_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+ - platform: template
+ id: task_3_state
+ name: Task 3 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_3_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ - lvgl.label.update:
+ id: task_3_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 3");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_3_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+ - platform: template
+ id: task_4_state
+ name: Task 4 State
+ optimistic: true
+ restore_value: false
+ initial_option: EMPTY
+ options:
+ - EMPTY
+ - RUNNING
+ - ATTENTION
+ - COMPLETE
+ on_value:
+ then:
+ - lvgl.widget.update:
+ id: task_4_card
+ bg_color: !lambda |-
+ if (x == "RUNNING") {
+ return lv_color_make(255, 255, 255);
+ }
+ if (x == "ATTENTION") {
+ return lv_color_make(244, 211, 94);
+ }
+ if (x == "COMPLETE") {
+ return lv_color_make(35, 155, 86);
+ }
+ return lv_color_make(107, 114, 128);
+ border_width: !lambda |-
+ return (x == "RUNNING" || x == "ATTENTION") ? 2 : 0;
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ - lvgl.label.update:
+ id: task_4_title_label
+ text: !lambda |-
+ return std::string(x == "EMPTY" ? "" : "TASK 4");
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+ - lvgl.label.update:
+ id: task_4_state_label
+ text: !lambda |-
+ if (x == "EMPTY") {
+ return "";
+ }
+ if (x == "ATTENTION") {
+ return "WAITING";
+ }
+ if (x == "COMPLETE") {
+ return "DONE";
+ }
+ return x;
+ text_color: !lambda |-
+ return x == "COMPLETE"
+ ? lv_color_make(255, 255, 255)
+ : lv_color_make(82, 82, 82);
+
+binary_sensor:
+ - platform: lvgl
+ id: task_1_pressed
+ name: Task 1
+ widget: task_1_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: task_2_pressed
+ name: Task 2
+ widget: task_2_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: task_3_pressed
+ name: Task 3
+ widget: task_3_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: task_4_pressed
+ name: Task 4
+ widget: task_4_card
+ state: PRESSED
+
+ - platform: lvgl
+ id: sidebar_pressed
+ name: Sidebar
+ widget: sidebar_button
+ state: PRESSED
+
+lvgl:
+ displays:
+ - tft_display
+ touchscreens:
+ - touchscreen_id: tft_touch
+ bottom_layer:
+ bg_color: 0x0B1118
+ bg_opa: COVER
+ pages:
+ - id: main_page
+ widgets:
+ - label:
+ x: 16
+ y: 12
+ width: 448
+ height: 34
+ text: JOYDEX PAD
+ text_font: montserrat_28
+ text_color: 0x525252
+ text_align: CENTER
+
+ - button:
+ id: task_1_card
+ x: 16
+ y: 56
+ width: 220
+ height: 124
+ radius: 14
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ widgets:
+ - label:
+ id: task_1_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_1_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: task_2_card
+ x: 244
+ y: 56
+ width: 220
+ height: 124
+ radius: 14
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ widgets:
+ - label:
+ id: task_2_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_2_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: task_3_card
+ x: 16
+ y: 188
+ width: 220
+ height: 124
+ radius: 14
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ widgets:
+ - label:
+ id: task_3_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_3_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: task_4_card
+ x: 244
+ y: 188
+ width: 220
+ height: 124
+ radius: 14
+ border_width: 0
+ border_color: 0x6B7280
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ widgets:
+ - label:
+ id: task_4_title_label
+ x: 8
+ y: 8
+ text: ""
+ text_font: montserrat_18
+ text_color: 0x525252
+ - label:
+ id: task_4_state_label
+ align: CENTER
+ text: ""
+ text_font: montserrat_26
+ text_color: 0x525252
+
+ - button:
+ id: sidebar_button
+ x: 16
+ y: 330
+ width: 448
+ height: 121
+ radius: 14
+ border_width: 2
+ border_color: 0x555C66
+ bg_color: 0x6B7280
+ pressed:
+ border_color: 0x66D9EF
+ border_width: 8
+ widgets:
+ - label:
+ align: CENTER
+ text: PLAN MODE
+ text_font: montserrat_28
+ text_color: 0xFFFFFF
diff --git a/firmware/esphome/requirements.txt b/firmware/esphome/requirements.txt
new file mode 100644
index 0000000..4b5e79f
--- /dev/null
+++ b/firmware/esphome/requirements.txt
@@ -0,0 +1 @@
+esphome==2026.7.2
diff --git a/firmware/esphome/secrets.example.yaml b/firmware/esphome/secrets.example.yaml
new file mode 100644
index 0000000..ab11f88
--- /dev/null
+++ b/firmware/esphome/secrets.example.yaml
@@ -0,0 +1,10 @@
+# Copy this file to secrets.yaml and replace every placeholder before validation,
+# compilation, or flashing. secrets.yaml is ignored by Git.
+
+wifi_ssid: "REPLACE_WITH_2_4_GHZ_WIFI_NAME"
+wifi_password: "REPLACE_WITH_WIFI_PASSWORD"
+
+web_server_username: "joydex"
+web_server_password: "REPLACE_WITH_A_LONG_RANDOM_WEB_PASSWORD"
+
+ota_password: "REPLACE_WITH_A_DIFFERENT_LONG_RANDOM_OTA_PASSWORD"
From f8da3f65d0387b4745dd0ab8279a284f96522596 Mon Sep 17 00:00:00 2001
From: Mattie <566406+Mattie@users.noreply.github.com>
Date: Mon, 27 Jul 2026 20:36:02 -0500
Subject: [PATCH 3/5] docs: publish the wireless touchscreen example
---
CHANGELOG.md | 8 +
README.md | 34 ++
THIRD_PARTY_NOTICES.md | 10 +
docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md | 293 ++++++++++++++++++
docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md | 201 ++++++++++++
.../joydex-esp32-4848s040c-in-action.jpg | Bin 0 -> 313536 bytes
...reless-touchscreen-architecture-bridge.png | Bin 0 -> 877928 bytes
7 files changed, 546 insertions(+)
create mode 100644 docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md
create mode 100644 docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md
create mode 100644 docs/images/joydex-esp32-4848s040c-in-action.jpg
create mode 100644 docs/images/wireless-touchscreen-architecture-bridge.png
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 839151c..e844ceb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
This file records the major capabilities and fixes added since Joydex was first uploaded to GitHub. Entries are grouped by date because the project has not used versioned releases yet. New entries go at the top.
+## 2026-07-27
+
+- Added an experimental direct ESPHome touchscreen example for the
+ ESP32-4848S040C_I, including neutral and bridge-console skins, live task
+ state, four task controls, PLAN MODE, authenticated REST/SSE transport,
+ DPAPI-protected host configuration, physical-device documentation, and
+ recovery guidance.
+
## 2026-07-24
- Kept the physical task monitor focused on real Codex sidebar tasks. The [hook relay](src/Joydex.HookRelay/Program.cs) now ignores delegated agents identified by `agent_id` and internal ephemeral sessions that have no persistent `transcript_path`.
diff --git a/README.md b/README.md
index 1c76e33..999e940 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,40 @@ Joydex is a source project, so the easiest path is to clone the repository, open
The task-status LEDs need a few additional steps because they use Codex hooks and VIRPIL LinkTool.
+## Experimental wireless touchscreen
+
+Joydex also includes an experimental ESPHome example for the
+`ESP32-4848S040C_I`: a 4-inch, 480×480 capacitive touchscreen that joins the
+normal 2.4 GHz LAN. It displays the same four primary task states as the
+throttle LEDs and provides touch controls for Task 1 through Task 4 plus PLAN
+MODE. It talks directly to Joydex through authenticated REST and Server-Sent
+Events; Home Assistant and MQTT are not required.
+
+
+
+The card is shown only for scale. Magic: The Gathering and its publisher are
+not affiliated with Joydex.
+
+The example was physically tested July 25–27, 2026 on one panel purchased from
+this [AliExpress listing](https://www.aliexpress.us/item/3256808028364930.html).
+Joydex has no affiliation with the seller. Listings and board
+revisions can change, so verify the `ESP32-4848S040C_I` model, 480×480
+resolution, and capacitive-touch option before flashing. The
+[GUITION specification](https://www.guition.com/ku/icms/upload/fb081940d6fc11f09850077a33e1404f/FTPData/UEditor/file/2026121/1768961092477/ESP32-4848S040%20Specifications-EN.pdf)
+documents the tested family.
+
+Start with the
+[ESPHome firmware guide](firmware/esphome/README.md). The repository includes
+a neutral white skin and a dark bridge-console skin. Both require local
+credentials, a trusted LAN, and a private unit-specific factory backup.
+Compiled firmware is deliberately not distributed because it embeds the
+credentials used to join and manage the panel.
+
+The [wireless research record](docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md)
+explains the direct ESPHome design, while the
+[device reference](docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md) records the
+tested timing, pins, flashing, redraw, and recovery findings.
+
## What the experiment produced
Joydex runs as a Windows tray app and reads one or more controllers through background, non-exclusive DirectInput. It leaves controller firmware and VPC profiles alone. The included mapping uses the CM3's shifted button ranges to expose Codex controls across three dial positions, while device-qualified bindings can also use a VIRPIL Alpha/WarBRD or another attached controller.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 0e0a5d6..605f18e 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -19,3 +19,13 @@ The Alpha/WarBRD button-map artwork in `docs/images/joydex-button-map_vpc-conste
Joydex's volatile LED feature-report implementation is informed by the Apache-2.0-licensed [VLEDCONTROL](https://github.com/Nereid42/VLEDCONTROL) project by Nereid42. Joydex contains a separately adapted implementation for its limited task-alert use case.
Joydex uses [HidSharp](https://github.com/SeekHisKingdom/HIDSharp), copyright 2010-2025 James F. Bellinger, under the Apache License 2.0, to open VIRPIL HID feature-report streams.
+
+## Wireless-panel scale photograph
+
+The photograph in
+`docs/images/joydex-esp32-4848s040c-in-action.jpg` was created for Joydex by
+Mattie Casper. The depicted Magic: The Gathering card is included only as a
+familiar size reference. Its artwork, text, product name, and trademarks remain
+the property of their respective rights holders and are not licensed under the
+Joydex MIT License. Those rights holders are not affiliated with or endorsing
+Joydex.
diff --git a/docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md b/docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md
new file mode 100644
index 0000000..7ae772c
--- /dev/null
+++ b/docs/ESP32_4848S040C_I_DEVICE_REFERENCE.md
@@ -0,0 +1,293 @@
+# ESP32-4848S040C_I device reference
+
+This document records reusable findings from bringing up Joydex on one
+ESP32-4848S040C_I panel. Treat it as evidence for the tested unit, not a
+promise that every similarly named seller listing has identical hardware.
+
+Last updated: July 27, 2026.
+
+## Confirmed identity
+
+| Property | Tested finding | Confidence for another unit |
+| --- | --- | --- |
+| Purchased model | `ESP32-4848S040C_I`, sold as a 4-inch capacitive-touch panel | Medium; listings change |
+| MCU | ESP32-S3 | High |
+| Flash | 16 MiB | High |
+| PSRAM | 8 MiB octal/OPI | High |
+| Display | 4-inch 480×480 IPS TFT, ST7701-family RGB interface | High |
+| Touch | Capacitive, using the GT911 ESPHome recipe | High for this recipe |
+| USB | CH340 USB-to-UART bridge | High on the tested unit |
+| Wi-Fi | Normal 2.4 GHz LAN | High |
+
+The tested unit's rear silkscreen, optional relay population, and exact PCB
+revision were not visible. Photograph and compare the rear of a new production
+run if its behavior differs.
+
+Primary references:
+
+- [Tested AliExpress listing](https://www.aliexpress.us/item/3256808028364930.html)
+ (no affiliation).
+- [GUITION specification](https://www.guition.com/ku/icms/upload/fb081940d6fc11f09850077a33e1404f/FTPData/UEditor/file/2026121/1768961092477/ESP32-4848S040%20Specifications-EN.pdf).
+- [ESPHome exact-board recipe](https://devices.esphome.io/devices/guition-esp32-s3-4848s040/).
+- [ESPHome MIPI RGB component](https://esphome.io/components/display/mipi_rgb/).
+
+## Display settings
+
+Joydex pins ESPHome 2026.7.2 and uses the integrated
+`GUITION-4848S040` model with explicit `C_I` timing:
+
+```yaml
+display:
+ - platform: mipi_rgb
+ model: GUITION-4848S040
+ spi_mode: MODE3
+ hsync_pulse_width: 8
+ hsync_front_porch: 10
+ hsync_back_porch: 20
+ vsync_pulse_width: 8
+ vsync_front_porch: 10
+ vsync_back_porch: 10
+```
+
+Keep these overrides together until a newer ESPHome preset is physically
+confirmed on the same panel revision. Compilation alone cannot prove
+orientation, pixel alignment, color order, or clean blanking.
+
+Relevant upstream reports:
+
+- [ESPHome issue #13569](https://github.com/esphome/esphome/issues/13569)
+- [ESPHome issue #17810](https://github.com/esphome/esphome/issues/17810)
+
+The display uses a framebuffer in octal PSRAM. The current configuration uses
+ESP-IDF, 8 MiB octal PSRAM at 80 MHz, a 12 MHz pixel clock, and RGB565.
+
+For a dark LVGL skin, set the page itself to an opaque dark background.
+Styling only the bottom layer left white gaps on the physical panel:
+
+```yaml
+pages:
+ - id: main_page
+ bg_color: 0x000000
+ bg_opa: COVER
+```
+
+## Confirmed pin map
+
+| Function | GPIO |
+| --- | --- |
+| Backlight PWM | 38 |
+| Display initialization clock | 48 |
+| Display initialization MOSI | 47 |
+| Display chip select | 39 |
+| RGB data enable | 18 |
+| RGB horizontal sync | 16 |
+| RGB vertical sync | 17 |
+| RGB pixel clock | 21 |
+| Red data | 11, 12, 13, 14, 0 |
+| Green data | 8, 20, 3, 46, 9, 10 |
+| Blue data | 4, 5, 6, 7, 15 |
+| GT911 I2C SDA | 19 |
+| GT911 I2C SCL | 45 |
+
+The current backlight uses LEDC at 150 Hz with `ALWAYS_ON` restore mode.
+
+### Pin hazards
+
+- GPIO19 and GPIO20 are normally associated with ESP32-S3 native USB. This
+ board uses them for touch and RGB display data, so ESPHome's native-USB
+ warnings are expected.
+- GPIO45 is a strapping pin used for GT911 SCL by the known board recipe.
+- GPIO0 is boot-sensitive and belongs to the RGB bus.
+- GPIO33 through GPIO37 belong to the octal flash/PSRAM configuration.
+- GPIO47 and GPIO48 may also appear in vendor microSD examples.
+- Vendor material assigns GPIO1, GPIO2, and GPIO40 to either audio or relay
+ options depending on board population.
+- The 16-bit RGB bus consumes most GPIOs. Treat any pin outside the confirmed
+ table as unavailable until the exact PCB and optional components are known.
+
+## USB, logging, and first flash
+
+The tested panel entered the ESP32-S3 bootloader through its CH340 bridge
+without a manual button sequence. A complete 16 MiB flash read, factory-image
+write, verification, and hard reset succeeded through that path.
+
+Re-discover the serial port before every operation. A Windows COM assignment
+is temporary and is not a reliable identity when several identical panels are
+connected.
+
+ESPHome logs through UART0 at 115200 baud. GPIO19/GPIO20 warnings concern the
+native USB path and do not prevent CH340 UART flashing.
+
+Before the first write to each physical unit:
+
+1. Connect only the intended panel.
+2. Record its chip identity and flash size.
+3. Read the complete 16 MiB flash into private storage.
+4. Hash the backup and bind that record to the physical unit.
+5. Validate and compile the chosen Joydex YAML.
+6. Hash the exact generated factory image.
+7. Reconfirm the port, unit, backup, and intended image before writing.
+
+Example backup command:
+
+```powershell
+.\firmware\esphome\.venv\Scripts\python.exe -m esptool `
+ --chip esp32s3 `
+ --port `
+ --baud 460800 `
+ read-flash 0x0 0x1000000 `
+
+```
+
+Example first-flash command:
+
+```powershell
+.\firmware\esphome\.venv\Scripts\python.exe -m esptool `
+ --chip esp32s3 `
+ --port `
+ --baud 460800 `
+ --after hard-reset `
+ write-flash 0x0
+```
+
+Whole-flash images can contain device data. Never publish them and never
+restore one unit's backup to another unit.
+
+## Wi-Fi findings
+
+The panel runs on the normal wider 2.4 GHz LAN. The firmware has no captive
+portal, fallback access point, Home Assistant dependency, or MQTT broker.
+Wi-Fi power saving is disabled for responsive touch use.
+
+Windows `.local` name resolution varied across tools during the canary. Direct
+authenticated requests to the DHCP address worked consistently. For routine
+use, reserve the panel's DHCP lease and configure Joydex with either the proven
+hostname or that reserved address.
+
+Do not publish the panel's LAN address, MAC address, SSID, or authentication
+material in documentation or logs.
+
+## REST and SSE behavior
+
+The ESPHome Web Server supplies:
+
+```text
+GET /events
+POST /select/Task%201%20State/set?option=RUNNING
+```
+
+The stream sends catch-up state without a separate catch-up marker. Joydex
+therefore suppresses the first state seen for each expected touch entity and
+acts only on later `OFF` to `ON` transitions.
+
+Normal state publications contain only changed task slots. Reconnects force a
+complete four-slot replacement. This combination reduced redraw artifacts
+without sacrificing recovery after a missed update.
+
+All requests use Digest authentication. Digest protects the password
+challenge/response, but the HTTP traffic has no TLS confidentiality or server
+identity. Keep the panel on a trusted LAN and never port-forward it.
+
+## Current screen contract
+
+The public firmware contains two compatible skins:
+
+- `joydex-panel.yaml`: neutral white baseline.
+- `joydex-panel-bridge.yaml`: dark bridge-console skin.
+
+Both expose four task controls and one visible PLAN MODE control:
+
+| Joydex meaning | ESPHome state | Card styling |
+| --- | --- | --- |
+| Empty | `EMPTY` | Blank gray |
+| Running | `RUNNING` | White, gray border and text |
+| Approval or attention | `ATTENTION` | Yellow, gray border and text |
+| Complete | `COMPLETE` | Green, white text |
+| Fault | `ATTENTION` | Same treatment as attention |
+
+The visible PLAN MODE control retains the ESPHome entity name `Sidebar` and
+internal ID `sidebar_pressed` for compatibility. Joydex's host model and action
+remain Plan Mode.
+
+Pressed feedback is local to LVGL: the touched control contracts slightly and
+gains a cyan border until release. The screen has no persistent action-result
+line.
+
+## Redraw and flicker findings
+
+The panel can show brief tearing when a state change causes multiple large
+regions to redraw. The current implementation limits this in two places:
+
+- static chrome is created once and does not change;
+- Joydex sends only changed task selects during ordinary operation.
+
+Press feedback alone should not trigger a full-screen refresh. A reconnect may
+refresh all four cards because Joydex cannot assume the panel retained every
+prior update.
+
+Experimental acceptance:
+
+- no incorrect task activation;
+- no white full-screen flash;
+- no sustained tearing;
+- any remaining artifact is brief and confined to the changed task card.
+
+## OTA and recovery
+
+Available:
+
+- password-protected native ESPHome OTA;
+- two generated OTA app slots;
+- ESP-IDF bootloader rollback support;
+- CH340 UART access;
+- a private, unit-specific factory backup.
+
+Physically confirmed on the tested unit:
+
+- repeated OTA uploads;
+- reboot and LAN rejoin;
+- SSE reconnection;
+- complete task-state convergence after reconnect.
+
+Not yet claimed:
+
+- full factory restoration and factory-firmware boot;
+- deliberately interrupted OTA recovery;
+- bad-image rollback under power loss;
+- Secure Boot or flash encryption.
+
+Keep irreversible eFuse changes out of this experiment. Prove the private
+factory restore and failure recovery on a spare panel before relying on them.
+
+## Repeatability checklist
+
+1. Verify the exact seller model, display resolution, and capacitive-touch
+ option.
+2. Photograph the front, rear, module markings, and connector population.
+3. Test the factory display, orientation, brightness, and touch.
+4. Verify stable 5 V USB power and a data-capable cable.
+5. Probe the ESP32-S3, flash, PSRAM, and bridge identity.
+6. Capture and hash that unit's complete factory flash.
+7. Provision unique Wi-Fi, Digest, and OTA secrets in ignored local storage.
+8. Validate and compile with the pinned ESPHome version.
+9. Hash and flash the exact factory artifact.
+10. Verify display alignment, color, every touch target, and pressed feedback.
+11. Verify trusted-LAN join and reserve the DHCP lease.
+12. Test authenticated REST state changes and the SSE stream.
+13. Cold power-cycle and verify host/panel convergence.
+14. Test the matching OTA image.
+15. Record results privately without credentials or network identifiers.
+
+## Open hardware questions
+
+- PCB revision and relay population across seller batches.
+- Long-duration display, Wi-Fi, and power stability.
+- Full private factory restoration.
+- Interrupted-update behavior.
+- microSD, relay, audio, and expansion-pin behavior on the exact hidden PCB
+ revision.
+- Peak current at full brightness during Wi-Fi activity.
+
+Update this document with reusable physical evidence. Keep credentials,
+addresses, MACs, whole-flash images, unit identities, and compiled artifacts
+in private storage.
diff --git a/docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md b/docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md
new file mode 100644
index 0000000..7428113
--- /dev/null
+++ b/docs/WIRELESS_TOUCHSCREEN_RESEARCH_V1.1.md
@@ -0,0 +1,201 @@
+# Joydex wireless touchscreen research V1.1
+
+> Public research record, updated July 27, 2026. The implementation is an
+> experimental source example tested on one ESP32-4848S040C_I panel.
+
+## Executive answer
+
+The fastest useful route was to reuse **ESPHome + LVGL** and let the panel talk
+directly to Joydex over the normal 2.4 GHz LAN.
+
+That choice avoided custom ESP-IDF display firmware, a new embedded protocol, a
+Windows web listener, Home Assistant, MQTT, and a separate runtime network.
+Joydex only needed a small adapter that:
+
+- projects its four primary task slots into four coarse display states;
+- receives five fixed touch intents;
+- invokes the existing task-navigation and semantic-action paths;
+- reconnects and restores the complete visible state after an interruption.
+
+
+
+## Decision: direct ESPHome Web Server API
+
+The implemented path uses:
+
+- the ESPHome `GUITION-4848S040` integrated display model;
+- explicit sync timing already proven on the `C_I` variant;
+- GT911 touch and LVGL;
+- four task controls and one PLAN MODE control;
+- Digest-authenticated REST state updates;
+- Digest-authenticated Server-Sent Events for touch;
+- USB for the first flash and password-protected ESPHome OTA afterward.
+
+The panel makes no decisions about Codex tasks. Joydex remains authoritative
+for task identity, state, navigation, safety policy, and Plan Mode.
+
+## Architecture
+
+```text
+Codex lifecycle hooks
+ |
+ v
+Joydex task-state pool -----> ESPHome REST selects -----> LVGL task cards
+ ^ |
+ | v
+Existing navigation/actions <----- authenticated SSE <--- touch controls
+```
+
+The panel joins the same trusted LAN as the Windows host. Joydex initiates
+every connection to the panel, so no Windows listener, URL reservation, or
+firewall exception is required.
+
+## Tested hardware
+
+The physical canary was purchased as an `ESP32-4848S040C_I`, described as a
+4-inch 480×480 capacitive touchscreen:
+
+- [Tested AliExpress listing](https://www.aliexpress.us/item/3256808028364930.html)
+ (no affiliation; seller listings and board revisions can change).
+- [GUITION specification](https://www.guition.com/ku/icms/upload/fb081940d6fc11f09850077a33e1404f/FTPData/UEditor/file/2026121/1768961092477/ESP32-4848S040%20Specifications-EN.pdf).
+- [ESPHome board recipe](https://devices.esphome.io/devices/guition-esp32-s3-4848s040/).
+
+The tested unit reported an ESP32-S3, 16 MiB flash, and 8 MiB octal PSRAM. Its
+factory display and capacitive touch worked before flashing. Exact PCB
+revision, optional relay population, and rear silkscreen were not recorded.
+
+See the
+[device reference](ESP32_4848S040C_I_DEVICE_REFERENCE.md) before adapting the
+example to another production run.
+
+## Screen contract
+
+| Joydex meaning | ESPHome value | Visible treatment |
+| --- | --- | --- |
+| Empty slot | `EMPTY` | Blank gray task pad |
+| Running | `RUNNING` | White fill, gray border, gray text |
+| Waiting or attention | `ATTENTION` | Yellow fill, gray border, gray text |
+| Completed | `COMPLETE` | Green fill, white text |
+| Fault/error | `ATTENTION` | Same treatment as waiting |
+
+Fault and approval currently share `ATTENTION`; Joydex has no separate red
+panel state today.
+
+The panel exposes these host-facing entities:
+
+| Entity | Direction | Purpose |
+| --- | --- | --- |
+| `Task 1 State` … `Task 4 State` | Joydex → panel | Writable optimistic selects |
+| `Task 1` … `Task 4` | Panel → Joydex | Momentary LVGL-backed binary sensors |
+| `Sidebar` | Panel → Joydex | Legacy wire name for the visible PLAN MODE control |
+| `/events` | Panel → Joydex | ESPHome SSE stream |
+
+The host models the fifth intent as Plan Mode. The firmware retains `Sidebar`
+and `sidebar_pressed` solely for compatibility with the deployed entity name.
+
+## Touch and state findings
+
+The physical test exposed three issues that were easy to miss in a browser
+mockup:
+
+1. **Pressed feedback must be local.** Waiting for the network round trip made
+ taps feel lost. The touched LVGL control now contracts and gains a cyan
+ border until release.
+2. **Touch coordinates must be proven on the panel.** Early mappings could
+ activate a neighboring control even though the layout looked correct.
+3. **Redraw scope matters.** Resending all four selects for a one-card change
+ caused visible tearing. Joydex now sends only changed slots during ordinary
+ operation and reserves the four-slot replacement for reconnect recovery.
+
+A task-state change can still produce a brief localized redraw on this
+single-framebuffer display. The experimental acceptance boundary is no white
+full-screen flash, sustained tearing, or incorrect action.
+
+## REST and SSE findings
+
+The standalone ESPHome Web Server path worked without Home Assistant:
+
+```text
+GET /events
+POST /select/Task%201%20State/set?option=RUNNING
+```
+
+Important behaviors:
+
+- ESPHome sends state catch-up events after each SSE connection without a
+ separate catch-up marker.
+- Joydex suppresses the first state observed for each expected touch entity,
+ then accepts live `OFF` to `ON` transitions.
+- Joydex prefers the SSE `name_id` field and falls back to `id`.
+- REST updates are serialized but not transactional.
+- A failed changed-slot batch is retried against the same unconfirmed
+ projection.
+- Every SSE reconnect requests a complete current four-slot replacement.
+- A 45-second idle timeout detects a silently dead event stream.
+- Host actions are never replayed because state feedback failed.
+
+The adapter owns no task identities. A task press resolves the current slot
+assignment at press time and uses the existing deep-link navigator. PLAN MODE
+uses the existing semantic action executor and its foreground/dry-run policy.
+
+## Security boundary
+
+The experimental configuration uses:
+
+- Digest authentication on all Web Server requests;
+- distinct ignored Wi-Fi, Web Server, and OTA secrets;
+- Windows CurrentUser DPAPI for the host-side panel password;
+- password-protected native ESPHome OTA;
+- no web-based OTA handler;
+- no Web Server log handler;
+- no captive portal or fallback access point.
+
+Digest avoids sending the password itself in plaintext, but HTTP traffic has
+no TLS confidentiality or cryptographic server identity. The example is for a
+trusted LAN only. Never expose or port-forward the panel.
+
+Generated ESPHome state and compiled binaries can contain expanded runtime
+credentials. `.esphome`, `secrets.yaml`, firmware artifacts, host
+configuration, and whole-flash backups must remain ignored and private.
+
+## Why not the alternatives?
+
+| Alternative | Why it was deferred |
+| --- | --- |
+| Home Assistant | Adds a service Joydex does not otherwise need |
+| MQTT | Adds a broker and another credential/lifecycle surface |
+| Custom ESP-IDF protocol | Reimplements working display, touch, OTA, and network infrastructure |
+| BLE runtime transport | Adds Windows pairing and reconnection work without helping the LAN use case |
+| Windows-hosted web UI | Requires the ESP32 to become a browser and the PC to expose a listener |
+| Encrypted ESPHome native API | Stronger transport, but no maintained .NET client was already in this project |
+
+If this grows beyond a trusted-LAN experiment, encrypted transport and explicit
+device enrollment should be reconsidered before adding more controls.
+
+## Reusable implementation lessons
+
+- Pin the ESPHome version and keep the known `C_I` timing overrides explicit.
+- Treat a successful compile as necessary, not as physical display proof.
+- Keep static decorative chrome separate from dynamic controls.
+- Update only the control whose visible state changed.
+- Make press acknowledgement independent of host/network latency.
+- Reserve a stable DHCP address when Windows `.local` resolution is unreliable.
+- Capture a separate complete factory backup for every physical panel.
+- Never distribute compiled firmware built with real credentials.
+- Keep the neutral skin as a simple rollback and comparison target.
+
+## Current boundary
+
+The public example supports four primary Joydex task slots and PLAN MODE on the
+tested `ESP32-4848S040C_I` family. It does not claim:
+
+- complete Codex sidebar visibility;
+- support for every similarly named 4-inch ESP32 panel;
+- Home Assistant integration;
+- BLE control;
+- TLS-protected panel traffic;
+- tested factory restoration, power-loss rollback, or secure boot;
+- generalized runtime theming.
+
+Those are future decisions only if the experimental example proves useful to
+more than one installation.
diff --git a/docs/images/joydex-esp32-4848s040c-in-action.jpg b/docs/images/joydex-esp32-4848s040c-in-action.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..0bb9e347fabf2fad2c158823a04e6bb7ceb6aeb8
GIT binary patch
literal 313536
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