fix: house plan editor script scoping, single announcement of switch state, ADR note

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-11 16:22:11 +02:00
1 parent 0d9a03dcd5
commit 0aa0947727
6 files changed
+26 -17

No files matched your search

+2
View File
@@ -72,6 +72,8 @@ führt Befehle aus und schreibt das Aktionsprotokoll. Die Passung von Befehl und
Die Haus-Seite lädt `overview` (schnell, nur Datenbank) und danach `states` (Geräteabfragen) und fragt alle 10 s erneut. Ein nicht erreichbares Gerät liefert `Online = false` mit Meldung, nie einen Fehler für die ganze Anfrage.
SignalR-Live-Updates sind nicht nötig: Geräte werden auch außerhalb der App geschaltet, daher bliebe Abfragen ohnehin nötig.
Hinweis: Batteriesensoren (z. B. Shelly H&T) schlafen zwischen den Meldungen und erscheinen bei Abfrage ggf. dauerhaft als „offline“; sie eignen sich nur mit Netzbetrieb oder einem späteren Push-/MQTT-Treiber.
## Folgen
* Neue Tabellen (Migration `Phase17Home`), neue Permissions mit Matrix-Tests je Zelle, neue Audit-Aktionen.
* Der Server braucht Netzwerkzugang zu den Geräten (siehe Punkt 3); ohne Echtgerät sind Seed und AppHost unverändert, Tests nutzen `FakeShellyServer`.
@@ -32,7 +32,7 @@
<MudButton Variant="@(isOn ? Variant.Filled : Variant.Outlined)" Color="@(isOn ? Color.Success : Color.Default)" Class="fp-device-toggle"
StartIcon="@Icons.Material.Filled.PowerSettingsNew" Disabled="@(!CanOperate)"
OnClick="() => OnCommand.InvokeAsync(isOn ? HomeCommand.TurnOff : HomeCommand.TurnOn)"
aria-pressed="@(isOn ? "true" : "false")" aria-label="@($"{Device.Name}: {(isOn ? "an" : "aus")}. Zum {(isOn ? "Ausschalten" : "Einschalten")} drücken")">
aria-pressed="@(isOn ? "true" : "false")" aria-label="@Device.Name">
@(isOn ? "An" : "Aus")
</MudButton>
}
@@ -18,7 +18,7 @@
class="@($"fp-pin fp-pin-{HouseUi.CssClass(status)}{(selected ? " fp-pin-selected" : string.Empty)}")"
style="@PinStyle(pin)"
aria-label="@HouseUi.PinLabel(device, state)"
aria-pressed="@(HouseUi.IsToggleable(device) ? (state?.IsOn is true ? "true" : "false") : null)"
aria-pressed="@(HouseUi.IsPressable(device, state) ? (state?.IsOn is true ? "true" : "false") : null)"
title="@($"{device.Name}: {HouseUi.StatusText(device, state)}")"
@onclick="() => OnPinClick.InvokeAsync(device)">
<MudIcon Icon="@HouseUi.Icon(device.Icon, device.Kind)" Size="Size.Small" aria-hidden="true" />
@@ -147,12 +147,16 @@ public static class HouseUi
public static bool IsToggleable(HomeDeviceDto device) =>
device.CanControl && HomeRules.IsCommandAllowed(device.Kind, HomeCommand.Toggle);
/// <summary>Beschriftung des Plan-Symbols für Screenreader, inklusive Aktion.</summary>
public static string PinLabel(HomeDeviceDto device, HomeDeviceStateDto? state)
{
var text = $"{device.Name}, {StatusText(device, state)}";
return IsToggleable(device) ? $"{text}. Zum Umschalten antippen." : $"{text}.";
}
/// <summary>
/// Zugänglicher Name des Plan-Symbols. Schaltbare, erreichbare Geräte tragen nur den Namen – der Zustand steht in <c>aria-pressed</c>
/// (sonst würde er doppelt vorgelesen); alle anderen nennen den Status im Namen.
/// </summary>
public static string PinLabel(HomeDeviceDto device, HomeDeviceStateDto? state) =>
IsPressable(device, state) ? device.Name : $"{device.Name}, {StatusText(device, state)}.";
/// <summary>Schalter-Zustand per <c>aria-pressed</c> ausdrücken (nur wenn schaltbar und erreichbar).</summary>
public static bool IsPressable(HomeDeviceDto device, HomeDeviceStateDto? state) =>
IsToggleable(device) && state is { Online: true, IsOn: not null };
public static string FormatKind(HomeDeviceKind kind) => HomeRules.DisplayName(kind);
+9 -7
View File
@@ -4,13 +4,20 @@
const clamp = value => Math.min(1, Math.max(0, value));
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const detach = container => {
if (container && container._fpHome) {
container._fpHome.abort();
delete container._fpHome;
}
};
window.fpHome = {
attach(container, reference) {
if (!container) {
return;
}
this.detach(container);
detach(container);
const controller = new AbortController();
const { signal } = controller;
container._fpHome = controller;
@@ -83,11 +90,6 @@
}, { signal });
},
detach(container) {
if (container && container._fpHome) {
container._fpHome.abort();
delete container._fpHome;
}
},
detach,
};
})();
@@ -241,9 +241,10 @@ public sealed class HousePageTests : BunitContext
Assert.Equal("left:25%;top:50%", pins[0].GetAttribute("style"));
Assert.Equal("left:80%;top:10%", pins[1].GetAttribute("style"));
Assert.All(pins, pin => Assert.Equal("BUTTON", pin.TagName));
Assert.Contains("Wohnzimmer-Licht, Aus. Zum Umschalten antippen.", pins[0].GetAttribute("aria-label"));
Assert.Equal("Wohnzimmer-Licht", pins[0].GetAttribute("aria-label")); // Zustand nur über aria-pressed
Assert.Equal("false", pins[0].GetAttribute("aria-pressed"));
Assert.Contains("Rollo, Offen.", pins[1].GetAttribute("aria-label"));
Assert.DoesNotContain("Umschalten", pins[1].GetAttribute("aria-label")); // Rollo: kein Klick-Umschalten
Assert.Null(pins[1].GetAttribute("aria-pressed")); // Rollo: kein Klick-Umschalten
Assert.Equal("Aus", pins[0].QuerySelector(".fp-pin-badge")!.TextContent); // Kurztext zusätzlich zur Farbe
Assert.Contains("fp-pin-off", pins[0].ClassName);
}