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:
1 parent
13ab82d9d6
commit
0d9a03dcd5
56 files changed
+11438
-76
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 > 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Generated
+3222
File diff suppressed because it is too large.
Load diff
+286
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user