feat: Phase 17 Haus - Shelly devices, per-device shares, floor plans with pins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-11 16:18:14 +02:00
1 parent 13ab82d9d6
commit 0d9a03dcd5
56 files changed
+11438 -76

No files matched your search

+12
View File
@@ -127,6 +127,18 @@ Voraussetzungen: Die App muss für die Geräte per **HTTPS** mit gültigem Zerti
Abos sind nur lesend; für Änderungen vom Handy aus gibt es die Zwei-Wege-Synchronisation (siehe nächster Abschnitt).
Der Feed-Endpunkt ist auf 60 Abrufe pro Minute und IP begrenzt (`RateLimiting__FeedPermitLimit`).
## Haus (Smart Home, Shelly)
Unter **Haus** bedient die Familie freigegebene Geräte (Licht, Steckdosen, Rollos; Sensoren zeigen Werte), nach Raum gruppiert oder auf einem
Grundriss mit Geräte-Symbolen. Administratoren legen unter **Hausverwaltung** Geräte (Shelly Gen1 und Gen2+/Plus/Pro/Gen3), Räume, Pläne und
Freigaben an (je Gerät für Rollen und/oder einzelne Personen: *nur ansehen* oder *schalten*) und sehen das Aktionsprotokoll.
* Der **Server** (Api-Container) spricht die Geräte direkt im Heimnetz an – er muss sie erreichen können. Eingetragen werden IP-Adressen oder
lokale Namen (`192.168.1.50`, `shelly-flur.local`); Adressen außerhalb des Heimnetzes sind aus Sicherheitsgründen gesperrt. Im Docker-Betrieb genügt
meist das Standard-Netzwerk; sonst Routing/Firewall des Hosts prüfen oder `network_mode: host` (siehe Kommentar in `deploy/docker-compose.yml`, [ADR-0023](docs/adr/0023-haus-smarthome.md)).
* Ist ein Gerät durch ein Passwort geschützt (bei Gen2+ Benutzer `admin`), wird es verschlüsselt gespeichert und nie wieder angezeigt.
* Ohne echtes Gerät lässt sich nichts schalten; Entwicklung und Tests benutzen einen Fake-Shelly-Server.
## Kalender mit dem Handy verbinden (CalDAV, Zwei-Wege)
Unter **Einstellungen › Kalender verbinden** (auch im Kalender-Menü: *Mit Handy verbinden*) erscheinen Server-Adresse
+5
View File
@@ -72,6 +72,11 @@ services:
Giphy__ApiKey: ${GIPHY_API_KEY:-}
# Kalender-Synchronisation (CalDAV, ADR-0017): Basic-Auth mit App-Passwort nur über HTTPS (true). false nur im reinen Heimnetz ohne Zertifikat.
CalDav__RequireHttps: ${CALDAV_REQUIRE_HTTPS:-true}
# Haus/Smart Home (ADR-0023): Die Api spricht Shelly-Geräte direkt im Heimnetz an (nur private Adressen). Im Standard-Bridge-Netzwerk
# erreicht ein Container das LAN des Docker-Hosts per NAT, solange der Host dorthin routet – meist genügt das.
# Falls nicht (Geräte in einem anderen VLAN/Subnetz, mDNS-Namen wie shelly.local): Geräte per IP-Adresse eintragen,
# Routing/Firewall des Hosts prüfen oder als Notlösung `network_mode: host` für die Api setzen (dann die Adresse der Api im
# web-Dienst `services__api__http__0` und den SQL-Host der Verbindungszeichenfolge anpassen).
depends_on:
migrations:
condition: service_completed_successfully
+6
View File
@@ -64,6 +64,7 @@ FamilyPlanner.Api/
Users/… # /api/admin/users/* (Phase 2)
Shopping/… # /api/shopping/* (Phase 3)
Meals/… Tasks/… Calendar/… Chat/… Dashboard/…
Home/… # Haus: Treiber-Abstraktion (IHomeDeviceDriver, Shelly), /api/home/* und /api/admin/home/* (Phase 17)
CalDav/… # /caldav/* (CalDAV-Server) + /api/caldav/* (App-Passwörter), Phase 12c
Hubs/ # SignalR
Infrastructure/ # Auth-Setup, Security, Rate Limiting
@@ -89,6 +90,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, AllowWebSearch, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt), `AiExchange` ✅ (12b: KI-Protokoll je Aufruf – Person, Anbieter, Modell, Zweck, Beginn, Dauer, Erfolg/Fehler, Bildanzahl, Online-Suche, Tokens, `ContentStored`, Prompt, Antwort; Prompt (inkl. System-Prompt) und Antwort immer, auch im KI-Chat und bei Fehlern (Phase 14); `AiUserSettings` ✅ (Phase 14: Admin-Vorgaben je Person – Anbieter, Modell, System-Prompt, Prompt deaktiviert); Aufbewahrung `FamilySettings.AiLogRetentionDays`, Standard 90) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, optionaler Lesetext `Passage` (Phase 16, Plain-Text ≤ 3000 Zeichen, Kinder sehen ihn bei jeder Frage), Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice/Paper (Paper je Aufgabe oder für das ganze Blatt in einem Schritt, 12a), CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
| Haus ✅ (Phase 17) | `HomeRoom` (Name, SortOrder, Soft Delete), `HomeDevice` (Name, Kind Schalter/Steckdose/Licht/Rollo/Sensor, Icon, RoomId, Driver (Shelly), Generation 1/2, Host (nur Heimnetz, normalisiert), Channel, AuthUser, `AuthPasswordProtected` (Data Protection, nie an den Client), SortOrder, RowVersion, Soft Delete), `HomeDeviceAccess` (Freigabe je Gerät für Rolle **oder** Person → View/Control), `HomePlan` (Name, SortOrder, ContentType, ImageVersion, Soft Delete) + `HomePlanImage` (Bytes in der Datenbank, ADR-0009), `HomeDevicePlacement` (Plan, Gerät, relative Position X/Y 0–1), `HomeActionLog` (Person, Gerät, Befehl, Erfolg/Fehlertext, Dauer; Namen als Momentaufnahme) – ADR-0023 |
| Dashboard ✅ | `DashboardNote` (Title, Text, Soft Delete; Freitext der Info-Kachel, wird nur als Text dargestellt) |
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
@@ -120,6 +122,8 @@ oder **None (–)**. Die Api registriert je Permission eine Policy `Permission:<
| `AiUse` | F | F | – | – | KI-Funktionen nutzen (ADR-0020) |
| `LearningManage` | F | F | – | – | Übungsblätter anlegen/zuweisen/prüfen/bestätigen (ADR-0021) |
| `LearningSolve` | F | F | R | – | Kind: nur eigene, zugewiesene Blätter lösen; fremde → 404 (ADR-0021) |
| `HomeUse` | F | R | R | R | Haus-Seite: Geräte nur nach Freigabe je Gerät (Rolle oder Person; Admin immer); nicht freigegeben → 404, nur „ansehen“ → 403 beim Schalten (ADR-0023) |
| `HomeManage` | F | – | – | – | Hausverwaltung: Geräte, Räume, Freigaben, Pläne, Aktionsprotokoll (ADR-0023) |
| `UserManagement`, `CalendarManagement`, `SettingsManagement`, `AuditLogView` | F | – | – | – | |
¹ `F`, wenn „Gäste dürfen die Einkaufsliste ergänzen/abhaken“ aktiviert ist. ² `F`, wenn „Gäste dürfen den Familienchat nutzen“ aktiviert ist.
@@ -162,6 +166,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019); Erweiterungen (ADR-0022): `GET /api/chat/features`, `POST /api/chat/ai` (eigener KI-Kanal, get-or-create), `GET /api/chat/gifs?q=`, `GET /api/chat/gifs/{id}/preview`, `POST …/channels/{id}/gifs`; Konfiguration `Giphy__ApiKey` (leer = keine GIF-Suche) |
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`, KI-Protokoll (12b): `GET /api/admin/ai/exchanges?purpose=&success=&userId=&from=&to=&page=&pageSize=`, `GET …/exchanges/people`, `GET/DELETE …/exchanges/{id}`, `DELETE …/exchanges` (alle); Eltern: `GET /api/ai/status` (inkl. `LearningWebSearchAvailable` für „Online recherchieren“; ADR-0020) |
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit`; Fotos (10c): `POST/DELETE /api/learning/my/attempts/{id}/items/{itemId}/photo` (Kind), `GET /api/learning/attempts/{id}/items/{itemId}/photo` (Eltern nach Abgabe, Kind; sonst 404), `POST /api/learning/attempts/{id}/ai-grade` (`LearningManage` + `AiUse`, KI-Vorschlag für Fotos und unklare Zahlenantworten); Lesetexte (Phase 16): `Passage` in Blatt-Anfragen/-DTOs und in Versuchs-DTOs (Kind, Prüfung), `CreateAiDraftRequest.Kind` (Standard/Leseverständnis/Textaufgaben); Automatik (11c, alle Routen `LearningManage` + `AiUse`): `GET/POST /api/learning/schedules`, `PUT/DELETE …/schedules/{id}`, `POST …/schedules/{id}/run` (ADR-0021) |
| Haus ✅ (Phase 17) | Haus-Seite (`HomeUse`, je Gerät nach Freigabe): `GET /api/home/overview` (Räume, sichtbare Geräte ohne Adresse/Zugangsdaten, Pläne mit sichtbaren Pins), `GET /api/home/states` (parallel, kurzer Server-Cache; ein offline Gerät bricht nichts ab), `GET /api/home/devices/{id}/state`, `POST /api/home/devices/{id}/command` (an/aus/umschalten, öffnen/schließen/stopp; protokolliert), `GET /api/home/plans/{id}/image` (nur für Admin oder wer ein platziertes Gerät sehen darf); Hausverwaltung (`HomeManage`): `GET/POST /api/admin/home/devices`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `POST …/devices/test` (Entwurf), `PUT …/{id}/access`, `GET/POST /api/admin/home/rooms`, `PUT/DELETE …/{id}`, `GET/POST /api/admin/home/plans` (Multipart), `PUT/DELETE …/{id}`, `PUT …/{id}/image`, `PUT …/{id}/placements`, `GET /api/admin/home/log` (ADR-0023) |
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018; inkl. Info-Kachel `Notes`), `POST /api/dashboard/notes`, `PUT/DELETE /api/dashboard/notes/{id}` (`DashboardNotesEdit`, Titel ≤ 80, Text ≤ 1000 Zeichen, höchstens 20 Notizen) |
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`, Lernen ✅ (`LearningChanged` ohne Inhalte); das Dashboard lädt bei diesen Ereignissen neu) |
| Betrieb | `/health`, `/alive` |
@@ -209,6 +214,7 @@ der `TaskReminderService` der Api versendet fällige Erinnerungen, der Service W
* Health Checks: `/alive` (Liveness), `/health` (Readiness inkl. DB bei der Api) – auch in Produktion gemappt (nur Status-Text, keine Details).
Der Web-Host beantwortet `/health` selbst und leitet es nicht an die Api weiter.
* Data Protection Keys werden in der Datenbank gespeichert (Api), damit Cookies Container-Neustarts überleben.
* **Haus (Phase 17):** Die **Api** spricht die Smart-Home-Geräte (Shelly) direkt im Heimnetz an – der Api-Container muss sie also erreichen (Bridge-Netzwerk mit Routing ins LAN genügt meist; sonst `network_mode: host` für die Api oder ein Routing/Macvlan-Netz, siehe ADR-0023). Die Adressprüfung erlaubt nur private Adressen/Loopback.
## 10. Querschnitt
+11
View File
@@ -206,6 +206,17 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [x] Textaufgaben: Sachtext in der Frage, Zahlen-/Einheitenantwort über die vorhandene Zahlenprüfung; KI-Aufgabenart im Entwurf-Dialog „Standard / Leseverständnis / Textaufgaben“ (`CreateAiDraftRequest.Kind`), Prompt, Schema (`passage` Pflicht bei Leseverständnis, Typ `truefalse`), Parser mit Lesetext-Prüfung; Admin-Prompt-Vorschau/-Override unverändert (Schema bleibt separat)
- [x] Migration `Phase16LearningText` (Spalte `LearningSets.Passage`); Tests: Unit (`LearningPassageTests`), bUnit (`PassageTests`), Integration (`LearningPassageTests`)
## Phase 17 – Haus (Smart Home, ADR-0023)
- [x] Navigation: „Haus“ (`/haus`, alle mit `HomeUse`) und „Hausverwaltung“ (`/haus/verwaltung`, nur Admin) unter „Verwaltung“; neue Permissions `HomeUse` (Admin F, Familie/Kind/Gast R – je Gerät freigegeben) und `HomeManage` (nur Admin) mit Matrix-Tests je Zelle
- [x] Geräte hinter `IHomeDeviceDriver` (Shelly-Treiber): Gen2+ per HTTP-RPC (`Switch.GetStatus/Set/Toggle`, `Cover.*`, `Light.*`, `Shelly.GetDeviceInfo`), Gen1 per REST (`/relay`, `/roller`, `/light`, `/status`, `/shelly`); Arten Schalter/Steckdose/Licht/Rollo/Sensor, Raum, Symbol, Kanal; Passwort mit Data Protection verschlüsselt (Gen2 Digest-SHA-256, Gen1 Basic); Zustand lesen, schalten, Verbindungstest (auch für Entwürfe), freundliche deutsche Fehler
- [x] SSRF-Schutz: nur http/https, nur Heimnetz (gemeinsame `NetworkRules`, von `AiRules` mitbenutzt), Link-Local/Metadaten gesperrt, **jede aufgelöste IP** wird beim Verbinden geprüft (DNS-Rebinding), kein Proxy, keine Weiterleitungen, 4-s-Zeitlimit, Antworten ≤ 256 KB, keine Wiederholungen (kein doppeltes Umschalten), nie Geräte-Text an den Client
- [x] Freigaben je Gerät für Rollen (Familie/Kind/Gast) und/oder Personen (ansehen/schalten), Admin immer alles; nicht freigegeben = unsichtbar (404), nur ansehen = 403; gleiche Filter für Pins und Planbilder
- [x] Haus-Seite: Räume mit Gerätekarten (Status als Text + Symbol, Schalter, Rollo-Tasten, Sensorwerte, offline), Zustände alle 10 s; Plan-Ansicht mit Geräte-Pins (Klick = umschalten + Details), barrierefrei (Buttons, Beschriftungen, Kurztext im Pin), mobil
- [x] Pläne: PNG/JPEG/WebP ≤ 5 MB (Signaturprüfung, Typ nie vom Client), Bilder in der Datenbank, mehrere Pläne mit Namen, Auslieferung nur an Berechtigte; Pin-Editor (Klick, Ziehen, Pfeiltasten, Prozentfelder), relative Koordinaten 0–1
- [x] Hausverwaltung (Admin): Geräte (anlegen/ändern/löschen mit Soft Delete, Test, Freigaben), Räume, Pläne mit Pins, Aktionsprotokoll (jede Schaltung inkl. Fehlschlägen, ohne Zugangsdaten); Audit-Log für alle Änderungen (ohne Passwörter)
- [x] Migration `Phase17Home`; Tests: Unit (Adressen/SSRF, Befehle je Art, Freigaben, Koordinaten, Bilder, Validatoren, Shelly-Treiber gegen Fake-Handler inkl. Digest-Referenzwert, Fehlerübersetzung), bUnit (Haus-Seite, Gerätekarten, Plan, Verwaltung, Dialoge, Pin-Editor, Navigation), Integration mit Fake-Shelly-Server (`HomeTests`: Rechte je Rolle/Gerät, Schalten Gen1/Gen2/Rollo, Anmeldung, Fehler/Timeout/Weiterleitung, Protokoll, Audit, Pläne und Pins)
- [ ] Weitere Treiber (MQTT, Home Assistant), Szenen, Dimmer-Helligkeit/Rollo-Position, Live-Updates per SignalR statt Abfrage alle 10 s, Praxistest mit echten Shelly-Geräten – bewusst nicht umgesetzt (ADR-0023)
## Backlog
- PIN-Login-Oberfläche für ein Familien-Tablet (Profilauswahl + Ziffernblock) – Api ist vorbereitet
- Selbst-Upload des eigenen Profilbilds, Profilseite für Anzeigename/Farbe
+82
View File
@@ -0,0 +1,82 @@
# ADR-0023: Haus – Smart-Home-Geräte im Heimnetz (Shelly), Freigaben je Gerät, Grundriss mit Pins
* Status: akzeptiert (Phase 17)
* Datum: 2026-10-11
## Kontext
Die Familie möchte Licht, Steckdosen und Rollos über die App bedienen und auf einem Grundriss sehen. Die ersten Geräte sind **Shelly** (Gen1 und Gen2+).
Die Geräte stehen im Heimnetz; die App läuft auf einem Server (Docker). Kinder und Gäste sollen nur bestimmte Geräte sehen/schalten, und die Anbindung darf
nicht zu einer Möglichkeit werden, den Server beliebige Adressen aufrufen zu lassen (SSRF). Weitere Anbindungen (MQTT, Home Assistant) sind denkbar.
## Entscheidung
### 1. Datenmodell (Domain/Home, Tabellen `Home…`)
* `HomeRoom` (Name, Reihenfolge, Soft Delete) – ein Raum ist eine einfache Entität.
* `HomeDevice` (Name, `Kind` Schalter/Steckdose/Licht/Rollo/Sensor, Symbol, `RoomId`, `Driver`, `Generation`, `Host`, `Channel`, `AuthUser`, `AuthPasswordProtected`, Reihenfolge,
RowVersion, Soft Delete). Beim Löschen wird das verschlüsselte Passwort entfernt, Freigaben und Pins werden hart gelöscht (wie Anbieter-Schlüssel in ADR-0020).
* `HomeDeviceAccess` (Gerät, **Rolle oder Person**, Stufe `View`/`Control`); je Gerät höchstens eine Freigabe je Rolle bzw. Person (eindeutiger Index).
* `HomePlan` (Name, Reihenfolge, Typ, `ImageVersion`) und `HomePlanImage` (Bytes) getrennt, damit Listen nie Bilddaten laden (Muster `UserAvatars`, ADR-0009).
* `HomeDevicePlacement` (Plan, Gerät, relative Position X/Y im Bereich 0–1, eindeutig je Plan + Gerät).
* `HomeActionLog` (Person, Gerät, Befehl, Erfolg, Fehlertext, Dauer; Personen- und Gerätename als **Momentaufnahme**, damit der Eintrag nach Löschungen lesbar bleibt). Nie Zugangsdaten, nie Antworten der Geräte.
* Erweiterbarkeit: `HomeDevice.Driver` + `Generation` + `Host`/`Channel` sind für Shelly ausreichend; ein weiterer Treiber braucht einen neuen `HomeDriverType`, eine `IHomeDeviceDriver`-Implementierung
und ggf. weitere (dann treiberspezifische) Spalten/Tabellen. Fachlogik, Rechte, Protokoll und UI kennen nur die Schnittstelle.
### 2. Treiber-Abstraktion
`IHomeDeviceDriver` (Api, `Features/Home`): `GetStateAsync`, `ExecuteAsync(HomeCommand)`, `TestAsync`; Fehler als `HomeException` mit fester deutscher Meldung (`HomeErrorKind`: nicht erreichbar, Zeitlimit,
Anmeldung, unbrauchbare Antwort, gesperrte Adresse, nicht unterstützt). `HomeDeviceService` wählt den Treiber, liest Zustände (parallel, höchstens 8 gleichzeitig, **3 s Server-Cache** gegen mehrere offene Browser),
führt Befehle aus und schreibt das Aktionsprotokoll. Die Passung von Befehl und Geräteart prüft die reine Regel `HomeRules.IsCommandAllowed` (Sensoren nehmen keine Befehle an) – serverseitig, vor jedem Geräteaufruf.
**Shelly-Treiber:**
* **Gen2+ (RPC über HTTP GET):** `Switch.GetStatus`/`Switch.Set?on=`/`Switch.Toggle`, `Cover.GetStatus`/`Open`/`Close`/`Stop`, `Light.GetStatus`/`Set`/`Toggle`, Sensoren über `Shelly.GetStatus` (`temperature:{id}`, `humidity:{id}`),
Test mit `Shelly.GetDeviceInfo` (Modell, Firmware) plus einer Statusabfrage des Kanals.
* **Gen1 (REST):** `/relay/{id}?turn=on|off|toggle`, `/roller/{id}?go=open|close|stop`, `/light/{id}?turn=…`, Zustand aus `/status`, Test mit `/shelly` (lehnt Gen2-Geräte mit Hinweis ab).
* **Anmeldung:** Gen2+ verwendet **Digest** (`algorithm=SHA-256`, `qop=auth`, Realm = Geräte-Id); der Treiber beantwortet die 401-Herausforderung selbst (kleine, per Referenzwert getestete Implementierung; MD5 wird bewusst nicht unterstützt).
Gen1 nutzt Basic (sofort mitgesendet). Es gibt nie mehr als einen Wiederholungsversuch, und der erste Versuch hat bei 401 noch nichts ausgeführt.
Die Zugangsdaten gehen nur an die eingetragene Adresse.
* **Geräteart „Licht“** meint die Light-Komponente (Dimmer, Leuchtmittel); ein Relais an einer Lampe ist ein „Schalter“ (das Symbol kann trotzdem eine Glühbirne sein).
### 3. SSRF-Schutz und Netzwerk
* **Eingabe** (`HomeRules.NormalizeHost`, gleiche Prüfung in Validator, Endpunkt und Dialog): nur `http`/`https` (ohne Schema gilt `http`), keine Zugangsdaten/Pfad/Query/Fragment, nur **Heimnetz-Adressen** (private IPv4/IPv6,
CGNAT/Tailscale, Loopback, `.local`/`.lan`/`.internal`/`.home.arpa`, Hostname ohne Punkt); **Link-Local/Cloud-Metadaten** (169.254.0.0/16, fe80::/10), `0.0.0.0`, Multicast und Broadcast sind gesperrt.
Die Regeln liegen gemeinsam in `Domain/Common/NetworkRules`; `AiRules` (ADR-0020) delegiert dorthin – eine Quelle für „Heimnetz“.
* **Beim Verbinden** (`SocketsHttpHandler.ConnectCallback`): Der Hostname wird aufgelöst und **jede** aufgelöste IP muss privat/Loopback und nicht gesperrt sein, sonst wird nicht verbunden (Schutz gegen Namen, die erst beim Verbinden
auf fremde Adressen zeigen = DNS-Rebinding); IPv4 wird zuerst versucht. Loopback bleibt erlaubt (Container-lokale Dienste, Tests mit dem Fake-Shelly).
* **Weitere Härtung:** eigener HttpClient `home` **ohne** Standard-Resilienz (automatische Wiederholungen könnten `toggle` doppelt auslösen), ohne Proxy, **ohne Weiterleitungen** (3xx = Fehler), Zeitlimit **4 s** je Anfrage
(Verbindungsaufbau 3 s), Antworten höchstens **256 KB**, nur JSON. Fehlertexte sind feste deutsche Meldungen – weder Text/Body des Geräts noch Adressen gelangen an den Client.
* **Docker-Betrieb:** Der **Api-Container** spricht die Geräte an, nicht der Browser. Er muss das Heimnetz erreichen: Im Standard-Bridge-Netzwerk geht der Verkehr per NAT über den Docker-Host ins LAN – das genügt, wenn der Host die Geräte erreicht.
Liegen die Geräte in einem anderen Subnetz/VLAN oder soll mDNS (`shelly.local`) funktionieren, sind IP-Adressen, Routing/Firewall des Hosts oder – als Notlösung – `network_mode: host` für die Api (oder ein Macvlan-Netz) nötig;
dann muss der Web-Container die Api anders adressieren (`services__api__http__0`). Ein Umbau von `deploy/` ist bewusst nicht Teil dieser Phase (nur Hinweis im Compose-Kommentar und im README).
Ist das Heimnetz nicht erreichbar, zeigt die App „Das Gerät ist nicht erreichbar … (bei Docker ggf. Host-Netzwerk oder Routing)“.
### 4. Berechtigungen
* Matrix (ADR-0008): `HomeUse` = Admin **F**, Familie/Kind/Gast **R**; `HomeManage` = nur Admin **F**. „R“ heißt hier: erreichbar, aber **je Gerät** freigegeben.
* Objektbezogen serverseitig (`HomeAccessService`, bei jedem Aufruf): Admin immer **Control**; sonst die höchste passende Freigabe aus Rolle und Person. Ohne Freigabe ist das Gerät **unsichtbar** – nicht in `overview`/`states`,
`state` und `command` antworten **404** (Muster Aufgaben/Lernen/Chat, kein Hinweis auf Existenz); mit „nur ansehen“ liefert `command` **403**. Dieselbe Sichtbarkeit gilt für Pins im Overview und für **Planbilder**
(ein Plan erscheint nur Admins oder Personen, die mindestens ein dort platziertes Gerät sehen; sonst 404). Räume ohne sichtbares Gerät sind für Nicht-Admins ausgeblendet.
* Kindern und Gästen werden nie Adresse, Kanal oder Zugangsdaten übertragen (`HomeDeviceDto` ohne diese Felder; die Verwaltungs-DTOs gibt es nur unter `/api/admin/home`).
* Standard ohne Freigabe: nur Admins sehen Geräte. Eine Freigabe für die Rolle „Admin“ ist unzulässig (Validator).
### 5. Plan und Pins
* Upload nur durch Admins (`HomeManage`), PNG/JPEG/WebP, **≤ 5 MB** (größer als Chatbilder mit 3 MB, weil Grundrisse oft Scans sind), Typ aus der **Signatur** (`HomeRules.ValidatePlanImage`, Erkennung wie
`UserAvatar.DetectContentType`/ADR-0009), kein SVG; Auslieferung mit `private`-Cache, `nosniff` und Versionsparameter (`?v=`). Mehrere Pläne mit Namen und Reihenfolge; „Bild ersetzen“ zählt die Version hoch.
* Positionen sind relative Koordinaten 0–1 (Validator; der Server rundet auf 4 Stellen); ein Gerät steht höchstens einmal je Plan. Der Editor setzt per Klick, Ziehen (kleines Skript `house.js`, liefert nur Koordinaten)
oder – ohne Zeiger – per Pfeiltasten und Prozentfeldern.
* Haus-Seite: Pins sind `<button>`s mit Beschriftung („Name, An. Zum Umschalten antippen.“); der Zustand steht zusätzlich als Kurztext im Pin und als Symbol – nie nur Farbe. Ein Klick schaltet (nur Schalter/Steckdose/Licht mit Schaltrecht)
und zeigt die Gerätekarte darunter.
### 6. Protokolle und Audit
* **Aktionsprotokoll:** jede Schalthandlung (nicht das Lesen), auch fehlgeschlagene, mit Person, Gerät, Befehl, Ergebnis, Dauer; Ansicht in der Hausverwaltung (Seiten, neueste zuerst). Keine automatische Löschung in dieser Phase.
* **Audit-Log:** `home.device_created/updated/deleted`, `home.access_changed`, `home.room_*`, `home.plan_*`, `home.placements_changed` – ohne Passwörter.
### 7. Zustände und Aktualisierung
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.
## 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`.
* Nicht umgesetzt: weitere Treiber (MQTT, Home Assistant), Szenen, Dimmer-Helligkeit und Rollo-Position, Zeitpläne/Automationen, Live-Push, Aufbewahrungsfrist des Aktionsprotokolls, Praxistest mit echten Geräten
(die Gen1/Gen2-Aufrufe folgen der Shelly-Dokumentation und werden gegen einen Fake geprüft).
## Offene Fragen
Ob Familienmitglieder neue Geräte standardmäßig sehen sollen (derzeit: nein, Freigabe nötig); ob das Aktionsprotokoll eine Aufbewahrungsfrist bekommt (wie `AiLogRetentionDays`); mDNS-Namen im Docker-Netz.
@@ -0,0 +1,482 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Home;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>
/// Hausverwaltung (nur Administratoren, <see cref="Permission.HomeManage"/>): Geräte, Räume, Freigaben, Pläne mit Pins, Aktionsprotokoll (ADR-0023).
/// Jede Änderung steht im Audit-Log – ohne Passwörter.
/// </summary>
internal static class HomeAdminEndpoints
{
private const string DeviceTarget = "HomeDevice";
private const string RoomTarget = "HomeRoom";
private const string PlanTarget = "HomePlan";
public static void Map(IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/home").WithTags("Admin – Haus").RequirePermission(Permission.HomeManage);
admin.MapGet("/devices", ListDevicesAsync);
admin.MapPost("/devices", CreateDeviceAsync).Validate<SaveHomeDeviceRequest>();
admin.MapPost("/devices/test", TestDraftAsync).Validate<TestHomeDeviceRequest>();
admin.MapPut("/devices/{id:guid}", UpdateDeviceAsync).Validate<SaveHomeDeviceRequest>();
admin.MapDelete("/devices/{id:guid}", DeleteDeviceAsync);
admin.MapPost("/devices/{id:guid}/test", TestStoredAsync);
admin.MapPut("/devices/{id:guid}/access", SetAccessAsync).Validate<SetHomeAccessRequest>();
admin.MapGet("/rooms", ListRoomsAsync);
admin.MapPost("/rooms", CreateRoomAsync).Validate<SaveHomeRoomRequest>();
admin.MapPut("/rooms/{id:guid}", UpdateRoomAsync).Validate<SaveHomeRoomRequest>();
admin.MapDelete("/rooms/{id:guid}", DeleteRoomAsync);
admin.MapGet("/plans", ListPlansAsync);
admin.MapPost("/plans", CreatePlanAsync).DisableAntiforgery();
admin.MapPut("/plans/{id:guid}", UpdatePlanAsync).Validate<SaveHomePlanRequest>();
admin.MapPut("/plans/{id:guid}/image", ReplacePlanImageAsync).DisableAntiforgery();
admin.MapPut("/plans/{id:guid}/placements", SetPlacementsAsync).Validate<SaveHomePlacementsRequest>();
admin.MapDelete("/plans/{id:guid}", DeletePlanAsync);
admin.MapGet("/log", LogAsync);
}
// ---------- Geräte ----------
internal static async Task<Ok<List<HomeAdminDeviceDto>>> ListDevicesAsync(AppDbContext db, CancellationToken ct)
{
var devices = await db.HomeDevices.AsNoTracking().OrderBy(d => d.SortOrder).ThenBy(d => d.Name).ToListAsync(ct);
var grants = (await db.HomeDeviceAccess.AsNoTracking().ToListAsync(ct)).ToLookup(a => a.DeviceId);
return TypedResults.Ok(devices.Select(d => ToDto(d, grants[d.Id])).ToList());
}
internal static async Task<Results<Created<HomeAdminDeviceDto>, ValidationProblem>> CreateDeviceAsync(
SaveHomeDeviceRequest request, ClaimsPrincipal user, AppDbContext db, IHomeSecretProtector protector, IAuditLogger audit, CancellationToken ct)
{
var device = new HomeDevice();
if (await ApplyAsync(device, request, db, protector, ct) is { } problem)
{
return problem;
}
db.HomeDevices.Add(device);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeDeviceCreated, DeviceTarget, device.Id.ToString(), device.Name, Describe(device), ct);
return TypedResults.Created($"/api/admin/home/devices/{device.Id}", ToDto(device, []));
}
internal static async Task<Results<Ok<HomeAdminDeviceDto>, NotFound, ProblemHttpResult, ValidationProblem>> UpdateDeviceAsync(
Guid id, SaveHomeDeviceRequest request, ClaimsPrincipal user, AppDbContext db, IHomeSecretProtector protector, HomeDeviceService devices, IAuditLogger audit, CancellationToken ct)
{
var device = await db.HomeDevices.SingleOrDefaultAsync(d => d.Id == id, ct);
if (device is null)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(device.RowVersion))
{
return EndpointHelpers.Conflict("Das Gerät wurde inzwischen geändert. Bitte neu laden.");
}
if (await ApplyAsync(device, request, db, protector, ct) is { } problem)
{
return problem;
}
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return EndpointHelpers.Conflict("Das Gerät wurde inzwischen geändert. Bitte neu laden.");
}
devices.Forget(device.Id);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeDeviceUpdated, DeviceTarget, device.Id.ToString(), device.Name, Describe(device), ct);
var grants = await db.HomeDeviceAccess.AsNoTracking().Where(a => a.DeviceId == id).ToListAsync(ct);
return TypedResults.Ok(ToDto(device, grants));
}
internal static async Task<NoContent> DeleteDeviceAsync(Guid id, ClaimsPrincipal user, AppDbContext db, HomeDeviceService devices, IAuditLogger audit, CancellationToken ct)
{
var device = await db.HomeDevices.SingleOrDefaultAsync(d => d.Id == id, ct);
if (device is null)
{
return TypedResults.NoContent(); // idempotent
}
device.AuthPasswordProtected = null; // Zugangsdaten nicht im Soft-Delete-Datensatz behalten
await db.HomeDeviceAccess.Where(a => a.DeviceId == id).ExecuteDeleteAsync(ct);
await db.HomeDevicePlacements.Where(p => p.DeviceId == id).ExecuteDeleteAsync(ct);
db.HomeDevices.Remove(device);
await db.SaveChangesAsync(ct);
devices.Forget(id);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeDeviceDeleted, DeviceTarget, device.Id.ToString(), device.Name, null, ct);
return TypedResults.NoContent();
}
internal static async Task<Results<Ok<HomeTestResultDto>, NotFound>> TestStoredAsync(Guid id, AppDbContext db, HomeDeviceService devices, CancellationToken ct)
{
var device = await db.HomeDevices.AsNoTracking().SingleOrDefaultAsync(d => d.Id == id, ct);
if (device is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await devices.TestAsync(devices.Connection(device), device.Driver, ct));
}
/// <summary>Testet die Eingaben aus dem Dialog (ohne zu speichern). Ohne neues Passwort gilt das gespeicherte Passwort des Geräts.</summary>
internal static async Task<Ok<HomeTestResultDto>> TestDraftAsync(TestHomeDeviceRequest request, AppDbContext db, IHomeSecretProtector protector, HomeDeviceService devices, CancellationToken ct)
{
var (host, _) = HomeRules.NormalizeHost(request.Host);
var password = request.Password;
if (password is null && request.DeviceId is { } deviceId
&& await db.HomeDevices.AsNoTracking().SingleOrDefaultAsync(d => d.Id == deviceId, ct) is { AuthPasswordProtected: { Length: > 0 } secret })
{
password = protector.Unprotect(secret);
}
var connection = new HomeDeviceConnection(host!, request.Generation, request.Channel, request.Kind, string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim(), password);
return TypedResults.Ok(await devices.TestAsync(connection, request.Driver, ct));
}
internal static async Task<Results<Ok<HomeAdminDeviceDto>, NotFound, ValidationProblem>> SetAccessAsync(
Guid id, SetHomeAccessRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var device = await db.HomeDevices.AsNoTracking().SingleOrDefaultAsync(d => d.Id == id, ct);
if (device is null)
{
return TypedResults.NotFound();
}
var userIds = request.Grants.Where(g => g.UserId is not null).Select(g => g.UserId!.Value).ToList();
if (userIds.Count > 0 && await db.Users.CountAsync(u => userIds.Contains(u.Id), ct) != userIds.Count)
{
return Invalid(nameof(SetHomeAccessRequest.Grants), "Eine der Personen existiert nicht mehr.");
}
var existing = await db.HomeDeviceAccess.Where(a => a.DeviceId == id).ToListAsync(ct);
db.HomeDeviceAccess.RemoveRange(existing);
var added = request.Grants
.Select(g => new HomeDeviceAccess { DeviceId = id, Role = g.Role, UserId = g.UserId, Level = g.Level })
.ToList();
db.HomeDeviceAccess.AddRange(added);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeAccessChanged, DeviceTarget, id.ToString(), device.Name, DescribeGrants(added), ct);
return TypedResults.Ok(ToDto(device, added));
}
// ---------- Räume ----------
internal static async Task<Ok<List<HomeAdminRoomDto>>> ListRoomsAsync(AppDbContext db, CancellationToken ct)
{
var counts = (await db.HomeDevices.AsNoTracking().Where(d => d.RoomId != null).GroupBy(d => d.RoomId!.Value).Select(g => new { g.Key, Count = g.Count() }).ToListAsync(ct))
.ToDictionary(x => x.Key, x => x.Count);
var rooms = await db.HomeRooms.AsNoTracking().OrderBy(r => r.SortOrder).ThenBy(r => r.Name).ToListAsync(ct);
return TypedResults.Ok(rooms.Select(r => new HomeAdminRoomDto(r.Id, r.Name, r.SortOrder, counts.GetValueOrDefault(r.Id))).ToList());
}
internal static async Task<Results<Created<HomeAdminRoomDto>, ValidationProblem>> CreateRoomAsync(
SaveHomeRoomRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var name = request.Name.Trim();
if (await db.HomeRooms.AnyAsync(r => r.Name == name, ct))
{
return Invalid(nameof(SaveHomeRoomRequest.Name), "Einen Raum mit diesem Namen gibt es schon.");
}
var room = new HomeRoom { Name = name, SortOrder = request.SortOrder };
db.HomeRooms.Add(room);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeRoomCreated, RoomTarget, room.Id.ToString(), room.Name, null, ct);
return TypedResults.Created($"/api/admin/home/rooms/{room.Id}", new HomeAdminRoomDto(room.Id, room.Name, room.SortOrder, 0));
}
internal static async Task<Results<Ok<HomeAdminRoomDto>, NotFound, ValidationProblem>> UpdateRoomAsync(
Guid id, SaveHomeRoomRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var room = await db.HomeRooms.SingleOrDefaultAsync(r => r.Id == id, ct);
if (room is null)
{
return TypedResults.NotFound();
}
var name = request.Name.Trim();
if (await db.HomeRooms.AnyAsync(r => r.Id != id && r.Name == name, ct))
{
return Invalid(nameof(SaveHomeRoomRequest.Name), "Einen Raum mit diesem Namen gibt es schon.");
}
var before = room.Name;
room.Name = name;
room.SortOrder = request.SortOrder;
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeRoomUpdated, RoomTarget, room.Id.ToString(), room.Name, before == name ? null : $"Name: „{before}“ → „{name}“", ct);
var count = await db.HomeDevices.CountAsync(d => d.RoomId == id, ct);
return TypedResults.Ok(new HomeAdminRoomDto(room.Id, room.Name, room.SortOrder, count));
}
internal static async Task<NoContent> DeleteRoomAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var room = await db.HomeRooms.SingleOrDefaultAsync(r => r.Id == id, ct);
if (room is null)
{
return TypedResults.NoContent();
}
await db.HomeDevices.Where(d => d.RoomId == id).ExecuteUpdateAsync(s => s.SetProperty(d => d.RoomId, (Guid?)null), ct);
db.HomeRooms.Remove(room);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeRoomDeleted, RoomTarget, room.Id.ToString(), room.Name, null, ct);
return TypedResults.NoContent();
}
// ---------- Pläne ----------
internal static async Task<Ok<List<HomeAdminPlanDto>>> ListPlansAsync(AppDbContext db, CancellationToken ct)
{
var plans = await db.HomePlans.AsNoTracking().OrderBy(p => p.SortOrder).ThenBy(p => p.Name).ToListAsync(ct);
var placements = (await db.HomeDevicePlacements.AsNoTracking().ToListAsync(ct)).ToLookup(p => p.PlanId);
return TypedResults.Ok(plans.Select(p => ToDto(p, placements[p.Id])).ToList());
}
internal static async Task<Results<Created<HomeAdminPlanDto>, ValidationProblem>> CreatePlanAsync(
IFormFile? file, [FromForm] string? name, [FromForm] int? sortOrder, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var planName = HomeRules.NormalizeName(name, HomePlan.NameMaxLength);
if (planName is null)
{
return Invalid(nameof(SaveHomePlanRequest.Name), $"Bitte einen Namen eingeben (höchstens {HomePlan.NameMaxLength} Zeichen).");
}
var (data, contentType, error) = await ReadImageAsync(file, ct);
if (data is null || contentType is null)
{
return Invalid("File", error ?? "Ungültige Datei.");
}
var plan = new HomePlan { Name = planName, SortOrder = Math.Clamp(sortOrder ?? 0, 0, 10_000), ContentType = contentType };
db.HomePlans.Add(plan);
db.HomePlanImages.Add(new HomePlanImage { PlanId = plan.Id, Data = data });
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomePlanCreated, PlanTarget, plan.Id.ToString(), plan.Name, $"{contentType}, {data.Length / 1024} KB", ct);
return TypedResults.Created($"/api/admin/home/plans/{plan.Id}", ToDto(plan, []));
}
internal static async Task<Results<Ok<HomeAdminPlanDto>, NotFound>> UpdatePlanAsync(
Guid id, SaveHomePlanRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var plan = await db.HomePlans.SingleOrDefaultAsync(p => p.Id == id, ct);
if (plan is null)
{
return TypedResults.NotFound();
}
var before = plan.Name;
plan.Name = request.Name.Trim();
plan.SortOrder = request.SortOrder;
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomePlanUpdated, PlanTarget, plan.Id.ToString(), plan.Name, before == plan.Name ? null : $"Name: „{before}“ → „{plan.Name}“", ct);
return TypedResults.Ok(ToDto(plan, await db.HomeDevicePlacements.AsNoTracking().Where(p => p.PlanId == id).ToListAsync(ct)));
}
internal static async Task<Results<Ok<HomeAdminPlanDto>, NotFound, ValidationProblem>> ReplacePlanImageAsync(
Guid id, IFormFile? file, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var plan = await db.HomePlans.SingleOrDefaultAsync(p => p.Id == id, ct);
if (plan is null)
{
return TypedResults.NotFound();
}
var (data, contentType, error) = await ReadImageAsync(file, ct);
if (data is null || contentType is null)
{
return Invalid("File", error ?? "Ungültige Datei.");
}
var image = await db.HomePlanImages.SingleOrDefaultAsync(i => i.PlanId == id, ct);
if (image is null)
{
image = new HomePlanImage { PlanId = id };
db.HomePlanImages.Add(image);
}
image.Data = data;
plan.ContentType = contentType;
plan.ImageVersion++;
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomePlanUpdated, PlanTarget, plan.Id.ToString(), plan.Name, $"Neues Bild: {contentType}, {data.Length / 1024} KB", ct);
return TypedResults.Ok(ToDto(plan, await db.HomeDevicePlacements.AsNoTracking().Where(p => p.PlanId == id).ToListAsync(ct)));
}
internal static async Task<Results<Ok<HomeAdminPlanDto>, NotFound, ValidationProblem>> SetPlacementsAsync(
Guid id, SaveHomePlacementsRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var plan = await db.HomePlans.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id, ct);
if (plan is null)
{
return TypedResults.NotFound();
}
var deviceIds = request.Pins.Select(p => p.DeviceId).ToList();
if (deviceIds.Count > 0 && await db.HomeDevices.CountAsync(d => deviceIds.Contains(d.Id), ct) != deviceIds.Count)
{
return Invalid(nameof(SaveHomePlacementsRequest.Pins), "Eines der Geräte existiert nicht mehr.");
}
db.HomeDevicePlacements.RemoveRange(await db.HomeDevicePlacements.Where(p => p.PlanId == id).ToListAsync(ct));
var placements = request.Pins
.Select(p => new HomeDevicePlacement { PlanId = id, DeviceId = p.DeviceId, X = HomeRules.RoundCoordinate(p.X), Y = HomeRules.RoundCoordinate(p.Y) })
.ToList();
db.HomeDevicePlacements.AddRange(placements);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomePlacementsChanged, PlanTarget, plan.Id.ToString(), plan.Name, $"{placements.Count} Geräte auf dem Plan", ct);
return TypedResults.Ok(ToDto(plan, placements));
}
internal static async Task<NoContent> DeletePlanAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var plan = await db.HomePlans.SingleOrDefaultAsync(p => p.Id == id, ct);
if (plan is null)
{
return TypedResults.NoContent();
}
await db.HomeDevicePlacements.Where(p => p.PlanId == id).ExecuteDeleteAsync(ct);
await db.HomePlanImages.Where(i => i.PlanId == id).ExecuteDeleteAsync(ct); // Bild nicht im Soft-Delete-Datensatz behalten
db.HomePlans.Remove(plan);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomePlanDeleted, PlanTarget, plan.Id.ToString(), plan.Name, null, ct);
return TypedResults.NoContent();
}
// ---------- Protokoll ----------
internal static async Task<Ok<PagedResult<HomeActionLogDto>>> LogAsync(AppDbContext db, int? page, int? pageSize, CancellationToken ct)
{
var (p, size) = PagedResult<HomeActionLogDto>.Normalize(page, pageSize);
var query = db.HomeActionLog.AsNoTracking();
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(l => l.CreatedAt).ThenByDescending(l => l.Id).Skip((p - 1) * size).Take(size)
.Select(l => new HomeActionLogDto(l.CreatedAt, l.UserName, l.DeviceId, l.DeviceName, l.Command, l.Success, l.Error, l.DurationMs))
.ToListAsync(ct);
return TypedResults.Ok(new PagedResult<HomeActionLogDto>(items, total, p, size));
}
// ---------- Hilfen ----------
private static async Task<ValidationProblem?> ApplyAsync(HomeDevice device, SaveHomeDeviceRequest request, AppDbContext db, IHomeSecretProtector protector, CancellationToken ct)
{
var (host, error) = HomeRules.NormalizeHost(request.Host);
if (host is null)
{
return Invalid(nameof(SaveHomeDeviceRequest.Host), error!);
}
if (request.RoomId is { } roomId && !await db.HomeRooms.AnyAsync(r => r.Id == roomId, ct))
{
return Invalid(nameof(SaveHomeDeviceRequest.RoomId), "Der Raum existiert nicht mehr.");
}
var user = string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim();
var hasPassword = !request.ClearPassword && (request.Password is not null || !string.IsNullOrEmpty(device.AuthPasswordProtected));
if (hasPassword && user is null)
{
return Invalid(nameof(SaveHomeDeviceRequest.AuthUser), "Bitte den Benutzernamen angeben (bei Shelly Gen2+ ist das „admin“).");
}
device.Name = request.Name.Trim();
device.Kind = request.Kind;
device.Icon = string.IsNullOrEmpty(request.Icon) ? null : request.Icon;
device.RoomId = request.RoomId;
device.Driver = request.Driver;
device.Generation = request.Generation;
device.Host = host;
device.Channel = request.Channel;
device.AuthUser = user;
device.SortOrder = request.SortOrder;
if (request.ClearPassword)
{
device.AuthPasswordProtected = null;
}
else if (request.Password is { } password)
{
device.AuthPasswordProtected = protector.Protect(password);
}
return null;
}
private static string Describe(HomeDevice d) =>
$"{HomeRules.DisplayName(d.Kind)}, Shelly Gen{d.Generation}, {d.Host}, Kanal {d.Channel}, {(string.IsNullOrEmpty(d.AuthPasswordProtected) ? "ohne Anmeldung" : "mit Anmeldung")}";
private static string DescribeGrants(IReadOnlyCollection<HomeDeviceAccess> grants)
{
if (grants.Count == 0)
{
return "Keine Freigaben (nur Administratoren)";
}
string Level(HomeAccessLevel level) => level == HomeAccessLevel.Control ? "schalten" : "ansehen";
var roles = grants.Where(g => g.Role is not null).Select(g => $"{g.Role} ({Level(g.Level)})");
var people = grants.Count(g => g.UserId is not null);
var parts = new List<string>();
if (roles.Any())
{
parts.Add("Rollen: " + string.Join(", ", roles));
}
if (people > 0)
{
parts.Add($"{people} {(people == 1 ? "Person" : "Personen")}");
}
return string.Join("; ", parts);
}
private static HomeAdminDeviceDto ToDto(HomeDevice d, IEnumerable<HomeDeviceAccess> grants) => new(
d.Id, d.Name, d.Kind, d.Icon, d.RoomId, d.Driver, d.Generation, d.Host, d.Channel, d.AuthUser,
!string.IsNullOrEmpty(d.AuthPasswordProtected), d.SortOrder, d.RowVersion,
grants.Select(g => new HomeGrantDto(g.Role, g.UserId, g.Level)).ToList());
private static HomeAdminPlanDto ToDto(HomePlan p, IEnumerable<HomeDevicePlacement> placements) => new(
p.Id, p.Name, p.SortOrder, p.ImageVersion, placements.Select(x => new HomePinDto(x.DeviceId, x.X, x.Y)).ToList());
private static async Task<(byte[]? Data, string? ContentType, string? Error)> ReadImageAsync(IFormFile? file, CancellationToken ct)
{
if (file is null || file.Length == 0)
{
return (null, null, "Bitte eine Bilddatei auswählen.");
}
if (file.Length > HomeRules.MaxPlanImageBytes)
{
return (null, null, $"Das Bild ist zu groß (höchstens {HomeRules.MaxPlanImageBytes / 1024 / 1024} MB).");
}
using var stream = new MemoryStream((int)file.Length);
await file.CopyToAsync(stream, ct);
var data = stream.ToArray();
// Der Typ kommt aus der Signatur, nie vom Client.
var (contentType, error) = HomeRules.ValidatePlanImage(data);
return contentType is null ? (null, null, error) : (data, contentType, null);
}
private static ValidationProblem Invalid(string field, string message) =>
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [field] = [message] }, title: "Eingaben prüfen");
}
@@ -0,0 +1,61 @@
using FamilyPlanner.Domain.Home;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>Was ein Treiber zum Ansprechen eines Geräts braucht (Passwort bereits entschlüsselt, nur im Arbeitsspeicher).</summary>
public sealed record HomeDeviceConnection(string Host, int Generation, int Channel, HomeDeviceKind Kind, string? User, string? Password);
/// <summary>Gelesener Zustand; nicht jedes Gerät liefert jede Größe.</summary>
public sealed record HomeDeviceState(
bool? IsOn = null,
string? CoverState = null,
int? CoverPosition = null,
double? PowerWatts = null,
double? TemperatureC = null,
double? HumidityPercent = null);
/// <summary>Kennung des Geräts aus dem Verbindungstest.</summary>
public sealed record HomeDeviceInfo(string? Model, string? Firmware);
public enum HomeErrorKind
{
/// <summary>Keine Verbindung (Netzwerk, Gerät aus, falsche Adresse).</summary>
Unreachable,
Timeout,
/// <summary>Benutzername/Passwort fehlen oder stimmen nicht.</summary>
Unauthorized,
/// <summary>Antwort unbrauchbar oder zu groß (kein Shelly, falsche Generation/Kanal).</summary>
BadResponse,
/// <summary>Die Adresse liegt (nach Namensauflösung) nicht im Heimnetz – aus Sicherheitsgründen gesperrt.</summary>
Blocked,
/// <summary>Befehl/Geräteart wird vom Treiber nicht unterstützt.</summary>
NotSupported,
}
/// <summary>Fehler eines Treibers mit fester deutscher Meldung (nie Texte oder Bodies des Geräts).</summary>
public sealed class HomeException(HomeErrorKind kind, string message, Exception? inner = null) : Exception(message, inner)
{
public HomeErrorKind Kind { get; } = kind;
}
/// <summary>
/// Abstraktion für Geräte-Treiber (ADR-0023). Fachlogik und Endpunkte kennen nur diese Schnittstelle; ein weiterer Treiber (MQTT, Home Assistant …)
/// ist eine weitere Implementierung plus ein <see cref="HomeDriverType"/>-Wert. Treiber werfen bei Fehlern <see cref="HomeException"/>.
/// </summary>
public interface IHomeDeviceDriver
{
HomeDriverType Type { get; }
Task<HomeDeviceState> GetStateAsync(HomeDeviceConnection connection, CancellationToken ct);
/// <summary>Führt den Befehl aus (die Passung zur Geräteart ist vorher geprüft). Der neue Zustand wird danach separat gelesen.</summary>
Task ExecuteAsync(HomeDeviceConnection connection, HomeCommand command, CancellationToken ct);
/// <summary>Verbindungstest: Gerät erreichbar, passende Generation, Kanal vorhanden.</summary>
Task<HomeDeviceInfo> TestAsync(HomeDeviceConnection connection, CancellationToken ct);
}
@@ -0,0 +1,140 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Home;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>
/// Haus-Seite: sichtbare Geräte, Zustände, Schalten und Planbilder (ADR-0023). Alle Endpunkte brauchen <see cref="Permission.HomeUse"/>;
/// welche Geräte eine Person sieht/schaltet, entscheidet <see cref="HomeAccessService"/> bei jedem Aufruf (nicht freigegeben = 404, nur ansehen = 403 beim Schalten).
/// </summary>
public static class HomeEndpoints
{
public static IEndpointRouteBuilder MapHomeEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/home").WithTags("Haus").RequirePermission(Permission.HomeUse);
group.MapGet("/overview", OverviewAsync);
group.MapGet("/states", StatesAsync);
group.MapGet("/devices/{id:guid}/state", StateAsync);
group.MapPost("/devices/{id:guid}/command", CommandAsync).Validate<HomeCommandRequest>();
group.MapGet("/plans/{id:guid}/image", PlanImageAsync);
HomeAdminEndpoints.Map(app);
return app;
}
internal static async Task<Ok<HomeOverviewDto>> OverviewAsync(ClaimsPrincipal user, AppDbContext db, HomeAccessService access, CancellationToken ct)
{
var visible = await access.VisibleDevicesAsync(user, ct);
var isAdmin = user.IsInRole(Domain.Roles.Admin);
var visibleIds = visible.Select(v => v.Device.Id).ToHashSet();
var usedRooms = visible.Where(v => v.Device.RoomId is not null).Select(v => v.Device.RoomId!.Value).ToHashSet();
var rooms = (await db.HomeRooms.AsNoTracking().OrderBy(r => r.SortOrder).ThenBy(r => r.Name).ToListAsync(ct))
.Where(r => isAdmin || usedRooms.Contains(r.Id))
.Select(r => new HomeRoomDto(r.Id, r.Name, r.SortOrder))
.ToList();
var plans = await VisiblePlansAsync(db, visibleIds, isAdmin, ct);
var devices = visible
.Select(v => new HomeDeviceDto(v.Device.Id, v.Device.Name, v.Device.Kind, v.Device.Icon, v.Device.RoomId, v.Level == HomeAccessLevel.Control))
.ToList();
return TypedResults.Ok(new HomeOverviewDto(rooms, devices, plans, isAdmin));
}
/// <summary>Pläne mit den für die Person sichtbaren Pins; ein Plan ohne sichtbaren Pin erscheint nur für Admins.</summary>
internal static async Task<List<HomePlanDto>> VisiblePlansAsync(AppDbContext db, HashSet<Guid> visibleDeviceIds, bool isAdmin, CancellationToken ct)
{
var plans = await db.HomePlans.AsNoTracking().OrderBy(p => p.SortOrder).ThenBy(p => p.Name).ToListAsync(ct);
var placements = (await db.HomeDevicePlacements.AsNoTracking().ToListAsync(ct)).ToLookup(p => p.PlanId);
return plans
.Select(plan => new HomePlanDto(
plan.Id,
plan.Name,
plan.ImageVersion,
placements[plan.Id].Where(p => visibleDeviceIds.Contains(p.DeviceId)).Select(p => new HomePinDto(p.DeviceId, p.X, p.Y)).ToList()))
.Where(plan => isAdmin || plan.Pins.Count > 0)
.ToList();
}
internal static async Task<Ok<HomeStatesDto>> StatesAsync(ClaimsPrincipal user, HomeAccessService access, HomeDeviceService devices, TimeProvider time, CancellationToken ct)
{
var visible = await access.VisibleDevicesAsync(user, ct);
var states = await devices.ReadStatesAsync(visible.Select(v => v.Device).ToList(), ct);
return TypedResults.Ok(new HomeStatesDto(states, time.GetUtcNow()));
}
internal static async Task<Results<Ok<HomeDeviceStateDto>, NotFound>> StateAsync(Guid id, ClaimsPrincipal user, HomeAccessService access, HomeDeviceService devices, CancellationToken ct)
{
if (await access.DeviceAsync(user, id, ct) is not { } visible)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await devices.ReadStateAsync(visible.Device, useCache: true, ct));
}
internal static async Task<Results<Ok<HomeCommandResultDto>, NotFound, ProblemHttpResult, ValidationProblem>> CommandAsync(
Guid id, HomeCommandRequest request, ClaimsPrincipal user, HomeAccessService access, HomeDeviceService devices, CancellationToken ct)
{
if (await access.DeviceAsync(user, id, ct) is not { } visible)
{
return TypedResults.NotFound();
}
if (visible.Level != HomeAccessLevel.Control)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Keine Berechtigung",
detail: "Dieses Gerät darfst du nur ansehen, nicht schalten.");
}
if (!HomeRules.IsCommandAllowed(visible.Device.Kind, request.Command))
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [nameof(HomeCommandRequest.Command)] = ["Dieser Befehl passt nicht zu diesem Gerät."] },
title: "Eingaben prüfen");
}
var result = await devices.ExecuteAsync(visible.Device, request.Command, user.UserId(), user.ActorName(), ct);
return TypedResults.Ok(result);
}
internal static async Task<Results<FileContentHttpResult, NotFound>> PlanImageAsync(
Guid id, ClaimsPrincipal user, AppDbContext db, HomeAccessService access, HttpContext context, CancellationToken ct)
{
var plan = await db.HomePlans.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id, ct);
if (plan is null)
{
return TypedResults.NotFound();
}
// Nur Admins oder Personen, die mindestens ein auf diesem Plan platziertes Gerät sehen dürfen.
if (!user.IsInRole(Domain.Roles.Admin))
{
var placed = await db.HomeDevicePlacements.AsNoTracking().Where(p => p.PlanId == id).Select(p => p.DeviceId).ToListAsync(ct);
var visibleIds = (await access.VisibleDevicesAsync(user, ct)).Select(v => v.Device.Id).ToHashSet();
if (!placed.Any(visibleIds.Contains))
{
return TypedResults.NotFound();
}
}
var image = await db.HomePlanImages.AsNoTracking().SingleOrDefaultAsync(i => i.PlanId == id, ct);
if (image is null)
{
return TypedResults.NotFound();
}
// Die URL trägt die Bildversion (?v=…), daher gut cachebar – aber nur privat (Grundriss).
context.Response.Headers.CacheControl = "private, max-age=86400";
context.Response.Headers.XContentTypeOptions = "nosniff";
return TypedResults.File(image.Data, plan.ContentType, lastModified: plan.UpdatedAt);
}
}
@@ -0,0 +1,279 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>Die aufgelöste Adresse eines Geräts liegt nicht im Heimnetz (SSRF-Schutz beim Verbindungsaufbau).</summary>
internal sealed class HomeBlockedAddressException(string host) : Exception($"Adresse {host} liegt nicht im Heimnetz.");
/// <summary>
/// HTTP-Zugriff auf Geräte im Heimnetz (ADR-0023): kurzes Zeitlimit, keine Weiterleitungen, begrenzte Antwortgröße, Digest/Basic-Anmeldung,
/// und der Verbindungsaufbau prüft <b>jede</b> aufgelöste IP-Adresse (nur privat/Loopback, nie Link-Local) – das schützt auch gegen Namen,
/// die erst beim Verbinden auf eine fremde Adresse zeigen (DNS-Rebinding).
/// </summary>
internal static partial class HomeHttp
{
public const string ClientName = "home";
/// <summary>Zeitlimit je Anfrage an ein Gerät.</summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(4);
/// <summary>Maximale Antwortgröße (ein Shelly.GetStatus ist wenige KB groß).</summary>
public const int MaxResponseBytes = 256 * 1024;
public static SocketsHttpHandler CreateHandler() => new()
{
AllowAutoRedirect = false,
UseProxy = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
ConnectTimeout = TimeSpan.FromSeconds(3),
ConnectCallback = ConnectAsync,
};
private static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct)
{
var host = context.DnsEndPoint.Host;
var addresses = IPAddress.TryParse(host.Trim('[', ']'), out var literal)
? [literal]
: await Dns.GetHostAddressesAsync(host, ct);
// Streng: ein Name, der auch nur auf eine nicht erlaubte Adresse zeigt, wird nicht benutzt.
if (addresses.Length == 0 || !addresses.All(NetworkRules.IsAllowedHomeAddress))
{
throw new HttpRequestException("Adresse nicht erlaubt.", new HomeBlockedAddressException(host));
}
Exception? last = null;
// IPv4 zuerst: Geräte im Heimnetz sind fast immer IPv4, und ein toter IPv6-Eintrag soll nicht das Zeitlimit aufbrauchen.
foreach (var address in addresses.OrderBy(a => a.AddressFamily == AddressFamily.InterNetwork ? 0 : 1))
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch (Exception ex) when (ex is SocketException)
{
socket.Dispose();
last = ex;
}
catch
{
socket.Dispose();
throw;
}
}
throw new HttpRequestException("Keine Verbindung.", last);
}
/// <summary>
/// GET <paramref name="pathAndQuery"/> und JSON-Antwort. Ohne Zugangsdaten wird ohne Anmeldung gefragt; antwortet das Gerät mit 401, wird
/// einmal mit Digest (Gen2) bzw. Basic angemeldet. Nie mehr als ein Wiederholungsversuch, und der erste Versuch hat bei 401 noch nichts ausgeführt.
/// </summary>
public static async Task<JsonElement> GetJsonAsync(HttpClient http, string host, string pathAndQuery, string? user, string? password, bool basicFirst, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(RequestTimeout);
try
{
var uri = new Uri(new Uri(host, UriKind.Absolute), pathAndQuery);
var hasCredentials = !string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(password);
using var first = new HttpRequestMessage(HttpMethod.Get, uri);
if (hasCredentials && basicFirst)
{
first.Headers.Authorization = Basic(user!, password!);
}
var (status, challenge, body) = await SendAsync(http, first, timeout.Token);
if (status == HttpStatusCode.Unauthorized)
{
if (!hasCredentials || (basicFirst && first.Headers.Authorization is not null))
{
throw new HomeException(HomeErrorKind.Unauthorized, hasCredentials
? "Anmeldung am Gerät fehlgeschlagen. Bitte Benutzername und Passwort prüfen."
: "Das Gerät verlangt eine Anmeldung. Bitte Benutzername und Passwort eintragen.");
}
using var second = new HttpRequestMessage(HttpMethod.Get, uri);
second.Headers.Authorization = Answer(challenge, user!, password!, uri.PathAndQuery);
(status, _, body) = await SendAsync(http, second, timeout.Token);
if (status == HttpStatusCode.Unauthorized)
{
throw new HomeException(HomeErrorKind.Unauthorized, "Anmeldung am Gerät fehlgeschlagen. Bitte Benutzername und Passwort prüfen.");
}
}
if ((int)status is >= 300 and < 400)
{
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät leitet weiter – das ist nicht erlaubt. Bitte Adresse und Generation prüfen.");
}
if (status == HttpStatusCode.NotFound)
{
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät kennt diese Funktion nicht. Bitte Generation, Geräteart und Kanal prüfen.");
}
if (!(status is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices))
{
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät hat die Anfrage abgelehnt. Bitte Einstellungen prüfen.");
}
try
{
using var document = JsonDocument.Parse(body);
return document.RootElement.Clone();
}
catch (JsonException ex)
{
throw new HomeException(HomeErrorKind.BadResponse, "Das ist kein Shelly-Gerät (unverständliche Antwort). Bitte Adresse prüfen.", ex);
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new HomeException(HomeErrorKind.Timeout, "Das Gerät antwortet nicht rechtzeitig.");
}
}
private static async Task<(HttpStatusCode Status, string? Challenge, byte[] Body)> SendAsync(HttpClient http, HttpRequestMessage request, CancellationToken ct)
{
try
{
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
var challenge = response.Headers.WwwAuthenticate.Select(h => $"{h.Scheme} {h.Parameter}").FirstOrDefault();
if ((int)response.StatusCode is >= 300 and < 400 or 401)
{
return (response.StatusCode, challenge, []);
}
await using var stream = await response.Content.ReadAsStreamAsync(ct);
using var limited = new MemoryStream();
var buffer = new byte[8 * 1024];
int read;
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
{
limited.Write(buffer, 0, read);
if (limited.Length > MaxResponseBytes)
{
throw new HomeException(HomeErrorKind.BadResponse, "Die Antwort des Geräts ist zu groß.");
}
}
return (response.StatusCode, challenge, limited.ToArray());
}
catch (HttpRequestException ex) when (IsBlocked(ex))
{
throw new HomeException(HomeErrorKind.Blocked, "Diese Adresse liegt nicht im Heimnetz und wird aus Sicherheitsgründen nicht angesprochen.", ex);
}
catch (HttpRequestException ex)
{
throw new HomeException(
HomeErrorKind.Unreachable,
"Das Gerät ist nicht erreichbar. Bitte Adresse prüfen und ob der Server das Heimnetz erreichen kann (bei Docker ggf. Host-Netzwerk oder Routing).",
ex);
}
}
/// <summary>Hat der Verbindungsaufbau die Adresse abgelehnt? (SocketsHttpHandler verpackt Ausnahmen aus dem ConnectCallback ggf. mehrfach.)</summary>
private static bool IsBlocked(Exception ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is HomeBlockedAddressException)
{
return true;
}
}
return false;
}
private static AuthenticationHeaderValue Basic(string user, string password) =>
new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}")));
/// <summary>Antwort auf die Herausforderung des Geräts: Digest (SHA-256, Shelly Gen2+) oder Basic.</summary>
private static AuthenticationHeaderValue Answer(string? challenge, string user, string password, string pathAndQuery)
{
if (challenge is not null && challenge.StartsWith("Digest ", StringComparison.OrdinalIgnoreCase))
{
var parameters = ParseChallenge(challenge["Digest ".Length..]);
var cnonce = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(8));
var value = ShellyDigest.Authorization(user, password, "GET", pathAndQuery, parameters, cnonce, 1)
?? throw new HomeException(HomeErrorKind.Unauthorized, "Das Gerät verlangt ein nicht unterstütztes Anmeldeverfahren.");
return new AuthenticationHeaderValue("Digest", value);
}
if (challenge is not null && challenge.StartsWith("Basic", StringComparison.OrdinalIgnoreCase))
{
return Basic(user, password);
}
throw new HomeException(HomeErrorKind.Unauthorized, "Das Gerät verlangt ein nicht unterstütztes Anmeldeverfahren.");
}
internal static Dictionary<string, string> ParseChallenge(string text)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in ChallengeParameter().Matches(text))
{
result[match.Groups[1].Value] = match.Groups[3].Success ? match.Groups[3].Value : match.Groups[4].Value;
}
return result;
}
[GeneratedRegex("""(\w+)=("([^"]*)"|([^,\s]*))""")]
private static partial Regex ChallengeParameter();
}
/// <summary>Digest-Anmeldung wie Shelly Gen2+ sie verlangt: <c>algorithm=SHA-256</c>, <c>qop=auth</c>, Realm = Geräte-Id.</summary>
internal static class ShellyDigest
{
/// <summary>Der Wert des Authorization-Headers (ohne „Digest “) oder <c>null</c>, wenn Algorithmus/qop nicht unterstützt werden.</summary>
public static string? Authorization(string user, string password, string method, string uri, IReadOnlyDictionary<string, string> challenge, string cnonce, int nonceCount)
{
if (!challenge.TryGetValue("nonce", out var nonce) || !challenge.TryGetValue("realm", out var realm))
{
return null;
}
var algorithm = challenge.GetValueOrDefault("algorithm") ?? "SHA-256";
if (!string.Equals(algorithm, "SHA-256", StringComparison.OrdinalIgnoreCase))
{
return null; // MD5 wird bewusst nicht unterstützt (alle Shelly Gen2+ nutzen SHA-256)
}
var qop = challenge.GetValueOrDefault("qop");
if (qop is not null && !qop.Split(',', StringSplitOptions.TrimEntries).Contains("auth", StringComparer.OrdinalIgnoreCase))
{
return null;
}
var nc = nonceCount.ToString("x8", System.Globalization.CultureInfo.InvariantCulture);
var ha1 = Sha256($"{user}:{realm}:{password}");
var ha2 = Sha256($"{method}:{uri}");
var response = qop is null ? Sha256($"{ha1}:{nonce}:{ha2}") : Sha256($"{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}");
var header = new StringBuilder()
.Append($"username=\"{user}\", realm=\"{realm}\", nonce=\"{nonce}\", uri=\"{uri}\", algorithm=SHA-256");
if (qop is not null)
{
header.Append($", qop=auth, nc={nc}, cnonce=\"{cnonce}\"");
}
header.Append($", response=\"{response}\"");
return header.ToString();
}
private static string Sha256(string value) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
}
@@ -0,0 +1,205 @@
using System.Diagnostics;
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Home;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>Verschlüsselt Geräte-Passwörter mit Data Protection (eigener Zweck, getrennt von den KI-Schlüsseln).</summary>
public interface IHomeSecretProtector
{
string Protect(string secret);
/// <summary>Entschlüsselt oder liefert <c>null</c>, wenn der Schlüsselring den Wert nicht mehr lesen kann.</summary>
string? Unprotect(string protectedSecret);
}
public sealed class HomeSecretProtector(IDataProtectionProvider provider) : IHomeSecretProtector
{
private readonly IDataProtector _protector = provider.CreateProtector("FamilyPlanner.Home.DevicePassword.v1");
public string Protect(string secret) => _protector.Protect(secret);
public string? Unprotect(string protectedSecret)
{
try
{
return _protector.Unprotect(protectedSecret);
}
catch (System.Security.Cryptography.CryptographicException)
{
return null;
}
}
}
/// <summary>Ein Gerät mit der wirksamen Zugriffsstufe der anfragenden Person.</summary>
public sealed record HomeVisibleDevice(HomeDevice Device, HomeAccessLevel Level);
/// <summary>
/// Objektbezogene Rechte je Gerät (ADR-0023): Admin sieht und schaltet alles, alle anderen nur freigegebene Geräte (Rolle oder Person; höchste Stufe zählt).
/// Nicht freigegebene Geräte sind unsichtbar (Api antwortet 404), nur „ansehen“ erlaubt kein Schalten (403).
/// </summary>
public sealed class HomeAccessService(AppDbContext db)
{
public async Task<List<HomeVisibleDevice>> VisibleDevicesAsync(ClaimsPrincipal user, CancellationToken ct = default)
{
var userId = user.UserId() ?? Guid.Empty;
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
var devices = await db.HomeDevices.AsNoTracking().OrderBy(d => d.SortOrder).ThenBy(d => d.Name).ToListAsync(ct);
if (roles.Contains(Domain.Roles.Admin))
{
return devices.Select(d => new HomeVisibleDevice(d, HomeAccessLevel.Control)).ToList();
}
var grants = await db.HomeDeviceAccess.AsNoTracking()
.Where(a => a.UserId == userId || (a.Role != null && roles.Contains(a.Role)))
.ToListAsync(ct);
var byDevice = grants.ToLookup(a => a.DeviceId, a => new HomeGrant(a.Role, a.UserId, a.Level));
return devices
.Select(d => new HomeVisibleDevice(d, HomeRules.ResolveAccess(userId, roles, byDevice[d.Id])))
.Where(v => v.Level != HomeAccessLevel.None)
.ToList();
}
/// <summary>Das Gerät, wenn die Person es sehen darf – sonst <c>null</c> (404).</summary>
public async Task<HomeVisibleDevice?> DeviceAsync(ClaimsPrincipal user, Guid id, CancellationToken ct = default) =>
(await VisibleDevicesAsync(user, ct)).FirstOrDefault(v => v.Device.Id == id);
}
/// <summary>
/// Liest Zustände (parallel, mit kurzem Server-Cache, damit offene Browser-Tabs die Geräte nicht belasten) und führt Befehle aus.
/// Jede Schalthandlung – auch eine fehlgeschlagene – kommt ins Aktionsprotokoll (ohne Zugangsdaten und Geräteantworten).
/// </summary>
public sealed class HomeDeviceService(
AppDbContext db,
IEnumerable<IHomeDeviceDriver> drivers,
IHomeSecretProtector protector,
IMemoryCache cache)
{
public static readonly TimeSpan StateCacheTime = TimeSpan.FromSeconds(3);
private const int MaxParallelReads = 8;
public IHomeDeviceDriver Driver(HomeDriverType type) =>
drivers.FirstOrDefault(d => d.Type == type)
?? throw new HomeException(HomeErrorKind.NotSupported, "Für dieses Gerät ist kein Treiber verfügbar.");
public HomeDeviceConnection Connection(HomeDevice device) => new(
device.Host,
device.Generation,
device.Channel,
device.Kind,
device.AuthUser,
device.AuthPasswordProtected is { Length: > 0 } secret ? protector.Unprotect(secret) : null);
public async Task<IReadOnlyList<HomeDeviceStateDto>> ReadStatesAsync(IReadOnlyList<HomeDevice> devices, CancellationToken ct)
{
using var gate = new SemaphoreSlim(MaxParallelReads);
var tasks = devices.Select(async device =>
{
await gate.WaitAsync(ct);
try
{
return await ReadStateAsync(device, useCache: true, ct);
}
finally
{
gate.Release();
}
});
return await Task.WhenAll(tasks);
}
public async Task<HomeDeviceStateDto> ReadStateAsync(HomeDevice device, bool useCache, CancellationToken ct)
{
var key = CacheKey(device.Id);
if (useCache && cache.TryGetValue(key, out HomeDeviceStateDto? cached) && cached is not null)
{
return cached;
}
HomeDeviceStateDto result;
try
{
var state = await Driver(device.Driver).GetStateAsync(Connection(device), ct);
result = ToDto(device.Id, state);
}
catch (HomeException ex)
{
result = Offline(device.Id, ex.Message);
}
cache.Set(key, result, StateCacheTime);
return result;
}
public async Task<HomeCommandResultDto> ExecuteAsync(HomeDevice device, HomeCommand command, Guid? userId, string userName, CancellationToken ct)
{
var started = Stopwatch.GetTimestamp();
string? error = null;
try
{
await Driver(device.Driver).ExecuteAsync(Connection(device), command, ct);
}
catch (HomeException ex)
{
error = ex.Message;
}
var duration = (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
cache.Remove(CacheKey(device.Id));
db.HomeActionLog.Add(new HomeActionLog
{
UserId = userId,
UserName = userName.Length > 100 ? userName[..100] : userName,
DeviceId = device.Id,
DeviceName = device.Name,
Command = command,
Success = error is null,
Error = error is null ? null : (error.Length > HomeActionLog.ErrorMaxLength ? error[..HomeActionLog.ErrorMaxLength] : error),
DurationMs = duration,
});
// Das Protokoll wird auch geschrieben, wenn die Anfrage inzwischen abgebrochen wurde (der Befehl ist dann schon gesendet).
await db.SaveChangesAsync(CancellationToken.None);
var state = error is null
? await ReadStateAsync(device, useCache: false, ct)
: Offline(device.Id, error);
return new HomeCommandResultDto(error is null, error, state);
}
public async Task<HomeTestResultDto> TestAsync(HomeDeviceConnection connection, HomeDriverType driver, CancellationToken ct)
{
var started = Stopwatch.GetTimestamp();
try
{
var info = await Driver(driver).TestAsync(connection, ct);
var model = string.IsNullOrWhiteSpace(info.Model) ? null : info.Model;
return new HomeTestResultDto(true, "Verbindung erfolgreich.", model, info.Firmware, Elapsed(started));
}
catch (HomeException ex)
{
return new HomeTestResultDto(false, ex.Message, null, null, Elapsed(started));
}
}
public void Forget(Guid deviceId) => cache.Remove(CacheKey(deviceId));
private static long Elapsed(long started) => (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
private static string CacheKey(Guid id) => $"home:state:{id}";
private static HomeDeviceStateDto ToDto(Guid id, HomeDeviceState s) =>
new(id, true, s.IsOn, s.CoverState, s.CoverPosition, s.PowerWatts, s.TemperatureC, s.HumidityPercent, null);
private static HomeDeviceStateDto Offline(Guid id, string message) =>
new(id, false, null, null, null, null, null, null, message);
}
@@ -0,0 +1,220 @@
using System.Text.Json;
using FamilyPlanner.Domain.Home;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>
/// Shelly-Treiber (ADR-0023). Gen2+ (Plus/Pro/Gen3/Gen4) per HTTP-RPC: <c>Switch.GetStatus/Set/Toggle</c>, <c>Cover.*</c>, <c>Light.*</c>, <c>Shelly.GetDeviceInfo</c>;
/// Gen1 per REST: <c>/relay/{id}?turn=…</c>, <c>/roller/{id}?go=…</c>, <c>/light/{id}?turn=…</c>, <c>/status</c>, <c>/shelly</c>.
/// </summary>
public sealed class ShellyDriver(IHttpClientFactory httpClientFactory) : IHomeDeviceDriver
{
public HomeDriverType Type => HomeDriverType.Shelly;
private HttpClient Http => httpClientFactory.CreateClient(HomeHttp.ClientName);
public async Task<HomeDeviceState> GetStateAsync(HomeDeviceConnection c, CancellationToken ct) =>
c.Generation >= 2 ? await GetGen2StateAsync(c, ct) : await GetGen1StateAsync(c, ct);
public async Task ExecuteAsync(HomeDeviceConnection c, HomeCommand command, CancellationToken ct)
{
var path = c.Generation >= 2 ? Gen2CommandPath(c, command) : Gen1CommandPath(c, command);
await CallAsync(c, path, ct);
}
public async Task<HomeDeviceInfo> TestAsync(HomeDeviceConnection c, CancellationToken ct)
{
HomeDeviceInfo info;
if (c.Generation >= 2)
{
var json = await CallAsync(c, "/rpc/Shelly.GetDeviceInfo", ct);
if (json.ValueKind != JsonValueKind.Object || (!json.TryGetProperty("gen", out _) && !json.TryGetProperty("model", out _)))
{
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät antwortet nicht wie ein Shelly Gen2+. Bitte Adresse und Generation prüfen.");
}
info = new HomeDeviceInfo(String(json, "model") ?? String(json, "app"), String(json, "ver"));
}
else
{
var json = await CallAsync(c, "/shelly", ct);
if (json.ValueKind != JsonValueKind.Object || (json.TryGetProperty("gen", out var gen) && gen.ValueKind == JsonValueKind.Number && gen.TryGetInt32(out var generation) && generation >= 2))
{
throw new HomeException(HomeErrorKind.BadResponse, "Dieses Gerät ist ein Shelly Gen2 oder neuer. Bitte Generation 2 wählen.");
}
info = new HomeDeviceInfo(String(json, "type"), String(json, "fw"));
}
// Kanal und Geräteart prüfen: eine Statusabfrage muss gelingen.
await GetStateAsync(c, ct);
return info;
}
// ---------- Gen2+ (RPC) ----------
private async Task<HomeDeviceState> GetGen2StateAsync(HomeDeviceConnection c, CancellationToken ct)
{
switch (c.Kind)
{
case HomeDeviceKind.Cover:
{
var json = await CallAsync(c, $"/rpc/Cover.GetStatus?id={c.Channel}", ct);
return new HomeDeviceState(
CoverState: Gen2CoverState(String(json, "state")),
CoverPosition: Int(json, "current_pos"),
PowerWatts: Number(json, "apower"));
}
case HomeDeviceKind.Light:
{
var json = await CallAsync(c, $"/rpc/Light.GetStatus?id={c.Channel}", ct);
return new HomeDeviceState(IsOn: Bool(json, "output"), PowerWatts: Number(json, "apower"));
}
case HomeDeviceKind.Sensor:
{
var json = await CallAsync(c, "/rpc/Shelly.GetStatus", ct);
var temperature = Child(json, $"temperature:{c.Channel}");
var humidity = Child(json, $"humidity:{c.Channel}");
if (temperature is null && humidity is null)
{
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät meldet keinen Temperatur- oder Feuchtesensor für diesen Kanal.");
}
return new HomeDeviceState(TemperatureC: temperature is { } t ? Number(t, "tC") : null, HumidityPercent: humidity is { } h ? Number(h, "rh") : null);
}
default:
{
var json = await CallAsync(c, $"/rpc/Switch.GetStatus?id={c.Channel}", ct);
return new HomeDeviceState(IsOn: Bool(json, "output") ?? throw NoState(), PowerWatts: Number(json, "apower"));
}
}
}
private static string Gen2CommandPath(HomeDeviceConnection c, HomeCommand command) => (c.Kind, command) switch
{
(HomeDeviceKind.Cover, HomeCommand.Open) => $"/rpc/Cover.Open?id={c.Channel}",
(HomeDeviceKind.Cover, HomeCommand.Close) => $"/rpc/Cover.Close?id={c.Channel}",
(HomeDeviceKind.Cover, HomeCommand.Stop) => $"/rpc/Cover.Stop?id={c.Channel}",
(HomeDeviceKind.Light, HomeCommand.TurnOn) => $"/rpc/Light.Set?id={c.Channel}&on=true",
(HomeDeviceKind.Light, HomeCommand.TurnOff) => $"/rpc/Light.Set?id={c.Channel}&on=false",
(HomeDeviceKind.Light, HomeCommand.Toggle) => $"/rpc/Light.Toggle?id={c.Channel}",
(HomeDeviceKind.Switch or HomeDeviceKind.Socket, HomeCommand.TurnOn) => $"/rpc/Switch.Set?id={c.Channel}&on=true",
(HomeDeviceKind.Switch or HomeDeviceKind.Socket, HomeCommand.TurnOff) => $"/rpc/Switch.Set?id={c.Channel}&on=false",
(HomeDeviceKind.Switch or HomeDeviceKind.Socket, HomeCommand.Toggle) => $"/rpc/Switch.Toggle?id={c.Channel}",
_ => throw new HomeException(HomeErrorKind.NotSupported, "Dieser Befehl passt nicht zu diesem Gerät."),
};
/// <summary>Gen2 meldet open, closed, opening, closing, stopped, calibrating – unbekannte Werte werden nicht durchgereicht.</summary>
private static string? Gen2CoverState(string? state) => state switch
{
"open" or "closed" or "opening" or "closing" or "stopped" => state,
"calibrating" => "stopped",
_ => null,
};
// ---------- Gen1 (REST) ----------
private async Task<HomeDeviceState> GetGen1StateAsync(HomeDeviceConnection c, CancellationToken ct)
{
var json = await CallAsync(c, "/status", ct);
switch (c.Kind)
{
case HomeDeviceKind.Cover:
{
var roller = At(json, "rollers", c.Channel) ?? throw NoChannel();
var position = Int(roller, "current_pos");
var state = String(roller, "state") switch
{
"open" => "opening",
"close" => "closing",
_ when position == 100 => "open",
_ when position == 0 => "closed",
_ => "stopped",
};
return new HomeDeviceState(CoverState: state, CoverPosition: position, PowerWatts: Number(roller, "power"));
}
case HomeDeviceKind.Light:
{
var light = At(json, "lights", c.Channel) ?? throw NoChannel();
return new HomeDeviceState(IsOn: Bool(light, "ison") ?? throw NoState(), PowerWatts: MeterPower(json, c.Channel));
}
case HomeDeviceKind.Sensor:
{
var temperature = Child(json, "tmp") is { } tmp ? Number(tmp, "tC") : null;
var humidity = Child(json, "hum") is { } hum ? Number(hum, "value") : null;
if (temperature is null && humidity is null)
{
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät meldet keine Temperatur oder Feuchte.");
}
return new HomeDeviceState(TemperatureC: temperature, HumidityPercent: humidity);
}
default:
{
var relay = At(json, "relays", c.Channel) ?? throw NoChannel();
return new HomeDeviceState(IsOn: Bool(relay, "ison") ?? throw NoState(), PowerWatts: MeterPower(json, c.Channel));
}
}
}
private static string Gen1CommandPath(HomeDeviceConnection c, HomeCommand command) => (c.Kind, command) switch
{
(HomeDeviceKind.Cover, HomeCommand.Open) => $"/roller/{c.Channel}?go=open",
(HomeDeviceKind.Cover, HomeCommand.Close) => $"/roller/{c.Channel}?go=close",
(HomeDeviceKind.Cover, HomeCommand.Stop) => $"/roller/{c.Channel}?go=stop",
(HomeDeviceKind.Light, HomeCommand.TurnOn) => $"/light/{c.Channel}?turn=on",
(HomeDeviceKind.Light, HomeCommand.TurnOff) => $"/light/{c.Channel}?turn=off",
(HomeDeviceKind.Light, HomeCommand.Toggle) => $"/light/{c.Channel}?turn=toggle",
(HomeDeviceKind.Switch or HomeDeviceKind.Socket, HomeCommand.TurnOn) => $"/relay/{c.Channel}?turn=on",
(HomeDeviceKind.Switch or HomeDeviceKind.Socket, HomeCommand.TurnOff) => $"/relay/{c.Channel}?turn=off",
(HomeDeviceKind.Switch or HomeDeviceKind.Socket, HomeCommand.Toggle) => $"/relay/{c.Channel}?turn=toggle",
_ => throw new HomeException(HomeErrorKind.NotSupported, "Dieser Befehl passt nicht zu diesem Gerät."),
};
// ---------- Hilfen ----------
private Task<JsonElement> CallAsync(HomeDeviceConnection c, string pathAndQuery, CancellationToken ct) =>
HomeHttp.GetJsonAsync(Http, c.Host, pathAndQuery, c.User, c.Password, basicFirst: c.Generation < 2, ct);
private static HomeException NoChannel() => new(HomeErrorKind.BadResponse, "Das Gerät hat diesen Kanal nicht. Bitte Kanal und Geräteart prüfen.");
private static HomeException NoState() => new(HomeErrorKind.BadResponse, "Das Gerät meldet keinen Schaltzustand. Bitte Geräteart und Kanal prüfen.");
private static JsonElement? Child(JsonElement element, string name) =>
element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Object ? value : null;
private static JsonElement? At(JsonElement element, string arrayName, int index) =>
element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty(arrayName, out var array)
&& array.ValueKind == JsonValueKind.Array
&& index < array.GetArrayLength()
&& array[index].ValueKind == JsonValueKind.Object
? array[index]
: null;
private static double? MeterPower(JsonElement status, int channel) => At(status, "meters", channel) is { } meter ? Number(meter, "power") : null;
private static string? String(JsonElement element, string name) =>
element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static bool? Bool(JsonElement element, string name) =>
element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
? value.GetBoolean()
: null;
private static double? Number(JsonElement element, string name) =>
element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d)
? Math.Round(d, 1, MidpointRounding.AwayFromZero)
: null;
private static int? Int(JsonElement element, string name) =>
element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var i)
? Math.Clamp(i, 0, 100)
: null;
}
+14
View File
@@ -5,6 +5,7 @@ using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.CalDav;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Dashboard;
using FamilyPlanner.Api.Features.Home;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Api.Features.Meals;
using FamilyPlanner.Api.Features.Push;
@@ -57,6 +58,18 @@ builder.Services.AddOptions<GiphyOptions>().BindConfiguration(GiphyOptions.Secti
builder.Services.AddHttpClient<IGifProvider, GiphyGifProvider>(client => client.Timeout = TimeSpan.FromSeconds(15))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false })
.RemoveAllLoggers();
// Haus (ADR-0023): Geräte im Heimnetz hinter IHomeDeviceDriver. Eigener HttpClient ohne Standard-Resilienz (Wiederholungen könnten einen Toggle doppelt auslösen),
// ohne Weiterleitungen/Proxy, mit Prüfung jeder aufgelösten IP-Adresse beim Verbinden (nur Heimnetz).
#pragma warning disable EXTEXP0001
builder.Services.AddHttpClient(HomeHttp.ClientName, client => client.Timeout = Timeout.InfiniteTimeSpan)
.ConfigurePrimaryHttpMessageHandler(HomeHttp.CreateHandler)
.RemoveAllResilienceHandlers();
#pragma warning restore EXTEXP0001
builder.Services.AddSingleton<IHomeSecretProtector, HomeSecretProtector>();
builder.Services.AddScoped<IHomeDeviceDriver, ShellyDriver>();
builder.Services.AddScoped<HomeAccessService>();
builder.Services.AddScoped<HomeDeviceService>();
builder.Services.AddScoped<LearningNotifier>();
builder.Services.AddScoped<LearningAttemptService>();
builder.Services.AddScoped<LearningScheduleRunner>();
@@ -109,6 +122,7 @@ app.MapDashboardEndpoints();
app.MapChatEndpoints();
app.MapAiEndpoints();
app.MapLearningEndpoints();
app.MapHomeEndpoints();
app.MapHub<LiveHub>("/hubs/live");
app.Run();
+4 -73
View File
@@ -1,5 +1,4 @@
using System.Net;
using System.Net.Sockets;
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Ai;
@@ -117,27 +116,9 @@ public static class AiRules
/// <summary>
/// Liegt die Adresse im Heimnetz (Loopback, private Netze, <c>.local</c>/<c>.lan</c>/<c>.internal</c>, Hostname ohne Punkt)?
/// Dann verlassen Daten das Heimnetz nicht – Bilder brauchen kein Opt-in.
/// Dann verlassen Daten das Heimnetz nicht – Bilder brauchen kein Opt-in. Die Regel liegt gemeinsam in <see cref="NetworkRules"/>.
/// </summary>
public static bool IsHomeNetwork(Uri uri)
{
var host = uri.IdnHost.TrimEnd('.');
if (host.Length == 0)
{
return false;
}
if (IPAddress.TryParse(host.Trim('[', ']'), out var address))
{
return IsPrivate(address);
}
return !host.Contains('.', StringComparison.Ordinal)
|| host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".lan", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".home.arpa", StringComparison.OrdinalIgnoreCase);
}
public static bool IsHomeNetwork(Uri uri) => NetworkRules.IsHomeNetwork(uri);
public static bool IsHomeNetwork(string baseUrl) => Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) && IsHomeNetwork(uri);
@@ -155,55 +136,5 @@ public static class AiRules
/// <summary>Wurde das Tageslimit noch nicht erreicht? Ein Limit von 0 oder weniger bedeutet unbegrenzt.</summary>
public static bool IsWithinDailyLimit(int usedToday, int limit) => limit <= 0 || usedToday < limit;
private static bool IsBlockedHost(Uri uri)
{
var host = uri.IdnHost.Trim('[', ']');
if (!IPAddress.TryParse(host, out var address))
{
return false;
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
return bytes[0] == 169 && bytes[1] == 254; // Link-Local, u. a. Cloud-Metadatendienste
}
return address.IsIPv6LinkLocal;
}
private static bool IsPrivate(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
var first = address.GetAddressBytes()[0];
return (first & 0xFE) == 0xFC; // fc00::/7 (Unique Local)
}
var bytes = address.GetAddressBytes();
return bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 100 && bytes[1] is >= 64 and <= 127); // CGNAT (z. B. Tailscale)
}
private static bool IsBlockedHost(Uri uri) => NetworkRules.IsBlockedHost(uri);
}
@@ -51,6 +51,17 @@ public static class AuditActions
public const string LearningScheduleDeleted = "learning.schedule_deleted";
public const string LearningScheduleRun = "learning.schedule_run";
public const string LearningSchedulePaused = "learning.schedule_paused";
public const string HomeDeviceCreated = "home.device_created";
public const string HomeDeviceUpdated = "home.device_updated";
public const string HomeDeviceDeleted = "home.device_deleted";
public const string HomeAccessChanged = "home.access_changed";
public const string HomeRoomCreated = "home.room_created";
public const string HomeRoomUpdated = "home.room_updated";
public const string HomeRoomDeleted = "home.room_deleted";
public const string HomePlanCreated = "home.plan_created";
public const string HomePlanUpdated = "home.plan_updated";
public const string HomePlanDeleted = "home.plan_deleted";
public const string HomePlacementsChanged = "home.placements_changed";
public static string DisplayName(string action) => action switch
{
@@ -74,6 +85,17 @@ public static class AuditActions
LearningScheduleDeleted => "Lern-Zeitplan gelöscht",
LearningScheduleRun => "Lern-Zeitplan erzeugt Blatt",
LearningSchedulePaused => "Lern-Zeitplan pausiert",
HomeDeviceCreated => "Haus: Gerät angelegt",
HomeDeviceUpdated => "Haus: Gerät geändert",
HomeDeviceDeleted => "Haus: Gerät gelöscht",
HomeAccessChanged => "Haus: Freigaben geändert",
HomeRoomCreated => "Haus: Raum angelegt",
HomeRoomUpdated => "Haus: Raum geändert",
HomeRoomDeleted => "Haus: Raum gelöscht",
HomePlanCreated => "Haus: Plan angelegt",
HomePlanUpdated => "Haus: Plan geändert",
HomePlanDeleted => "Haus: Plan gelöscht",
HomePlacementsChanged => "Haus: Positionen auf dem Plan geändert",
UserCreated => "Benutzer angelegt",
UserUpdated => "Benutzer geändert",
UserDeleted => "Benutzer gelöscht",
@@ -22,6 +22,8 @@ public enum Permission
AiUse,
LearningManage,
LearningSolve,
HomeUse,
HomeManage,
UserManagement,
CalendarManagement,
SettingsManagement,
@@ -74,6 +76,8 @@ public static class PermissionMatrix
[Permission.AiUse] = [F, F, N, N], // KI-Funktionen nutzen: nur Eltern (ADR-0020)
[Permission.LearningManage] = [F, F, N, N], // Übungsblätter anlegen/zuweisen/prüfen (ADR-0021)
[Permission.LearningSolve] = [F, F, R, N], // Kind: nur eigene Blätter lösen (ADR-0021)
[Permission.HomeUse] = [F, R, R, R], // Haus: Geräte nur nach Freigabe je Gerät (ADR-0023)
[Permission.HomeManage] = [F, N, N, N], // Hausverwaltung: Geräte, Freigaben, Pläne
[Permission.UserManagement] = [F, N, N, N],
[Permission.CalendarManagement] = [F, N, N, N],
[Permission.SettingsManagement] = [F, N, N, N],
@@ -23,6 +23,9 @@ public static class Policies
private const string PermissionPrefix = "Permission:";
/// <summary>Konstante der Policy <c>Permission:HomeUse</c> (für <c>[Authorize]</c> und Navigation; gleich <c>For(Permission.HomeUse)</c>).</summary>
public const string HomeUse = PermissionPrefix + nameof(Permission.HomeUse);
/// <summary>Policy-Name für eine fachliche Berechtigung (Zugriff, wenn Stufe &gt; None).</summary>
public static string For(Permission permission) => PermissionPrefix + permission;
@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Sockets;
namespace FamilyPlanner.Domain.Common;
/// <summary>
/// Gemeinsame Netzwerkregeln für ausgehende Verbindungen (KI-Anbieter, Smart-Home-Geräte): Was gilt als Heimnetz, was ist gesperrt (SSRF-Schutz).
/// Reine Funktionen ohne Namensauflösung; wer Hostnamen auflöst, prüft jede aufgelöste Adresse mit <see cref="IsPrivate"/>/<see cref="IsBlocked"/>.
/// </summary>
public static class NetworkRules
{
/// <summary>
/// Liegt die Adresse im Heimnetz (Loopback, private Netze, <c>.local</c>/<c>.lan</c>/<c>.internal</c>, Hostname ohne Punkt)?
/// </summary>
public static bool IsHomeNetwork(Uri uri)
{
var host = uri.IdnHost.TrimEnd('.');
if (host.Length == 0)
{
return false;
}
if (IPAddress.TryParse(host.Trim('[', ']'), out var address))
{
return IsPrivate(address);
}
return !host.Contains('.', StringComparison.Ordinal)
|| host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".lan", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".home.arpa", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Ist der Host eine gesperrte IP-Adresse (Link-Local inkl. Cloud-Metadaten, <c>0.0.0.0</c>)? Hostnamen sind hier nie gesperrt.</summary>
public static bool IsBlockedHost(Uri uri)
{
var host = uri.IdnHost.Trim('[', ']');
return IPAddress.TryParse(host, out var address) && IsBlocked(address);
}
/// <summary>Gesperrte Adresse: unspezifiziert, Link-Local (169.254.0.0/16 u. a. Cloud-Metadatendienste, fe80::/10), Multicast, Broadcast.</summary>
public static bool IsBlocked(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) || address.Equals(IPAddress.Broadcast))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
return (bytes[0] == 169 && bytes[1] == 254) || bytes[0] >= 224;
}
return address.IsIPv6LinkLocal || address.IsIPv6Multicast;
}
/// <summary>Loopback, private Netze (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10, z. B. Tailscale) oder Unique-Local (fc00::/7).</summary>
public static bool IsPrivate(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
var first = address.GetAddressBytes()[0];
return (first & 0xFE) == 0xFC; // fc00::/7 (Unique Local)
}
var bytes = address.GetAddressBytes();
return bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 100 && bytes[1] is >= 64 and <= 127);
}
/// <summary>Darf zu dieser aufgelösten Adresse eine Verbindung aufgebaut werden (Heimnetz und nicht gesperrt)?</summary>
public static bool IsAllowedHomeAddress(IPAddress address) => IsPrivate(address) && !IsBlocked(address);
}
@@ -0,0 +1,186 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Home;
/// <summary>Art des Geräts: bestimmt die erlaubten Befehle (<see cref="HomeRules.IsCommandAllowed"/>) und die Standard-Darstellung.</summary>
public enum HomeDeviceKind
{
/// <summary>Schalter / Relais (z. B. Shelly Plus 1).</summary>
Switch = 0,
/// <summary>Steckdose mit Verbrauchsmessung (z. B. Shelly Plug).</summary>
Socket = 1,
/// <summary>Licht / Dimmer / Leuchtmittel mit eigener Light-Komponente.</summary>
Light = 2,
/// <summary>Rollo / Jalousie (Cover bzw. Roller).</summary>
Cover = 3,
/// <summary>Nur lesend: Temperatur/Luftfeuchte (und ggf. Leistung).</summary>
Sensor = 4,
}
/// <summary>Treiber, der das Gerät anspricht. Weitere Treiber (MQTT, Home Assistant …) kommen als neuer Wert plus <c>IHomeDeviceDriver</c>.</summary>
public enum HomeDriverType
{
Shelly = 0,
}
/// <summary>Was darf eine Freigabe? <see cref="View"/> = Zustand sehen, <see cref="Control"/> = zusätzlich schalten.</summary>
public enum HomeAccessLevel
{
None = 0,
View = 1,
Control = 2,
}
/// <summary>Befehl an ein Gerät. Ob er zur Geräteart passt, prüft <see cref="HomeRules.IsCommandAllowed"/>.</summary>
public enum HomeCommand
{
TurnOn = 0,
TurnOff = 1,
Toggle = 2,
Open = 3,
Close = 4,
Stop = 5,
}
public sealed class HomeRoom : AuditableEntity, ISoftDeletable
{
public const int NameMaxLength = 60;
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
}
/// <summary>Ein Gerät im Heimnetz (ADR-0023). Zugangsdaten nur verschlüsselt, nie an den Client.</summary>
public sealed class HomeDevice : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int NameMaxLength = 80;
public const int HostMaxLength = 200;
public const int AuthUserMaxLength = 60;
public const int PasswordMaxLength = 100;
public const int ProtectedPasswordMaxLength = 1000;
public const int IconMaxLength = 40;
public string Name { get; set; } = string.Empty;
public HomeDeviceKind Kind { get; set; }
public Guid? RoomId { get; set; }
public HomeDriverType Driver { get; set; }
/// <summary>Shelly: 1 = Gen1 (REST), 2 = Gen2+ (RPC).</summary>
public int Generation { get; set; } = 2;
/// <summary>Normalisierte Basisadresse im Heimnetz, z. B. <c>http://192.168.1.50</c> (<see cref="HomeRules.NormalizeHost"/>).</summary>
public string Host { get; set; } = string.Empty;
/// <summary>Kanal bzw. Relais-/Cover-Id (0-basiert).</summary>
public int Channel { get; set; }
public string? AuthUser { get; set; }
/// <summary>Passwort des Geräts, mit Data Protection verschlüsselt.</summary>
public string? AuthPasswordProtected { get; set; }
/// <summary>Schlüssel eines Symbols aus <see cref="HomeRules.Icons"/>; leer = Standard der Geräteart.</summary>
public string? Icon { get; set; }
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Freigabe eines Geräts für eine Rolle <b>oder</b> eine Person. Admins brauchen keine Freigabe.
/// Genau eines von <see cref="Role"/>/<see cref="UserId"/> ist gesetzt.
/// </summary>
public sealed class HomeDeviceAccess : AuditableEntity
{
public Guid DeviceId { get; set; }
/// <summary>Rollenname (Familie, Kind, Gast) oder <c>null</c> bei einer Personenfreigabe.</summary>
public string? Role { get; set; }
public Guid? UserId { get; set; }
public HomeAccessLevel Level { get; set; }
}
/// <summary>Ein Grundriss/Plan (Stockwerk). Das Bild liegt getrennt in <see cref="HomePlanImage"/> (ADR-0009: in der Datenbank).</summary>
public sealed class HomePlan : AuditableEntity, ISoftDeletable
{
public const int NameMaxLength = 60;
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
public string ContentType { get; set; } = string.Empty;
/// <summary>Wird bei jedem neuen Bild hochgezählt (Cache-Schlüssel der Bild-URL).</summary>
public int ImageVersion { get; set; } = 1;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
}
public sealed class HomePlanImage : AuditableEntity
{
public Guid PlanId { get; set; }
public byte[] Data { get; set; } = [];
}
/// <summary>Position eines Geräts auf einem Plan in relativen Koordinaten (0–1, links/oben).</summary>
public sealed class HomeDevicePlacement : AuditableEntity
{
public Guid PlanId { get; set; }
public Guid DeviceId { get; set; }
public double X { get; set; }
public double Y { get; set; }
}
/// <summary>Protokoll jeder Schalthandlung (auch fehlgeschlagener). Namen sind Momentaufnahmen und bleiben nach Löschungen lesbar. Nie Zugangsdaten oder Geräteantworten.</summary>
public sealed class HomeActionLog : AuditableEntity
{
public const int ErrorMaxLength = 300;
public Guid? UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public Guid DeviceId { get; set; }
public string DeviceName { get; set; } = string.Empty;
public HomeCommand Command { get; set; }
public bool Success { get; set; }
public string? Error { get; set; }
public long DurationMs { get; set; }
}
+192
View File
@@ -0,0 +1,192 @@
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Users;
namespace FamilyPlanner.Domain.Home;
/// <summary>Zusammengeführte Freigabe eines Geräts für die Rechteprüfung.</summary>
public sealed record HomeGrant(string? Role, Guid? UserId, HomeAccessLevel Level);
/// <summary>Reine Regeln des Haus-Moduls (ADR-0023): Adressen, Befehle, Freigaben, Positionen, Bilder.</summary>
public static class HomeRules
{
public const int MaxChannel = 15;
public const int MaxPlanImageBytes = 5 * 1024 * 1024;
/// <summary>Rollen, an die ein Gerät freigegeben werden kann (Admins haben immer Zugriff).</summary>
public static IReadOnlyList<string> GrantableRoles { get; } = [Roles.Family, Roles.Child, Roles.Guest];
/// <summary>Auswahl der Symbole (Schlüssel → Anzeigename). Die Oberfläche ordnet jedem Schlüssel ein Material-Symbol zu.</summary>
public static IReadOnlyDictionary<string, string> Icons { get; } = new Dictionary<string, string>(StringComparer.Ordinal)
{
["bulb"] = "Glühbirne",
["ceiling"] = "Deckenlampe",
["lamp"] = "Stehlampe",
["plug"] = "Steckdose",
["fan"] = "Ventilator",
["heater"] = "Heizung",
["tv"] = "Fernseher",
["coffee"] = "Kaffeemaschine",
["blinds"] = "Rollo",
["door"] = "Tür",
["garage"] = "Garage",
["thermometer"] = "Thermometer",
};
public static string DisplayName(HomeDeviceKind kind) => kind switch
{
HomeDeviceKind.Switch => "Schalter",
HomeDeviceKind.Socket => "Steckdose",
HomeDeviceKind.Light => "Licht",
HomeDeviceKind.Cover => "Rollo",
HomeDeviceKind.Sensor => "Sensor",
_ => kind.ToString(),
};
public static string DisplayName(HomeCommand command) => command switch
{
HomeCommand.TurnOn => "eingeschaltet",
HomeCommand.TurnOff => "ausgeschaltet",
HomeCommand.Toggle => "umgeschaltet",
HomeCommand.Open => "geöffnet",
HomeCommand.Close => "geschlossen",
HomeCommand.Stop => "gestoppt",
_ => command.ToString(),
};
/// <summary>Passt der Befehl zur Geräteart? Sensoren nehmen keine Befehle an.</summary>
public static bool IsCommandAllowed(HomeDeviceKind kind, HomeCommand command) => kind switch
{
HomeDeviceKind.Switch or HomeDeviceKind.Socket or HomeDeviceKind.Light =>
command is HomeCommand.TurnOn or HomeCommand.TurnOff or HomeCommand.Toggle,
HomeDeviceKind.Cover => command is HomeCommand.Open or HomeCommand.Close or HomeCommand.Stop,
_ => false,
};
/// <summary>Wirksame Zugriffsstufe: Admin immer <see cref="HomeAccessLevel.Control"/>, sonst die höchste passende Rollen-/Personenfreigabe.</summary>
public static HomeAccessLevel ResolveAccess(Guid userId, IReadOnlyCollection<string> roles, IEnumerable<HomeGrant> grants)
{
if (roles.Contains(Roles.Admin, StringComparer.Ordinal))
{
return HomeAccessLevel.Control;
}
var level = HomeAccessLevel.None;
foreach (var grant in grants)
{
var applies = (grant.UserId is { } id && id == userId)
|| (grant.Role is not null && roles.Contains(grant.Role, StringComparer.Ordinal));
if (applies && grant.Level > level)
{
level = grant.Level;
}
}
return level;
}
/// <summary>Gültige Freigabe: genau eines von Rolle/Person, Rolle aus <see cref="GrantableRoles"/>, Stufe View oder Control.</summary>
public static string? ValidateGrant(string? role, Guid? userId, HomeAccessLevel level)
{
if (level is not (HomeAccessLevel.View or HomeAccessLevel.Control))
{
return "Bitte „Nur ansehen“ oder „Schalten“ wählen.";
}
if ((role is null) == (userId is null))
{
return "Eine Freigabe gilt entweder für eine Rolle oder für eine Person.";
}
if (role is not null && !GrantableRoles.Contains(role, StringComparer.Ordinal))
{
return "Diese Rolle kann nicht freigegeben werden (Administratoren haben immer Zugriff).";
}
return null;
}
/// <summary>Liegt die Koordinate im Bereich 0–1 (und ist eine endliche Zahl)?</summary>
public static bool IsValidCoordinate(double value) => double.IsFinite(value) && value is >= 0 and <= 1;
/// <summary>Auf vier Nachkommastellen runden (reicht für jeden Plan, hält die Datenbank sauber).</summary>
public static double RoundCoordinate(double value) => Math.Round(Math.Clamp(value, 0, 1), 4);
public static bool IsValidChannel(int channel) => channel is >= 0 and <= MaxChannel;
public static bool IsValidGeneration(HomeDriverType driver, int generation) => driver switch
{
HomeDriverType.Shelly => generation is 1 or 2,
_ => false,
};
public static bool IsValidIcon(string? icon) => string.IsNullOrEmpty(icon) || Icons.ContainsKey(icon);
/// <summary>Kürzt Leerraum; <c>null</c>, wenn leer oder zu lang.</summary>
public static string? NormalizeName(string? name, int maxLength)
{
var trimmed = name?.Trim();
return string.IsNullOrEmpty(trimmed) || trimmed.Length > maxLength ? null : trimmed;
}
/// <summary>
/// Prüft und normalisiert die Geräteadresse. Erlaubt: IP oder Hostname mit optionalem Port, optional mit <c>http://</c>/<c>https://</c>
/// (ohne Schema gilt http, Shelly-Geräte sprechen im Heimnetz Klartext). Nur Heimnetz-Adressen (privat, Loopback, <c>.local</c>/<c>.lan</c>, Hostname ohne Punkt),
/// keine Zugangsdaten/Pfad/Query/Fragment, keine Link-Local-/Metadaten-Adressen. Liefert <c>http(s)://host[:port]</c> oder eine deutsche Fehlermeldung.
/// </summary>
public static (string? Host, string? Error) NormalizeHost(string? input)
{
var text = input?.Trim();
if (string.IsNullOrEmpty(text))
{
return (null, "Bitte die Adresse des Geräts im Heimnetz angeben, z. B. 192.168.1.50.");
}
if (text.Length > HomeDevice.HostMaxLength)
{
return (null, $"Die Adresse darf höchstens {HomeDevice.HostMaxLength} Zeichen haben.");
}
if (!text.Contains("://", StringComparison.Ordinal))
{
text = "http://" + text;
}
if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || string.IsNullOrEmpty(uri.Host))
{
return (null, "Bitte eine gültige Adresse eingeben (IP-Adresse oder Name, optional mit Port).");
}
if (!string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || uri.AbsolutePath is not ("/" or ""))
{
return (null, "Die Adresse darf keine Zugangsdaten, Pfade oder Parameter enthalten.");
}
if (NetworkRules.IsBlockedHost(uri))
{
return (null, "Diese Adresse ist nicht erlaubt.");
}
if (!NetworkRules.IsHomeNetwork(uri))
{
return (null, "Nur Adressen im Heimnetz sind erlaubt (z. B. 192.168.x.x, 10.x.x.x oder ein Name wie shelly.local).");
}
return (uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'), null);
}
/// <summary>Prüft ein Planbild anhand der Signatur (PNG/JPEG/WebP) und der Größe; der Typ des Clients wird nie vertraut.</summary>
public static (string? ContentType, string? Error) ValidatePlanImage(ReadOnlySpan<byte> data)
{
if (data.Length == 0)
{
return (null, "Die Datei ist leer.");
}
if (data.Length > MaxPlanImageBytes)
{
return (null, $"Das Bild ist zu groß (höchstens {MaxPlanImageBytes / 1024 / 1024} MB).");
}
return UserAvatar.DetectContentType(data) is { } type ? (type, null) : (null, "Bitte ein Bild im Format PNG, JPEG oder WebP hochladen.");
}
}
@@ -3,6 +3,7 @@ using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Push;
@@ -94,6 +95,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<LearningScheduleRun> LearningScheduleRuns => Set<LearningScheduleRun>();
public DbSet<HomeRoom> HomeRooms => Set<HomeRoom>();
public DbSet<HomeDevice> HomeDevices => Set<HomeDevice>();
public DbSet<HomeDeviceAccess> HomeDeviceAccess => Set<HomeDeviceAccess>();
public DbSet<HomePlan> HomePlans => Set<HomePlan>();
public DbSet<HomePlanImage> HomePlanImages => Set<HomePlanImage>();
public DbSet<HomeDevicePlacement> HomeDevicePlacements => Set<HomeDevicePlacement>();
public DbSet<HomeActionLog> HomeActionLog => Set<HomeActionLog>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -0,0 +1,95 @@
using FamilyPlanner.Domain.Home;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class HomeRoomConfiguration : IEntityTypeConfiguration<HomeRoom>
{
public void Configure(EntityTypeBuilder<HomeRoom> builder)
{
builder.ToTable("HomeRooms");
builder.Property(r => r.Name).HasMaxLength(HomeRoom.NameMaxLength).IsRequired();
}
}
internal sealed class HomeDeviceConfiguration : IEntityTypeConfiguration<HomeDevice>
{
public void Configure(EntityTypeBuilder<HomeDevice> builder)
{
builder.ToTable("HomeDevices");
builder.Property(d => d.Name).HasMaxLength(HomeDevice.NameMaxLength).IsRequired();
builder.Property(d => d.Kind).HasConversion<string>().HasMaxLength(20);
builder.Property(d => d.Driver).HasConversion<string>().HasMaxLength(20);
builder.Property(d => d.Host).HasMaxLength(HomeDevice.HostMaxLength).IsRequired();
builder.Property(d => d.AuthUser).HasMaxLength(HomeDevice.AuthUserMaxLength);
builder.Property(d => d.AuthPasswordProtected).HasMaxLength(HomeDevice.ProtectedPasswordMaxLength);
builder.Property(d => d.Icon).HasMaxLength(HomeDevice.IconMaxLength);
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasOne<HomeRoom>().WithMany().HasForeignKey(d => d.RoomId).OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(d => d.RoomId);
}
}
internal sealed class HomeDeviceAccessConfiguration : IEntityTypeConfiguration<HomeDeviceAccess>
{
public void Configure(EntityTypeBuilder<HomeDeviceAccess> builder)
{
builder.ToTable("HomeDeviceAccess");
builder.Property(a => a.Role).HasMaxLength(20);
builder.Property(a => a.Level).HasConversion<string>().HasMaxLength(20);
builder.HasOne<HomeDevice>().WithMany().HasForeignKey(a => a.DeviceId).OnDelete(DeleteBehavior.Cascade);
// Je Gerät höchstens eine Freigabe je Rolle bzw. je Person.
builder.HasIndex(a => new { a.DeviceId, a.Role }).IsUnique().HasFilter("[Role] IS NOT NULL");
builder.HasIndex(a => new { a.DeviceId, a.UserId }).IsUnique().HasFilter("[UserId] IS NOT NULL");
builder.HasIndex(a => a.UserId);
}
}
internal sealed class HomePlanConfiguration : IEntityTypeConfiguration<HomePlan>
{
public void Configure(EntityTypeBuilder<HomePlan> builder)
{
builder.ToTable("HomePlans");
builder.Property(p => p.Name).HasMaxLength(HomePlan.NameMaxLength).IsRequired();
builder.Property(p => p.ContentType).HasMaxLength(32).IsRequired();
}
}
internal sealed class HomePlanImageConfiguration : IEntityTypeConfiguration<HomePlanImage>
{
public void Configure(EntityTypeBuilder<HomePlanImage> builder)
{
builder.ToTable("HomePlanImages");
builder.Property(i => i.Data).IsRequired();
builder.HasIndex(i => i.PlanId).IsUnique();
builder.HasOne<HomePlan>().WithMany().HasForeignKey(i => i.PlanId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class HomeDevicePlacementConfiguration : IEntityTypeConfiguration<HomeDevicePlacement>
{
public void Configure(EntityTypeBuilder<HomeDevicePlacement> builder)
{
builder.ToTable("HomeDevicePlacements");
builder.HasOne<HomePlan>().WithMany().HasForeignKey(p => p.PlanId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<HomeDevice>().WithMany().HasForeignKey(p => p.DeviceId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => new { p.PlanId, p.DeviceId }).IsUnique();
builder.HasIndex(p => p.DeviceId);
}
}
internal sealed class HomeActionLogConfiguration : IEntityTypeConfiguration<HomeActionLog>
{
public void Configure(EntityTypeBuilder<HomeActionLog> builder)
{
builder.ToTable("HomeActionLog");
builder.Property(l => l.UserName).HasMaxLength(100).IsRequired();
builder.Property(l => l.DeviceName).HasMaxLength(HomeDevice.NameMaxLength).IsRequired();
builder.Property(l => l.Command).HasConversion<string>().HasMaxLength(20);
builder.Property(l => l.Error).HasMaxLength(HomeActionLog.ErrorMaxLength);
builder.HasIndex(l => l.CreatedAt);
builder.HasIndex(l => l.DeviceId);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,286 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase17Home : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "HomeActionLog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
DeviceId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DeviceName = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Command = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Success = table.Column<bool>(type: "bit", nullable: false),
Error = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
DurationMs = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomeActionLog", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HomePlans",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ImageVersion = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomePlans", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HomeRooms",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomeRooms", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HomePlanImages",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Data = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomePlanImages", x => x.Id);
table.ForeignKey(
name: "FK_HomePlanImages_HomePlans_PlanId",
column: x => x.PlanId,
principalTable: "HomePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HomeDevices",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Kind = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
RoomId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Driver = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Generation = table.Column<int>(type: "int", nullable: false),
Host = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Channel = table.Column<int>(type: "int", nullable: false),
AuthUser = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: true),
AuthPasswordProtected = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Icon = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomeDevices", x => x.Id);
table.ForeignKey(
name: "FK_HomeDevices_HomeRooms_RoomId",
column: x => x.RoomId,
principalTable: "HomeRooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "HomeDeviceAccess",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DeviceId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Role = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Level = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomeDeviceAccess", x => x.Id);
table.ForeignKey(
name: "FK_HomeDeviceAccess_HomeDevices_DeviceId",
column: x => x.DeviceId,
principalTable: "HomeDevices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HomeDevicePlacements",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PlanId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DeviceId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
X = table.Column<double>(type: "float", nullable: false),
Y = table.Column<double>(type: "float", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HomeDevicePlacements", x => x.Id);
table.ForeignKey(
name: "FK_HomeDevicePlacements_HomeDevices_DeviceId",
column: x => x.DeviceId,
principalTable: "HomeDevices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HomeDevicePlacements_HomePlans_PlanId",
column: x => x.PlanId,
principalTable: "HomePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_HomeActionLog_CreatedAt",
table: "HomeActionLog",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_HomeActionLog_DeviceId",
table: "HomeActionLog",
column: "DeviceId");
migrationBuilder.CreateIndex(
name: "IX_HomeDeviceAccess_DeviceId_Role",
table: "HomeDeviceAccess",
columns: new[] { "DeviceId", "Role" },
unique: true,
filter: "[Role] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_HomeDeviceAccess_DeviceId_UserId",
table: "HomeDeviceAccess",
columns: new[] { "DeviceId", "UserId" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_HomeDeviceAccess_UserId",
table: "HomeDeviceAccess",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_HomeDevicePlacements_DeviceId",
table: "HomeDevicePlacements",
column: "DeviceId");
migrationBuilder.CreateIndex(
name: "IX_HomeDevicePlacements_PlanId_DeviceId",
table: "HomeDevicePlacements",
columns: new[] { "PlanId", "DeviceId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HomeDevices_IsDeleted",
table: "HomeDevices",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_HomeDevices_RoomId",
table: "HomeDevices",
column: "RoomId");
migrationBuilder.CreateIndex(
name: "IX_HomePlanImages_PlanId",
table: "HomePlanImages",
column: "PlanId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HomePlans_IsDeleted",
table: "HomePlans",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_HomeRooms_IsDeleted",
table: "HomeRooms",
column: "IsDeleted");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "HomeActionLog");
migrationBuilder.DropTable(
name: "HomeDeviceAccess");
migrationBuilder.DropTable(
name: "HomeDevicePlacements");
migrationBuilder.DropTable(
name: "HomePlanImages");
migrationBuilder.DropTable(
name: "HomeDevices");
migrationBuilder.DropTable(
name: "HomePlans");
migrationBuilder.DropTable(
name: "HomeRooms");
}
}
}
@@ -929,6 +929,363 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("DashboardNotes", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeActionLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Command")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DeviceId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<long>("DurationMs")
.HasColumnType("bigint");
b.Property<string>("Error")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<bool>("Success")
.HasColumnType("bit");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("DeviceId");
b.ToTable("HomeActionLog", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDevice", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AuthPasswordProtected")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("AuthUser")
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("Channel")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Driver")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("Generation")
.HasColumnType("int");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Icon")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<Guid?>("RoomId")
.HasColumnType("uniqueidentifier");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("RoomId");
b.ToTable("HomeDevices", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDeviceAccess", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DeviceId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Role")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "Role")
.IsUnique()
.HasFilter("[Role] IS NOT NULL");
b.HasIndex("DeviceId", "UserId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("HomeDeviceAccess", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDevicePlacement", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DeviceId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("PlanId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<double>("X")
.HasColumnType("float");
b.Property<double>("Y")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("PlanId", "DeviceId")
.IsUnique();
b.ToTable("HomeDevicePlacements", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomePlan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("ImageVersion")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.ToTable("HomePlans", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomePlanImage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<Guid>("PlanId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PlanId")
.IsUnique();
b.ToTable("HomePlanImages", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.ToTable("HomeRooms", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.Property<Guid>("Id")
@@ -2655,6 +3012,47 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDevice", b =>
{
b.HasOne("FamilyPlanner.Domain.Home.HomeRoom", null)
.WithMany()
.HasForeignKey("RoomId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDeviceAccess", b =>
{
b.HasOne("FamilyPlanner.Domain.Home.HomeDevice", null)
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDevicePlacement", b =>
{
b.HasOne("FamilyPlanner.Domain.Home.HomeDevice", null)
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FamilyPlanner.Domain.Home.HomePlan", null)
.WithMany()
.HasForeignKey("PlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomePlanImage", b =>
{
b.HasOne("FamilyPlanner.Domain.Home.HomePlan", null)
.WithMany()
.HasForeignKey("PlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningAttempt", null)
@@ -0,0 +1,269 @@
using FamilyPlanner.Domain.Home;
using FluentValidation;
namespace FamilyPlanner.Shared.Home;
public static class HomeApiRoutes
{
public const string Overview = "api/home/overview";
public const string States = "api/home/states";
public const string Rooms = "api/admin/home/rooms";
public const string Devices = "api/admin/home/devices";
public const string DeviceTest = "api/admin/home/devices/test";
public const string Plans = "api/admin/home/plans";
public const string Log = "api/admin/home/log";
public static string DeviceState(Guid id) => $"api/home/devices/{id}/state";
public static string DeviceCommand(Guid id) => $"api/home/devices/{id}/command";
/// <summary>Bild eines Plans (nur für Berechtigte); die Version steuert den Browser-Cache.</summary>
public static string PlanImage(Guid id, int version) => $"api/home/plans/{id}/image?v={version}";
public static string AdminDevice(Guid id) => $"{Devices}/{id}";
public static string AdminDeviceTest(Guid id) => $"{Devices}/{id}/test";
public static string AdminDeviceAccess(Guid id) => $"{Devices}/{id}/access";
public static string AdminRoom(Guid id) => $"{Rooms}/{id}";
public static string AdminPlan(Guid id) => $"{Plans}/{id}";
public static string AdminPlanImage(Guid id) => $"{Plans}/{id}/image";
public static string AdminPlanPlacements(Guid id) => $"{Plans}/{id}/placements";
}
// ---------- Nutzersicht (Haus-Seite) ----------
public sealed record HomeRoomDto(Guid Id, string Name, int SortOrder);
/// <summary>Ein für die Person sichtbares Gerät – ohne Adresse und Zugangsdaten. <see cref="CanControl"/>: schalten erlaubt (sonst nur ansehen).</summary>
public sealed record HomeDeviceDto(Guid Id, string Name, HomeDeviceKind Kind, string? Icon, Guid? RoomId, bool CanControl);
/// <summary>Position eines Geräts auf einem Plan, relativ (0–1).</summary>
public sealed record HomePinDto(Guid DeviceId, double X, double Y);
/// <summary>Plan mit den für die Person sichtbaren Pins.</summary>
public sealed record HomePlanDto(Guid Id, string Name, int ImageVersion, IReadOnlyList<HomePinDto> Pins);
public sealed record HomeOverviewDto(
IReadOnlyList<HomeRoomDto> Rooms,
IReadOnlyList<HomeDeviceDto> Devices,
IReadOnlyList<HomePlanDto> Plans,
bool CanManage);
/// <summary>
/// Zustand eines Geräts. <see cref="Online"/> <c>false</c>: nicht erreichbar, <see cref="Message"/> nennt den Grund (feste deutsche Meldung, nie Geräte-Text).
/// Nicht jedes Gerät liefert jede Größe (<c>null</c> = unbekannt/nicht zutreffend).
/// </summary>
public sealed record HomeDeviceStateDto(
Guid DeviceId,
bool Online,
bool? IsOn,
string? CoverState,
int? CoverPosition,
double? PowerWatts,
double? TemperatureC,
double? HumidityPercent,
string? Message);
public sealed record HomeStatesDto(IReadOnlyList<HomeDeviceStateDto> States, DateTimeOffset At);
public sealed record HomeCommandRequest(HomeCommand Command);
/// <summary>Ergebnis eines Schaltbefehls: <see cref="Success"/> <c>false</c> mit fester deutscher <see cref="Message"/> (Gerät nicht erreichbar, Anmeldung …); <see cref="State"/> ist der Zustand danach.</summary>
public sealed record HomeCommandResultDto(bool Success, string? Message, HomeDeviceStateDto State);
public sealed class HomeCommandRequestValidator : AbstractValidator<HomeCommandRequest>
{
public HomeCommandRequestValidator() =>
RuleFor(r => r.Command).IsInEnum().WithMessage("Unbekannter Befehl.");
}
// ---------- Verwaltung ----------
public sealed record HomeGrantDto(string? Role, Guid? UserId, HomeAccessLevel Level);
/// <summary>Gerät aus Sicht der Hausverwaltung. Das Passwort wird nie übertragen, nur <see cref="HasPassword"/>.</summary>
public sealed record HomeAdminDeviceDto(
Guid Id,
string Name,
HomeDeviceKind Kind,
string? Icon,
Guid? RoomId,
HomeDriverType Driver,
int Generation,
string Host,
int Channel,
string? AuthUser,
bool HasPassword,
int SortOrder,
byte[] Version,
IReadOnlyList<HomeGrantDto> Grants);
/// <summary>
/// Gerät anlegen/ändern. <see cref="Password"/> <c>null</c> lässt ein vorhandenes Passwort unverändert, ein Wert ersetzt es,
/// <see cref="ClearPassword"/> entfernt es.
/// </summary>
public sealed record SaveHomeDeviceRequest(
string Name,
HomeDeviceKind Kind,
string? Icon,
Guid? RoomId,
HomeDriverType Driver,
int Generation,
string Host,
int Channel,
string? AuthUser,
string? Password,
bool ClearPassword,
int SortOrder = 0,
byte[]? Version = null);
public sealed class SaveHomeDeviceRequestValidator : AbstractValidator<SaveHomeDeviceRequest>
{
public SaveHomeDeviceRequestValidator()
{
RuleFor(r => r.Name).NotEmpty().WithMessage("Bitte einen Namen eingeben.")
.MaximumLength(HomeDevice.NameMaxLength).WithMessage($"Der Name darf höchstens {HomeDevice.NameMaxLength} Zeichen haben.");
RuleFor(r => r.Kind).IsInEnum().WithMessage("Unbekannte Geräteart.");
RuleFor(r => r.Driver).IsInEnum().WithMessage("Unbekannter Treiber.");
RuleFor(r => r.Generation).Must((r, g) => !Enum.IsDefined(r.Driver) || HomeRules.IsValidGeneration(r.Driver, g))
.WithMessage("Bitte Generation 1 (Gen1) oder 2 (Gen2/Plus/Pro/Gen3) wählen.");
RuleFor(r => r.Channel).Must(HomeRules.IsValidChannel)
.WithMessage($"Der Kanal muss zwischen 0 und {HomeRules.MaxChannel} liegen.");
RuleFor(r => r.Host).Custom((host, context) =>
{
if (HomeRules.NormalizeHost(host).Error is { } error)
{
context.AddFailure(nameof(SaveHomeDeviceRequest.Host), error);
}
});
RuleFor(r => r.Icon).Must(HomeRules.IsValidIcon).WithMessage("Unbekanntes Symbol.");
RuleFor(r => r.AuthUser).MaximumLength(HomeDevice.AuthUserMaxLength)
.WithMessage($"Der Benutzername darf höchstens {HomeDevice.AuthUserMaxLength} Zeichen haben.");
RuleFor(r => r.Password).Must(p => p is null || (p.Length is > 0 and <= HomeDevice.PasswordMaxLength))
.WithMessage($"Das Passwort darf höchstens {HomeDevice.PasswordMaxLength} Zeichen haben.");
RuleFor(r => r).Must(r => !(r.ClearPassword && r.Password is not null))
.WithMessage("Passwort entfernen und neu setzen schließt sich aus.").OverridePropertyName(nameof(SaveHomeDeviceRequest.Password));
RuleFor(r => r.SortOrder).InclusiveBetween(0, 10_000).WithMessage("Die Reihenfolge muss zwischen 0 und 10000 liegen.");
}
}
/// <summary>
/// Verbindungstest. Mit <see cref="DeviceId"/> und ohne <see cref="Password"/> gilt das gespeicherte Passwort dieses Geräts;
/// so lässt sich ein geänderter Entwurf testen, ohne das Passwort erneut einzugeben.
/// </summary>
public sealed record TestHomeDeviceRequest(
Guid? DeviceId,
HomeDeviceKind Kind,
HomeDriverType Driver,
int Generation,
string Host,
int Channel,
string? AuthUser,
string? Password);
public sealed class TestHomeDeviceRequestValidator : AbstractValidator<TestHomeDeviceRequest>
{
public TestHomeDeviceRequestValidator()
{
RuleFor(r => r.Kind).IsInEnum().WithMessage("Unbekannte Geräteart.");
RuleFor(r => r.Driver).IsInEnum().WithMessage("Unbekannter Treiber.");
RuleFor(r => r.Generation).Must((r, g) => !Enum.IsDefined(r.Driver) || HomeRules.IsValidGeneration(r.Driver, g))
.WithMessage("Bitte Generation 1 (Gen1) oder 2 (Gen2/Plus/Pro/Gen3) wählen.");
RuleFor(r => r.Channel).Must(HomeRules.IsValidChannel).WithMessage($"Der Kanal muss zwischen 0 und {HomeRules.MaxChannel} liegen.");
RuleFor(r => r.Host).Custom((host, context) =>
{
if (HomeRules.NormalizeHost(host).Error is { } error)
{
context.AddFailure(nameof(TestHomeDeviceRequest.Host), error);
}
});
RuleFor(r => r.AuthUser).MaximumLength(HomeDevice.AuthUserMaxLength).WithMessage("Der Benutzername ist zu lang.");
RuleFor(r => r.Password).Must(p => p is null || p.Length <= HomeDevice.PasswordMaxLength).WithMessage("Das Passwort ist zu lang.");
}
}
/// <summary>Ergebnis eines Verbindungstests (feste deutsche Meldung; Modell/Firmware aus <c>Shelly.GetDeviceInfo</c> bzw. <c>/shelly</c>).</summary>
public sealed record HomeTestResultDto(bool Success, string Message, string? Model, string? Firmware, long DurationMs);
public sealed record SetHomeAccessRequest(IReadOnlyList<HomeGrantDto> Grants);
public sealed class SetHomeAccessRequestValidator : AbstractValidator<SetHomeAccessRequest>
{
public const int MaxGrants = 100;
public SetHomeAccessRequestValidator()
{
RuleFor(r => r.Grants).NotNull().Must(g => g is null || g.Count <= MaxGrants).WithMessage($"Höchstens {MaxGrants} Freigaben.");
RuleForEach(r => r.Grants).Custom((grant, context) =>
{
if (HomeRules.ValidateGrant(grant.Role, grant.UserId, grant.Level) is { } error)
{
context.AddFailure(nameof(SetHomeAccessRequest.Grants), error);
}
});
RuleFor(r => r.Grants).Must(grants => grants is null
|| (grants.Where(g => g.Role is not null).Select(g => g.Role).Distinct().Count() == grants.Count(g => g.Role is not null)
&& grants.Where(g => g.UserId is not null).Select(g => g.UserId).Distinct().Count() == grants.Count(g => g.UserId is not null)))
.WithMessage("Jede Rolle und jede Person darf nur einmal vorkommen.");
}
}
public sealed record HomeAdminRoomDto(Guid Id, string Name, int SortOrder, int DeviceCount);
public sealed record SaveHomeRoomRequest(string Name, int SortOrder = 0);
public sealed class SaveHomeRoomRequestValidator : AbstractValidator<SaveHomeRoomRequest>
{
public SaveHomeRoomRequestValidator()
{
RuleFor(r => r.Name).NotEmpty().WithMessage("Bitte einen Namen eingeben.")
.MaximumLength(HomeRoom.NameMaxLength).WithMessage($"Der Name darf höchstens {HomeRoom.NameMaxLength} Zeichen haben.");
RuleFor(r => r.SortOrder).InclusiveBetween(0, 10_000).WithMessage("Die Reihenfolge muss zwischen 0 und 10000 liegen.");
}
}
/// <summary>Plan aus Sicht der Verwaltung (alle Pins, auch ohne Freigabe).</summary>
public sealed record HomeAdminPlanDto(Guid Id, string Name, int SortOrder, int ImageVersion, IReadOnlyList<HomePinDto> Pins);
public sealed record SaveHomePlanRequest(string Name, int SortOrder = 0);
public sealed class SaveHomePlanRequestValidator : AbstractValidator<SaveHomePlanRequest>
{
public SaveHomePlanRequestValidator()
{
RuleFor(r => r.Name).NotEmpty().WithMessage("Bitte einen Namen eingeben.")
.MaximumLength(HomePlan.NameMaxLength).WithMessage($"Der Name darf höchstens {HomePlan.NameMaxLength} Zeichen haben.");
RuleFor(r => r.SortOrder).InclusiveBetween(0, 10_000).WithMessage("Die Reihenfolge muss zwischen 0 und 10000 liegen.");
}
}
public sealed record SaveHomePlacementsRequest(IReadOnlyList<HomePinDto> Pins);
public sealed class SaveHomePlacementsRequestValidator : AbstractValidator<SaveHomePlacementsRequest>
{
public const int MaxPins = 200;
public SaveHomePlacementsRequestValidator()
{
RuleFor(r => r.Pins).NotNull().Must(p => p is null || p.Count <= MaxPins).WithMessage($"Höchstens {MaxPins} Geräte je Plan.");
RuleForEach(r => r.Pins).Must(p => HomeRules.IsValidCoordinate(p.X) && HomeRules.IsValidCoordinate(p.Y))
.WithMessage("Die Position muss auf dem Plan liegen.");
RuleFor(r => r.Pins).Must(p => p is null || p.Select(x => x.DeviceId).Distinct().Count() == p.Count)
.WithMessage("Ein Gerät kann nur einmal auf einem Plan stehen.");
}
}
public sealed record HomeActionLogDto(
DateTimeOffset At,
string UserName,
Guid DeviceId,
string DeviceName,
HomeCommand Command,
bool Success,
string? Error,
long DurationMs);
@@ -0,0 +1,70 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@* Eine Gerätekarte: Symbol, Name, Status als Text + Symbol (nie nur Farbe), Bedienelemente nach Geräteart und Schaltrecht. *@
<MudPaper Outlined="true" Class="@($"fp-device fp-device-{HouseUi.CssClass(Status)}")" role="group" aria-label="@Device.Name">
<div class="fp-device-head">
<span class="fp-device-icon" aria-hidden="true"><MudIcon Icon="@HouseUi.Icon(Device.Icon, Device.Kind)" /></span>
<div class="fp-device-main">
<MudText Typo="Typo.subtitle1" HtmlTag="h3" Class="fp-device-name">@Device.Name</MudText>
<span class="fp-device-status" role="status">
<MudIcon Icon="@HouseUi.StatusIcon(Status)" Size="Size.Small" aria-hidden="true" />
<span class="fp-device-status-text">@HouseUi.StatusText(Device, State)</span>
</span>
@if (HouseUi.Detail(Device, State) is { } detail)
{
<MudText Typo="Typo.caption" Class="fp-device-detail">@detail</MudText>
}
</div>
<div class="fp-device-controls">
@if (Device.Kind == HomeDeviceKind.Cover)
{
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward" Size="Size.Large" Disabled="@(!CanOperate)" Class="fp-device-open"
OnClick="() => OnCommand.InvokeAsync(HomeCommand.Open)" aria-label="@($"{Device.Name} öffnen")" />
<MudIconButton Icon="@Icons.Material.Filled.Stop" Size="Size.Large" Disabled="@(!CanOperate)" Class="fp-device-stop"
OnClick="() => OnCommand.InvokeAsync(HomeCommand.Stop)" aria-label="@($"{Device.Name} stoppen")" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Large" Disabled="@(!CanOperate)" Class="fp-device-close"
OnClick="() => OnCommand.InvokeAsync(HomeCommand.Close)" aria-label="@($"{Device.Name} schließen")" />
}
else if (Device.Kind != HomeDeviceKind.Sensor)
{
var isOn = State?.IsOn is true;
<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")">
@(isOn ? "An" : "Aus")
</MudButton>
}
</div>
</div>
@if (!Device.CanControl && Device.Kind != HomeDeviceKind.Sensor)
{
<MudText Typo="Typo.caption" Class="fp-device-readonly">Nur ansehen – du darfst dieses Gerät nicht schalten.</MudText>
}
@if (State is { Online: false, Message: { Length: > 0 } message })
{
<MudText Typo="Typo.caption" Class="fp-device-message">@message</MudText>
}
</MudPaper>
@code {
[Parameter, EditorRequired]
public HomeDeviceDto Device { get; set; } = default!;
[Parameter]
public HomeDeviceStateDto? State { get; set; }
/// <summary>Ein Befehl für dieses Gerät ist unterwegs: Bedienung sperren.</summary>
[Parameter]
public bool Busy { get; set; }
[Parameter]
public EventCallback<HomeCommand> OnCommand { get; set; }
private HouseUi.Status Status => HouseUi.StatusOf(Device, State);
private bool CanOperate => Device.CanControl && !Busy && State is { Online: true };
}
@@ -0,0 +1,49 @@
@using FamilyPlanner.Shared.Home
@* Grundriss mit Geräte-Pins. Pins sind echte Buttons (Tastatur, Fokus, Beschriftung); der Status steht zusätzlich als Kurztext im Pin. *@
<div class="fp-plan-wrap">
<div class="fp-plan" role="group" aria-label="@($"Plan {Plan.Name}")">
<img class="fp-plan-image" src="@HomeApiRoutes.PlanImage(Plan.Id, Plan.ImageVersion)" alt="@($"Grundriss {Plan.Name}")" draggable="false" />
@foreach (var pin in Plan.Pins)
{
if (!Devices.TryGetValue(pin.DeviceId, out var device))
{
continue;
}
var state = States.GetValueOrDefault(pin.DeviceId);
var status = HouseUi.StatusOf(device, state);
var selected = SelectedId == device.Id;
<button type="button"
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)"
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" />
<span class="fp-pin-badge" aria-hidden="true">@HouseUi.Badge(status)</span>
</button>
}
</div>
</div>
@code {
[Parameter, EditorRequired]
public HomePlanDto Plan { get; set; } = default!;
[Parameter, EditorRequired]
public IReadOnlyDictionary<Guid, HomeDeviceDto> Devices { get; set; } = default!;
[Parameter]
public IReadOnlyDictionary<Guid, HomeDeviceStateDto> States { get; set; } = new Dictionary<Guid, HomeDeviceStateDto>();
[Parameter]
public Guid? SelectedId { get; set; }
[Parameter]
public EventCallback<HomeDeviceDto> OnPinClick { get; set; }
internal static string PinStyle(HomePinDto pin) =>
FormattableString.Invariant($"left:{pin.X * 100:0.##}%;top:{pin.Y * 100:0.##}%");
}
@@ -0,0 +1,145 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="house-access" id="house-access-form">
<MudStack Spacing="3">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_error</MudAlert>
}
<MudText Typo="Typo.body2">
Administratoren dürfen immer alles. Ohne Freigabe ist das Gerät für alle anderen unsichtbar. Bei mehreren passenden Freigaben (Rolle und Person) gilt die weitergehende.
</MudText>
<div>
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Freigabe nach Rolle</MudText>
@foreach (var role in HomeRules.GrantableRoles)
{
var current = role;
<MudSelect T="HomeAccessLevel" Value="_roles.GetValueOrDefault(current)" ValueChanged="v => _roles[current] = v"
Label="@Roles.DisplayName(current)" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mt-2 fp-access-role">
<MudSelectItem T="HomeAccessLevel" Value="HomeAccessLevel.None">Kein Zugriff</MudSelectItem>
<MudSelectItem T="HomeAccessLevel" Value="HomeAccessLevel.View">Nur ansehen</MudSelectItem>
<MudSelectItem T="HomeAccessLevel" Value="HomeAccessLevel.Control">Ansehen und schalten</MudSelectItem>
</MudSelect>
}
</div>
<div>
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Freigabe für einzelne Personen</MudText>
@if (_people.Count == 0)
{
<MudText Typo="Typo.caption">Keine einzelnen Personen freigegeben.</MudText>
}
@foreach (var person in _people.ToList())
{
var current = person.Key;
<div class="d-flex align-center gap-2 mt-2 fp-access-person">
<MudSelect T="HomeAccessLevel" Value="person.Value" ValueChanged="v => _people[current] = v"
Label="@PersonName(current)" Variant="Variant.Outlined" Margin="Margin.Dense">
<MudSelectItem T="HomeAccessLevel" Value="HomeAccessLevel.View">Nur ansehen</MudSelectItem>
<MudSelectItem T="HomeAccessLevel" Value="HomeAccessLevel.Control">Ansehen und schalten</MudSelectItem>
</MudSelect>
<MudIconButton Icon="@Icons.Material.Filled.Close" OnClick="() => _people.Remove(current)" aria-label="@($"Freigabe für {PersonName(current)} entfernen")" />
</div>
}
@if (AvailablePeople.Count > 0)
{
<div class="d-flex align-center gap-2 mt-3">
<MudSelect T="Guid?" @bind-Value="_addPerson" Label="Person hinzufügen" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true" Class="fp-access-add">
@foreach (var user in AvailablePeople)
{
<MudSelectItem T="Guid?" Value="@user.Id">@user.DisplayName (@Roles.DisplayName(user.Role))</MudSelectItem>
}
</MudSelect>
<MudButton Variant="Variant.Outlined" Disabled="@(_addPerson is null)" OnClick="AddPerson" Class="fp-access-add-button">Hinzufügen</MudButton>
</div>
}
</div>
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton ButtonType="ButtonType.Submit" form="house-access-form" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public HomeAdminDeviceDto Device { get; set; } = default!;
[Parameter]
public IReadOnlyList<Shared.Admin.UserDirectoryEntry> Users { get; set; } = [];
private readonly Dictionary<string, HomeAccessLevel> _roles = [];
private readonly Dictionary<Guid, HomeAccessLevel> _people = [];
private Guid? _addPerson;
private string? _error;
private bool _busy;
private List<Shared.Admin.UserDirectoryEntry> AvailablePeople =>
Users.Where(u => u.Role != Roles.Admin && !_people.ContainsKey(u.Id)).OrderBy(u => u.DisplayName).ToList();
protected override void OnInitialized()
{
foreach (var grant in Device.Grants)
{
if (grant.Role is { } role)
{
_roles[role] = grant.Level;
}
else if (grant.UserId is { } id)
{
_people[id] = grant.Level;
}
}
}
private string PersonName(Guid id) => Users.FirstOrDefault(u => u.Id == id)?.DisplayName ?? "Unbekannte Person";
private void AddPerson()
{
if (_addPerson is { } id)
{
_people[id] = HomeAccessLevel.Control;
_addPerson = null;
}
}
private async Task SaveAsync()
{
var grants = _roles.Where(r => r.Value != HomeAccessLevel.None).Select(r => new HomeGrantDto(r.Key, null, r.Value))
.Concat(_people.Select(p => new HomeGrantDto(null, p.Key, p.Value)))
.ToList();
var request = new SetHomeAccessRequest(grants);
var validation = new SetHomeAccessRequestValidator().Validate(request);
if (!validation.IsValid)
{
_error = validation.Errors[0].ErrorMessage;
return;
}
_busy = true;
var result = await Api.SetAccessAsync(Device.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_error = problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,207 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="house-device" id="house-device-form">
<MudStack Spacing="3">
@if (_generalError is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_generalError</MudAlert>
}
<MudTextField @bind-Value="_name" Label="Name" InputId="device-name" Variant="Variant.Outlined" AutoFocus="true"
Placeholder="z. B. Wohnzimmer Stehlampe" MaxLength="HomeDevice.NameMaxLength"
Error="@HasError("Name")" ErrorText="@ErrorFor("Name")" />
<MudStack Row="true" Spacing="3" Class="fp-device-row">
<MudSelect T="HomeDeviceKind" @bind-Value="_kind" Label="Art" Variant="Variant.Outlined" Class="fp-device-kind"
HelperText="@KindHint">
@foreach (var kind in Enum.GetValues<HomeDeviceKind>())
{
<MudSelectItem T="HomeDeviceKind" Value="@kind">@HomeRules.DisplayName(kind)</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="_icon" Label="Symbol" Variant="Variant.Outlined" Clearable="true" Class="fp-device-icon-select">
@foreach (var icon in HomeRules.Icons)
{
<MudSelectItem T="string" Value="@icon.Key">
<MudIcon Icon="@HouseUi.Icon(icon.Key, _kind)" Size="Size.Small" Class="mr-2" /> @icon.Value
</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudSelect T="Guid?" @bind-Value="_roomId" Label="Raum" Variant="Variant.Outlined" Class="fp-device-room"
Error="@HasError("RoomId")" ErrorText="@ErrorFor("RoomId")">
<MudSelectItem T="Guid?" Value="null">Kein Raum</MudSelectItem>
@foreach (var room in Rooms)
{
<MudSelectItem T="Guid?" Value="@room.Id">@room.Name</MudSelectItem>
}
</MudSelect>
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Verbindung</MudText>
<MudSelect T="int" @bind-Value="_generation" Label="Shelly-Generation" Variant="Variant.Outlined" Class="fp-device-generation"
HelperText="Gen2 und neuer (Plus, Pro, Gen3, Gen4) sprechen RPC; ältere Geräte (z. B. Shelly 1, 2.5, Plug S) Gen1."
Error="@HasError("Generation")" ErrorText="@ErrorFor("Generation")">
<MudSelectItem T="int" Value="2">Gen2 und neuer (Plus, Pro, Gen3, Gen4)</MudSelectItem>
<MudSelectItem T="int" Value="1">Gen1 (älter)</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="_host" Label="Adresse im Heimnetz" InputId="device-host" Variant="Variant.Outlined"
Placeholder="192.168.1.50 oder shelly-flur.local" MaxLength="HomeDevice.HostMaxLength"
HelperText="Nur Adressen im Heimnetz sind erlaubt. Der Server muss das Gerät erreichen können."
Error="@HasError("Host")" ErrorText="@ErrorFor("Host")" />
<MudNumericField T="int" @bind-Value="_channel" Label="Kanal / Relais-Nr." InputId="device-channel" Variant="Variant.Outlined" Min="0" Max="@HomeRules.MaxChannel"
HelperText="Beginnt bei 0 (bei Geräten mit zwei Relais: 0 und 1)."
Error="@HasError("Channel")" ErrorText="@ErrorFor("Channel")" />
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Anmeldung am Gerät (optional)</MudText>
<MudTextField @bind-Value="_authUser" Label="Benutzername" InputId="device-user" Variant="Variant.Outlined" autocomplete="off"
HelperText="Bei Shelly Gen2+ immer „admin“. Leer lassen, wenn das Gerät keinen Schutz hat."
Error="@HasError("AuthUser")" ErrorText="@ErrorFor("AuthUser")" />
<MudTextField @bind-Value="_password" Label="@(Device?.HasPassword == true ? "Passwort (leer lassen = unverändert)" : "Passwort")" InputId="device-password"
Variant="Variant.Outlined" InputType="InputType.Password" Disabled="_clearPassword" autocomplete="new-password"
HelperText="Wird verschlüsselt gespeichert und nie wieder angezeigt."
Error="@HasError("Password")" ErrorText="@ErrorFor("Password")" />
@if (Device?.HasPassword == true)
{
<MudCheckBox T="bool" @bind-Value="_clearPassword" Label="Gespeichertes Passwort entfernen" Class="fp-device-clearpw" />
}
<div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestAsync" Disabled="@(_testing || _busy)" Class="fp-device-test">
@(_testing ? "Wird geprüft …" : "Verbindung testen")
</MudButton>
@if (_testResult is { } test)
{
<MudAlert Severity="@(test.Success ? Severity.Success : Severity.Error)" Class="mt-2 fp-device-testresult" role="status" Dense="true">
@test.Message
@if (test.Success)
{
<span> (@test.DurationMs ms@(test.Model is null ? string.Empty : $", Modell {test.Model}")@(test.Firmware is null ? string.Empty : $", Firmware {test.Firmware}"))</span>
}
</MudAlert>
}
</div>
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton ButtonType="ButtonType.Submit" form="house-device-form" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
/// <summary>Zu bearbeitendes Gerät; <c>null</c> legt ein neues an.</summary>
[Parameter]
public HomeAdminDeviceDto? Device { get; set; }
[Parameter]
public IReadOnlyList<HomeAdminRoomDto> Rooms { get; set; } = [];
private string _name = string.Empty;
private HomeDeviceKind _kind;
private string? _icon;
private Guid? _roomId;
private int _generation = 2;
private string _host = string.Empty;
private int _channel;
private string? _authUser;
private string? _password;
private bool _clearPassword;
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
private string? _generalError;
private HomeTestResultDto? _testResult;
private bool _busy;
private bool _testing;
private string KindHint => _kind switch
{
HomeDeviceKind.Switch => "Relais, z. B. Shelly Plus 1 oder Shelly 1.",
HomeDeviceKind.Socket => "Steckdose, z. B. Shelly Plug.",
HomeDeviceKind.Light => "Dimmer oder Leuchtmittel (Light-Komponente). Ein Relais an einer Lampe ist ein „Schalter“ – das Symbol kann trotzdem eine Glühbirne sein.",
HomeDeviceKind.Cover => "Rollo oder Jalousie (Cover/Roller-Modus).",
_ => "Nur Anzeige: Temperatur und Luftfeuchte.",
};
protected override void OnInitialized()
{
if (Device is not { } d)
{
return;
}
(_name, _kind, _icon, _roomId, _generation, _host, _channel, _authUser) = (d.Name, d.Kind, d.Icon, d.RoomId, d.Generation, d.Host, d.Channel, d.AuthUser);
}
private SaveHomeDeviceRequest BuildRequest() => new(
_name.Trim(), _kind, string.IsNullOrEmpty(_icon) ? null : _icon, _roomId, HomeDriverType.Shelly, _generation, _host.Trim(), _channel,
string.IsNullOrWhiteSpace(_authUser) ? null : _authUser.Trim(), string.IsNullOrEmpty(_password) ? null : _password, _clearPassword,
Device?.SortOrder ?? 0, Device?.Version);
private async Task SaveAsync()
{
_generalError = null;
var request = BuildRequest();
var validation = new SaveHomeDeviceRequestValidator().Validate(request);
_errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
if (!validation.IsValid)
{
return;
}
_busy = true;
var result = Device is null ? await Api.CreateDeviceAsync(request) : await Api.UpdateDeviceAsync(Device.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_errors = (problem.Errors ?? [])
.Where(e => e.Key.Length > 0)
.ToDictionary(e => e.Key, e => string.Join(" ", e.Value), StringComparer.OrdinalIgnoreCase);
if (_errors.Count == 0)
{
_generalError = problem.Message;
}
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private async Task TestAsync()
{
_testResult = null;
var request = new TestHomeDeviceRequest(
Device?.Id, _kind, HomeDriverType.Shelly, _generation, _host.Trim(), _channel,
string.IsNullOrWhiteSpace(_authUser) ? null : _authUser.Trim(), string.IsNullOrEmpty(_password) ? null : _password);
var validation = new TestHomeDeviceRequestValidator().Validate(request);
if (!validation.IsValid)
{
_errors = validation.Errors.GroupBy(e => e.PropertyName).ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
return;
}
_errors = new(StringComparer.OrdinalIgnoreCase);
_testing = true;
var result = await Api.TestDraftAsync(request);
_testing = false;
_testResult = result.Value ?? new HomeTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", null, null, 0);
}
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,164 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">Geräte</MudText>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditAsync(null)" Class="fp-device-add">Gerät hinzufügen</MudButton>
</div>
<MudText Typo="Typo.body2" Class="mb-3">
Shelly-Geräte im Heimnetz. Der Server spricht sie direkt an – er muss das Heimnetz erreichen können (bei Docker ggf. Host-Netzwerk oder Routing, siehe ADR-0023).
Nicht freigegebene Geräte sehen nur Administratoren.
</MudText>
@if (_devices is null)
{
<LoadingIndicator />
}
else if (_devices.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-device-empty">Noch keine Geräte eingerichtet.</MudAlert>
}
else
{
<MudTable T="HomeAdminDeviceDto" Items="_devices" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Class="fp-device-table">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Raum</MudTh>
<MudTh>Verbindung</MudTh>
<MudTh>Freigaben</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
<span class="fp-device-admin-name">
<MudIcon Icon="@HouseUi.Icon(context.Icon, context.Kind)" Size="Size.Small" aria-hidden="true" /> @context.Name
</span>
<MudText Typo="Typo.caption">@HomeRules.DisplayName(context.Kind)</MudText>
</MudTd>
<MudTd DataLabel="Raum">@RoomName(context.RoomId)</MudTd>
<MudTd DataLabel="Verbindung">
@context.Host
<MudText Typo="Typo.caption">Gen@(context.Generation) · Kanal @context.Channel · @(context.HasPassword ? "mit Passwort" : "ohne Passwort")</MudText>
</MudTd>
<MudTd DataLabel="Freigaben"><span class="fp-device-grants">@DescribeGrants(context)</span></MudTd>
<MudTd Style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="@(_testing == context.Id)"
OnClick="() => TestAsync(context)" Class="fp-device-test-row">Testen</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Group" OnClick="() => AccessAsync(context)" aria-label="@($"Freigaben von {context.Name}")" Class="fp-device-access" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => EditAsync(context)" aria-label="@($"{context.Name} bearbeiten")" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(context)" aria-label="@($"{context.Name} löschen")" />
</MudTd>
</RowTemplate>
</MudTable>
}
@if (_testOutcome is { } outcome)
{
<MudAlert Severity="@(outcome.Result.Success ? Severity.Success : Severity.Error)" Class="mt-3 fp-device-testoutcome" role="status">
<strong>@outcome.Name:</strong> @outcome.Result.Message
@if (outcome.Result.Success)
{
<span> (@outcome.Result.DurationMs ms@(outcome.Result.Model is null ? string.Empty : $", Modell {outcome.Result.Model}")@(outcome.Result.Firmware is null ? string.Empty : $", Firmware {outcome.Result.Firmware}"))</span>
}
</MudAlert>
}
@code {
private sealed record TestOutcome(string Name, HomeTestResultDto Result);
private List<HomeAdminDeviceDto>? _devices;
private List<HomeAdminRoomDto> _rooms = [];
private List<Shared.Admin.UserDirectoryEntry> _users = [];
private TestOutcome? _testOutcome;
private Guid? _testing;
/// <summary>Wird nach Änderungen ausgelöst (Pläne und Pins hängen an den Geräten).</summary>
[Parameter]
public EventCallback OnChanged { get; set; }
protected override Task OnInitializedAsync() => LoadAsync();
/// <summary>Lädt Geräte und Räume neu (z. B. nach Änderungen im Reiter „Räume“).</summary>
public async Task LoadAsync()
{
_devices = (await Api.GetDevicesAsync()).Value ?? [];
_rooms = (await Api.GetRoomsAsync()).Value ?? [];
}
private string RoomName(Guid? id) => id is { } r ? _rooms.FirstOrDefault(x => x.Id == r)?.Name ?? "–" : "–";
private string DescribeGrants(HomeAdminDeviceDto device)
{
if (device.Grants.Count == 0)
{
return "Nur Administratoren";
}
var parts = device.Grants.Where(g => g.Role is not null)
.Select(g => $"{Roles.DisplayName(g.Role!)}: {(g.Level == HomeAccessLevel.Control ? "schalten" : "ansehen")}")
.ToList();
var people = device.Grants.Count(g => g.UserId is not null);
if (people > 0)
{
parts.Add($"{people} {(people == 1 ? "Person" : "Personen")}");
}
return string.Join(" · ", parts);
}
private async Task EditAsync(HomeAdminDeviceDto? device)
{
var dialog = await DialogService.ShowAsync<HouseDeviceDialog>(device is null ? "Gerät hinzufügen" : $"{device.Name} bearbeiten",
new DialogParameters<HouseDeviceDialog> { { d => d.Device, device }, { d => d.Rooms, _rooms } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Gerät gespeichert.", Severity.Success);
await LoadAsync();
await OnChanged.InvokeAsync();
}
}
private async Task AccessAsync(HomeAdminDeviceDto device)
{
if (_users.Count == 0)
{
_users = (await Api.GetUsersAsync()).Value ?? [];
}
var dialog = await DialogService.ShowAsync<HouseAccessDialog>($"Freigaben: {device.Name}",
new DialogParameters<HouseAccessDialog> { { d => d.Device, device }, { d => d.Users, _users } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Freigaben gespeichert.", Severity.Success);
await LoadAsync();
}
}
private async Task DeleteAsync(HomeAdminDeviceDto device)
{
if (await DialogService.ShowMessageBoxAsync("Gerät löschen?", $"„{device.Name}“ wird entfernt – samt Freigaben, Positionen auf Plänen und gespeichertem Passwort.",
yesText: "Löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.DeleteDeviceAsync(device.Id);
Snackbar.Add(result.Problem?.Message ?? "Gerät gelöscht.", result.Succeeded ? Severity.Success : Severity.Error);
await LoadAsync();
await OnChanged.InvokeAsync();
}
private async Task TestAsync(HomeAdminDeviceDto device)
{
_testing = device.Id;
var result = await Api.TestDeviceAsync(device.Id);
_testing = null;
_testOutcome = new TestOutcome(device.Name, result.Value ?? new HomeTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", null, null, 0));
}
}
@@ -0,0 +1,71 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-1">Aktionsprotokoll</MudText>
<MudText Typo="Typo.body2" Class="mb-3">Jede Schalthandlung der letzten Zeit: wer, welches Gerät, was – auch fehlgeschlagene Versuche.</MudText>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-3" role="alert">@_error</MudAlert>
}
<MudTable @ref="_table" T="HomeActionLogDto" ServerData="LoadAsync" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"
Elevation="0" Outlined="true" RowsPerPage="25" Class="fp-house-log">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Wer</MudTh>
<MudTh>Gerät</MudTh>
<MudTh>Aktion</MudTh>
<MudTh>Ergebnis</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Zeitpunkt">@HouseUi.FormatTime(context.At)</MudTd>
<MudTd DataLabel="Wer">@context.UserName</MudTd>
<MudTd DataLabel="Gerät">@context.DeviceName</MudTd>
<MudTd DataLabel="Aktion">@Describe(context.Command)</MudTd>
<MudTd DataLabel="Ergebnis">
@if (context.Success)
{
<span class="fp-log-ok"><MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Small" aria-hidden="true" /> OK · @context.DurationMs ms</span>
}
else
{
<span class="fp-log-failed"><MudIcon Icon="@Icons.Material.Filled.ErrorOutline" Size="Size.Small" aria-hidden="true" /> Fehlgeschlagen: @context.Error</span>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>Noch keine Schalthandlungen.</MudText>
</NoRecordsContent>
<PagerContent>
<MudTablePager PageSizeOptions="@(new[] { 25, 50, 100 })" RowsPerPageString="Einträge pro Seite:" InfoFormat="{first_item}–{last_item} von {all_items}" />
</PagerContent>
</MudTable>
@code {
private MudTable<HomeActionLogDto>? _table;
private string? _error;
private static string Describe(HomeCommand command) => command switch
{
HomeCommand.TurnOn => "Einschalten",
HomeCommand.TurnOff => "Ausschalten",
HomeCommand.Toggle => "Umschalten",
HomeCommand.Open => "Öffnen",
HomeCommand.Close => "Schließen",
HomeCommand.Stop => "Stoppen",
_ => command.ToString(),
};
private async Task<TableData<HomeActionLogDto>> LoadAsync(TableState state, CancellationToken ct)
{
var result = await Api.GetLogAsync(state.Page + 1, state.PageSize, ct);
_error = result.Problem?.Message;
return result.Value is { } page
? new TableData<HomeActionLogDto> { Items = page.Items, TotalItems = page.TotalCount }
: new TableData<HomeActionLogDto> { Items = [], TotalItems = 0 };
}
public Task ReloadAsync() => _table is null ? Task.CompletedTask : _table.ReloadServerData();
}
@@ -0,0 +1,65 @@
@* Kleiner Dialog für Name und Reihenfolge (Räume, Pläne). Das Speichern übernimmt der Aufrufer über <see cref="OnSave"/>. *@
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="house-name" id="house-name-form">
<MudStack Spacing="3">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_error</MudAlert>
}
<MudTextField @bind-Value="_name" Label="Name" InputId="house-name" Variant="Variant.Outlined" AutoFocus="true" MaxLength="MaxLength" />
<MudNumericField T="int" @bind-Value="_sortOrder" Label="Reihenfolge" InputId="house-sort" Variant="Variant.Outlined" Min="0" Max="10000"
HelperText="Kleinere Zahlen stehen weiter vorn." />
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton ButtonType="ButtonType.Submit" form="house-name-form" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter]
public string Name { get; set; } = string.Empty;
[Parameter]
public int SortOrder { get; set; }
[Parameter]
public int MaxLength { get; set; } = 60;
/// <summary>Speichert und liefert eine Fehlermeldung oder <c>null</c> bei Erfolg.</summary>
[Parameter, EditorRequired]
public Func<string, int, Task<string?>> OnSave { get; set; } = default!;
private string _name = string.Empty;
private int _sortOrder;
private string? _error;
private bool _busy;
protected override void OnInitialized() => (_name, _sortOrder) = (Name, SortOrder);
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(_name))
{
_error = "Bitte einen Namen eingeben.";
return;
}
_busy = true;
_error = await OnSave(_name.Trim(), _sortOrder);
_busy = false;
if (_error is null)
{
Dialog?.Close(DialogResult.Ok(true));
}
}
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,198 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">Pläne</MudText>
</div>
<MudText Typo="Typo.body2" Class="mb-3">
Lade einen Grundriss pro Stockwerk hoch (PNG, JPEG oder WebP, höchstens @(HomeRules.MaxPlanImageBytes / 1024 / 1024) MB) und platziere die Geräte darauf.
Die Haus-Seite zeigt Personen nur die Pins der Geräte, die für sie freigegeben sind. Ohne Plan gibt es die reine Raumansicht.
</MudText>
<MudPaper Outlined="true" Class="pa-3 mb-4 fp-plan-upload">
<MudText Typo="Typo.subtitle2" HtmlTag="h3" Class="mb-2">Neuen Plan hochladen</MudText>
<div class="d-flex align-center flex-wrap gap-3">
<MudTextField @bind-Value="_newName" Label="Name des Plans" InputId="plan-name" Variant="Variant.Outlined" Margin="Margin.Dense" MaxLength="HomePlan.NameMaxLength"
Placeholder="z. B. Erdgeschoss" Error="@(_nameError is not null)" ErrorText="@_nameError" Style="max-width:22rem" />
<MudFileUpload T="IBrowserFile" FilesChanged="UploadAsync" Accept=".png,.jpg,.jpeg,.webp">
<CustomContent Context="upload">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Upload" Disabled="_busy"
OnClick="@upload.OpenFilePickerAsync" Class="fp-plan-pick">Bild wählen und hochladen</MudButton>
</CustomContent>
</MudFileUpload>
</div>
</MudPaper>
@if (_plans is null)
{
<LoadingIndicator />
}
else if (_plans.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-plan-empty">Noch kein Plan hochgeladen.</MudAlert>
}
else
{
<MudGrid Spacing="3">
@foreach (var plan in _plans)
{
var current = plan;
<MudItem xs="12" md="6">
<MudPaper Outlined="true" Class="pa-3 fp-plan-card" role="group" aria-label="@current.Name">
<MudText Typo="Typo.subtitle1" HtmlTag="h3">@current.Name</MudText>
<MudText Typo="Typo.caption" Class="d-block mb-2">@current.Pins.Count @(current.Pins.Count == 1 ? "Gerät" : "Geräte") auf dem Plan</MudText>
<img class="fp-plan-thumb" src="@HomeApiRoutes.PlanImage(current.Id, current.ImageVersion)" alt="@($"Vorschau {current.Name}")" loading="lazy" />
<div class="d-flex flex-wrap gap-1 mt-2">
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PinDrop"
OnClick="() => _editing = current.Id" Class="fp-plan-pins">Geräte platzieren</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Edit" OnClick="() => RenameAsync(current)">Umbenennen</MudButton>
<MudFileUpload T="IBrowserFile" FilesChanged="file => ReplaceAsync(current, file)" Accept=".png,.jpg,.jpeg,.webp">
<CustomContent Context="upload">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Image" Disabled="_busy"
OnClick="@upload.OpenFilePickerAsync">Bild ersetzen</MudButton>
</CustomContent>
</MudFileUpload>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" OnClick="() => DeleteAsync(current)">Löschen</MudButton>
</div>
</MudPaper>
</MudItem>
}
</MudGrid>
@if (_plans.FirstOrDefault(p => p.Id == _editing) is { } editing)
{
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" HtmlTag="h3" Class="mb-2">Geräte auf „@editing.Name“ platzieren</MudText>
<PlanPinEditor Plan="editing" Devices="_devices" OnSaved="OnPinsSavedAsync" OnClose="() => _editing = null" />
}
}
@code {
private List<HomeAdminPlanDto>? _plans;
private List<HomeAdminDeviceDto> _devices = [];
private string _newName = string.Empty;
private string? _nameError;
private Guid? _editing;
private bool _busy;
protected override Task OnInitializedAsync() => LoadAsync();
/// <summary>Lädt Pläne und Geräte neu.</summary>
public async Task LoadAsync()
{
_plans = (await Api.GetPlansAsync()).Value ?? [];
_devices = (await Api.GetDevicesAsync()).Value ?? [];
}
private async Task UploadAsync(IBrowserFile? file)
{
_nameError = null;
if (file is null)
{
return;
}
var name = _newName.Trim();
if (name.Length == 0)
{
_nameError = "Bitte zuerst einen Namen eingeben.";
return;
}
_busy = true;
try
{
await using var stream = file.OpenReadStream(HomeRules.MaxPlanImageBytes);
var result = await Api.CreatePlanAsync(name, (_plans?.Count ?? 0) * 10, stream, file.Name, file.ContentType);
if (result.Value is { } plan)
{
_newName = string.Empty;
_editing = plan.Id;
Snackbar.Add("Plan hochgeladen. Platziere jetzt die Geräte.", Severity.Success);
await LoadAsync();
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Der Plan konnte nicht hochgeladen werden.", Severity.Error);
}
}
catch (IOException)
{
Snackbar.Add($"Das Bild ist zu groß (höchstens {HomeRules.MaxPlanImageBytes / 1024 / 1024} MB).", Severity.Error);
}
finally
{
_busy = false;
}
}
private async Task ReplaceAsync(HomeAdminPlanDto plan, IBrowserFile? file)
{
if (file is null)
{
return;
}
_busy = true;
try
{
await using var stream = file.OpenReadStream(HomeRules.MaxPlanImageBytes);
var result = await Api.ReplacePlanImageAsync(plan.Id, stream, file.Name, file.ContentType);
Snackbar.Add(result.Problem?.Message ?? "Bild ersetzt.", result.Succeeded ? Severity.Success : Severity.Error);
await LoadAsync();
}
catch (IOException)
{
Snackbar.Add($"Das Bild ist zu groß (höchstens {HomeRules.MaxPlanImageBytes / 1024 / 1024} MB).", Severity.Error);
}
finally
{
_busy = false;
}
}
private async Task RenameAsync(HomeAdminPlanDto plan)
{
var dialog = await DialogService.ShowAsync<HouseNameDialog>($"{plan.Name} umbenennen",
new DialogParameters<HouseNameDialog>
{
{ d => d.Name, plan.Name },
{ d => d.SortOrder, plan.SortOrder },
{ d => d.MaxLength, HomePlan.NameMaxLength },
{ d => d.OnSave, async (string name, int sort) => (await Api.UpdatePlanAsync(plan.Id, new SaveHomePlanRequest(name, sort))).Problem?.Message },
},
new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Plan gespeichert.", Severity.Success);
await LoadAsync();
}
}
private async Task DeleteAsync(HomeAdminPlanDto plan)
{
if (await DialogService.ShowMessageBoxAsync("Plan löschen?", $"„{plan.Name}“ und alle Positionen darauf werden entfernt. Die Geräte bleiben erhalten.",
yesText: "Löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.DeletePlanAsync(plan.Id);
if (_editing == plan.Id)
{
_editing = null;
}
Snackbar.Add(result.Problem?.Message ?? "Plan gelöscht.", result.Succeeded ? Severity.Success : Severity.Error);
await LoadAsync();
}
private async Task OnPinsSavedAsync(HomeAdminPlanDto saved)
{
_plans = _plans?.Select(p => p.Id == saved.Id ? saved : p).ToList();
await Task.CompletedTask;
}
}
@@ -0,0 +1,99 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">Räume</MudText>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditAsync(null)" Class="fp-room-add">Raum hinzufügen</MudButton>
</div>
<MudText Typo="Typo.body2" Class="mb-3">Räume gruppieren die Geräte auf der Haus-Seite. Beim Löschen eines Raums bleiben seine Geräte erhalten und stehen danach unter „Weitere Geräte“.</MudText>
@if (_rooms is null)
{
<LoadingIndicator />
}
else if (_rooms.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-room-empty">Noch keine Räume. Geräte ohne Raum werden unter „Geräte“ angezeigt.</MudAlert>
}
else
{
<MudTable T="HomeAdminRoomDto" Items="_rooms" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Class="fp-room-table">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Geräte</MudTh>
<MudTh>Reihenfolge</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Geräte">@context.DeviceCount</MudTd>
<MudTd DataLabel="Reihenfolge">@context.SortOrder</MudTd>
<MudTd Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => EditAsync(context)" aria-label="@($"{context.Name} bearbeiten")" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(context)" aria-label="@($"{context.Name} löschen")" />
</MudTd>
</RowTemplate>
</MudTable>
}
@code {
private List<HomeAdminRoomDto>? _rooms;
/// <summary>Wird nach jeder Änderung ausgelöst (die Geräteliste zeigt Raumnamen).</summary>
[Parameter]
public EventCallback OnChanged { get; set; }
protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync() => _rooms = (await Api.GetRoomsAsync()).Value ?? [];
private async Task EditAsync(HomeAdminRoomDto? room)
{
var dialog = await DialogService.ShowAsync<HouseNameDialog>(room is null ? "Raum hinzufügen" : $"{room.Name} bearbeiten",
new DialogParameters<HouseNameDialog>
{
{ d => d.Name, room?.Name ?? string.Empty },
{ d => d.SortOrder, room?.SortOrder ?? (_rooms?.Count ?? 0) * 10 },
{ d => d.MaxLength, HomeRoom.NameMaxLength },
{ d => d.OnSave, (string name, int sort) => SaveAsync(room, name, sort) },
},
new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Raum gespeichert.", Severity.Success);
await LoadAsync();
await OnChanged.InvokeAsync();
}
}
private async Task<string?> SaveAsync(HomeAdminRoomDto? room, string name, int sort)
{
var request = new SaveHomeRoomRequest(name, sort);
var validation = new SaveHomeRoomRequestValidator().Validate(request);
if (!validation.IsValid)
{
return validation.Errors[0].ErrorMessage;
}
var result = room is null ? await Api.CreateRoomAsync(request) : await Api.UpdateRoomAsync(room.Id, request);
return result.Problem?.Message;
}
private async Task DeleteAsync(HomeAdminRoomDto room)
{
if (await DialogService.ShowMessageBoxAsync("Raum löschen?", $"„{room.Name}“ wird gelöscht. Die {room.DeviceCount} Geräte darin bleiben erhalten.",
yesText: "Löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.DeleteRoomAsync(room.Id);
Snackbar.Add(result.Problem?.Message ?? "Raum gelöscht.", result.Succeeded ? Severity.Success : Severity.Error);
await LoadAsync();
await OnChanged.InvokeAsync();
}
}
@@ -0,0 +1,161 @@
using System.Globalization;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared.Home;
using MudBlazor;
namespace FamilyPlanner.Web.Client.Components.House;
/// <summary>Darstellung der Haus-Geräte: Symbole, Statustexte (nie nur Farbe), Beschriftungen für Hilfstechnik.</summary>
public static class HouseUi
{
/// <summary>Anzeigeklasse eines Zustands: bestimmt Farbe <b>und</b> Symbol/Text.</summary>
public enum Status
{
Unknown,
On,
Off,
Offline,
Moving,
Open,
Closed,
Value,
}
public static string Icon(string? key, HomeDeviceKind kind) => key switch
{
"bulb" => Icons.Material.Filled.Lightbulb,
"ceiling" => Icons.Material.Filled.Light,
"lamp" => Icons.Material.Filled.FlashlightOn,
"plug" => Icons.Material.Filled.Power,
"fan" => Icons.Material.Filled.Air,
"heater" => Icons.Material.Filled.Whatshot,
"tv" => Icons.Material.Filled.Tv,
"coffee" => Icons.Material.Filled.Coffee,
"blinds" => Icons.Material.Filled.Blinds,
"door" => Icons.Material.Filled.DoorFront,
"garage" => Icons.Material.Filled.Garage,
"thermometer" => Icons.Material.Filled.Thermostat,
_ => kind switch
{
HomeDeviceKind.Light => Icons.Material.Filled.Lightbulb,
HomeDeviceKind.Socket => Icons.Material.Filled.Power,
HomeDeviceKind.Cover => Icons.Material.Filled.Blinds,
HomeDeviceKind.Sensor => Icons.Material.Filled.Thermostat,
_ => Icons.Material.Filled.ToggleOn,
},
};
public static Status StatusOf(HomeDeviceDto device, HomeDeviceStateDto? state)
{
if (state is null)
{
return Status.Unknown;
}
if (!state.Online)
{
return Status.Offline;
}
return device.Kind switch
{
HomeDeviceKind.Cover => state.CoverState switch
{
"opening" or "closing" => Status.Moving,
"open" => Status.Open,
"closed" => Status.Closed,
_ => Status.Value,
},
HomeDeviceKind.Sensor => Status.Value,
_ => state.IsOn is true ? Status.On : state.IsOn is false ? Status.Off : Status.Unknown,
};
}
public static string StatusText(HomeDeviceDto device, HomeDeviceStateDto? state) => StatusOf(device, state) switch
{
Status.Unknown => "Status wird geladen …",
Status.Offline => "Offline",
Status.On => "An",
Status.Off => "Aus",
Status.Open => "Offen",
Status.Closed => "Geschlossen",
Status.Moving => state?.CoverState == "opening" ? "Öffnet …" : "Schließt …",
_ => device.Kind == HomeDeviceKind.Cover
? $"Gestoppt{(state?.CoverPosition is { } p ? $" ({p} % offen)" : string.Empty)}"
: SensorText(state),
};
/// <summary>Kurzer Text für das Symbol auf dem Plan (zusätzlich zur Farbe).</summary>
public static string Badge(Status status) => status switch
{
Status.On => "An",
Status.Off => "Aus",
Status.Offline => "!",
Status.Open => "Auf",
Status.Closed => "Zu",
Status.Moving => "…",
Status.Value => "–",
_ => "?",
};
public static string StatusIcon(Status status) => status switch
{
Status.On => Icons.Material.Filled.CheckCircle,
Status.Off => Icons.Material.Outlined.Circle,
Status.Offline => Icons.Material.Filled.CloudOff,
Status.Moving => Icons.Material.Filled.Sync,
Status.Open => Icons.Material.Filled.ArrowUpward,
Status.Closed => Icons.Material.Filled.ArrowDownward,
_ => Icons.Material.Outlined.HelpOutline,
};
public static string CssClass(Status status) => status.ToString().ToLowerInvariant();
/// <summary>Zusatzzeile: Leistung bzw. Messwerte.</summary>
public static string? Detail(HomeDeviceDto device, HomeDeviceStateDto? state)
{
if (state is not { Online: true })
{
return null;
}
if (device.Kind == HomeDeviceKind.Sensor)
{
return null; // steht schon im Status
}
return state.PowerWatts is { } w and > 0.5 ? $"{w.ToString("0.#", CultureInfo.GetCultureInfo("de-DE"))} W" : null;
}
private static string SensorText(HomeDeviceStateDto? state)
{
var parts = new List<string>();
if (state?.TemperatureC is { } t)
{
parts.Add($"{t.ToString("0.0", CultureInfo.GetCultureInfo("de-DE"))} °C");
}
if (state?.HumidityPercent is { } h)
{
parts.Add($"{h.ToString("0", CultureInfo.GetCultureInfo("de-DE"))} % Luftfeuchte");
}
return parts.Count == 0 ? "Keine Werte" : string.Join(" · ", parts);
}
/// <summary>Kann dieses Gerät mit einem Klick umgeschaltet werden (Schalter/Steckdose/Licht, Schaltrecht)?</summary>
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}.";
}
public static string FormatKind(HomeDeviceKind kind) => HomeRules.DisplayName(kind);
public static string FormatTime(DateTimeOffset at) =>
TimeZoneInfo.ConvertTime(at, FamilyPlanner.Domain.Common.FamilyTime.Berlin).ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-DE"));
}
@@ -0,0 +1,236 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@implements IAsyncDisposable
@inject IHomeApi Api
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@* Pins auf einem Plan setzen: Gerät links wählen und aufs Bild klicken, Symbole ziehen oder mit Pfeiltasten/Feldern genau positionieren. *@
<section class="fp-pin-editor" aria-label="@($"Geräte auf dem Plan {Plan.Name} platzieren")">
<MudText Typo="Typo.body2" Class="mb-3">
Wähle ein Gerät in der Liste und klicke auf den Plan, um es dort zu platzieren. Platzierte Symbole lassen sich ziehen; mit den Pfeiltasten
(Umschalt = große Schritte) oder den Feldern unten stellst du die Position genau ein. Gespeichert wird erst mit „Positionen speichern“.
</MudText>
<div class="fp-pin-editor-grid">
<div class="fp-pin-devices" role="group" aria-label="Geräte">
@if (Devices.Count == 0)
{
<MudText Typo="Typo.caption">Noch keine Geräte angelegt.</MudText>
}
@foreach (var device in Devices)
{
var current = device;
var placed = _pins.ContainsKey(current.Id);
<button type="button" class="@($"fp-pin-device{(_selected == current.Id ? " selected" : string.Empty)}")" aria-pressed="@(_selected == current.Id ? "true" : "false")"
@onclick="() => _selected = current.Id">
<MudIcon Icon="@HouseUi.Icon(current.Icon, current.Kind)" Size="Size.Small" aria-hidden="true" />
<span class="fp-pin-device-name">@current.Name</span>
<span class="fp-pin-device-state">@(placed ? "platziert" : "nicht platziert")</span>
</button>
}
</div>
<div>
<div @ref="_container" class="fp-plan fp-plan-edit" role="group" aria-label="@($"Plan {Plan.Name}")">
<img class="fp-plan-image" src="@HomeApiRoutes.PlanImage(Plan.Id, Plan.ImageVersion)" alt="@($"Grundriss {Plan.Name}")" draggable="false" />
@foreach (var device in Devices)
{
if (!_pins.TryGetValue(device.Id, out var pin))
{
continue;
}
<button type="button" data-device="@device.Id"
class="@($"fp-pin fp-pin-edit fp-pin-unknown{(_selected == device.Id ? " fp-pin-selected" : string.Empty)}")"
style="@FloorPlanView.PinStyle(new HomePinDto(device.Id, pin.X, pin.Y))"
aria-label="@($"{device.Name}, Position {Percent(pin.X)} Prozent von links, {Percent(pin.Y)} Prozent von oben. Pfeiltasten verschieben.")"
@onclick="() => _selected = device.Id">
<MudIcon Icon="@HouseUi.Icon(device.Icon, device.Kind)" Size="Size.Small" aria-hidden="true" />
</button>
}
</div>
@if (SelectedDevice is { } selected)
{
<div class="fp-pin-position mt-3" role="group" aria-label="@($"Position von {selected.Name}")">
<MudText Typo="Typo.subtitle2" HtmlTag="h3">@selected.Name</MudText>
@if (_pins.TryGetValue(selected.Id, out var position))
{
<div class="d-flex flex-wrap align-center gap-3 mt-1">
<MudNumericField T="int" Value="Percent(position.X)" ValueChanged="v => SetPosition(selected.Id, v / 100.0, position.Y)" Label="Von links (%)"
Min="0" Max="100" Variant="Variant.Outlined" Margin="Margin.Dense" Class="fp-pin-x" Style="max-width:9rem" />
<MudNumericField T="int" Value="Percent(position.Y)" ValueChanged="v => SetPosition(selected.Id, position.X, v / 100.0)" Label="Von oben (%)"
Min="0" Max="100" Variant="Variant.Outlined" Margin="Margin.Dense" Class="fp-pin-y" Style="max-width:9rem" />
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.PinDrop" OnClick="() => RemovePin(selected.Id)" Class="fp-pin-remove">
Vom Plan entfernen
</MudButton>
</div>
}
else
{
<MudText Typo="Typo.caption">Noch nicht platziert – klicke auf den Plan, um „@selected.Name“ dort abzulegen.</MudText>
<div class="mt-1">
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.AddLocation" OnClick="() => SetPosition(selected.Id, 0.5, 0.5)" Class="fp-pin-center">
In der Mitte ablegen
</MudButton>
</div>
}
</div>
}
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" Disabled="@(!_dirty || _busy)" OnClick="SaveAsync" Class="fp-pin-save">Positionen speichern</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(!_dirty || _busy)" OnClick="Reset" Class="fp-pin-reset">Änderungen verwerfen</MudButton>
<MudButton Variant="Variant.Text" OnClick="() => OnClose.InvokeAsync()" Class="fp-pin-close">Schließen</MudButton>
</div>
</section>
@code {
private ElementReference _container;
private DotNetObjectReference<PlanPinEditor>? _reference;
private readonly Dictionary<Guid, (double X, double Y)> _pins = [];
private Guid _loadedPlan;
private Guid? _selected;
private bool _dirty;
private bool _busy;
private bool _attached;
[Parameter, EditorRequired]
public HomeAdminPlanDto Plan { get; set; } = default!;
[Parameter]
public IReadOnlyList<HomeAdminDeviceDto> Devices { get; set; } = [];
[Parameter]
public EventCallback<HomeAdminPlanDto> OnSaved { get; set; }
[Parameter]
public EventCallback OnClose { get; set; }
private HomeAdminDeviceDto? SelectedDevice => Devices.FirstOrDefault(d => d.Id == _selected);
private static int Percent(double value) => (int)Math.Round(value * 100, MidpointRounding.AwayFromZero);
protected override void OnParametersSet()
{
if (_loadedPlan == Plan.Id)
{
return;
}
_loadedPlan = Plan.Id;
Reset();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || _attached)
{
return;
}
_attached = true;
_reference = DotNetObjectReference.Create(this);
try
{
await JS.InvokeVoidAsync("fpHome.attach", _container, _reference);
}
catch (JSException)
{
// Ohne Skript bleiben Auswahl, Felder und „In der Mitte ablegen“ nutzbar.
}
}
private void Reset()
{
_pins.Clear();
foreach (var pin in Plan.Pins)
{
_pins[pin.DeviceId] = (pin.X, pin.Y);
}
_dirty = false;
}
private void SetPosition(Guid id, double x, double y)
{
_pins[id] = (HomeRules.RoundCoordinate(x), HomeRules.RoundCoordinate(y));
_selected = id;
_dirty = true;
}
private void RemovePin(Guid id)
{
_pins.Remove(id);
_dirty = true;
}
[JSInvokable]
public Task PinMoved(string id, double x, double y) => InvokeAsync(() =>
{
if (Guid.TryParse(id, out var deviceId))
{
SetPosition(deviceId, x, y);
StateHasChanged();
}
});
[JSInvokable]
public Task PinNudged(string id, double dx, double dy) => InvokeAsync(() =>
{
if (Guid.TryParse(id, out var deviceId) && _pins.TryGetValue(deviceId, out var pin))
{
SetPosition(deviceId, pin.X + dx, pin.Y + dy);
StateHasChanged();
}
});
[JSInvokable]
public Task PlanClicked(double x, double y) => InvokeAsync(() =>
{
if (_selected is { } id)
{
SetPosition(id, x, y);
StateHasChanged();
}
});
private async Task SaveAsync()
{
_busy = true;
var request = new SaveHomePlacementsRequest(_pins.Select(p => new HomePinDto(p.Key, p.Value.X, p.Value.Y)).ToList());
var result = await Api.SetPlacementsAsync(Plan.Id, request);
_busy = false;
if (result.Value is { } plan)
{
_dirty = false;
Snackbar.Add("Positionen gespeichert.", Severity.Success);
await OnSaved.InvokeAsync(plan);
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Die Positionen konnten nicht gespeichert werden.", Severity.Error);
}
}
public async ValueTask DisposeAsync()
{
try
{
if (_attached)
{
await JS.InvokeVoidAsync("fpHome.detach", _container);
}
}
catch (Exception ex) when (ex is JSDisconnectedException or JSException or InvalidOperationException or TaskCanceledException)
{
// Seite wird verlassen.
}
_reference?.Dispose();
}
}
@@ -0,0 +1,58 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@* Geräte nach Raum gruppiert; Geräte ohne Raum stehen unter „Weitere Geräte“. *@
@foreach (var group in Groups)
{
<section class="fp-room mb-5" aria-labelledby="@group.HeadingId">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2 fp-room-title" id="@group.HeadingId">@group.Name</MudText>
<MudGrid Spacing="2">
@foreach (var device in group.Devices)
{
var current = device;
<MudItem xs="12" sm="6" md="4">
<DeviceCard Device="current" State="States.GetValueOrDefault(current.Id)" Busy="Busy.Contains(current.Id)"
OnCommand="c => OnCommand.InvokeAsync((current, c))" />
</MudItem>
}
</MudGrid>
</section>
}
@code {
private sealed record Group(string HeadingId, string Name, List<HomeDeviceDto> Devices);
[Parameter, EditorRequired]
public HomeOverviewDto Overview { get; set; } = default!;
[Parameter]
public IReadOnlyDictionary<Guid, HomeDeviceStateDto> States { get; set; } = new Dictionary<Guid, HomeDeviceStateDto>();
[Parameter]
public IReadOnlySet<Guid> Busy { get; set; } = new HashSet<Guid>();
[Parameter]
public EventCallback<(HomeDeviceDto Device, HomeCommand Command)> OnCommand { get; set; }
private IEnumerable<Group> Groups
{
get
{
foreach (var room in Overview.Rooms.OrderBy(r => r.SortOrder).ThenBy(r => r.Name, StringComparer.CurrentCultureIgnoreCase))
{
var devices = Overview.Devices.Where(d => d.RoomId == room.Id).ToList();
if (devices.Count > 0)
{
yield return new Group($"room-{room.Id:N}", room.Name, devices);
}
}
var roomIds = Overview.Rooms.Select(r => r.Id).ToHashSet();
var others = Overview.Devices.Where(d => d.RoomId is null || !roomIds.Contains(d.RoomId.Value)).ToList();
if (others.Count > 0)
{
yield return new Group("room-none", Overview.Rooms.Count == 0 ? "Geräte" : "Weitere Geräte", others);
}
}
}
}
@@ -3,7 +3,8 @@ using MudBlazor;
namespace FamilyPlanner.Web.Client.Layout;
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null, bool Separated = false);
/// <param name="Child">Untereintrag: eingerückt unter dem vorigen Eintrag (z. B. „Hausverwaltung“ unter „Haus“).</param>
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null, bool Separated = false, bool Child = false);
/// <summary>Zentrale Navigationseinträge für Drawer (Desktop) und Bottom-Navigation (Smartphone).</summary>
public static class NavItems
@@ -18,5 +19,9 @@ public static class NavItems
new("chat", "Chat", "Chat", Icons.Material.Filled.Chat, InBottomNav: true),
new("lernen", "Lernen", "Lernen", Icons.Material.Filled.School, InBottomNav: false, Policies.FamilyMember),
new("admin", "Verwaltung", "Admin", Icons.Material.Filled.AdminPanelSettings, InBottomNav: false, Policies.Admin, Separated: true),
// Haus (ADR-0023): Seite für alle mit Haus-Recht (Geräte nur nach Freigabe), Hausverwaltung nur für Administratoren.
new("haus", "Haus", "Haus", Icons.Material.Filled.Home, InBottomNav: false, Policies.HomeUse),
new("haus/verwaltung", "Hausverwaltung", "Hausverw.", Icons.Material.Filled.HomeRepairService, InBottomNav: false, Policies.Admin, Child: true),
];
}
@@ -23,7 +23,8 @@
{
<MudDivider Class="my-2" />
}
<MudNavLink Href="@item.Href" Icon="@item.Icon" Match="NavLinkMatch.Prefix">@item.Title</MudNavLink>
<MudNavLink Href="@item.Href" Icon="@item.Icon" Class="@(item.Child ? "fp-nav-child" : null)"
Match="@(item.Href == "haus" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@item.Title</MudNavLink>
</AuthorizeView>
}
}
@@ -0,0 +1,210 @@
@page "/haus"
@attribute [Authorize(Policy = Policies.HomeUse)]
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@using FamilyPlanner.Web.Client.Components.House
@implements IDisposable
@inject IHomeApi Api
@inject ISnackbar Snackbar
<PageTitle>Haus – Familienplaner</PageTitle>
<div class="d-flex align-center flex-wrap gap-2 mb-3">
<MudText Typo="Typo.h4" HtmlTag="h1">Haus</MudText>
<MudSpacer />
@if (_overview?.CanManage == true)
{
<MudButton Variant="Variant.Outlined" Href="haus/verwaltung" StartIcon="@Icons.Material.Filled.Settings" Class="fp-house-manage">Hausverwaltung</MudButton>
}
</div>
@if (_overview is null)
{
@if (_error is not null)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="fp-house-error">@_error</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Outlined" OnClick="ReloadAsync">Erneut versuchen</MudButton>
}
else
{
<LoadingIndicator />
}
}
else if (_overview.Devices.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-house-empty">
Für dich sind noch keine Geräte freigegeben.
@if (_overview.CanManage)
{
<span> Lege Geräte in der <MudLink Href="haus/verwaltung">Hausverwaltung</MudLink> an.</span>
}
else
{
<span> Wende dich an die Verwaltung, wenn du Licht, Steckdosen oder Rollos bedienen möchtest.</span>
}
</MudAlert>
}
else
{
@if (_stateError is not null)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3 fp-house-stateerror" role="status">@_stateError</MudAlert>
}
@if (_overview.Plans.Count == 0)
{
<RoomView Overview="_overview" States="_states" Busy="_busy" OnCommand="RoomCommandAsync" />
}
else
{
<MudTabs Elevation="0" Rounded="true" Outlined="true" ApplyEffectsToContainer="true" TabPanelsClass="pt-4" Class="fp-house-tabs">
<MudTabPanel Text="Räume" Icon="@Icons.Material.Filled.MeetingRoom">
<RoomView Overview="_overview" States="_states" Busy="_busy" OnCommand="RoomCommandAsync" />
</MudTabPanel>
@foreach (var plan in _overview.Plans)
{
var current = plan;
<MudTabPanel Text="@current.Name" Icon="@Icons.Material.Filled.Map" Class="fp-house-plan-tab">
<FloorPlanView Plan="current" Devices="_deviceMap" States="_states" SelectedId="_selectedId" OnPinClick="PinClickedAsync" />
<MudText Typo="Typo.caption" Class="d-block mt-2">
Tippe auf ein Symbol, um das Gerät zu schalten. Farbe und Kurztext am Symbol zeigen den Zustand (An, Aus, ! = offline).
</MudText>
@if (_selectedId is { } id && _deviceMap.TryGetValue(id, out var selected) && current.Pins.Any(p => p.DeviceId == id))
{
<div class="fp-house-detail mt-3" aria-live="polite">
<DeviceCard Device="selected" State="_states.GetValueOrDefault(id)" Busy="_busy.Contains(id)" OnCommand="c => CommandAsync(selected, c)" />
</div>
}
</MudTabPanel>
}
</MudTabs>
}
}
@code {
/// <summary>Zustände werden alle 10 Sekunden neu gelesen, solange die Seite offen ist (der Server puffert sie kurz).</summary>
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10);
private readonly CancellationTokenSource _cts = new();
private readonly HashSet<Guid> _busy = [];
private HomeOverviewDto? _overview;
private Dictionary<Guid, HomeDeviceDto> _deviceMap = [];
private Dictionary<Guid, HomeDeviceStateDto> _states = [];
private Guid? _selectedId;
private string? _error;
private string? _stateError;
protected override async Task OnInitializedAsync()
{
await ReloadAsync();
_ = PollAsync();
}
private async Task ReloadAsync()
{
_error = null;
var result = await Api.GetOverviewAsync(_cts.Token);
if (result.Value is { } overview)
{
_overview = overview;
_deviceMap = overview.Devices.ToDictionary(d => d.Id);
if (overview.Devices.Count > 0)
{
await RefreshStatesAsync();
}
}
else
{
_error = result.Problem?.Message ?? "Das Haus konnte nicht geladen werden.";
}
}
private async Task RefreshStatesAsync()
{
var result = await Api.GetStatesAsync(_cts.Token);
if (result.Value is { } states)
{
_states = states.States.ToDictionary(s => s.DeviceId);
_stateError = null;
}
else
{
_stateError = "Die aktuellen Zustände konnten nicht geladen werden. " + (result.Problem?.Message ?? string.Empty);
}
}
private async Task PollAsync()
{
using var timer = new PeriodicTimer(PollInterval);
var token = _cts.Token;
try
{
while (await timer.WaitForNextTickAsync(token))
{
if (_overview is { Devices.Count: > 0 } && _busy.Count == 0)
{
await RefreshStatesAsync();
await InvokeAsync(StateHasChanged);
}
}
}
catch (OperationCanceledException)
{
// Seite verlassen.
}
}
private async Task PinClickedAsync(HomeDeviceDto device)
{
_selectedId = device.Id;
if (HouseUi.IsToggleable(device) && _states.GetValueOrDefault(device.Id) is { Online: true } state)
{
await CommandAsync(device, state.IsOn is true ? HomeCommand.TurnOff : HomeCommand.TurnOn);
}
}
private Task RoomCommandAsync((HomeDeviceDto Device, HomeCommand Command) args) => CommandAsync(args.Device, args.Command);
private async Task CommandAsync(HomeDeviceDto device, HomeCommand command)
{
if (!_busy.Add(device.Id))
{
return;
}
try
{
var result = await Api.SendCommandAsync(device.Id, command, _cts.Token);
if (result.Value is { } outcome)
{
_states[device.Id] = outcome.State;
if (outcome.Success)
{
Snackbar.Add($"{device.Name} {HomeRules.DisplayName(command)}.", Severity.Success);
}
else
{
Snackbar.Add($"{device.Name}: {outcome.Message}", Severity.Error);
}
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Der Befehl ist fehlgeschlagen.", Severity.Error);
if (result.Problem?.Status == 404)
{
await ReloadAsync(); // Gerät wurde entfernt oder die Freigabe entzogen
}
}
}
finally
{
_busy.Remove(device.Id);
}
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,32 @@
@page "/haus/verwaltung"
@attribute [Authorize(Policy = Policies.Admin)]
@using FamilyPlanner.Web.Client.Components.House
<PageTitle>Hausverwaltung – Familienplaner</PageTitle>
<div class="d-flex align-center flex-wrap gap-2 mb-4">
<MudText Typo="Typo.h4" HtmlTag="h1">Hausverwaltung</MudText>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Href="haus" StartIcon="@Icons.Material.Filled.Home" Class="fp-house-back">Zur Haus-Ansicht</MudButton>
</div>
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" TabPanelsClass="pt-4" Outlined="true" Class="fp-house-admin-tabs">
<MudTabPanel Text="Geräte" Icon="@Icons.Material.Filled.DevicesOther">
<HouseDevicesAdmin @ref="_devices" />
</MudTabPanel>
<MudTabPanel Text="Räume" Icon="@Icons.Material.Filled.MeetingRoom">
<HouseRoomsAdmin OnChanged="ReloadDevicesAsync" />
</MudTabPanel>
<MudTabPanel Text="Pläne" Icon="@Icons.Material.Filled.Map">
<HousePlansAdmin />
</MudTabPanel>
<MudTabPanel Text="Protokoll" Icon="@Icons.Material.Filled.History">
<HouseLogView />
</MudTabPanel>
</MudTabs>
@code {
private HouseDevicesAdmin? _devices;
private Task ReloadDevicesAsync() => _devices is null ? Task.CompletedTask : _devices.LoadAsync();
}
+1
View File
@@ -42,6 +42,7 @@ builder.Services.AddScoped<IDashboardApi, DashboardApi>();
builder.Services.AddScoped<IChatApi, ChatApi>();
builder.Services.AddScoped<IAiApi, AiApi>();
builder.Services.AddScoped<ILearningApi, LearningApi>();
builder.Services.AddScoped<IHomeApi, HomeApi>();
builder.Services.AddScoped<IPushService, PushService>();
builder.Services.AddScoped<ILocalStore, BrowserLocalStore>();
builder.Services.AddScoped<ILiveConnection, LiveConnection>();
@@ -0,0 +1,183 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Home;
namespace FamilyPlanner.Web.Client.Services;
/// <summary>Api-Zugriff des Haus-Moduls: Haus-Seite (alle Berechtigten) und Hausverwaltung (Admin). Testdoubles überschreiben nur, was sie brauchen.</summary>
public interface IHomeApi
{
// Haus-Seite
Task<ApiResult<HomeOverviewDto>> GetOverviewAsync(CancellationToken ct = default);
Task<ApiResult<HomeStatesDto>> GetStatesAsync(CancellationToken ct = default);
/// <summary>Schaltet ein Gerät. Gerätefehler kommen als <see cref="HomeCommandResultDto"/> mit <c>Success = false</c>, Rechtefehler als <see cref="ApiResult{T}.Problem"/>.</summary>
Task<ApiResult<HomeCommandResultDto>> SendCommandAsync(Guid deviceId, HomeCommand command, CancellationToken ct = default);
// Verwaltung: Geräte
Task<ApiResult<List<HomeAdminDeviceDto>>> GetDevicesAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<HomeAdminDeviceDto>>.Ok([]));
Task<ApiResult<HomeAdminDeviceDto>> CreateDeviceAsync(SaveHomeDeviceRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminDeviceDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminDeviceDto>> UpdateDeviceAsync(Guid id, SaveHomeDeviceRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminDeviceDto>.Fail(ApiProblem.Offline));
Task<ApiResult<bool>> DeleteDeviceAsync(Guid id, CancellationToken ct = default) => Task.FromResult(ApiResult<bool>.Ok(true));
Task<ApiResult<HomeTestResultDto>> TestDeviceAsync(Guid id, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeTestResultDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeTestResultDto>> TestDraftAsync(TestHomeDeviceRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeTestResultDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminDeviceDto>> SetAccessAsync(Guid id, SetHomeAccessRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminDeviceDto>.Fail(ApiProblem.Offline));
Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<UserDirectoryEntry>>.Ok([]));
// Verwaltung: Räume
Task<ApiResult<List<HomeAdminRoomDto>>> GetRoomsAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<HomeAdminRoomDto>>.Ok([]));
Task<ApiResult<HomeAdminRoomDto>> CreateRoomAsync(SaveHomeRoomRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminRoomDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminRoomDto>> UpdateRoomAsync(Guid id, SaveHomeRoomRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminRoomDto>.Fail(ApiProblem.Offline));
Task<ApiResult<bool>> DeleteRoomAsync(Guid id, CancellationToken ct = default) => Task.FromResult(ApiResult<bool>.Ok(true));
// Verwaltung: Pläne
Task<ApiResult<List<HomeAdminPlanDto>>> GetPlansAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<HomeAdminPlanDto>>.Ok([]));
Task<ApiResult<HomeAdminPlanDto>> CreatePlanAsync(string name, int sortOrder, Stream file, string fileName, string contentType, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminPlanDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminPlanDto>> UpdatePlanAsync(Guid id, SaveHomePlanRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminPlanDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminPlanDto>> ReplacePlanImageAsync(Guid id, Stream file, string fileName, string contentType, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminPlanDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminPlanDto>> SetPlacementsAsync(Guid id, SaveHomePlacementsRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminPlanDto>.Fail(ApiProblem.Offline));
Task<ApiResult<bool>> DeletePlanAsync(Guid id, CancellationToken ct = default) => Task.FromResult(ApiResult<bool>.Ok(true));
// Verwaltung: Protokoll
Task<ApiResult<PagedResult<HomeActionLogDto>>> GetLogAsync(int page, int pageSize, CancellationToken ct = default) =>
Task.FromResult(ApiResult<PagedResult<HomeActionLogDto>>.Ok(new PagedResult<HomeActionLogDto>([], 0, page, pageSize)));
}
public sealed class HomeApi(HttpClient http) : IHomeApi
{
public Task<ApiResult<HomeOverviewDto>> GetOverviewAsync(CancellationToken ct = default) =>
Send<HomeOverviewDto>(() => http.GetAsync(HomeApiRoutes.Overview, ct), ct);
public Task<ApiResult<HomeStatesDto>> GetStatesAsync(CancellationToken ct = default) =>
Send<HomeStatesDto>(() => http.GetAsync(HomeApiRoutes.States, ct), ct);
public Task<ApiResult<HomeCommandResultDto>> SendCommandAsync(Guid deviceId, HomeCommand command, CancellationToken ct = default) =>
Send<HomeCommandResultDto>(() => http.PostAsJsonAsync(HomeApiRoutes.DeviceCommand(deviceId), new HomeCommandRequest(command), ct), ct);
public Task<ApiResult<List<HomeAdminDeviceDto>>> GetDevicesAsync(CancellationToken ct = default) =>
Send<List<HomeAdminDeviceDto>>(() => http.GetAsync(HomeApiRoutes.Devices, ct), ct);
public Task<ApiResult<HomeAdminDeviceDto>> CreateDeviceAsync(SaveHomeDeviceRequest request, CancellationToken ct = default) =>
Send<HomeAdminDeviceDto>(() => http.PostAsJsonAsync(HomeApiRoutes.Devices, request, ct), ct);
public Task<ApiResult<HomeAdminDeviceDto>> UpdateDeviceAsync(Guid id, SaveHomeDeviceRequest request, CancellationToken ct = default) =>
Send<HomeAdminDeviceDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminDevice(id), request, ct), ct);
public Task<ApiResult<bool>> DeleteDeviceAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync(HomeApiRoutes.AdminDevice(id), ct), ct);
public Task<ApiResult<HomeTestResultDto>> TestDeviceAsync(Guid id, CancellationToken ct = default) =>
Send<HomeTestResultDto>(() => http.PostAsync(HomeApiRoutes.AdminDeviceTest(id), null, ct), ct);
public Task<ApiResult<HomeTestResultDto>> TestDraftAsync(TestHomeDeviceRequest request, CancellationToken ct = default) =>
Send<HomeTestResultDto>(() => http.PostAsJsonAsync(HomeApiRoutes.DeviceTest, request, ct), ct);
public Task<ApiResult<HomeAdminDeviceDto>> SetAccessAsync(Guid id, SetHomeAccessRequest request, CancellationToken ct = default) =>
Send<HomeAdminDeviceDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminDeviceAccess(id), request, ct), ct);
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
Send<List<UserDirectoryEntry>>(() => http.GetAsync("api/users", ct), ct);
public Task<ApiResult<List<HomeAdminRoomDto>>> GetRoomsAsync(CancellationToken ct = default) =>
Send<List<HomeAdminRoomDto>>(() => http.GetAsync(HomeApiRoutes.Rooms, ct), ct);
public Task<ApiResult<HomeAdminRoomDto>> CreateRoomAsync(SaveHomeRoomRequest request, CancellationToken ct = default) =>
Send<HomeAdminRoomDto>(() => http.PostAsJsonAsync(HomeApiRoutes.Rooms, request, ct), ct);
public Task<ApiResult<HomeAdminRoomDto>> UpdateRoomAsync(Guid id, SaveHomeRoomRequest request, CancellationToken ct = default) =>
Send<HomeAdminRoomDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminRoom(id), request, ct), ct);
public Task<ApiResult<bool>> DeleteRoomAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync(HomeApiRoutes.AdminRoom(id), ct), ct);
public Task<ApiResult<List<HomeAdminPlanDto>>> GetPlansAsync(CancellationToken ct = default) =>
Send<List<HomeAdminPlanDto>>(() => http.GetAsync(HomeApiRoutes.Plans, ct), ct);
public Task<ApiResult<HomeAdminPlanDto>> CreatePlanAsync(string name, int sortOrder, Stream file, string fileName, string contentType, CancellationToken ct = default) =>
Send<HomeAdminPlanDto>(() =>
{
var form = new MultipartFormDataContent
{
{ new StringContent(name), "name" },
{ new StringContent(sortOrder.ToString(System.Globalization.CultureInfo.InvariantCulture)), "sortOrder" },
};
form.Add(FileContent(file, contentType), "file", fileName);
return http.PostAsync(HomeApiRoutes.Plans, form, ct);
}, ct);
public Task<ApiResult<HomeAdminPlanDto>> UpdatePlanAsync(Guid id, SaveHomePlanRequest request, CancellationToken ct = default) =>
Send<HomeAdminPlanDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminPlan(id), request, ct), ct);
public Task<ApiResult<HomeAdminPlanDto>> ReplacePlanImageAsync(Guid id, Stream file, string fileName, string contentType, CancellationToken ct = default) =>
Send<HomeAdminPlanDto>(() =>
{
var form = new MultipartFormDataContent { { FileContent(file, contentType), "file", fileName } };
return http.PutAsync(HomeApiRoutes.AdminPlanImage(id), form, ct);
}, ct);
public Task<ApiResult<HomeAdminPlanDto>> SetPlacementsAsync(Guid id, SaveHomePlacementsRequest request, CancellationToken ct = default) =>
Send<HomeAdminPlanDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminPlanPlacements(id), request, ct), ct);
public Task<ApiResult<bool>> DeletePlanAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync(HomeApiRoutes.AdminPlan(id), ct), ct);
public Task<ApiResult<PagedResult<HomeActionLogDto>>> GetLogAsync(int page, int pageSize, CancellationToken ct = default) =>
Send<PagedResult<HomeActionLogDto>>(() => http.GetAsync($"{HomeApiRoutes.Log}?page={page}&pageSize={pageSize}", ct), ct);
private static StreamContent FileContent(Stream file, string contentType)
{
var content = new StreamContent(file);
content.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
return content;
}
/// <summary>409 liefert die Fehlermeldung des Servers (kein aktueller Stand im Body).</summary>
private static async Task<ApiResult<T>> Send<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{
try
{
using var response = await send();
return response.IsSuccessStatusCode
? ApiResult<T>.Ok(await response.Content.ReadFromJsonAsync<T>(ct))
: ApiResult<T>.Fail(await ApiClient.ReadProblemAsync(response, ct));
}
catch (HttpRequestException)
{
return ApiResult<T>.Fail(ApiProblem.Offline);
}
}
}
@@ -40,6 +40,7 @@
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["js/pwa.js"]"></script>
<script src="@Assets["js/chat.js"]"></script>
<script src="@Assets["js/house.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]" autostart="false"></script>
<script src="@Assets["js/boot.js"]"></script>
</body>
+231
View File
@@ -2057,3 +2057,234 @@ a.fp-learning-title:hover {
flex: 1 1 100%;
}
}
/* ---------- Haus (ADR-0023) ---------- */
.fp-nav-child .mud-nav-link-text,
.fp-nav-child.mud-nav-link {
padding-left: 12px;
}
.fp-device {
padding: 12px 14px;
border-left-width: 4px;
}
.fp-device-head {
display: flex;
align-items: center;
gap: 12px;
}
.fp-device-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
flex: 0 0 44px;
border-radius: 50%;
background: var(--mud-palette-background-gray);
}
.fp-device-main {
flex: 1 1 auto;
min-width: 0;
}
.fp-device-name {
overflow-wrap: anywhere;
line-height: 1.25;
}
.fp-device-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: .875rem;
font-weight: 500;
}
.fp-device-controls {
display: flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
}
.fp-device-toggle {
min-width: 88px;
min-height: 44px;
}
.fp-device-message,
.fp-device-readonly {
display: block;
margin-top: 6px;
color: var(--mud-palette-text-secondary);
}
/* Der Status steht immer auch als Text und Symbol – die Randfarbe ist nur eine zusätzliche Hilfe. */
.fp-device-on { border-left-color: var(--mud-palette-success); }
.fp-device-off { border-left-color: var(--mud-palette-lines-inputs); }
.fp-device-offline { border-left-color: var(--mud-palette-error); }
.fp-device-moving { border-left-color: var(--mud-palette-warning); }
.fp-device-open, .fp-device-closed, .fp-device-value { border-left-color: var(--mud-palette-info); }
.fp-device-offline .fp-device-status { color: var(--mud-palette-error); }
.fp-device-on .fp-device-status { color: var(--mud-palette-success-darken, var(--mud-palette-success)); }
@media (max-width: 599.98px) {
.fp-device-head {
flex-wrap: wrap;
}
.fp-device-controls {
flex: 1 1 100%;
justify-content: flex-end;
}
}
/* Plan und Pins */
.fp-plan-wrap {
max-width: 100%;
}
.fp-plan {
position: relative;
display: inline-block;
max-width: 100%;
line-height: 0;
border: 1px solid var(--mud-palette-lines-default);
border-radius: 8px;
overflow: hidden;
background: var(--mud-palette-background-gray);
touch-action: manipulation;
}
.fp-plan-image {
display: block;
max-width: 100%;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
.fp-plan-thumb {
display: block;
max-width: 100%;
max-height: 180px;
border-radius: 6px;
border: 1px solid var(--mud-palette-lines-default);
object-fit: contain;
}
.fp-pin {
position: absolute;
transform: translate(-50%, -50%);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 2px;
border-radius: 50%;
border: 2px solid var(--mud-palette-lines-inputs);
background: var(--mud-palette-surface);
color: var(--mud-palette-text-primary);
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
}
.fp-pin-badge {
margin-top: 1px;
font-size: .625rem;
font-weight: 700;
letter-spacing: .02em;
}
.fp-pin:focus-visible {
outline: 3px solid var(--mud-palette-primary);
outline-offset: 2px;
}
.fp-pin-selected {
box-shadow: 0 0 0 3px var(--mud-palette-primary), 0 1px 4px rgba(0, 0, 0, .35);
}
.fp-pin-on { border-color: var(--mud-palette-success); background: var(--mud-palette-success); color: var(--mud-palette-success-text); }
.fp-pin-off { border-style: dashed; }
.fp-pin-offline { border-color: var(--mud-palette-error); background: var(--mud-palette-surface); color: var(--mud-palette-error); }
.fp-pin-moving { border-color: var(--mud-palette-warning); }
.fp-pin-open, .fp-pin-closed, .fp-pin-value { border-color: var(--mud-palette-info); }
.fp-pin-edit {
touch-action: none;
cursor: grab;
}
.fp-plan-edit {
cursor: crosshair;
}
.fp-pin-editor-grid {
display: grid;
grid-template-columns: minmax(180px, 260px) 1fr;
gap: 16px;
align-items: start;
}
.fp-pin-devices {
display: flex;
flex-direction: column;
gap: 6px;
}
.fp-pin-device {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 6px 10px;
text-align: left;
border: 1px solid var(--mud-palette-lines-default);
border-radius: 8px;
background: var(--mud-palette-surface);
color: var(--mud-palette-text-primary);
cursor: pointer;
font: inherit;
}
.fp-pin-device.selected {
border-color: var(--mud-palette-primary);
box-shadow: inset 0 0 0 1px var(--mud-palette-primary);
font-weight: 600;
}
.fp-pin-device:focus-visible {
outline: 3px solid var(--mud-palette-primary);
outline-offset: 2px;
}
.fp-pin-device-name {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.fp-pin-device-state {
font-size: .75rem;
color: var(--mud-palette-text-secondary);
}
@media (max-width: 719.98px) {
.fp-pin-editor-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: no-preference) {
.fp-pin {
transition: box-shadow .15s ease;
}
}
+93
View File
@@ -0,0 +1,93 @@
// Hilfen für den Plan-Editor der Hausverwaltung: Symbole ziehen, auf den Plan klicken, mit Pfeiltasten verschieben.
// Koordinaten sind relativ (0–1) zum Planbereich; die Entscheidung, was gespeichert wird, trifft die Blazor-Komponente.
(() => {
const clamp = value => Math.min(1, Math.max(0, value));
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
window.fpHome = {
attach(container, reference) {
if (!container) {
return;
}
this.detach(container);
const controller = new AbortController();
const { signal } = controller;
container._fpHome = controller;
const relative = event => {
const rect = container.getBoundingClientRect();
return [clamp((event.clientX - rect.left) / rect.width), clamp((event.clientY - rect.top) / rect.height)];
};
let drag = null;
container.addEventListener('pointerdown', event => {
const pin = event.target.closest('[data-device]');
if (!pin || event.button !== 0) {
return;
}
drag = { pin, id: pin.dataset.device, moved: false, x: 0, y: 0 };
pin.setPointerCapture(event.pointerId);
}, { signal });
container.addEventListener('pointermove', event => {
if (!drag) {
return;
}
const [x, y] = relative(event);
drag.moved = true;
drag.x = x;
drag.y = y;
drag.pin.style.left = `${x * 100}%`;
drag.pin.style.top = `${y * 100}%`;
}, { signal });
const finish = () => {
if (!drag) {
return;
}
const finished = drag;
drag = null;
if (finished.moved) {
reference.invokeMethodAsync('PinMoved', finished.id, finished.x, finished.y);
}
};
container.addEventListener('pointerup', finish, { signal });
container.addEventListener('pointercancel', finish, { signal });
// Klick auf freie Fläche: ausgewähltes Gerät dort ablegen (Klicks auf Pins wählen nur aus).
container.addEventListener('click', event => {
if (event.target.closest('[data-device]')) {
return;
}
const [x, y] = relative(event);
reference.invokeMethodAsync('PlanClicked', x, y);
}, { signal });
// Pfeiltasten auf einem fokussierten Pin: verschieben (Umschalt = große Schritte).
container.addEventListener('keydown', event => {
const pin = event.target.closest('[data-device]');
const direction = arrows[event.key];
if (!pin || !direction) {
return;
}
event.preventDefault();
const step = event.shiftKey ? 0.05 : 0.01;
reference.invokeMethodAsync('PinNudged', pin.dataset.device, direction[0] * step, direction[1] * step);
}, { signal });
},
detach(container) {
if (container && container._fpHome) {
container._fpHome.abort();
delete container._fpHome;
}
},
};
})();
@@ -0,0 +1,279 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Winziger zustandsbehafteter HTTP-Server, der Shelly-Geräte vortäuscht (Gen2+ RPC und Gen1 REST). Zeichnet Pfad und Query auf,
/// kann Digest-/Basic-Anmeldung verlangen, Fehler, Verzögerung und Weiterleitungen einspielen.
/// </summary>
internal sealed partial class FakeShellyServer : IAsyncDisposable
{
public const string Realm = "shellyplus1-fake";
private readonly HttpListener _listener = new();
private readonly CancellationTokenSource _stop = new();
private readonly List<RecordedCall> _calls = [];
private FakeShellyServer(int port)
{
Port = port;
_listener.Prefixes.Add($"http://localhost:{port}/");
}
public int Port { get; }
/// <summary>Adresse, wie sie im Gerät eingetragen wird (Heimnetz-Name ohne Punkt, Loopback).</summary>
public string Host => $"localhost:{Port}";
/// <summary>Gen2: Digest-Anmeldung mit diesem Passwort (Benutzer „admin“) verlangen.</summary>
public string? DigestPassword { get; set; }
/// <summary>Gen1: Basic-Anmeldung mit diesem Passwort (Benutzer „admin“) verlangen.</summary>
public string? BasicPassword { get; set; }
/// <summary>Alle Anfragen mit diesem Status beantworten (z. B. 500).</summary>
public int? FailWith { get; set; }
public TimeSpan Delay { get; set; }
/// <summary>Jede Anfrage auf diese Adresse weiterleiten (302).</summary>
public string? RedirectTo { get; set; }
public Dictionary<int, bool> Switches { get; } = new() { [0] = false, [1] = true };
public Dictionary<int, bool> Relays { get; } = new() { [0] = false };
public string CoverState { get; set; } = "closed";
public int CoverPosition { get; set; }
public IReadOnlyList<RecordedCall> Calls
{
get
{
lock (_calls)
{
return [.. _calls];
}
}
}
public static FakeShellyServer Start()
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
var server = new FakeShellyServer(port);
server._listener.Start();
_ = server.LoopAsync();
return server;
}
private async Task LoopAsync()
{
while (!_stop.IsCancellationRequested)
{
HttpListenerContext context;
try
{
context = await _listener.GetContextAsync();
}
catch (Exception) when (_stop.IsCancellationRequested || !_listener.IsListening)
{
return;
}
_ = Task.Run(() => HandleAsync(context));
}
}
private async Task HandleAsync(HttpListenerContext context)
{
var request = context.Request;
var path = request.Url!.AbsolutePath;
var query = request.Url.Query.TrimStart('?');
var pathAndQuery = request.Url.PathAndQuery;
var authorization = request.Headers["Authorization"];
var authRequired = DigestPassword is not null || BasicPassword is not null;
var authorized = !authRequired || IsAuthorized(request.HttpMethod, pathAndQuery, authorization);
lock (_calls)
{
_calls.Add(new RecordedCall(request.HttpMethod, path, query, authorization is not null, authorized));
}
try
{
if (Delay > TimeSpan.Zero)
{
await Task.Delay(Delay, _stop.Token);
}
if (RedirectTo is not null)
{
context.Response.StatusCode = 302;
context.Response.RedirectLocation = RedirectTo;
context.Response.Close();
return;
}
if (FailWith is { } failure)
{
await WriteAsync(context, failure, "{\"message\":\"secret internal stack trace\"}");
return;
}
if (authRequired && !authorized && path != "/shelly")
{
context.Response.StatusCode = 401;
context.Response.AddHeader(
"WWW-Authenticate",
DigestPassword is not null
? $"Digest qop=\"auth\", realm=\"{Realm}\", nonce=\"1700000000\", algorithm=SHA-256"
: "Basic realm=\"shelly\"");
context.Response.Close();
return;
}
var (status, body) = Route(path, Parse(query));
await WriteAsync(context, status, body);
}
catch (Exception)
{
// Client hat aufgegeben (Timeout-Test) oder der Server wird beendet.
}
}
private (int Status, string Body) Route(string path, Dictionary<string, string> q)
{
int Id() => q.TryGetValue("id", out var v) && int.TryParse(v, CultureInfo.InvariantCulture, out var id) ? id : 0;
var inv = CultureInfo.InvariantCulture;
var power = 12.5.ToString(inv);
switch (path)
{
case "/rpc/Shelly.GetDeviceInfo":
return (200, $$"""{"name":"Fake","id":"{{Realm}}","model":"SNSW-001X16EU","gen":2,"ver":"1.4.4","app":"Plus1","auth_en":{{(DigestPassword is not null).ToString().ToLowerInvariant()}}}""");
case "/rpc/Switch.GetStatus":
return Switches.TryGetValue(Id(), out var on)
? (200, $$"""{"id":{{Id()}},"source":"init","output":{{on.ToString().ToLowerInvariant()}},"apower":{{power}}}""")
: (404, "{\"code\":404,\"message\":\"No handler\"}");
case "/rpc/Switch.Set" when Switches.ContainsKey(Id()):
{
var was = Switches[Id()];
Switches[Id()] = q.GetValueOrDefault("on") == "true";
return (200, $$"""{"was_on":{{was.ToString().ToLowerInvariant()}}}""");
}
case "/rpc/Switch.Toggle" when Switches.ContainsKey(Id()):
{
var was = Switches[Id()];
Switches[Id()] = !was;
return (200, $$"""{"was_on":{{was.ToString().ToLowerInvariant()}}}""");
}
case "/rpc/Cover.GetStatus":
return (200, $$"""{"id":0,"state":"{{CoverState}}","current_pos":{{CoverPosition}},"apower":0.0}""");
case "/rpc/Cover.Open":
CoverState = "opening";
CoverPosition = 50;
return (200, "{}");
case "/rpc/Cover.Close":
CoverState = "closing";
CoverPosition = 50;
return (200, "{}");
case "/rpc/Cover.Stop":
CoverState = "stopped";
return (200, "{}");
case "/shelly":
return (200, """{"type":"SHSW-1","mac":"AABBCC112233","auth":false,"fw":"20230913-114008/v1.14.0","num_outputs":1}""");
case "/status":
return (200, $$"""{"relays":[{"ison":{{Relays.GetValueOrDefault(0).ToString().ToLowerInvariant()}}}],"meters":[{"power":{{power}}}]}""");
}
var relay = RelayRoute().Match(path);
if (relay.Success && Relays.ContainsKey(int.Parse(relay.Groups[1].Value, inv)))
{
var channel = int.Parse(relay.Groups[1].Value, inv);
Relays[channel] = q.GetValueOrDefault("turn") switch
{
"on" => true,
"off" => false,
"toggle" => !Relays[channel],
_ => Relays[channel],
};
return (200, $$"""{"ison":{{Relays[channel].ToString().ToLowerInvariant()}}}""");
}
return (404, "{\"code\":404}");
}
private bool IsAuthorized(string method, string pathAndQuery, string? header)
{
if (header is null)
{
return false;
}
if (BasicPassword is not null && header.StartsWith("Basic ", StringComparison.Ordinal))
{
return Encoding.UTF8.GetString(Convert.FromBase64String(header["Basic ".Length..])) == $"admin:{BasicPassword}";
}
if (DigestPassword is not null && header.StartsWith("Digest ", StringComparison.Ordinal))
{
var p = DigestParameter().Matches(header["Digest ".Length..]).ToDictionary(m => m.Groups[1].Value, m => m.Groups[3].Success ? m.Groups[3].Value : m.Groups[4].Value, StringComparer.OrdinalIgnoreCase);
if (p.GetValueOrDefault("username") != "admin" || p.GetValueOrDefault("realm") != Realm || p.GetValueOrDefault("nonce") != "1700000000"
|| p.GetValueOrDefault("uri") != pathAndQuery || !string.Equals(p.GetValueOrDefault("algorithm"), "SHA-256", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var ha1 = Sha256($"admin:{Realm}:{DigestPassword}");
var ha2 = Sha256($"{method}:{pathAndQuery}");
var expected = Sha256($"{ha1}:1700000000:{p.GetValueOrDefault("nc")}:{p.GetValueOrDefault("cnonce")}:auth:{ha2}");
return expected == p.GetValueOrDefault("response");
}
return false;
}
private static string Sha256(string value) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
private static Dictionary<string, string> Parse(string query) =>
query.Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Split('=', 2))
.ToDictionary(p => p[0], p => p.Length > 1 ? WebUtility.UrlDecode(p[1]) : string.Empty);
private async Task WriteAsync(HttpListenerContext context, int status, string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
context.Response.StatusCode = status;
context.Response.ContentType = "application/json";
await context.Response.OutputStream.WriteAsync(bytes, _stop.Token);
context.Response.Close();
}
public ValueTask DisposeAsync()
{
_stop.Cancel();
_listener.Close();
return ValueTask.CompletedTask;
}
[GeneratedRegex(@"^/relay/(\d+)$")]
private static partial Regex RelayRoute();
[GeneratedRegex("""(\w+)=("([^"]*)"|([^,\s]*))""")]
private static partial Regex DigestParameter();
}
internal sealed record RecordedCall(string Method, string Path, string Query, bool HadAuthorization, bool Authorized)
{
public string PathAndQuery => Query.Length == 0 ? Path : $"{Path}?{Query}";
}
@@ -0,0 +1,790 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Home;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Haus-Modul end-to-end (ADR-0023) gegen einen Fake-Shelly: Rechte je Rolle und je Gerät (unsichtbar = 404, nur ansehen = 403), Schalten Gen1/Gen2/Rollo,
/// Anmeldung am Gerät, Fehlermeldungen, Aktionsprotokoll, Audit, SSRF-Eingaben, Pläne und Pins.
/// </summary>
public sealed class HomeTests(AppFixture fixture)
{
private const string DevicePassword = "GERAETE-PASSWORT-4711";
private static readonly byte[] Png = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static SaveHomeDeviceRequest Switch(string name, string host, int generation = 2, int channel = 0, HomeDeviceKind kind = HomeDeviceKind.Switch,
string? user = null, string? password = null, Guid? roomId = null) =>
new(name, kind, "bulb", roomId, HomeDriverType.Shelly, generation, host, channel, user, password, false);
private static async Task<HomeAdminDeviceDto> CreateDeviceAsync(HttpClient admin, SaveHomeDeviceRequest request)
{
var response = await admin.PostAsJsonAsync("/api/admin/home/devices", request, Ct);
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<HomeAdminDeviceDto>(Ct))!;
}
private static async Task ShareAsync(HttpClient admin, Guid deviceId, params HomeGrantDto[] grants)
{
var response = await admin.PutAsJsonAsync($"/api/admin/home/devices/{deviceId}/access", new SetHomeAccessRequest(grants), Ct);
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(Ct));
}
private static async Task<HomeOverviewDto> OverviewAsync(HttpClient client)
{
var response = await client.GetAsync("/api/home/overview", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<HomeOverviewDto>(Ct))!;
}
private static Task<HttpResponseMessage> CommandAsync(HttpClient client, Guid id, HomeCommand command) =>
client.PostAsJsonAsync($"/api/home/devices/{id}/command", new HomeCommandRequest(command), Ct);
private static async Task<HomeCommandResultDto> CommandOkAsync(HttpClient client, Guid id, HomeCommand command)
{
var response = await CommandAsync(client, id, command);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<HomeCommandResultDto>(Ct))!;
}
private static async Task<List<HomeActionLogDto>> LogAsync(HttpClient admin) =>
[.. (await admin.GetFromJsonAsync<PagedResult<HomeActionLogDto>>("/api/admin/home/log?pageSize=100", Ct))!.Items];
// ---------- Rechte auf den Endpunkten ----------
[Fact]
public async Task Endpoints_need_login_and_administration_is_admin_only()
{
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _f = family;
using var _c = child;
using var _g = guest;
using var anonymous = fixture.CreateBrowserClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/home/overview", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/home/states", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/admin/home/devices", Ct)).StatusCode);
foreach (var client in new[] { family, child, guest })
{
// Die Haus-Seite ist für alle erreichbar (ohne Freigabe leer) ...
var overview = await OverviewAsync(client);
Assert.False(overview.CanManage);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/home/states", Ct)).StatusCode);
// ... die Verwaltung nie.
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/home/devices", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsJsonAsync("/api/admin/home/devices", Switch("x", "192.168.1.9"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync($"/api/admin/home/devices/{Guid.NewGuid()}/test", null, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PutAsJsonAsync($"/api/admin/home/devices/{Guid.NewGuid()}/access", new SetHomeAccessRequest([]), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/home/rooms", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/home/plans", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/home/log", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.DeleteAsync($"/api/admin/home/plans/{Guid.NewGuid()}", Ct)).StatusCode);
}
}
[Fact]
public async Task Admin_sees_and_controls_everything_without_grants()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Admin-Licht", shelly.Host));
var overview = await OverviewAsync(admin);
var visible = Assert.Single(overview.Devices, d => d.Id == device.Id);
Assert.True(visible.CanControl);
Assert.True(overview.CanManage);
var result = await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn);
Assert.True(result.Success);
Assert.True(result.State.IsOn);
Assert.True(shelly.Switches[0]);
}
// ---------- Objektbezogene Rechte ----------
[Fact]
public async Task Child_without_a_share_cannot_see_state_or_switch_the_device()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Unsichtbar", shelly.Host));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
Assert.DoesNotContain(device.Id, (await OverviewAsync(child)).Devices.Select(d => d.Id));
Assert.DoesNotContain(device.Id, (await child.GetFromJsonAsync<HomeStatesDto>("/api/home/states", Ct))!.States.Select(s => s.DeviceId));
Assert.Equal(HttpStatusCode.NotFound, (await child.GetAsync($"/api/home/devices/{device.Id}/state", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await CommandAsync(child, device.Id, HomeCommand.TurnOn)).StatusCode);
Assert.Empty(shelly.Calls); // nichts davon hat das Gerät erreicht
Assert.False(shelly.Switches[0]);
}
[Fact]
public async Task View_only_share_shows_state_but_forbids_switching()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Nur ansehen", shelly.Host));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
var entry = Assert.Single((await OverviewAsync(child)).Devices, d => d.Id == device.Id);
Assert.False(entry.CanControl);
var state = (await child.GetFromJsonAsync<HomeDeviceStateDto>($"/api/home/devices/{device.Id}/state", Ct))!;
Assert.True(state.Online);
Assert.False(state.IsOn);
Assert.Equal(HttpStatusCode.Forbidden, (await CommandAsync(child, device.Id, HomeCommand.TurnOn)).StatusCode);
Assert.DoesNotContain(shelly.Calls, c => c.Path.Contains("Switch.Set", StringComparison.Ordinal));
Assert.False(shelly.Switches[0]);
}
[Fact]
public async Task Control_share_by_role_lets_each_granted_role_switch_and_nobody_else()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Familienlicht", shelly.Host));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Family, null, HomeAccessLevel.Control), new HomeGrantDto(Roles.Guest, null, HomeAccessLevel.View));
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _f = family;
using var _g = guest;
using var _c = child;
Assert.True((await CommandOkAsync(family, device.Id, HomeCommand.TurnOn)).Success);
Assert.True(shelly.Switches[0]);
Assert.Single((await OverviewAsync(guest)).Devices, d => d.Id == device.Id && !d.CanControl);
Assert.Equal(HttpStatusCode.Forbidden, (await CommandAsync(guest, device.Id, HomeCommand.TurnOff)).StatusCode);
Assert.True(shelly.Switches[0]);
Assert.Equal(HttpStatusCode.NotFound, (await CommandAsync(child, device.Id, HomeCommand.TurnOff)).StatusCode);
Assert.True(shelly.Switches[0]);
}
[Fact]
public async Task Share_for_a_single_person_applies_to_that_person_only()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Nur Lena", shelly.Host));
var (lena, lenaClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, tomClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _l = lenaClient;
using var _t = tomClient;
await ShareAsync(admin, device.Id, new HomeGrantDto(null, lena.Id, HomeAccessLevel.Control));
Assert.Single((await OverviewAsync(lenaClient)).Devices, d => d.Id == device.Id && d.CanControl);
Assert.True((await CommandOkAsync(lenaClient, device.Id, HomeCommand.Toggle)).State.IsOn);
Assert.DoesNotContain(device.Id, (await OverviewAsync(tomClient)).Devices.Select(d => d.Id));
Assert.Equal(HttpStatusCode.NotFound, (await CommandAsync(tomClient, device.Id, HomeCommand.Toggle)).StatusCode);
// Freigabe entziehen wirkt sofort.
await ShareAsync(admin, device.Id);
Assert.Equal(HttpStatusCode.NotFound, (await CommandAsync(lenaClient, device.Id, HomeCommand.Toggle)).StatusCode);
}
[Fact]
public async Task Highest_level_of_role_and_person_share_wins()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Gemischt", shelly.Host));
var (kid, kidClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _k = kidClient;
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View), new HomeGrantDto(null, kid.Id, HomeAccessLevel.Control));
Assert.True((await CommandOkAsync(kidClient, device.Id, HomeCommand.TurnOn)).Success);
}
[Fact]
public async Task Command_must_fit_the_device_kind()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var sensor = await CreateDeviceAsync(admin, Switch("Sensor", shelly.Host, kind: HomeDeviceKind.Sensor));
var sw = await CreateDeviceAsync(admin, Switch("Schalter", shelly.Host));
Assert.Equal(HttpStatusCode.BadRequest, (await CommandAsync(admin, sensor.Id, HomeCommand.TurnOn)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await CommandAsync(admin, sw.Id, HomeCommand.Open)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync($"/api/home/devices/{sw.Id}/command", new HomeCommandRequest((HomeCommand)77), Ct)).StatusCode);
Assert.Empty(shelly.Calls);
}
[Fact]
public async Task Overview_never_exposes_address_or_credentials()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Geheim", shelly.Host, user: "admin", password: DevicePassword));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.Control));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
var json = await child.GetStringAsync("/api/home/overview", Ct);
Assert.Contains("Geheim", json);
Assert.DoesNotContain(shelly.Host, json);
Assert.DoesNotContain("localhost", json);
Assert.DoesNotContain(DevicePassword, json);
}
// ---------- Geräte schalten ----------
[Fact]
public async Task Gen2_switch_turns_on_off_and_toggles_and_every_action_is_logged()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Gen2-Schalter", shelly.Host));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Family, null, HomeAccessLevel.Control));
var (mom, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _f = family;
Assert.True((await CommandOkAsync(family, device.Id, HomeCommand.TurnOn)).State.IsOn);
Assert.True(shelly.Switches[0]);
Assert.False((await CommandOkAsync(family, device.Id, HomeCommand.TurnOff)).State.IsOn);
Assert.False(shelly.Switches[0]);
Assert.True((await CommandOkAsync(family, device.Id, HomeCommand.Toggle)).State.IsOn);
Assert.Contains(shelly.Calls, c => c.PathAndQuery == "/rpc/Switch.Set?id=0&on=true");
Assert.Contains(shelly.Calls, c => c.PathAndQuery == "/rpc/Switch.Set?id=0&on=false");
Assert.Contains(shelly.Calls, c => c.PathAndQuery == "/rpc/Switch.Toggle?id=0");
var log = (await LogAsync(admin)).Where(l => l.DeviceId == device.Id).ToList();
Assert.Equal(3, log.Count);
Assert.All(log, l =>
{
Assert.True(l.Success);
Assert.Equal(mom.DisplayName, l.UserName);
Assert.Equal("Gen2-Schalter", l.DeviceName);
});
Assert.Equal([HomeCommand.Toggle, HomeCommand.TurnOff, HomeCommand.TurnOn], log.Select(l => l.Command));
}
[Fact]
public async Task Second_channel_and_gen1_relay_work()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var channel1 = await CreateDeviceAsync(admin, Switch("Kanal 1", shelly.Host, channel: 1));
var gen1 = await CreateDeviceAsync(admin, Switch("Gen1-Relais", shelly.Host, generation: 1, kind: HomeDeviceKind.Socket));
Assert.False((await CommandOkAsync(admin, channel1.Id, HomeCommand.TurnOff)).State.IsOn);
Assert.False(shelly.Switches[1]);
Assert.True((await CommandOkAsync(admin, gen1.Id, HomeCommand.TurnOn)).State.IsOn);
Assert.True(shelly.Relays[0]);
Assert.Contains(shelly.Calls, c => c.PathAndQuery == "/relay/0?turn=on");
Assert.Contains(shelly.Calls, c => c.Path == "/status");
var state = (await admin.GetFromJsonAsync<HomeDeviceStateDto>($"/api/home/devices/{gen1.Id}/state", Ct))!;
Assert.Equal(12.5, state.PowerWatts);
}
[Fact]
public async Task Cover_opens_closes_and_stops()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var cover = await CreateDeviceAsync(admin, Switch("Rollo", shelly.Host, kind: HomeDeviceKind.Cover));
Assert.Equal("opening", (await CommandOkAsync(admin, cover.Id, HomeCommand.Open)).State.CoverState);
Assert.Equal("closing", (await CommandOkAsync(admin, cover.Id, HomeCommand.Close)).State.CoverState);
var stopped = (await CommandOkAsync(admin, cover.Id, HomeCommand.Stop)).State;
Assert.Equal("stopped", stopped.CoverState);
Assert.Equal(50, stopped.CoverPosition);
Assert.Equal(HttpStatusCode.BadRequest, (await CommandAsync(admin, cover.Id, HomeCommand.TurnOn)).StatusCode);
}
[Fact]
public async Task Device_with_password_uses_digest_login_and_stores_the_password_encrypted()
{
await using var shelly = FakeShellyServer.Start();
shelly.DigestPassword = DevicePassword;
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await admin.PostAsJsonAsync("/api/admin/home/devices", Switch("Mit Passwort", shelly.Host, user: "admin", password: DevicePassword), Ct);
var raw = await created.Content.ReadAsStringAsync(Ct);
var device = System.Text.Json.JsonSerializer.Deserialize<HomeAdminDeviceDto>(raw, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
Assert.True(device.HasPassword);
Assert.DoesNotContain(DevicePassword, raw);
Assert.DoesNotContain(DevicePassword, await admin.GetStringAsync("/api/admin/home/devices", Ct));
var result = await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn);
Assert.True(result.Success, result.Message);
Assert.True(shelly.Switches[0]);
Assert.Contains(shelly.Calls, c => c.Authorized && c.Path == "/rpc/Switch.Set");
Assert.Contains(shelly.Calls, c => !c.HadAuthorization); // erst die Herausforderung, dann die Anmeldung
// Passwort bei späterer Änderung beibehalten (Password = null).
var update = Switch("Mit Passwort 2", shelly.Host, user: "admin") with { Version = device.Version };
var updated = await admin.PutAsJsonAsync($"/api/admin/home/devices/{device.Id}", update, Ct);
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
Assert.True((await CommandOkAsync(admin, device.Id, HomeCommand.TurnOff)).Success);
// Falsches Passwort: freundliche Meldung ohne Passwort, protokolliert als Fehlschlag.
shelly.DigestPassword = "anderes";
var failed = await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn);
Assert.False(failed.Success);
Assert.Contains("Anmeldung", failed.Message);
Assert.False(failed.State.Online);
Assert.DoesNotContain(DevicePassword, failed.Message);
var failure = (await LogAsync(admin)).First(l => l.DeviceId == device.Id);
Assert.False(failure.Success);
Assert.Contains("Anmeldung", failure.Error);
}
[Fact]
public async Task Gen1_device_with_password_uses_basic_login()
{
await using var shelly = FakeShellyServer.Start();
shelly.BasicPassword = DevicePassword;
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Gen1 mit Passwort", shelly.Host, generation: 1, user: "admin", password: DevicePassword));
Assert.True((await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn)).Success);
Assert.True(shelly.Relays[0]);
}
[Fact]
public async Task Unreachable_device_gives_a_friendly_message_and_is_logged()
{
int closedPort;
await using (var gone = FakeShellyServer.Start())
{
closedPort = gone.Port;
}
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Weg", $"localhost:{closedPort}"));
var result = await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn);
Assert.False(result.Success);
// Je nach Betriebssystem „Verbindung abgelehnt“ (nicht erreichbar) oder erst das Verbindungs-Zeitlimit.
Assert.Matches("nicht erreichbar|nicht rechtzeitig", result.Message);
Assert.False(result.State.Online);
var states = (await admin.GetFromJsonAsync<HomeStatesDto>("/api/home/states", Ct))!;
var state = Assert.Single(states.States, s => s.DeviceId == device.Id);
Assert.False(state.Online);
Assert.False(string.IsNullOrWhiteSpace(state.Message));
var entry = (await LogAsync(admin)).First(l => l.DeviceId == device.Id);
Assert.False(entry.Success);
Assert.Equal(HomeCommand.TurnOn, entry.Command);
}
[Fact]
public async Task Device_errors_never_leak_device_text_and_one_failing_device_does_not_break_the_states()
{
await using var broken = FakeShellyServer.Start();
await using var healthy = FakeShellyServer.Start();
broken.FailWith = 500;
using var admin = await fixture.CreateAdminClientAsync(Ct);
var bad = await CreateDeviceAsync(admin, Switch("Kaputt", broken.Host));
var good = await CreateDeviceAsync(admin, Switch("Heil", healthy.Host));
var json = await admin.GetStringAsync("/api/home/states", Ct);
var states = System.Text.Json.JsonSerializer.Deserialize<HomeStatesDto>(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
Assert.False(states.States.Single(s => s.DeviceId == bad.Id).Online);
Assert.True(states.States.Single(s => s.DeviceId == good.Id).Online);
Assert.DoesNotContain("secret internal stack trace", json);
}
[Fact]
public async Task Redirects_from_a_device_are_not_followed()
{
await using var target = FakeShellyServer.Start();
await using var shelly = FakeShellyServer.Start();
shelly.RedirectTo = $"http://localhost:{target.Port}/rpc/Switch.Set?id=0&on=true";
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Umleitung", shelly.Host));
var result = await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn);
Assert.False(result.Success);
Assert.Empty(target.Calls);
Assert.False(target.Switches[0]);
}
[Fact]
public async Task Slow_device_times_out_with_a_message()
{
await using var shelly = FakeShellyServer.Start();
shelly.Delay = TimeSpan.FromSeconds(7);
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Langsam", shelly.Host));
var result = await CommandOkAsync(admin, device.Id, HomeCommand.TurnOn);
Assert.False(result.Success);
Assert.Contains("nicht rechtzeitig", result.Message);
}
// ---------- Verwaltung: Geräte ----------
[Fact]
public async Task Device_input_is_validated_including_ssrf_addresses()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
async Task<HttpStatusCode> PostAsync(SaveHomeDeviceRequest request) =>
(await admin.PostAsJsonAsync("/api/admin/home/devices", request, Ct)).StatusCode;
var good = Switch("Gültig", "192.168.1.50");
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Name = " " }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "8.8.8.8" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "example.com" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "169.254.169.254" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "http://[fe80::1]" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "ftp://192.168.1.5" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "http://user:pw@192.168.1.5" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Host = "http://192.168.1.5/admin?x=1" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Generation = 7 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Channel = 99 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Kind = (HomeDeviceKind)40 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Icon = "<script>" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { RoomId = Guid.NewGuid() }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Password = "x" })); // Passwort ohne Benutzername
Assert.Equal(HttpStatusCode.Created, await PostAsync(good));
var created = await CreateDeviceAsync(admin, good with { Name = "Normalisiert", Host = " 192.168.1.60:8080 " });
Assert.Equal("http://192.168.1.60:8080", created.Host);
}
[Fact]
public async Task Device_update_checks_the_version_and_delete_hides_it_everywhere()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Alt", shelly.Host));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Family, null, HomeAccessLevel.Control));
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _f = family;
var first = await admin.PutAsJsonAsync($"/api/admin/home/devices/{device.Id}", Switch("Neu", shelly.Host) with { Version = device.Version }, Ct);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var stale = await admin.PutAsJsonAsync($"/api/admin/home/devices/{device.Id}", Switch("Veraltet", shelly.Host) with { Version = device.Version }, Ct);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await admin.PutAsJsonAsync($"/api/admin/home/devices/{Guid.NewGuid()}", Switch("x", shelly.Host), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/devices/{device.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/devices/{device.Id}", Ct)).StatusCode); // idempotent
Assert.DoesNotContain(device.Id, (await OverviewAsync(admin)).Devices.Select(d => d.Id));
Assert.DoesNotContain(device.Id, (await OverviewAsync(family)).Devices.Select(d => d.Id));
Assert.Equal(HttpStatusCode.NotFound, (await CommandAsync(family, device.Id, HomeCommand.TurnOn)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await CommandAsync(admin, device.Id, HomeCommand.TurnOn)).StatusCode);
Assert.DoesNotContain(device.Id, (await admin.GetFromJsonAsync<List<HomeAdminDeviceDto>>("/api/admin/home/devices", Ct))!.Select(d => d.Id));
}
[Fact]
public async Task Connection_test_reports_model_firmware_and_problems()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Test", shelly.Host));
var ok = (await (await admin.PostAsync($"/api/admin/home/devices/{device.Id}/test", null, Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct))!;
Assert.True(ok.Success);
Assert.Equal("SNSW-001X16EU", ok.Model);
Assert.Equal("1.4.4", ok.Firmware);
// Entwurf testen, ohne zu speichern: falsche Generation.
var draft = new TestHomeDeviceRequest(null, HomeDeviceKind.Switch, HomeDriverType.Shelly, 1, shelly.Host, 0, null, null);
var gen1OnGen2 = (await (await admin.PostAsJsonAsync("/api/admin/home/devices/test", draft, Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct))!;
Assert.True(gen1OnGen2.Success); // der Fake antwortet auf /shelly ohne gen – als Gen1 erkannt
var wrongChannel = draft with { Generation = 2, Channel = 9 };
var failed = (await (await admin.PostAsJsonAsync("/api/admin/home/devices/test", wrongChannel, Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct))!;
Assert.False(failed.Success);
Assert.False(string.IsNullOrWhiteSpace(failed.Message));
// Gespeichertes Passwort wird beim Test des Entwurfs mit DeviceId verwendet.
shelly.DigestPassword = DevicePassword;
var secured = await CreateDeviceAsync(admin, Switch("Gesichert", shelly.Host, user: "admin", password: DevicePassword));
var viaStored = draft with { DeviceId = secured.Id, Generation = 2, AuthUser = "admin" };
Assert.True((await (await admin.PostAsJsonAsync("/api/admin/home/devices/test", viaStored, Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct))!.Success);
var withoutPassword = draft with { Generation = 2, AuthUser = "admin" };
var denied = (await (await admin.PostAsJsonAsync("/api/admin/home/devices/test", withoutPassword, Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct))!;
Assert.False(denied.Success);
Assert.Contains("Anmeldung", denied.Message);
Assert.Equal(HttpStatusCode.NotFound, (await admin.PostAsync($"/api/admin/home/devices/{Guid.NewGuid()}/test", null, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/admin/home/devices/test", draft with { Host = "8.8.8.8" }, Ct)).StatusCode);
}
[Fact]
public async Task Access_validation_rejects_admin_role_unknown_people_and_duplicates()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Freigaben", "192.168.1.70"));
async Task<HttpStatusCode> PutAsync(params HomeGrantDto[] grants) =>
(await admin.PutAsJsonAsync($"/api/admin/home/devices/{device.Id}/access", new SetHomeAccessRequest(grants), Ct)).StatusCode;
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomeGrantDto(Roles.Admin, null, HomeAccessLevel.View)));
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomeGrantDto("Hacker", null, HomeAccessLevel.View)));
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomeGrantDto(null, Guid.NewGuid(), HomeAccessLevel.View))); // unbekannte Person
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View), new HomeGrantDto(Roles.Child, null, HomeAccessLevel.Control)));
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomeGrantDto(Roles.Child, null, HomeAccessLevel.None)));
Assert.Equal(HttpStatusCode.OK, await PutAsync(new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View)));
Assert.Equal(HttpStatusCode.NotFound, (await admin.PutAsJsonAsync($"/api/admin/home/devices/{Guid.NewGuid()}/access", new SetHomeAccessRequest([]), Ct)).StatusCode);
var stored = (await admin.GetFromJsonAsync<List<HomeAdminDeviceDto>>("/api/admin/home/devices", Ct))!.Single(d => d.Id == device.Id);
Assert.Equal([new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View)], stored.Grants);
}
// ---------- Räume ----------
[Fact]
public async Task Rooms_group_devices_and_deleting_a_room_unassigns_them()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var name = $"Küche {Guid.NewGuid():N}"[..14];
var created = await admin.PostAsJsonAsync("/api/admin/home/rooms", new SaveHomeRoomRequest(name, 5), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var room = (await created.Content.ReadFromJsonAsync<HomeAdminRoomDto>(Ct))!;
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/admin/home/rooms", new SaveHomeRoomRequest(name), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/admin/home/rooms", new SaveHomeRoomRequest(" "), Ct)).StatusCode);
var device = await CreateDeviceAsync(admin, Switch("Im Raum", "192.168.1.80", roomId: room.Id));
var rooms = (await admin.GetFromJsonAsync<List<HomeAdminRoomDto>>("/api/admin/home/rooms", Ct))!;
Assert.Equal(1, rooms.Single(r => r.Id == room.Id).DeviceCount);
Assert.Contains(room.Id, (await OverviewAsync(admin)).Rooms.Select(r => r.Id));
var renamed = await admin.PutAsJsonAsync($"/api/admin/home/rooms/{room.Id}", new SaveHomeRoomRequest(name + "!", 1), Ct);
Assert.Equal(HttpStatusCode.OK, renamed.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/rooms/{room.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/rooms/{room.Id}", Ct)).StatusCode);
var after = (await admin.GetFromJsonAsync<List<HomeAdminDeviceDto>>("/api/admin/home/devices", Ct))!.Single(d => d.Id == device.Id);
Assert.Null(after.RoomId);
}
[Fact]
public async Task Rooms_without_visible_devices_are_hidden_from_non_admins()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var room = (await (await admin.PostAsJsonAsync("/api/admin/home/rooms", new SaveHomeRoomRequest($"Privat {Guid.NewGuid():N}"[..14]), Ct)).Content.ReadFromJsonAsync<HomeAdminRoomDto>(Ct))!;
var device = await CreateDeviceAsync(admin, Switch("Privat-Gerät", "192.168.1.81", roomId: room.Id));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
Assert.DoesNotContain(room.Id, (await OverviewAsync(child)).Rooms.Select(r => r.Id));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
Assert.Contains(room.Id, (await OverviewAsync(child)).Rooms.Select(r => r.Id));
}
// ---------- Pläne und Pins ----------
private static MultipartFormDataContent PlanForm(byte[] data, string name = "Erdgeschoss", string fileName = "plan.png", string contentType = "image/png")
{
var content = new MultipartFormDataContent { { new StringContent(name), "name" } };
var file = new ByteArrayContent(data);
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(file, "file", fileName);
return content;
}
private static async Task<HomeAdminPlanDto> CreatePlanAsync(HttpClient admin, byte[]? data = null, string name = "Erdgeschoss")
{
var response = await admin.PostAsync("/api/admin/home/plans", PlanForm(data ?? Png, name), Ct);
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<HomeAdminPlanDto>(Ct))!;
}
[Fact]
public async Task Plan_upload_checks_signature_size_and_name_and_stores_the_detected_type()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var svg = "<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>"u8.ToArray();
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsync("/api/admin/home/plans", PlanForm(svg, fileName: "plan.png"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsync("/api/admin/home/plans", PlanForm("GIF89a......"u8.ToArray(), contentType: "image/png"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsync("/api/admin/home/plans", PlanForm([], name: "Leer"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsync("/api/admin/home/plans", PlanForm(Png, name: " "), Ct)).StatusCode);
var big = new byte[HomeRules.MaxPlanImageBytes + 1];
Png.CopyTo(big, 0);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsync("/api/admin/home/plans", PlanForm(big), Ct)).StatusCode);
// Das Bild ist ein PNG, auch wenn der Client „image/jpeg“ behauptet.
var response = await admin.PostAsync("/api/admin/home/plans", PlanForm(Png, contentType: "image/jpeg"), Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var plan = (await response.Content.ReadFromJsonAsync<HomeAdminPlanDto>(Ct))!;
var image = await admin.GetAsync(HomeApiRoutes.PlanImage(plan.Id, plan.ImageVersion), Ct);
Assert.Equal(HttpStatusCode.OK, image.StatusCode);
Assert.Equal("image/png", image.Content.Headers.ContentType!.MediaType);
Assert.Equal(Png, await image.Content.ReadAsByteArrayAsync(Ct));
Assert.Equal("nosniff", image.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Contains("private", image.Headers.CacheControl!.ToString());
}
[Fact]
public async Task Plan_image_and_pins_are_only_shown_for_devices_the_person_may_see()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var plan = await CreatePlanAsync(admin, name: "Obergeschoss");
var shared = await CreateDeviceAsync(admin, Switch("Auf dem Plan (frei)", "192.168.1.90"));
var hidden = await CreateDeviceAsync(admin, Switch("Auf dem Plan (geheim)", "192.168.1.91"));
await ShareAsync(admin, shared.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.Control));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _c = child;
using var _g = guest;
var imageUrl = HomeApiRoutes.PlanImage(plan.Id, plan.ImageVersion);
// Ohne Pins: Plan nur für Admins.
Assert.DoesNotContain(plan.Id, (await OverviewAsync(child)).Plans.Select(p => p.Id));
Assert.Equal(HttpStatusCode.NotFound, (await child.GetAsync(imageUrl, Ct)).StatusCode);
var placed = await admin.PutAsJsonAsync($"/api/admin/home/plans/{plan.Id}/placements",
new SaveHomePlacementsRequest([new HomePinDto(shared.Id, 0.25, 0.5), new HomePinDto(hidden.Id, 0.75, 0.5)]), Ct);
Assert.Equal(HttpStatusCode.OK, placed.StatusCode);
var childPlan = Assert.Single((await OverviewAsync(child)).Plans, p => p.Id == plan.Id);
var pin = Assert.Single(childPlan.Pins);
Assert.Equal(shared.Id, pin.DeviceId);
Assert.Equal(0.25, pin.X);
Assert.Equal(HttpStatusCode.OK, (await child.GetAsync(imageUrl, Ct)).StatusCode);
// Gast ohne Freigabe: weder Plan noch Bild.
Assert.DoesNotContain(plan.Id, (await OverviewAsync(guest)).Plans.Select(p => p.Id));
Assert.Equal(HttpStatusCode.NotFound, (await guest.GetAsync(imageUrl, Ct)).StatusCode);
// Admin sieht beide Pins.
Assert.Equal(2, Assert.Single((await OverviewAsync(admin)).Plans, p => p.Id == plan.Id).Pins.Count);
// Anonym: 401.
using var anonymous = fixture.CreateBrowserClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(imageUrl, Ct)).StatusCode);
}
[Fact]
public async Task Placements_are_validated_and_replace_the_previous_ones()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var plan = await CreatePlanAsync(admin);
var device = await CreateDeviceAsync(admin, Switch("Pin-Gerät", "192.168.1.92"));
async Task<HttpStatusCode> PutAsync(params HomePinDto[] pins) =>
(await admin.PutAsJsonAsync($"/api/admin/home/plans/{plan.Id}/placements", new SaveHomePlacementsRequest(pins), Ct)).StatusCode;
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomePinDto(device.Id, 1.5, 0.5)));
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomePinDto(device.Id, 0.5, -0.1)));
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomePinDto(Guid.NewGuid(), 0.5, 0.5))); // unbekanntes Gerät
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(new HomePinDto(device.Id, 0.1, 0.1), new HomePinDto(device.Id, 0.2, 0.2)));
Assert.Equal(HttpStatusCode.OK, await PutAsync(new HomePinDto(device.Id, 0.123456, 0.9)));
Assert.Equal(HttpStatusCode.NotFound, (await admin.PutAsJsonAsync($"/api/admin/home/plans/{Guid.NewGuid()}/placements", new SaveHomePlacementsRequest([]), Ct)).StatusCode);
var stored = (await admin.GetFromJsonAsync<List<HomeAdminPlanDto>>("/api/admin/home/plans", Ct))!.Single(p => p.Id == plan.Id);
Assert.Equal(0.1235, Assert.Single(stored.Pins).X);
Assert.Equal(HttpStatusCode.OK, await PutAsync());
Assert.Empty((await admin.GetFromJsonAsync<List<HomeAdminPlanDto>>("/api/admin/home/plans", Ct))!.Single(p => p.Id == plan.Id).Pins);
}
[Fact]
public async Task Replacing_the_plan_image_bumps_the_version_and_deleting_removes_everything()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var plan = await CreatePlanAsync(admin);
var device = await CreateDeviceAsync(admin, Switch("Mit Pin", "192.168.1.93"));
await admin.PutAsJsonAsync($"/api/admin/home/plans/{plan.Id}/placements", new SaveHomePlacementsRequest([new HomePinDto(device.Id, 0.5, 0.5)]), Ct);
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 16, 74, 70, 73, 70, 0];
var replaced = await admin.PutAsync($"/api/admin/home/plans/{plan.Id}/image", PlanForm(jpeg, contentType: "image/png"), Ct);
Assert.Equal(HttpStatusCode.OK, replaced.StatusCode);
var updated = (await replaced.Content.ReadFromJsonAsync<HomeAdminPlanDto>(Ct))!;
Assert.Equal(plan.ImageVersion + 1, updated.ImageVersion);
Assert.Single(updated.Pins);
var image = await admin.GetAsync(HomeApiRoutes.PlanImage(plan.Id, updated.ImageVersion), Ct);
Assert.Equal("image/jpeg", image.Content.Headers.ContentType!.MediaType);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PutAsync($"/api/admin/home/plans/{plan.Id}/image", PlanForm("not an image"u8.ToArray()), Ct)).StatusCode);
var renamed = await admin.PutAsJsonAsync($"/api/admin/home/plans/{plan.Id}", new SaveHomePlanRequest("Dachgeschoss", 3), Ct);
Assert.Equal("Dachgeschoss", (await renamed.Content.ReadFromJsonAsync<HomeAdminPlanDto>(Ct))!.Name);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/plans/{plan.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/plans/{plan.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await admin.GetAsync(HomeApiRoutes.PlanImage(plan.Id, 1), Ct)).StatusCode);
Assert.DoesNotContain(plan.Id, (await OverviewAsync(admin)).Plans.Select(p => p.Id));
}
[Fact]
public async Task Deleting_a_device_removes_its_pins_and_shares()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var plan = await CreatePlanAsync(admin);
var device = await CreateDeviceAsync(admin, Switch("Wird gelöscht", "192.168.1.94"));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
await admin.PutAsJsonAsync($"/api/admin/home/plans/{plan.Id}/placements", new SaveHomePlacementsRequest([new HomePinDto(device.Id, 0.3, 0.3)]), Ct);
await admin.DeleteAsync($"/api/admin/home/devices/{device.Id}", Ct);
Assert.Empty((await admin.GetFromJsonAsync<List<HomeAdminPlanDto>>("/api/admin/home/plans", Ct))!.Single(p => p.Id == plan.Id).Pins);
}
// ---------- Protokolle ----------
[Fact]
public async Task Administration_is_audited_without_secrets()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Audit-Gerät", "192.168.1.95", user: "admin", password: DevicePassword));
await ShareAsync(admin, device.Id, new HomeGrantDto(Roles.Family, null, HomeAccessLevel.Control));
var plan = await CreatePlanAsync(admin, name: "Audit-Plan");
await admin.PutAsJsonAsync($"/api/admin/home/plans/{plan.Id}/placements", new SaveHomePlacementsRequest([new HomePinDto(device.Id, 0.5, 0.5)]), Ct);
await admin.PutAsJsonAsync($"/api/admin/home/devices/{device.Id}", Switch("Audit-Gerät 2", "192.168.1.95", user: "admin") with { Version = null }, Ct);
await admin.DeleteAsync($"/api/admin/home/devices/{device.Id}", Ct);
await admin.DeleteAsync($"/api/admin/home/plans/{plan.Id}", Ct);
var audit = await admin.GetStringAsync("/api/admin/audit?pageSize=100", Ct);
foreach (var action in new[] { "home.device_created", "home.device_updated", "home.device_deleted", "home.access_changed", "home.plan_created", "home.placements_changed", "home.plan_deleted" })
{
Assert.Contains(action, audit);
}
Assert.DoesNotContain(DevicePassword, audit);
}
[Fact]
public async Task Action_log_is_paged_newest_first_and_admin_only()
{
await using var shelly = FakeShellyServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var device = await CreateDeviceAsync(admin, Switch("Protokoll", shelly.Host));
for (var i = 0; i < 3; i++)
{
await CommandOkAsync(admin, device.Id, i % 2 == 0 ? HomeCommand.TurnOn : HomeCommand.TurnOff);
}
var page = (await admin.GetFromJsonAsync<PagedResult<HomeActionLogDto>>("/api/admin/home/log?page=1&pageSize=2", Ct))!;
Assert.Equal(2, page.Items.Count);
Assert.True(page.TotalCount >= 3);
Assert.True(page.Items[0].At >= page.Items[1].At);
Assert.Equal(HomeCommand.TurnOn, page.Items[0].Command);
Assert.DoesNotContain(shelly.Host, await admin.GetStringAsync("/api/admin/home/log", Ct));
}
}
@@ -32,6 +32,8 @@ public sealed class PermissionMatrixTests
{ Permission.AiUse, F, F, N, N }, // KI nur für Eltern (ADR-0020)
{ Permission.LearningManage, F, F, N, N }, // Übungsblätter verwalten und prüfen (ADR-0021)
{ Permission.LearningSolve, F, F, R, N }, // Kind: nur eigene Blätter lösen (ADR-0021)
{ Permission.HomeUse, F, R, R, R }, // Haus: je Gerät freigegeben (ADR-0023)
{ Permission.HomeManage, F, N, N, N }, // Hausverwaltung nur Admin
{ Permission.UserManagement, F, N, N, N },
{ Permission.CalendarManagement, F, N, N, N },
{ Permission.SettingsManagement, F, N, N, N },
@@ -0,0 +1,297 @@
using System.Net;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared.Home;
namespace FamilyPlanner.UnitTests.Home;
/// <summary>Reine Regeln des Haus-Moduls (ADR-0023): Adressen/SSRF, Befehle, Freigaben, Positionen, Bilder, Validatoren.</summary>
public sealed class HomeRulesTests
{
[Theory]
[InlineData("192.168.1.50", "http://192.168.1.50")]
[InlineData(" 192.168.1.50:8080 ", "http://192.168.1.50:8080")]
[InlineData("http://10.0.0.5/", "http://10.0.0.5")]
[InlineData("shelly1.local", "http://shelly1.local")]
[InlineData("SHELLY.lan", "http://shelly.lan")]
[InlineData("shelly", "http://shelly")]
[InlineData("localhost:1234", "http://localhost:1234")]
[InlineData("https://192.168.0.7", "https://192.168.0.7")]
[InlineData("[fd00::1]", "http://[fd00::1]")]
[InlineData("100.64.1.2", "http://100.64.1.2")]
public void Home_network_hosts_are_accepted_and_normalized(string input, string expected)
{
var (host, error) = HomeRules.NormalizeHost(input);
Assert.Null(error);
Assert.Equal(expected, host);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
[InlineData("ftp://192.168.1.5")]
[InlineData("file:///etc/passwd")]
[InlineData("http://user:pw@192.168.1.5")]
[InlineData("http://192.168.1.5/admin")]
[InlineData("http://192.168.1.5/?x=1")]
[InlineData("http://192.168.1.5/#a")]
[InlineData("169.254.169.254")]
[InlineData("http://[fe80::1]")]
[InlineData("0.0.0.0")]
[InlineData("8.8.8.8")]
[InlineData("example.com")]
[InlineData("http://172.32.0.1")]
[InlineData("http://[2001:db8::1]")]
[InlineData("224.0.0.1")]
public void Foreign_blocked_or_malformed_hosts_are_rejected_with_a_message(string? input)
{
var (host, error) = HomeRules.NormalizeHost(input);
Assert.Null(host);
Assert.False(string.IsNullOrWhiteSpace(error));
}
[Fact]
public void Overlong_host_is_rejected()
{
Assert.NotNull(HomeRules.NormalizeHost(new string('a', 300)).Error);
}
[Theory]
[InlineData("127.0.0.1", true)]
[InlineData("::1", true)]
[InlineData("10.1.2.3", true)]
[InlineData("172.16.0.1", true)]
[InlineData("172.31.255.255", true)]
[InlineData("192.168.178.20", true)]
[InlineData("fd12:3456::1", true)]
[InlineData("::ffff:192.168.1.5", true)]
[InlineData("169.254.169.254", false)]
[InlineData("::ffff:169.254.169.254", false)]
[InlineData("fe80::1", false)]
[InlineData("0.0.0.0", false)]
[InlineData("8.8.8.8", false)]
[InlineData("172.32.0.1", false)]
[InlineData("255.255.255.255", false)]
[InlineData("2001:db8::1", false)]
public void Resolved_addresses_must_be_private_and_not_link_local(string address, bool allowed)
{
Assert.Equal(allowed, NetworkRules.IsAllowedHomeAddress(IPAddress.Parse(address)));
}
[Theory]
[InlineData(HomeDeviceKind.Switch, HomeCommand.TurnOn, true)]
[InlineData(HomeDeviceKind.Switch, HomeCommand.Toggle, true)]
[InlineData(HomeDeviceKind.Switch, HomeCommand.Open, false)]
[InlineData(HomeDeviceKind.Socket, HomeCommand.TurnOff, true)]
[InlineData(HomeDeviceKind.Light, HomeCommand.TurnOn, true)]
[InlineData(HomeDeviceKind.Light, HomeCommand.Stop, false)]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Open, true)]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Close, true)]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Stop, true)]
[InlineData(HomeDeviceKind.Cover, HomeCommand.TurnOn, false)]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Toggle, false)]
[InlineData(HomeDeviceKind.Sensor, HomeCommand.TurnOn, false)]
[InlineData(HomeDeviceKind.Sensor, HomeCommand.Toggle, false)]
[InlineData(HomeDeviceKind.Sensor, HomeCommand.Open, false)]
public void Commands_must_fit_the_device_kind(HomeDeviceKind kind, HomeCommand command, bool allowed)
{
Assert.Equal(allowed, HomeRules.IsCommandAllowed(kind, command));
}
private static readonly Guid Me = Guid.NewGuid();
[Fact]
public void Admin_always_controls_without_any_grant()
{
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Admin], []));
}
[Theory]
[InlineData(Roles.Family)]
[InlineData(Roles.Child)]
[InlineData(Roles.Guest)]
public void Without_a_grant_nobody_but_admin_has_access(string role)
{
Assert.Equal(HomeAccessLevel.None, HomeRules.ResolveAccess(Me, [role], []));
}
[Fact]
public void Role_grant_applies_to_that_role_only()
{
var grants = new[] { new HomeGrant(Roles.Family, null, HomeAccessLevel.Control), new HomeGrant(Roles.Guest, null, HomeAccessLevel.View) };
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Family], grants));
Assert.Equal(HomeAccessLevel.View, HomeRules.ResolveAccess(Me, [Roles.Guest], grants));
Assert.Equal(HomeAccessLevel.None, HomeRules.ResolveAccess(Me, [Roles.Child], grants));
}
[Fact]
public void Person_grant_applies_to_that_person_only()
{
var grants = new[] { new HomeGrant(null, Me, HomeAccessLevel.Control) };
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Child], grants));
Assert.Equal(HomeAccessLevel.None, HomeRules.ResolveAccess(Guid.NewGuid(), [Roles.Child], grants));
}
[Fact]
public void Highest_level_of_role_and_person_grants_wins()
{
var grants = new[] { new HomeGrant(Roles.Child, null, HomeAccessLevel.View), new HomeGrant(null, Me, HomeAccessLevel.Control) };
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Child], grants));
Assert.Equal(HomeAccessLevel.View, HomeRules.ResolveAccess(Guid.NewGuid(), [Roles.Child], grants));
}
[Theory]
[InlineData(Roles.Family, null, HomeAccessLevel.View, true)]
[InlineData(Roles.Child, null, HomeAccessLevel.Control, true)]
[InlineData(Roles.Guest, null, HomeAccessLevel.View, true)]
[InlineData(Roles.Admin, null, HomeAccessLevel.Control, false)]
[InlineData("Hacker", null, HomeAccessLevel.View, false)]
[InlineData(Roles.Child, null, HomeAccessLevel.None, false)]
[InlineData(null, null, HomeAccessLevel.View, false)]
public void Grant_validation_checks_role_and_level(string? role, Guid? user, HomeAccessLevel level, bool valid)
{
Assert.Equal(valid, HomeRules.ValidateGrant(role, user, level) is null);
}
[Fact]
public void Grant_is_for_a_role_or_a_person_never_both()
{
Assert.Null(HomeRules.ValidateGrant(null, Guid.NewGuid(), HomeAccessLevel.View));
Assert.NotNull(HomeRules.ValidateGrant(Roles.Child, Guid.NewGuid(), HomeAccessLevel.View));
}
[Theory]
[InlineData(0.0, true)]
[InlineData(1.0, true)]
[InlineData(0.5, true)]
[InlineData(-0.001, false)]
[InlineData(1.001, false)]
[InlineData(double.NaN, false)]
[InlineData(double.PositiveInfinity, false)]
public void Placement_coordinates_are_relative(double value, bool valid)
{
Assert.Equal(valid, HomeRules.IsValidCoordinate(value));
}
[Fact]
public void Coordinates_are_rounded_and_clamped()
{
Assert.Equal(0.1235, HomeRules.RoundCoordinate(0.123456));
Assert.Equal(1, HomeRules.RoundCoordinate(1.5));
Assert.Equal(0, HomeRules.RoundCoordinate(-3));
}
[Theory]
[InlineData(1, true)]
[InlineData(2, true)]
[InlineData(0, false)]
[InlineData(3, false)]
public void Shelly_generation_is_one_or_two(int generation, bool valid)
{
Assert.Equal(valid, HomeRules.IsValidGeneration(HomeDriverType.Shelly, generation));
}
[Fact]
public void Plan_image_type_comes_from_the_signature()
{
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0];
var webp = "RIFF\0\0\0\0WEBPVP8 "u8.ToArray();
Assert.Equal("image/png", HomeRules.ValidatePlanImage(png).ContentType);
Assert.Equal("image/jpeg", HomeRules.ValidatePlanImage(jpeg).ContentType);
Assert.Equal("image/webp", HomeRules.ValidatePlanImage(webp).ContentType);
Assert.Null(HomeRules.ValidatePlanImage("<svg xmlns='http://www.w3.org/2000/svg'/>"u8).ContentType);
Assert.Null(HomeRules.ValidatePlanImage("GIF89a......"u8).ContentType);
Assert.NotNull(HomeRules.ValidatePlanImage([]).Error);
}
[Fact]
public void Plan_image_size_is_limited()
{
var big = new byte[HomeRules.MaxPlanImageBytes + 1];
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }.CopyTo(big, 0);
Assert.Contains("zu groß", HomeRules.ValidatePlanImage(big).Error);
}
// ---------- Validatoren ----------
private static SaveHomeDeviceRequest Device() =>
new("Wohnzimmer-Licht", HomeDeviceKind.Switch, "bulb", null, HomeDriverType.Shelly, 2, "192.168.1.50", 0, null, null, false);
[Fact]
public void Valid_device_request_passes()
{
Assert.True(new SaveHomeDeviceRequestValidator().Validate(Device()).IsValid);
}
[Fact]
public void Device_request_is_validated_field_by_field()
{
var validator = new SaveHomeDeviceRequestValidator();
Assert.False(validator.Validate(Device() with { Name = " " }).IsValid);
Assert.False(validator.Validate(Device() with { Name = new string('x', 81) }).IsValid);
Assert.False(validator.Validate(Device() with { Host = "8.8.8.8" }).IsValid);
Assert.False(validator.Validate(Device() with { Host = "169.254.169.254" }).IsValid);
Assert.False(validator.Validate(Device() with { Generation = 3 }).IsValid);
Assert.False(validator.Validate(Device() with { Channel = -1 }).IsValid);
Assert.False(validator.Validate(Device() with { Channel = 16 }).IsValid);
Assert.False(validator.Validate(Device() with { Kind = (HomeDeviceKind)99 }).IsValid);
Assert.False(validator.Validate(Device() with { Icon = "rocket" }).IsValid);
Assert.False(validator.Validate(Device() with { Password = new string('p', 101) }).IsValid);
Assert.False(validator.Validate(Device() with { Password = "x", ClearPassword = true }).IsValid);
}
[Fact]
public void Access_request_rejects_admin_role_duplicates_and_mixed_grants()
{
var validator = new SetHomeAccessRequestValidator();
var person = Guid.NewGuid();
Assert.True(validator.Validate(new SetHomeAccessRequest([])).IsValid);
Assert.True(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, null, HomeAccessLevel.View), new(null, person, HomeAccessLevel.Control)])).IsValid);
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Admin, null, HomeAccessLevel.View)])).IsValid);
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, null, HomeAccessLevel.View), new(Roles.Child, null, HomeAccessLevel.Control)])).IsValid);
Assert.False(validator.Validate(new SetHomeAccessRequest([new(null, person, HomeAccessLevel.View), new(null, person, HomeAccessLevel.Control)])).IsValid);
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, person, HomeAccessLevel.View)])).IsValid);
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, null, HomeAccessLevel.None)])).IsValid);
}
[Fact]
public void Placements_request_keeps_pins_on_the_plan_and_unique()
{
var validator = new SaveHomePlacementsRequestValidator();
var device = Guid.NewGuid();
Assert.True(validator.Validate(new SaveHomePlacementsRequest([new(device, 0.2, 0.8)])).IsValid);
Assert.False(validator.Validate(new SaveHomePlacementsRequest([new(device, 1.2, 0.8)])).IsValid);
Assert.False(validator.Validate(new SaveHomePlacementsRequest([new(device, 0.2, -0.1)])).IsValid);
Assert.False(validator.Validate(new SaveHomePlacementsRequest([new(device, 0.2, 0.2), new(device, 0.3, 0.3)])).IsValid);
}
[Fact]
public void Room_and_plan_names_are_required()
{
Assert.False(new SaveHomeRoomRequestValidator().Validate(new SaveHomeRoomRequest(" ")).IsValid);
Assert.False(new SaveHomeRoomRequestValidator().Validate(new SaveHomeRoomRequest(new string('r', 61))).IsValid);
Assert.True(new SaveHomeRoomRequestValidator().Validate(new SaveHomeRoomRequest("Küche")).IsValid);
Assert.False(new SaveHomePlanRequestValidator().Validate(new SaveHomePlanRequest("")).IsValid);
Assert.True(new SaveHomePlanRequestValidator().Validate(new SaveHomePlanRequest("Erdgeschoss")).IsValid);
}
[Fact]
public void Unknown_command_is_rejected()
{
Assert.False(new HomeCommandRequestValidator().Validate(new HomeCommandRequest((HomeCommand)42)).IsValid);
Assert.True(new HomeCommandRequestValidator().Validate(new HomeCommandRequest(HomeCommand.Toggle)).IsValid);
}
}
@@ -0,0 +1,479 @@
using System.Net;
using System.Text;
using FamilyPlanner.Api.Features.Home;
using FamilyPlanner.Domain.Home;
namespace FamilyPlanner.UnitTests.Home;
/// <summary>Shelly-Treiber gegen einen Fake-HTTP-Handler: Adressen/Abfragen je Generation, Zustand, Anmeldung, Fehlerübersetzung.</summary>
public sealed class ShellyDriverTests
{
private sealed class FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<HttpRequestMessage> Calls { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls.Add(request);
return Task.FromResult(respond(request));
}
}
private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private static (ShellyDriver Driver, FakeHandler Handler) Make(Func<HttpRequestMessage, HttpResponseMessage> respond)
{
var handler = new FakeHandler(respond);
return (new ShellyDriver(new Factory(handler)), handler);
}
private static HomeDeviceConnection Gen2(HomeDeviceKind kind = HomeDeviceKind.Switch, int channel = 0, string? user = null, string? password = null) =>
new("http://192.168.1.50", 2, channel, kind, user, password);
private static HomeDeviceConnection Gen1(HomeDeviceKind kind = HomeDeviceKind.Switch, int channel = 0, string? user = null, string? password = null) =>
new("http://192.168.1.51", 1, channel, kind, user, password);
private static async Task<HomeException> FailsAsync(Task task) => await Assert.ThrowsAsync<HomeException>(() => task);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// ---------- Gen2+ ----------
[Fact]
public async Task Gen2_switch_state_reads_output_and_power()
{
var (driver, handler) = Make(_ => Json("""{"id":0,"source":"init","output":true,"apower":23.456,"voltage":230.1}"""));
var state = await driver.GetStateAsync(Gen2(channel: 1), Ct);
Assert.True(state.IsOn);
Assert.Equal(23.5, state.PowerWatts);
Assert.Equal("http://192.168.1.50/rpc/Switch.GetStatus?id=1", Assert.Single(handler.Calls).RequestUri!.ToString());
}
[Theory]
[InlineData(HomeCommand.TurnOn, "/rpc/Switch.Set?id=0&on=true")]
[InlineData(HomeCommand.TurnOff, "/rpc/Switch.Set?id=0&on=false")]
[InlineData(HomeCommand.Toggle, "/rpc/Switch.Toggle?id=0")]
public async Task Gen2_switch_commands_use_rpc(HomeCommand command, string expected)
{
var (driver, handler) = Make(_ => Json("""{"was_on":false}"""));
await driver.ExecuteAsync(Gen2(), command, Ct);
Assert.Equal(expected, Assert.Single(handler.Calls).RequestUri!.PathAndQuery);
Assert.Equal(HttpMethod.Get, handler.Calls[0].Method);
}
[Theory]
[InlineData(HomeCommand.Open, "/rpc/Cover.Open?id=0")]
[InlineData(HomeCommand.Close, "/rpc/Cover.Close?id=0")]
[InlineData(HomeCommand.Stop, "/rpc/Cover.Stop?id=0")]
public async Task Gen2_cover_commands_use_cover_rpc(HomeCommand command, string expected)
{
var (driver, handler) = Make(_ => Json("{}"));
await driver.ExecuteAsync(Gen2(HomeDeviceKind.Cover), command, Ct);
Assert.Equal(expected, Assert.Single(handler.Calls).RequestUri!.PathAndQuery);
}
[Fact]
public async Task Gen2_light_uses_light_component()
{
var (driver, handler) = Make(_ => Json("""{"output":false,"brightness":40}"""));
var state = await driver.GetStateAsync(Gen2(HomeDeviceKind.Light), Ct);
await driver.ExecuteAsync(Gen2(HomeDeviceKind.Light), HomeCommand.TurnOn, Ct);
Assert.False(state.IsOn);
Assert.Equal("/rpc/Light.GetStatus?id=0", handler.Calls[0].RequestUri!.PathAndQuery);
Assert.Equal("/rpc/Light.Set?id=0&on=true", handler.Calls[1].RequestUri!.PathAndQuery);
}
[Theory]
[InlineData("open", "open")]
[InlineData("closed", "closed")]
[InlineData("opening", "opening")]
[InlineData("closing", "closing")]
[InlineData("stopped", "stopped")]
[InlineData("calibrating", "stopped")]
public async Task Gen2_cover_state_maps_known_values(string state, string expected)
{
var (driver, _) = Make(_ => Json($$"""{"id":0,"state":"{{state}}","current_pos":40}"""));
var result = await driver.GetStateAsync(Gen2(HomeDeviceKind.Cover), Ct);
Assert.Equal(expected, result.CoverState);
Assert.Equal(40, result.CoverPosition);
}
[Fact]
public async Task Gen2_cover_state_ignores_unknown_values_from_the_device()
{
var (driver, _) = Make(_ => Json("""{"state":"<script>alert(1)</script>"}"""));
Assert.Null((await driver.GetStateAsync(Gen2(HomeDeviceKind.Cover), Ct)).CoverState);
}
[Fact]
public async Task Gen2_sensor_reads_temperature_and_humidity_of_its_channel()
{
var (driver, _) = Make(_ => Json("""{"temperature:0":{"id":0,"tC":21.34,"tF":70.4},"humidity:0":{"id":0,"rh":48.2},"temperature:1":{"id":1,"tC":5.0}}"""));
var state = await driver.GetStateAsync(Gen2(HomeDeviceKind.Sensor), Ct);
Assert.Equal(21.3, state.TemperatureC);
Assert.Equal(48.2, state.HumidityPercent);
Assert.Null(state.IsOn);
}
[Fact]
public async Task Gen2_sensor_without_matching_component_fails_with_a_hint()
{
var (driver, _) = Make(_ => Json("""{"switch:0":{"output":true}}"""));
var error = await FailsAsync(driver.GetStateAsync(Gen2(HomeDeviceKind.Sensor, channel: 3), Ct));
Assert.Equal(HomeErrorKind.BadResponse, error.Kind);
}
[Fact]
public async Task Switch_without_output_property_is_reported_as_wrong_kind()
{
var (driver, _) = Make(_ => Json("""{"state":"open"}"""));
Assert.Equal(HomeErrorKind.BadResponse, (await FailsAsync(driver.GetStateAsync(Gen2(), Ct))).Kind);
}
[Fact]
public async Task Gen2_test_reads_device_info_and_checks_the_channel()
{
var (driver, handler) = Make(request => request.RequestUri!.AbsolutePath.EndsWith("Shelly.GetDeviceInfo", StringComparison.Ordinal)
? Json("""{"name":"Flur","id":"shellyplus1-a8032abe5c34","model":"SNSW-001X16EU","gen":2,"ver":"1.4.4","app":"Plus1"}""")
: Json("""{"id":0,"output":true}"""));
var info = await driver.TestAsync(Gen2(), Ct);
Assert.Equal("SNSW-001X16EU", info.Model);
Assert.Equal("1.4.4", info.Firmware);
Assert.Equal(2, handler.Calls.Count);
}
[Fact]
public async Task Gen2_test_of_a_gen1_device_explains_the_generation()
{
var (driver, _) = Make(_ => Json("{}", HttpStatusCode.NotFound));
var error = await FailsAsync(driver.TestAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.BadResponse, error.Kind);
Assert.Contains("Generation", error.Message);
}
// ---------- Gen1 ----------
[Fact]
public async Task Gen1_relay_state_reads_status_and_meter()
{
var (driver, handler) = Make(_ => Json("""{"relays":[{"ison":false},{"ison":true}],"meters":[{"power":0},{"power":61.04}]}"""));
var state = await driver.GetStateAsync(Gen1(channel: 1), Ct);
Assert.True(state.IsOn);
Assert.Equal(61.0, state.PowerWatts);
Assert.Equal("http://192.168.1.51/status", Assert.Single(handler.Calls).RequestUri!.ToString());
}
[Fact]
public async Task Gen1_unknown_channel_is_reported()
{
var (driver, _) = Make(_ => Json("""{"relays":[{"ison":false}]}"""));
Assert.Equal(HomeErrorKind.BadResponse, (await FailsAsync(driver.GetStateAsync(Gen1(channel: 4), Ct))).Kind);
}
[Theory]
[InlineData(HomeDeviceKind.Switch, HomeCommand.TurnOn, "/relay/0?turn=on")]
[InlineData(HomeDeviceKind.Socket, HomeCommand.TurnOff, "/relay/0?turn=off")]
[InlineData(HomeDeviceKind.Switch, HomeCommand.Toggle, "/relay/0?turn=toggle")]
[InlineData(HomeDeviceKind.Light, HomeCommand.TurnOn, "/light/0?turn=on")]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Open, "/roller/0?go=open")]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Close, "/roller/0?go=close")]
[InlineData(HomeDeviceKind.Cover, HomeCommand.Stop, "/roller/0?go=stop")]
public async Task Gen1_commands_use_rest(HomeDeviceKind kind, HomeCommand command, string expected)
{
var (driver, handler) = Make(_ => Json("{}"));
await driver.ExecuteAsync(Gen1(kind), command, Ct);
Assert.Equal(expected, Assert.Single(handler.Calls).RequestUri!.PathAndQuery);
}
[Theory]
[InlineData("stop", 100, "open")]
[InlineData("stop", 0, "closed")]
[InlineData("stop", 35, "stopped")]
[InlineData("open", 10, "opening")]
[InlineData("close", 90, "closing")]
public async Task Gen1_roller_state_is_derived_from_movement_and_position(string state, int position, string expected)
{
var (driver, _) = Make(_ => Json($$"""{"rollers":[{"state":"{{state}}","current_pos":{{position}},"power":0}]}"""));
var result = await driver.GetStateAsync(Gen1(HomeDeviceKind.Cover), Ct);
Assert.Equal(expected, result.CoverState);
Assert.Equal(position, result.CoverPosition);
}
[Fact]
public async Task Gen1_sensor_reads_tmp_and_hum()
{
var (driver, _) = Make(_ => Json("""{"tmp":{"tC":19.87,"is_valid":true},"hum":{"value":55.5}}"""));
var state = await driver.GetStateAsync(Gen1(HomeDeviceKind.Sensor), Ct);
Assert.Equal(19.9, state.TemperatureC);
Assert.Equal(55.5, state.HumidityPercent);
}
[Fact]
public async Task Gen1_test_uses_shelly_endpoint_and_rejects_gen2_devices()
{
var (driver, handler) = Make(request => request.RequestUri!.AbsolutePath == "/shelly"
? Json("""{"type":"SHSW-1","mac":"AABBCC","auth":false,"fw":"20230913-114008/v1.14.0","num_outputs":1}""")
: Json("""{"relays":[{"ison":true}]}"""));
var info = await driver.TestAsync(Gen1(), Ct);
Assert.Equal("SHSW-1", info.Model);
Assert.Equal("20230913-114008/v1.14.0", info.Firmware);
var (modern, _) = Make(_ => Json("""{"name":null,"id":"shellyplus1","gen":2,"model":"SNSW-001X16EU"}"""));
Assert.Contains("Gen2", (await FailsAsync(modern.TestAsync(Gen1(), Ct))).Message);
Assert.Equal(2, handler.Calls.Count);
}
// ---------- Anmeldung ----------
[Fact]
public async Task Gen1_sends_basic_credentials_directly()
{
var (driver, handler) = Make(_ => Json("""{"relays":[{"ison":true}]}"""));
await driver.GetStateAsync(Gen1(user: "admin", password: "geheim"), Ct);
var header = Assert.Single(handler.Calls).Headers.Authorization!;
Assert.Equal("Basic", header.Scheme);
Assert.Equal("admin:geheim", Encoding.UTF8.GetString(Convert.FromBase64String(header.Parameter!)));
}
[Fact]
public async Task Gen2_answers_a_digest_challenge_once_and_then_succeeds()
{
var (driver, handler) = Make(request => request.Headers.Authorization is null
? ChallengeResponse()
: Json("""{"output":true}"""));
var state = await driver.GetStateAsync(Gen2(user: "admin", password: "geheim"), Ct);
Assert.True(state.IsOn);
Assert.Equal(2, handler.Calls.Count);
var header = handler.Calls[1].Headers.Authorization!;
Assert.Equal("Digest", header.Scheme);
Assert.Contains("username=\"admin\"", header.Parameter);
Assert.Contains("realm=\"shellyplus1-a8032abe5c34\"", header.Parameter);
Assert.Contains("algorithm=SHA-256", header.Parameter);
Assert.Contains("uri=\"/rpc/Switch.GetStatus?id=0\"", header.Parameter);
Assert.DoesNotContain("geheim", header.Parameter);
}
private static HttpResponseMessage ChallengeResponse()
{
var response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
response.Headers.TryAddWithoutValidation("WWW-Authenticate", "Digest qop=\"auth\", realm=\"shellyplus1-a8032abe5c34\", nonce=\"1700000000\", algorithm=SHA-256");
return response;
}
[Fact]
public async Task Gen2_without_credentials_reports_that_a_login_is_needed()
{
var (driver, handler) = Make(_ => ChallengeResponse());
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.Unauthorized, error.Kind);
Assert.Single(handler.Calls); // kein zweiter Versuch ohne Zugangsdaten
}
[Fact]
public async Task Gen2_with_wrong_credentials_gives_up_after_one_retry()
{
var (driver, handler) = Make(_ => ChallengeResponse());
var error = await FailsAsync(driver.GetStateAsync(Gen2(user: "admin", password: "falsch"), Ct));
Assert.Equal(HomeErrorKind.Unauthorized, error.Kind);
Assert.Equal(2, handler.Calls.Count);
Assert.DoesNotContain("falsch", error.Message);
}
[Fact]
public async Task Gen1_with_wrong_credentials_does_not_retry()
{
var (driver, handler) = Make(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
Assert.Equal(HomeErrorKind.Unauthorized, (await FailsAsync(driver.GetStateAsync(Gen1(user: "a", password: "b"), Ct))).Kind);
Assert.Single(handler.Calls);
}
[Fact]
public void Digest_response_matches_the_reference_computation()
{
var challenge = HomeHttp.ParseChallenge("qop=\"auth\", realm=\"shellyplus1-a8032abe5c34\", nonce=\"1700000000\", algorithm=SHA-256");
var header = ShellyDigest.Authorization("admin", "geheim", "GET", "/rpc/Switch.GetStatus?id=0", challenge, "abcdef0123456789", 1);
Assert.NotNull(header);
Assert.Contains("response=\"9027fb5ae8a0bea708f04e159de2637e46f6dc67b68d9e38febd07bcebb0d242\"", header);
Assert.Contains("nc=00000001", header);
Assert.Contains("cnonce=\"abcdef0123456789\"", header);
}
[Fact]
public void Digest_with_unsupported_algorithm_is_not_answered()
{
var challenge = HomeHttp.ParseChallenge("qop=\"auth\", realm=\"x\", nonce=\"1\", algorithm=MD5");
Assert.Null(ShellyDigest.Authorization("admin", "pw", "GET", "/", challenge, "c", 1));
}
// ---------- Fehler ----------
[Fact]
public async Task Redirects_are_never_followed_and_reported()
{
var (driver, handler) = Make(_ =>
{
var response = new HttpResponseMessage(HttpStatusCode.Redirect);
response.Headers.Location = new Uri("http://169.254.169.254/latest/meta-data/");
return response;
});
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.BadResponse, error.Kind);
Assert.Single(handler.Calls);
Assert.DoesNotContain("169.254", error.Message);
}
[Fact]
public async Task Not_found_means_unknown_function()
{
var (driver, _) = Make(_ => Json("{}", HttpStatusCode.NotFound));
Assert.Equal(HomeErrorKind.BadResponse, (await FailsAsync(driver.GetStateAsync(Gen2(), Ct))).Kind);
}
[Fact]
public async Task Server_error_is_translated_without_device_text()
{
var (driver, _) = Make(_ => Json("secret internal stack trace", HttpStatusCode.InternalServerError));
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.BadResponse, error.Kind);
Assert.DoesNotContain("secret", error.Message);
}
[Fact]
public async Task Non_json_answer_is_reported_as_not_a_shelly()
{
var (driver, _) = Make(_ => Json("<html>Router login</html>"));
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.BadResponse, error.Kind);
Assert.DoesNotContain("Router", error.Message);
}
[Fact]
public async Task Oversized_answers_are_cut_off()
{
var (driver, _) = Make(_ => Json("{\"output\":true,\"pad\":\"" + new string('x', HomeHttp.MaxResponseBytes + 10) + "\"}"));
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.BadResponse, error.Kind);
Assert.Contains("zu groß", error.Message);
}
[Fact]
public async Task Connection_errors_become_a_friendly_unreachable_message()
{
var (driver, _) = Make(_ => throw new HttpRequestException("Connection refused 192.168.1.50:80"));
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.Unreachable, error.Kind);
Assert.Contains("nicht erreichbar", error.Message);
Assert.Contains("Docker", error.Message);
Assert.DoesNotContain("refused", error.Message);
}
[Fact]
public async Task Resolved_foreign_address_is_reported_as_blocked()
{
var (driver, _) = Make(_ => throw new HttpRequestException("x", new HomeBlockedAddressException("evil.example")));
var error = await FailsAsync(driver.GetStateAsync(Gen2(), Ct));
Assert.Equal(HomeErrorKind.Blocked, error.Kind);
Assert.DoesNotContain("evil.example", error.Message);
}
[Fact]
public async Task Command_not_matching_the_kind_is_not_supported()
{
var (driver, handler) = Make(_ => Json("{}"));
var error = await FailsAsync(driver.ExecuteAsync(Gen2(HomeDeviceKind.Sensor), HomeCommand.TurnOn, Ct));
Assert.Equal(HomeErrorKind.NotSupported, error.Kind);
Assert.Empty(handler.Calls);
}
// ---------- Verbindungsaufbau (echter Handler, ohne Netzwerkzugriff: die Adresse wird vor dem Verbinden abgelehnt) ----------
[Theory]
[InlineData("http://8.8.8.8")]
[InlineData("http://169.254.169.254")]
[InlineData("http://[2001:db8::1]")]
[InlineData("http://1.1.1.1:8080")]
public async Task Real_handler_refuses_non_home_addresses_before_connecting(string host)
{
using var http = new HttpClient(HomeHttp.CreateHandler());
var error = await FailsAsync(HomeHttp.GetJsonAsync(http, host, "/rpc/Shelly.GetDeviceInfo", null, null, basicFirst: false, Ct));
Assert.Equal(HomeErrorKind.Blocked, error.Kind);
Assert.DoesNotContain("8.8.8.8", error.Message);
Assert.DoesNotContain("169.254", error.Message);
}
[Fact]
public async Task Real_handler_does_not_follow_redirects_or_use_a_proxy()
{
var handler = HomeHttp.CreateHandler();
Assert.False(handler.AllowAutoRedirect);
Assert.False(handler.UseProxy);
Assert.NotNull(handler.ConnectCallback);
await Task.CompletedTask;
}
}
@@ -47,7 +47,7 @@ public sealed class AuthenticationStateProviderTests
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, admin.Policy);
Assert.True(admin.Separated);
Assert.All(NavItems.All.Where(i => i.Href is not ("admin" or "lernen")), i => Assert.Null(i.Policy));
Assert.All(NavItems.All.Where(i => i.Href is not ("admin" or "lernen" or "haus" or "haus/verwaltung")), i => Assert.Null(i.Policy));
}
[Fact]
@@ -60,4 +60,28 @@ public sealed class AuthenticationStateProviderTests
Assert.False(learning.InBottomNav);
Assert.False(learning.Separated);
}
[Fact]
public void House_navigation_has_a_page_for_device_users_and_an_admin_only_management_entry_below_it()
{
var items = NavItems.All.ToList();
var house = Assert.Single(items, i => i.Href == "haus");
var management = Assert.Single(items, i => i.Href == "haus/verwaltung");
Assert.Equal("Haus", house.Title);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.For(FamilyPlanner.Domain.Authorization.Permission.HomeUse), house.Policy);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.HomeUse, house.Policy);
Assert.False(house.Child);
Assert.Equal("Hausverwaltung", management.Title);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, management.Policy);
Assert.True(management.Child);
// Beide stehen unter „Verwaltung“, direkt untereinander, und nicht in der Bottom-Navigation.
var admin = items.FindIndex(i => i.Href == "admin");
Assert.Equal(admin + 1, items.IndexOf(house));
Assert.Equal(admin + 2, items.IndexOf(management));
Assert.False(house.InBottomNav);
Assert.False(management.InBottomNav);
}
}
@@ -0,0 +1,206 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Home;
using FamilyPlanner.Web.Client.Services;
namespace FamilyPlanner.Web.Tests.House;
internal sealed class FakeHomeApi : IHomeApi
{
public HomeOverviewDto Overview { get; set; } = new([], [], [], false);
public Dictionary<Guid, HomeDeviceStateDto> States { get; } = [];
public List<(Guid Id, HomeCommand Command)> Commands { get; } = [];
/// <summary>Antwort auf Befehle; <c>null</c> = der Standard (Gerät folgt dem Befehl).</summary>
public Func<Guid, HomeCommand, ApiResult<HomeCommandResultDto>>? CommandHandler { get; set; }
public int OverviewCalls { get; private set; }
public int StateCalls { get; private set; }
public ApiProblem? OverviewProblem { get; set; }
// Verwaltung
public List<HomeAdminDeviceDto> Devices { get; } = [];
public List<HomeAdminRoomDto> Rooms { get; } = [];
public List<HomeAdminPlanDto> Plans { get; } = [];
public List<UserDirectoryEntry> Users { get; } = [];
public List<SaveHomeDeviceRequest> SavedDevices { get; } = [];
public List<(Guid Id, SetHomeAccessRequest Request)> SavedAccess { get; } = [];
public List<TestHomeDeviceRequest> DraftTests { get; } = [];
public List<(Guid Id, SaveHomePlacementsRequest Request)> SavedPlacements { get; } = [];
public List<SaveHomeRoomRequest> SavedRooms { get; } = [];
public List<Guid> Deleted { get; } = [];
public HomeTestResultDto TestResult { get; set; } = new(true, "Verbindung erfolgreich.", "SNSW-001X16EU", "1.4.4", 42);
public ApiProblem? SaveProblem { get; set; }
public static HomeDeviceDto Device(string name, HomeDeviceKind kind = HomeDeviceKind.Switch, Guid? room = null, bool canControl = true, string? icon = null) =>
new(Guid.NewGuid(), name, kind, icon, room, canControl);
public static HomeDeviceStateDto On(Guid id, double? watts = null) => new(id, true, true, null, null, watts, null, null, null);
public static HomeDeviceStateDto Off(Guid id) => new(id, true, false, null, null, null, null, null, null);
public static HomeDeviceStateDto Offline(Guid id, string message = "Das Gerät ist nicht erreichbar.") => new(id, false, null, null, null, null, null, null, message);
public static HomeDeviceStateDto Cover(Guid id, string state, int? position = null) => new(id, true, null, state, position, null, null, null, null);
public static HomeDeviceStateDto Sensor(Guid id, double temperature, double humidity) => new(id, true, null, null, null, null, temperature, humidity, null);
public Task<ApiResult<HomeOverviewDto>> GetOverviewAsync(CancellationToken ct = default)
{
OverviewCalls++;
return Task.FromResult(OverviewProblem is { } problem ? ApiResult<HomeOverviewDto>.Fail(problem) : ApiResult<HomeOverviewDto>.Ok(Overview));
}
public Task<ApiResult<HomeStatesDto>> GetStatesAsync(CancellationToken ct = default)
{
StateCalls++;
return Task.FromResult(ApiResult<HomeStatesDto>.Ok(new HomeStatesDto([.. States.Values], DateTimeOffset.UtcNow)));
}
public Task<ApiResult<HomeCommandResultDto>> SendCommandAsync(Guid deviceId, HomeCommand command, CancellationToken ct = default)
{
Commands.Add((deviceId, command));
if (CommandHandler is { } handler)
{
return Task.FromResult(handler(deviceId, command));
}
var state = command switch
{
HomeCommand.TurnOn => On(deviceId),
HomeCommand.TurnOff => Off(deviceId),
HomeCommand.Toggle => States.TryGetValue(deviceId, out var s) && s.IsOn is true ? Off(deviceId) : On(deviceId),
HomeCommand.Open => Cover(deviceId, "opening", 50),
HomeCommand.Close => Cover(deviceId, "closing", 50),
_ => Cover(deviceId, "stopped", 50),
};
States[deviceId] = state;
return Task.FromResult(ApiResult<HomeCommandResultDto>.Ok(new HomeCommandResultDto(true, null, state)));
}
public Task<ApiResult<List<HomeAdminDeviceDto>>> GetDevicesAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<List<HomeAdminDeviceDto>>.Ok([.. Devices]));
public Task<ApiResult<HomeAdminDeviceDto>> CreateDeviceAsync(SaveHomeDeviceRequest request, CancellationToken ct = default)
{
if (SaveProblem is { } problem)
{
return Task.FromResult(ApiResult<HomeAdminDeviceDto>.Fail(problem));
}
SavedDevices.Add(request);
var (host, _) = HomeRules.NormalizeHost(request.Host);
var dto = new HomeAdminDeviceDto(Guid.NewGuid(), request.Name, request.Kind, request.Icon, request.RoomId, request.Driver, request.Generation, host!, request.Channel,
request.AuthUser, request.Password is not null, request.SortOrder, [1], []);
Devices.Add(dto);
return Task.FromResult(ApiResult<HomeAdminDeviceDto>.Ok(dto));
}
public Task<ApiResult<HomeAdminDeviceDto>> UpdateDeviceAsync(Guid id, SaveHomeDeviceRequest request, CancellationToken ct = default)
{
if (SaveProblem is { } problem)
{
return Task.FromResult(ApiResult<HomeAdminDeviceDto>.Fail(problem));
}
SavedDevices.Add(request);
var index = Devices.FindIndex(d => d.Id == id);
Devices[index] = Devices[index] with
{
Name = request.Name, Kind = request.Kind, Icon = request.Icon, RoomId = request.RoomId, Generation = request.Generation, Host = HomeRules.NormalizeHost(request.Host).Host!,
Channel = request.Channel, AuthUser = request.AuthUser, HasPassword = request.ClearPassword ? false : request.Password is not null || Devices[index].HasPassword,
};
return Task.FromResult(ApiResult<HomeAdminDeviceDto>.Ok(Devices[index]));
}
public Task<ApiResult<bool>> DeleteDeviceAsync(Guid id, CancellationToken ct = default)
{
Deleted.Add(id);
Devices.RemoveAll(d => d.Id == id);
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public List<Guid> Tested { get; } = [];
public Task<ApiResult<HomeTestResultDto>> TestDeviceAsync(Guid id, CancellationToken ct = default)
{
Tested.Add(id);
return Task.FromResult(ApiResult<HomeTestResultDto>.Ok(TestResult));
}
public Task<ApiResult<HomeTestResultDto>> TestDraftAsync(TestHomeDeviceRequest request, CancellationToken ct = default)
{
DraftTests.Add(request);
return Task.FromResult(ApiResult<HomeTestResultDto>.Ok(TestResult));
}
public Task<ApiResult<HomeAdminDeviceDto>> SetAccessAsync(Guid id, SetHomeAccessRequest request, CancellationToken ct = default)
{
SavedAccess.Add((id, request));
var index = Devices.FindIndex(d => d.Id == id);
Devices[index] = Devices[index] with { Grants = request.Grants };
return Task.FromResult(ApiResult<HomeAdminDeviceDto>.Ok(Devices[index]));
}
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<List<UserDirectoryEntry>>.Ok([.. Users]));
public Task<ApiResult<List<HomeAdminRoomDto>>> GetRoomsAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<List<HomeAdminRoomDto>>.Ok([.. Rooms]));
public Task<ApiResult<HomeAdminRoomDto>> CreateRoomAsync(SaveHomeRoomRequest request, CancellationToken ct = default)
{
SavedRooms.Add(request);
var room = new HomeAdminRoomDto(Guid.NewGuid(), request.Name, request.SortOrder, 0);
Rooms.Add(room);
return Task.FromResult(ApiResult<HomeAdminRoomDto>.Ok(room));
}
public Task<ApiResult<bool>> DeleteRoomAsync(Guid id, CancellationToken ct = default)
{
Deleted.Add(id);
Rooms.RemoveAll(r => r.Id == id);
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public Task<ApiResult<List<HomeAdminPlanDto>>> GetPlansAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<List<HomeAdminPlanDto>>.Ok([.. Plans]));
public Task<ApiResult<HomeAdminPlanDto>> SetPlacementsAsync(Guid id, SaveHomePlacementsRequest request, CancellationToken ct = default)
{
SavedPlacements.Add((id, request));
var index = Plans.FindIndex(p => p.Id == id);
Plans[index] = Plans[index] with { Pins = request.Pins };
return Task.FromResult(ApiResult<HomeAdminPlanDto>.Ok(Plans[index]));
}
public Task<ApiResult<bool>> DeletePlanAsync(Guid id, CancellationToken ct = default)
{
Deleted.Add(id);
Plans.RemoveAll(p => p.Id == id);
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public List<HomeActionLogDto> Log { get; } = [];
public Task<ApiResult<PagedResult<HomeActionLogDto>>> GetLogAsync(int page, int pageSize, CancellationToken ct = default) =>
Task.FromResult(ApiResult<PagedResult<HomeActionLogDto>>.Ok(new PagedResult<HomeActionLogDto>([.. Log], Log.Count, page, pageSize)));
public static HomeAdminDeviceDto AdminDevice(string name, HomeDeviceKind kind = HomeDeviceKind.Switch, Guid? room = null, IReadOnlyList<HomeGrantDto>? grants = null, bool hasPassword = false) =>
new(Guid.NewGuid(), name, kind, null, room, HomeDriverType.Shelly, 2, "http://192.168.1.50", 0, hasPassword ? "admin" : null, hasPassword, 0, [1], grants ?? []);
public static UserDirectoryEntry User(string name, string role = Roles.Child) => new(Guid.NewGuid(), name, "#43A047", null, role);
}
@@ -0,0 +1,434 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Home;
using FamilyPlanner.Web.Client.Components.House;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.House;
/// <summary>Hausverwaltung: Geräteliste und -dialog (lokale Prüfung, Test, Passwort), Freigaben, Räume, Protokoll, Pin-Editor.</summary>
public sealed class HouseAdminTests : BunitContext
{
private readonly FakeHomeApi _api = new();
public HouseAdminTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IHomeApi>(_api);
}
private IRenderedComponent<HouseHost> Open<T>(Action<ComponentParameterCollectionBuilder<T>>? parameters = null)
where T : Microsoft.AspNetCore.Components.IComponent =>
Render<HouseHost>(p => p.AddChildContent<T>(parameters));
// ---------- Geräteliste ----------
[Fact]
public void Empty_device_list_explains_the_next_step()
{
var cut = Open<HouseDevicesAdmin>();
Assert.Contains("Noch keine Geräte", cut.Find(".fp-device-empty").TextContent);
}
[Fact]
public void Device_list_shows_connection_shares_and_never_the_password()
{
var room = new HomeAdminRoomDto(Guid.NewGuid(), "Flur", 0, 1);
_api.Rooms.Add(room);
_api.Devices.Add(FakeHomeApi.AdminDevice("Flurlicht", room: room.Id, hasPassword: true, grants:
[
new(Roles.Family, null, HomeAccessLevel.Control),
new(Roles.Child, null, HomeAccessLevel.View),
new(null, Guid.NewGuid(), HomeAccessLevel.Control),
]));
_api.Devices.Add(FakeHomeApi.AdminDevice("Privat", HomeDeviceKind.Cover));
var cut = Open<HouseDevicesAdmin>();
var rows = cut.FindAll(".fp-device-table tbody tr");
Assert.Equal(2, rows.Count);
Assert.Contains("Flur", rows[0].TextContent);
Assert.Contains("http://192.168.1.50", rows[0].TextContent);
Assert.Contains("Gen2 · Kanal 0 · mit Passwort", rows[0].TextContent);
Assert.Contains("Familie: schalten · Kind: ansehen · 1 Person", rows[0].QuerySelector(".fp-device-grants")!.TextContent);
Assert.Contains("Nur Administratoren", rows[1].QuerySelector(".fp-device-grants")!.TextContent);
Assert.Contains("Rollo", rows[1].TextContent);
}
[Fact]
public void Connection_test_of_a_row_shows_model_firmware_and_duration()
{
var device = FakeHomeApi.AdminDevice("Licht");
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-test-row").Click();
cut.WaitForAssertion(() => Assert.Contains("Verbindung erfolgreich", cut.Find(".fp-device-testoutcome").TextContent));
Assert.Contains("SNSW-001X16EU", cut.Find(".fp-device-testoutcome").TextContent);
Assert.Contains("1.4.4", cut.Find(".fp-device-testoutcome").TextContent);
Assert.Equal([device.Id], _api.Tested);
_api.TestResult = new HomeTestResultDto(false, "Das Gerät ist nicht erreichbar.", null, null, 0);
cut.Find(".fp-device-test-row").Click();
cut.WaitForAssertion(() => Assert.Contains("nicht erreichbar", cut.Find(".fp-device-testoutcome").TextContent));
}
[Fact]
public void Delete_asks_for_confirmation_and_removes_the_device()
{
var device = FakeHomeApi.AdminDevice("Alt");
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find("button[aria-label='Alt löschen']").Click();
cut.WaitForElement(".mud-message-box");
Assert.Contains("Gerät löschen?", cut.Markup);
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Equal([device.Id], _api.Deleted));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-device-empty")));
}
// ---------- Gerätedialog ----------
[Fact]
public void Device_dialog_validates_locally_including_foreign_addresses()
{
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#house-device-form").Submit(); // Name und Adresse fehlen
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben", cut.Markup));
Assert.Empty(_api.SavedDevices);
cut.Find("#device-name").Change("Licht");
cut.Find("#device-host").Change("8.8.8.8");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Nur Adressen im Heimnetz sind erlaubt", cut.Markup));
Assert.Empty(_api.SavedDevices);
cut.Find("#device-host").Change("169.254.169.254");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("nicht erlaubt", cut.Markup));
Assert.Empty(_api.SavedDevices);
}
[Fact]
public void Device_dialog_saves_a_valid_request_with_defaults()
{
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-name").Change(" Stehlampe ");
cut.Find("#device-host").Change("192.168.1.77");
cut.Find("#device-user").Change("admin");
cut.Find("#device-password").Change("geheim");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices));
var saved = _api.SavedDevices.Single();
Assert.Equal(("Stehlampe", HomeDeviceKind.Switch, 2, "192.168.1.77", 0, "admin", "geheim", false), (saved.Name, saved.Kind, saved.Generation, saved.Host, saved.Channel, saved.AuthUser, saved.Password, saved.ClearPassword));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-device-table tbody tr")));
Assert.DoesNotContain("geheim", cut.Markup);
}
[Fact]
public void Server_validation_problems_are_shown_in_the_dialog()
{
_api.SaveProblem = new ApiProblem { Status = 400, Errors = new() { ["RoomId"] = ["Der Raum existiert nicht mehr."] } };
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-name").Change("Licht");
cut.Find("#device-host").Change("192.168.1.5");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Der Raum existiert nicht mehr", cut.Markup));
}
[Fact]
public void Edit_dialog_keeps_the_password_unless_replaced_and_never_shows_it()
{
_api.Devices.Add(FakeHomeApi.AdminDevice("Mit Passwort", hasPassword: true));
var cut = Open<HouseDevicesAdmin>();
cut.Find("button[aria-label='Mit Passwort bearbeiten']").Click();
cut.WaitForElement("#house-device-form");
Assert.Equal(string.Empty, cut.Find("#device-password").GetAttribute("value") ?? string.Empty);
Assert.Contains("leer lassen = unverändert", cut.Markup);
Assert.Equal("admin", cut.Find("#device-user").GetAttribute("value"));
cut.Find("#device-name").Change("Umbenannt");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices));
var saved = _api.SavedDevices.Single();
Assert.Equal(("Umbenannt", null, false), (saved.Name, saved.Password, saved.ClearPassword));
Assert.NotNull(saved.Version); // Versionsprüfung wird mitgeschickt
Assert.True(_api.Devices[0].HasPassword);
}
[Fact]
public void Dialog_test_button_sends_the_draft_with_the_device_id_and_shows_the_result()
{
var device = FakeHomeApi.AdminDevice("Gesichert", hasPassword: true);
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find("button[aria-label='Gesichert bearbeiten']").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-channel").Change("1");
cut.Find(".fp-device-test").Click();
cut.WaitForAssertion(() => Assert.Contains("Verbindung erfolgreich", cut.Find(".fp-device-testresult").TextContent));
var draft = _api.DraftTests.Single();
Assert.Equal((device.Id, "http://192.168.1.50", 1, null), (draft.DeviceId, draft.Host, draft.Channel, draft.Password)); // gespeichertes Passwort bleibt auf dem Server
}
[Fact]
public void Dialog_test_with_an_invalid_address_does_not_call_the_server()
{
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-host").Change("example.com");
cut.Find(".fp-device-test").Click();
cut.WaitForAssertion(() => Assert.Contains("Nur Adressen im Heimnetz", cut.Markup));
Assert.Empty(_api.DraftTests);
}
// ---------- Freigaben ----------
[Fact]
public void Access_dialog_shows_existing_shares_and_saves_removals()
{
var anna = FakeHomeApi.User("Anna");
var tom = FakeHomeApi.User("Tom");
_api.Users.AddRange([anna, tom, FakeHomeApi.User("Chefin", Roles.Admin)]);
var device = FakeHomeApi.AdminDevice("Licht", grants: [new(Roles.Family, null, HomeAccessLevel.Control), new(null, anna.Id, HomeAccessLevel.View)]);
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-access").Click();
cut.WaitForElement("#house-access-form");
Assert.Equal(3, cut.FindAll(".fp-access-role").Count); // Familie, Kind, Gast – nie Admin
Assert.Single(cut.FindAll(".fp-access-person"));
Assert.Contains("Anna", cut.Find(".fp-access-person").TextContent);
cut.Find("button[aria-label='Freigabe für Anna entfernen']").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-access-person")));
cut.Find("#house-access-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedAccess));
var (id, request) = _api.SavedAccess.Single();
Assert.Equal(device.Id, id);
Assert.Equal([new HomeGrantDto(Roles.Family, null, HomeAccessLevel.Control)], request.Grants);
}
// ---------- Räume ----------
[Fact]
public void Rooms_can_be_added_and_deleted_after_confirmation()
{
_api.Rooms.Add(new HomeAdminRoomDto(Guid.NewGuid(), "Küche", 0, 2));
var changes = 0;
var cut = Open<HouseRoomsAdmin>(p => p.Add(c => c.OnChanged, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, () => changes++)));
Assert.Single(cut.FindAll(".fp-room-table tbody tr"));
Assert.Contains("2", cut.Find(".fp-room-table tbody tr").TextContent);
cut.Find(".fp-room-add").Click();
cut.WaitForElement("#house-name-form");
cut.Find("#house-name").Change("Bad");
cut.Find("#house-name-form").Submit();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-room-table tbody tr").Count));
Assert.Equal("Bad", _api.SavedRooms.Single().Name);
Assert.Equal(1, changes);
cut.Find("button[aria-label='Küche löschen']").Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-room-table tbody tr")));
Assert.Equal(2, changes);
}
[Fact]
public void Room_dialog_requires_a_name()
{
var cut = Open<HouseRoomsAdmin>();
cut.Find(".fp-room-add").Click();
cut.WaitForElement("#house-name-form");
cut.Find("#house-name-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben", cut.Markup));
Assert.Empty(_api.SavedRooms);
}
// ---------- Protokoll ----------
[Fact]
public void Action_log_shows_who_did_what_and_failures_with_text()
{
var device = Guid.NewGuid();
_api.Log.Add(new HomeActionLogDto(new DateTimeOffset(2026, 10, 12, 8, 0, 0, TimeSpan.Zero), "Mama", device, "Flurlicht", HomeCommand.TurnOn, true, null, 85));
_api.Log.Add(new HomeActionLogDto(new DateTimeOffset(2026, 10, 12, 7, 0, 0, TimeSpan.Zero), "Lena", device, "Rollo", HomeCommand.Close, false, "Das Gerät ist nicht erreichbar.", 3000));
var cut = Open<HouseLogView>();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-house-log tbody tr").Count));
var rows = cut.FindAll(".fp-house-log tbody tr");
Assert.Contains("Mama", rows[0].TextContent);
Assert.Contains("Einschalten", rows[0].TextContent);
Assert.Contains("OK · 85 ms", rows[0].QuerySelector(".fp-log-ok")!.TextContent);
Assert.Contains("10:00", rows[0].TextContent); // Europe/Berlin (Sommerzeit)
Assert.Contains("Fehlgeschlagen: Das Gerät ist nicht erreichbar.", rows[1].QuerySelector(".fp-log-failed")!.TextContent);
}
// ---------- Pin-Editor ----------
private (HomeAdminPlanDto Plan, HomeAdminDeviceDto A, HomeAdminDeviceDto B) PlanSetup()
{
var a = FakeHomeApi.AdminDevice("Licht");
var b = FakeHomeApi.AdminDevice("Steckdose", HomeDeviceKind.Socket);
_api.Devices.AddRange([a, b]);
var plan = new HomeAdminPlanDto(Guid.NewGuid(), "Erdgeschoss", 0, 2, [new HomePinDto(a.Id, 0.5, 0.5)]);
_api.Plans.Add(plan);
return (plan, a, b);
}
[Fact]
public void Pin_editor_lists_all_devices_with_their_placement_state_and_positions_pins()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var devices = cut.FindAll(".fp-pin-device");
Assert.Equal(2, devices.Count);
Assert.Contains("platziert", devices[0].QuerySelector(".fp-pin-device-state")!.TextContent);
Assert.Contains("nicht platziert", devices[1].QuerySelector(".fp-pin-device-state")!.TextContent);
var pin = Assert.Single(cut.FindAll(".fp-pin-edit"));
Assert.Equal("left:50%;top:50%", pin.GetAttribute("style"));
Assert.Equal(a.Id.ToString(), pin.GetAttribute("data-device"));
Assert.Contains("Pfeiltasten", pin.GetAttribute("aria-label"));
Assert.True(cut.Find(".fp-pin-save").HasAttribute("disabled")); // noch nichts geändert
Assert.Contains(JSInterop.Invocations, i => i.Identifier == "fpHome.attach");
}
[Fact]
public void Placing_a_device_by_clicking_the_plan_and_saving_sends_relative_coordinates()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var editor = cut.FindComponent<PlanPinEditor>();
cut.FindAll(".fp-pin-device")[1].Click(); // Steckdose wählen
cut.InvokeAsync(() => editor.Instance.PlanClicked(0.3333333, 0.75)); // Klick aufs Bild (kommt vom Skript)
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-pin-edit").Count));
Assert.Equal("33", cut.Find(".fp-pin-x input").GetAttribute("value"));
Assert.Equal("75", cut.Find(".fp-pin-y input").GetAttribute("value"));
cut.Find(".fp-pin-save").Click();
cut.WaitForAssertion(() => Assert.Single(_api.SavedPlacements));
var (planId, request) = _api.SavedPlacements.Single();
Assert.Equal(plan.Id, planId);
Assert.Equal(2, request.Pins.Count);
var placed = request.Pins.Single(p => p.DeviceId == b.Id);
Assert.Equal((0.3333, 0.75), (placed.X, placed.Y));
Assert.Contains("Positionen gespeichert", cut.Find(".mud-snackbar").TextContent);
}
[Fact]
public void Dragging_and_arrow_keys_move_the_pin_and_stay_inside_the_plan()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var editor = cut.FindComponent<PlanPinEditor>();
cut.InvokeAsync(() => editor.Instance.PinMoved(a.Id.ToString(), 0.2, 0.9));
cut.WaitForAssertion(() => Assert.Equal("left:20%;top:90%", cut.Find(".fp-pin-edit").GetAttribute("style")));
cut.InvokeAsync(() => editor.Instance.PinNudged(a.Id.ToString(), 0.05, 0.05));
cut.WaitForAssertion(() => Assert.Equal("left:25%;top:95%", cut.Find(".fp-pin-edit").GetAttribute("style")));
cut.InvokeAsync(() => editor.Instance.PinNudged(a.Id.ToString(), 0, 0.5));
cut.WaitForAssertion(() => Assert.Equal("left:25%;top:100%", cut.Find(".fp-pin-edit").GetAttribute("style")));
}
[Fact]
public void Removing_a_pin_and_resetting_works_and_clicks_without_selection_are_ignored()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var editor = cut.FindComponent<PlanPinEditor>();
cut.InvokeAsync(() => editor.Instance.PlanClicked(0.1, 0.1)); // nichts gewählt
Assert.Single(cut.FindAll(".fp-pin-edit"));
cut.FindAll(".fp-pin-device")[0].Click();
cut.Find(".fp-pin-remove").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-pin-edit")));
Assert.False(cut.Find(".fp-pin-save").HasAttribute("disabled"));
cut.Find(".fp-pin-reset").Click();
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-pin-edit")));
Assert.True(cut.Find(".fp-pin-save").HasAttribute("disabled"));
Assert.Empty(_api.SavedPlacements);
}
[Fact]
public void Unplaced_device_can_be_put_in_the_middle_without_a_pointer()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
cut.FindAll(".fp-pin-device")[1].Click();
cut.Find(".fp-pin-center").Click();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-pin-edit").Count));
}
[Fact]
public void Plans_admin_lists_plans_with_preview_and_pin_counts()
{
var (plan, _, _) = PlanSetup();
var cut = Open<HousePlansAdmin>();
var card = cut.Find(".fp-plan-card");
Assert.Contains("Erdgeschoss", card.TextContent);
Assert.Contains("1 Gerät auf dem Plan", card.TextContent);
Assert.Equal($"api/home/plans/{plan.Id}/image?v=2", card.QuerySelector(".fp-plan-thumb")!.GetAttribute("src"));
Assert.Contains("Vorschau Erdgeschoss", card.QuerySelector(".fp-plan-thumb")!.GetAttribute("alt"));
Assert.Empty(cut.FindAll(".fp-pin-editor"));
cut.Find(".fp-plan-pins").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-pin-editor")));
}
[Fact]
public void Plan_delete_asks_for_confirmation()
{
var (plan, _, _) = PlanSetup();
var cut = Open<HousePlansAdmin>();
cut.FindAll(".fp-plan-card button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForElement(".mud-message-box");
Assert.Contains("Plan löschen?", cut.Markup);
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Equal([plan.Id], _api.Deleted));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-plan-empty")));
}
}
@@ -0,0 +1,10 @@
@* Testhülle: MudBlazor-Provider (Dialoge, Hinweise, Tooltips) um beliebigen Inhalt. *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
@ChildContent
@code {
[Parameter]
public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,329 @@
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Home;
using FamilyPlanner.Web.Client.Components.House;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
using HousePage = FamilyPlanner.Web.Client.Pages.House;
namespace FamilyPlanner.Web.Tests.House;
/// <summary>Haus-Seite: Räume, Gerätekarten (Status als Text), Schalten, Rechte (nur ansehen), Offline, Rollos, Sensoren, Plan mit Pins.</summary>
public sealed class HousePageTests : BunitContext
{
private readonly FakeHomeApi _api = new();
public HousePageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IHomeApi>(_api);
}
private IRenderedComponent<HouseHost> Open(HomeOverviewDto? overview = null)
{
if (overview is not null)
{
_api.Overview = overview;
}
return Render<HouseHost>(p => p.AddChildContent<HousePage>());
}
private static HomeOverviewDto Overview(IEnumerable<HomeDeviceDto> devices, IEnumerable<HomeRoomDto>? rooms = null, IEnumerable<HomePlanDto>? plans = null, bool manage = false) =>
new([.. rooms ?? []], [.. devices], [.. plans ?? []], manage);
[Fact]
public void Empty_state_for_people_without_any_share_has_no_manage_link()
{
var cut = Open(Overview([]));
Assert.Contains("noch keine Geräte freigegeben", cut.Find(".fp-house-empty").TextContent);
Assert.Empty(cut.FindAll(".fp-house-manage"));
Assert.Equal(0, _api.StateCalls); // ohne Geräte keine Zustandsabfrage
}
[Fact]
public void Admin_sees_the_link_to_the_administration()
{
var cut = Open(Overview([], manage: true));
Assert.Equal("haus/verwaltung", cut.Find(".fp-house-manage").GetAttribute("href"));
Assert.Contains("Hausverwaltung", cut.Find(".fp-house-empty").TextContent);
}
[Fact]
public void Load_error_is_shown_with_a_retry()
{
_api.OverviewProblem = new ApiProblem { Status = 500, Detail = "Der Server ist gerade nicht erreichbar." };
var cut = Open();
Assert.Contains("nicht erreichbar", cut.Find(".fp-house-error").TextContent);
_api.OverviewProblem = null;
_api.Overview = Overview([FakeHomeApi.Device("Licht")]);
cut.FindAll("button").Single(b => b.TextContent.Contains("Erneut versuchen")).Click();
cut.WaitForAssertion(() => Assert.Contains("Licht", cut.Markup));
}
[Fact]
public void Devices_are_grouped_by_room_and_unassigned_ones_come_last()
{
var kitchen = new HomeRoomDto(Guid.NewGuid(), "Küche", 10);
var living = new HomeRoomDto(Guid.NewGuid(), "Wohnzimmer", 5);
var a = FakeHomeApi.Device("Deckenlicht", room: living.Id);
var b = FakeHomeApi.Device("Kaffeemaschine", HomeDeviceKind.Socket, kitchen.Id);
var c = FakeHomeApi.Device("Flurlicht");
_api.States[a.Id] = FakeHomeApi.On(a.Id);
_api.States[b.Id] = FakeHomeApi.Off(b.Id);
_api.States[c.Id] = FakeHomeApi.Offline(c.Id);
var cut = Open(Overview([a, b, c], [kitchen, living]));
var headings = cut.FindAll(".fp-room-title").Select(h => h.TextContent.Trim()).ToList();
Assert.Equal(["Wohnzimmer", "Küche", "Weitere Geräte"], headings);
var cards = cut.FindAll(".fp-device");
Assert.Equal(3, cards.Count);
Assert.Contains("An", cards[0].QuerySelector(".fp-device-status-text")!.TextContent);
Assert.Contains("Aus", cards[1].QuerySelector(".fp-device-status-text")!.TextContent);
Assert.Contains("Offline", cards[2].QuerySelector(".fp-device-status-text")!.TextContent);
Assert.Contains("nicht erreichbar", cards[2].QuerySelector(".fp-device-message")!.TextContent);
}
[Fact]
public void Status_is_never_only_a_color()
{
var d = FakeHomeApi.Device("Lampe");
_api.States[d.Id] = FakeHomeApi.On(d.Id, 12.5);
var cut = Open(Overview([d]));
var card = cut.Find(".fp-device");
Assert.Contains("fp-device-on", card.ClassName);
Assert.NotEmpty(card.QuerySelectorAll(".fp-device-status svg")); // Symbol
Assert.Equal("An", card.QuerySelector(".fp-device-status-text")!.TextContent.Trim()); // Text
Assert.Contains("12,5 W", card.QuerySelector(".fp-device-detail")!.TextContent);
Assert.Equal("true", card.QuerySelector(".fp-device-toggle")!.GetAttribute("aria-pressed"));
}
[Fact]
public void Toggle_button_sends_the_matching_command_and_updates_the_card()
{
var d = FakeHomeApi.Device("Stehlampe");
_api.States[d.Id] = FakeHomeApi.Off(d.Id);
var cut = Open(Overview([d]));
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Equal("An", cut.Find(".fp-device-status-text").TextContent.Trim()));
Assert.Equal([(d.Id, HomeCommand.TurnOn)], _api.Commands);
Assert.Contains("Stehlampe eingeschaltet", cut.Find(".mud-snackbar").TextContent);
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Equal("Aus", cut.Find(".fp-device-status-text").TextContent.Trim()));
Assert.Equal(HomeCommand.TurnOff, _api.Commands[1].Command);
}
[Fact]
public void Failed_command_shows_the_friendly_device_message()
{
var d = FakeHomeApi.Device("Steckdose", HomeDeviceKind.Socket);
_api.States[d.Id] = FakeHomeApi.Off(d.Id);
_api.CommandHandler = (id, _) => ApiResult<HomeCommandResultDto>.Ok(new HomeCommandResultDto(false, "Das Gerät ist nicht erreichbar.", FakeHomeApi.Offline(id)));
var cut = Open(Overview([d]));
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Contains("Steckdose: Das Gerät ist nicht erreichbar.", cut.Find(".mud-snackbar").TextContent));
Assert.Contains("Offline", cut.Find(".fp-device-status-text").TextContent);
Assert.True(cut.Find(".fp-device-toggle").HasAttribute("disabled")); // offline: nicht bedienbar
}
[Fact]
public void Permission_error_from_the_server_is_shown()
{
var d = FakeHomeApi.Device("Lampe");
_api.States[d.Id] = FakeHomeApi.Off(d.Id);
_api.CommandHandler = (_, _) => ApiResult<HomeCommandResultDto>.Fail(new ApiProblem { Status = 403, Detail = "Dieses Gerät darfst du nur ansehen, nicht schalten." });
var cut = Open(Overview([d]));
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Contains("nur ansehen", cut.Find(".mud-snackbar").TextContent));
}
[Fact]
public void View_only_devices_have_disabled_controls_and_say_so()
{
var d = FakeHomeApi.Device("Nur ansehen", canControl: false);
var cover = FakeHomeApi.Device("Rollo (ansehen)", HomeDeviceKind.Cover, canControl: false);
_api.States[d.Id] = FakeHomeApi.On(d.Id);
_api.States[cover.Id] = FakeHomeApi.Cover(cover.Id, "open", 100);
var cut = Open(Overview([d, cover]));
Assert.True(cut.Find(".fp-device-toggle").HasAttribute("disabled"));
Assert.All(cut.FindAll("button.fp-device-open, button.fp-device-stop, button.fp-device-close"), b => Assert.True(b.HasAttribute("disabled")));
Assert.Equal(2, cut.FindAll(".fp-device-readonly").Count);
cut.Find(".fp-device-toggle").Click();
Assert.Empty(_api.Commands);
}
[Fact]
public void Cover_has_open_stop_close_with_labels_and_shows_the_movement()
{
var d = FakeHomeApi.Device("Wohnzimmer-Rollo", HomeDeviceKind.Cover);
_api.States[d.Id] = FakeHomeApi.Cover(d.Id, "closed", 0);
var cut = Open(Overview([d]));
Assert.Contains("Geschlossen", cut.Find(".fp-device-status-text").TextContent);
Assert.Equal("Wohnzimmer-Rollo öffnen", cut.Find(".fp-device-open").GetAttribute("aria-label"));
Assert.Equal("Wohnzimmer-Rollo stoppen", cut.Find(".fp-device-stop").GetAttribute("aria-label"));
Assert.Equal("Wohnzimmer-Rollo schließen", cut.Find(".fp-device-close").GetAttribute("aria-label"));
cut.Find(".fp-device-open").Click();
cut.WaitForAssertion(() => Assert.Contains("Öffnet", cut.Find(".fp-device-status-text").TextContent));
cut.Find(".fp-device-stop").Click();
cut.WaitForAssertion(() => Assert.Contains("Gestoppt (50 % offen)", cut.Find(".fp-device-status-text").TextContent));
cut.Find(".fp-device-close").Click();
cut.WaitForAssertion(() => Assert.Contains("Schließt", cut.Find(".fp-device-status-text").TextContent));
Assert.Equal([HomeCommand.Open, HomeCommand.Stop, HomeCommand.Close], _api.Commands.Select(c => c.Command));
}
[Fact]
public void Sensor_shows_values_and_has_no_controls()
{
var d = FakeHomeApi.Device("Keller", HomeDeviceKind.Sensor);
_api.States[d.Id] = FakeHomeApi.Sensor(d.Id, 18.46, 61);
var cut = Open(Overview([d]));
Assert.Equal("18,5 °C · 61 % Luftfeuchte", cut.Find(".fp-device-status-text").TextContent.Trim());
Assert.Empty(cut.FindAll(".fp-device-toggle, .fp-device-open"));
Assert.Empty(cut.FindAll(".fp-device-readonly"));
}
[Fact]
public void Without_plans_there_are_no_tabs()
{
var cut = Open(Overview([FakeHomeApi.Device("A")]));
Assert.Empty(cut.FindAll(".fp-house-tabs"));
}
// ---------- Plan ----------
private (HomeDeviceDto Light, HomeDeviceDto Cover, HomePlanDto Plan) PlanSetup()
{
var light = FakeHomeApi.Device("Wohnzimmer-Licht", icon: "bulb");
var cover = FakeHomeApi.Device("Rollo", HomeDeviceKind.Cover);
var plan = new HomePlanDto(Guid.NewGuid(), "Erdgeschoss", 3, [new HomePinDto(light.Id, 0.25, 0.5), new HomePinDto(cover.Id, 0.8, 0.1)]);
_api.States[light.Id] = FakeHomeApi.Off(light.Id);
_api.States[cover.Id] = FakeHomeApi.Cover(cover.Id, "open", 100);
return (light, cover, plan);
}
[Fact]
public void Plan_tab_shows_the_image_and_accessible_pins_at_relative_positions()
{
var (light, cover, plan) = PlanSetup();
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-plan")));
var image = cut.Find(".fp-plan-image");
Assert.Equal($"api/home/plans/{plan.Id}/image?v=3", image.GetAttribute("src"));
Assert.Contains("Grundriss Erdgeschoss", image.GetAttribute("alt"));
var pins = cut.FindAll(".fp-pin");
Assert.Equal(2, pins.Count);
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.Contains("Rollo, Offen.", pins[1].GetAttribute("aria-label"));
Assert.DoesNotContain("Umschalten", pins[1].GetAttribute("aria-label")); // 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);
}
[Fact]
public void Clicking_a_switch_pin_toggles_the_device_and_shows_its_details()
{
var (light, cover, plan) = PlanSetup();
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForElement(".fp-pin");
cut.FindAll(".fp-pin")[0].Click();
cut.WaitForAssertion(() => Assert.Contains("fp-pin-on", cut.FindAll(".fp-pin")[0].ClassName));
Assert.Equal([(light.Id, HomeCommand.TurnOn)], _api.Commands);
var detail = cut.Find(".fp-house-detail");
Assert.Contains("Wohnzimmer-Licht", detail.TextContent);
Assert.Contains("An", detail.QuerySelector(".fp-device-status-text")!.TextContent);
}
[Fact]
public void Clicking_a_cover_pin_only_shows_the_details()
{
var (light, cover, plan) = PlanSetup();
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForElement(".fp-pin");
cut.FindAll(".fp-pin")[1].Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-house-detail .fp-device-open")));
Assert.Empty(_api.Commands);
}
[Fact]
public void Offline_pin_is_marked_with_an_exclamation_mark_and_does_not_send_commands()
{
var (light, cover, plan) = PlanSetup();
_api.States[light.Id] = FakeHomeApi.Offline(light.Id);
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForElement(".fp-pin");
var pin = cut.FindAll(".fp-pin")[0];
Assert.Equal("!", pin.QuerySelector(".fp-pin-badge")!.TextContent);
Assert.Contains("Offline", pin.GetAttribute("aria-label"));
pin.Click();
Assert.Empty(_api.Commands);
}
// ---------- Darstellung ----------
[Fact]
public void Pin_style_uses_invariant_percentages()
{
Assert.Equal("left:12.35%;top:100%", FloorPlanView.PinStyle(new HomePinDto(Guid.NewGuid(), 0.1235, 1)));
Assert.Equal("left:0%;top:50%", FloorPlanView.PinStyle(new HomePinDto(Guid.NewGuid(), 0, 0.5)));
}
[Theory]
[InlineData("opening", null, "Öffnet …")]
[InlineData("closing", null, "Schließt …")]
[InlineData("open", 100, "Offen")]
[InlineData("closed", 0, "Geschlossen")]
[InlineData("stopped", 30, "Gestoppt (30 % offen)")]
[InlineData("stopped", null, "Gestoppt")]
public void Cover_status_texts(string state, int? position, string expected)
{
var device = FakeHomeApi.Device("Rollo", HomeDeviceKind.Cover);
Assert.Equal(expected, HouseUi.StatusText(device, FakeHomeApi.Cover(device.Id, state, position)));
}
[Fact]
public void Unknown_state_shows_a_loading_text_not_a_wrong_status()
{
var device = FakeHomeApi.Device("Lampe");
Assert.Equal("Status wird geladen …", HouseUi.StatusText(device, null));
Assert.Equal(HouseUi.Status.Unknown, HouseUi.StatusOf(device, null));
}
}