diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2037bfb..e4c7c4b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,7 +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) + Home/… # Haus: Treiber-Abstraktion (IHomeDeviceDriver, Shelly, Vallox), /api/home/* und /api/admin/home/* (Phase 17/19) CalDav/… # /caldav/* (CalDAV-Server) + /api/caldav/* (App-Passwörter), Phase 12c Hubs/ # SignalR Infrastructure/ # Auth-Setup, Security, Rate Limiting @@ -91,7 +91,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und | 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 | | Kameras ✅ (Phase 18) | `HomeCamera` (Name, `StreamUrl` mit Pfad/Query, nur Heimnetz, AuthUser, `AuthPasswordProtected`, ShowOnDashboard, SortOrder, RowVersion, Soft Delete), `HomeCameraAccess` (Freigabe für Rolle **oder** Person, nur ansehen) – ADR-0024 | -| 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 | +| Haus ✅ (Phase 17) | `HomeRoom` (Name, SortOrder, Soft Delete), `HomeDevice` (Name, Kind Schalter/Steckdose/Licht/Rollo/Sensor/Lüftungsanlage, Icon, RoomId, Driver (Shelly, Vallox – ADR-0025), 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) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cca80c8..7441e18 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -227,6 +227,14 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te - [ ] Integrationstests mit `FakeMjpegServer` (`HomeCameraTests`: Rechte je Rolle/Person, Strom weiterreichen und loslassen, Fehlerfälle, Grenze, Dashboard, Audit) sind geschrieben, aber noch nicht gelaufen (Docker war nicht gestartet); ebenso der Aspire-Start mit echtem Strom über das Web-BFF - [ ] RTSP/HLS/WebRTC, Digest-Anmeldung der Kamera, gemeinsamer Upstream für mehrere Zuschauer, Kameras auf dem Grundriss, Praxistest mit echter Kamera – bewusst nicht umgesetzt (ADR-0024) +## Phase 19 – Lüftungsanlage (Haus, ADR-0025) +- [x] Geräteart „Lüftungsanlage“ (`HomeDeviceKind.Ventilation`) mit Treiber `Vallox` für Vallox MV (z. B. ValloPlus 350 MV) über das WebSocket-Protokoll der Weboberfläche (`READ_TABLES`/`WRITE_DATA`, Prüfsumme, big-endian-Tabelle); keine Migration (Enums als Text) +- [x] Profil auslesen (Zuhause, Abwesend, Stoßlüften, Kamin, Extra, Automatik inkl. Restlaufzeit), Temperaturen (Außen-, Zu-, Ab-, Fortluft), Luftfeuchte, CO₂, Lüfterstufe +- [x] Profile aktivieren (`ProfileHome/Away/Boost/Fireplace`, Stoßlüften/Kamin mit der am Gerät eingestellten Dauer); Rechte, Aktionsprotokoll und SSRF-Schutz wie bei Shelly (gleicher Verbindungs-Handler) +- [x] Hausverwaltung: Dialog ohne Generation/Kanal/Anmeldung für Lüftungsanlagen, Verbindungstest mit Modellname; Gerätekarte mit Messwerten und Profil-Knöpfen (`aria-pressed`) +- [x] Tests: Unit (`ValloxDriverTests`: Rahmen/Prüfsumme gegen Referenzbytes, Tabelle, Profile, Schreibbefehle, Treiber, echter WebSocket über Loopback, SSRF; `HomeRulesTests`), bUnit (Gerätekarte, Profilwahl, nur ansehen, Verwaltungsdialog) +- [ ] Praxistest mit der echten ValloPlus 350 MV, Integrationstest mit Fake-Vallox-Server (Docker), Extra/Automatik wählen, Solltemperaturen/Lüfterstufen je Profil, Filter-/Alarmanzeige – bewusst nicht umgesetzt (ADR-0025) + ## 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 diff --git a/docs/adr/0025-haus-lueftung-vallox.md b/docs/adr/0025-haus-lueftung-vallox.md new file mode 100644 index 0000000..274d12a --- /dev/null +++ b/docs/adr/0025-haus-lueftung-vallox.md @@ -0,0 +1,46 @@ +# ADR-0025: Haus – Lüftungsanlage Vallox MV (Profil, Temperaturen) + +* Status: akzeptiert (Phase 19) +* Datum: 2026-10-16 + +## Kontext +In der Hausverwaltung soll eine Lüftungsanlage angelegt werden können, konkret eine **Vallox ValloPlus 350 MV**. Ziel: das aktive Profil auslesen, die Temperaturen anzeigen und Profile aktivieren. +Die MV-Geräte haben keine dokumentierte REST-API. Ihre eingebaute Weboberfläche spricht mit dem Gerät über ein binäres **WebSocket-Protokoll** (`ws:///`), das die Bibliothek +[vallox_websocket_api](https://github.com/yozik04/vallox_websocket_api) (Grundlage der Home-Assistant-Integration „Vallox“) nachbildet. Modbus/RS-485 bräuchte zusätzliche Hardware. + +## Entscheidung + +### 1. Eingliederung in das Geräte-Modell (ADR-0023) +* Neue Geräteart `HomeDeviceKind.Ventilation` („Lüftungsanlage“) und neuer Treiber `HomeDriverType.Vallox` hinter `IHomeDeviceDriver`. Damit gelten Räume, Pläne/Pins, Freigaben (ansehen/schalten), + Aktionsprotokoll, Audit und SSRF-Schutz unverändert. Die Enums werden als Text gespeichert – **keine Migration** nötig. +* Treiber und Art gehören zusammen (`HomeRules.IsValidDriverForKind`): Vallox nur für Lüftungsanlagen, Shelly für alle anderen. Vallox nutzt immer „Generation“ 1, Kanal 0 und **keine Anmeldung** + (die Weboberfläche hat keine); beim Speichern werden Benutzer/Passwort eines Vallox-Geräts verworfen. +* Neue Befehle `ProfileHome`, `ProfileAway`, `ProfileBoost`, `ProfileFireplace` (nur für `Ventilation` erlaubt). Ein Profilwechsel braucht wie Schalten die Stufe „Schalten“; „nur ansehen“ zeigt Werte und Profil. +* Der Zustand bekommt optional `HomeVentilationDto` (Profil, Restminuten, Außen-/Zu-/Ab-/Fortlufttemperatur, Lüfterstufe %, CO₂); Luftfeuchte steht im vorhandenen Feld `HumidityPercent`, `TemperatureC` ist die Ablufttemperatur (Raumluft). + +### 2. Protokoll (`ValloxProtocol`) +* Anfrage: 16-Bit-Wörter little-endian – Länge (Wörter nach dem Längenfeld, inkl. Prüfsumme), Befehl, Nutzdaten, Prüfsumme = Summe der Wörter davor mod 2¹⁶. +* Lesen: `READ_TABLES` (246) → `03 00 F6 00 00 00 F9 00`; Antwort ist die komplette Datentabelle als 16-Bit-Wörter **big-endian**. Die genutzten Werte liegen in den ersten Adressbereichen + (`g_cyclone_general_info`, `g_cyclone_hw_state`, `g_cyclone_sw_state`, `g_cyclone_settings`), die bei den Firmware-Ständen 2.0.16 und 2.0.22 identisch angeordnet sind; die Positionen sind daher fest + hinterlegt (statt die Konstanten zur Laufzeit aus `js/bundle.js` des Geräts zu lesen). Zu kurze/ungerade Antworten gelten als „kein Vallox“. +* Temperaturen: Kelvin × 100 → °C (eine Nachkommastelle); 0/`FFFF` oder unplausible Werte = Fühler fehlt. +* Profil wie die Weboberfläche: Stoßlüften-, Kamin-, Extra-Timer > 0 (in dieser Reihenfolge) haben Vorrang, sonst `A_CYC_STATE` 0 = Zuhause, 1 = Abwesend, 2 = Automatik. Timer `FFFF` = ohne Zeitbegrenzung. +* Schreiben: `WRITE_DATA` (249) mit Adresse/Wert-Paaren, nach Adresse sortiert – exakt wie `set_profile` der Bibliothek: Zuhause/Abwesend setzen `A_CYC_STATE` und beenden alle Timer; + Stoßlüften/Kamin setzen ihren Timer auf die **am Gerät eingestellte Dauer** (`A_CYC_BOOST_TIME`/`A_CYC_FIREPLACE_TIME`, dafür wird vorher gelesen; ohne Wert 30 bzw. 15 min) und beenden die anderen Timer. +* Keine Wiederholungen (ein Profilbefehl soll nie doppelt laufen), eine WebSocket-Verbindung je Anfrage, Zeitlimit 4 s, Antwort ≤ 64 KB. + +### 3. Netzwerk und SSRF +Die WebSocket-Verbindung nutzt denselben `SocketsHttpHandler` wie Shelly (`HomeHttp.CreateHandler`, als eigener `HttpMessageInvoker` – ein `HttpClient` würde die 101-Antwort puffern wollen): jede aufgelöste IP wird beim +Verbinden geprüft (nur Heimnetz, DNS-Rebinding), kein Proxy, keine Weiterleitungen. Fehler werden auf feste deutsche Meldungen abgebildet, nie Geräteinhalte. + +### 4. Oberfläche +* Hausverwaltung → Gerät anlegen, Art „Lüftungsanlage“: nur Name, Symbol, Raum und Adresse; Hinweis auf Vallox MV/WebSocket; „Verbindung testen“ zeigt das Modell (z. B. „ValloPlus 350 MV“). +* Gerätekarte: Status „Profil: Stoßlüften · noch 25 min“, Messwerte als Liste (Außenluft, Zuluft, Abluft, Fortluft, Luftfeuchte, CO₂, Lüfter), Profil-Knöpfe Zuhause/Abwesend/Stoßlüften/Kamin + (`aria-pressed` am aktiven Profil, gesperrt bei „nur ansehen“ oder offline). Aktualisierung wie alle Geräte alle 10 s. + +## Konsequenzen +* Ein neues Firmware-Layout könnte die festen Positionen verschieben; dann zeigt der Verbindungstest unplausible Werte bzw. „kein Vallox“, und die Positionen müssen ergänzt werden + (Alternative: Konstanten aus `js/bundle.js` des Geräts lesen – bewusst nicht umgesetzt, solange beide bekannten Stände gleich sind). +* Bewusst nicht umgesetzt: Profil „Extra“/„Automatik“ wählen, Dauer je Aufruf, Solltemperaturen und Lüfterstufen je Profil ändern, Filter-/Alarmanzeige, Verlauf (`LOG_RAW`). Alles ließe sich über + dieselben Befehle ergänzen. +* Praxistest mit der echten Anlage steht aus; getestet ist gegen die Referenzwerte der Bibliothek, einen Fake-Transport und einen lokalen WebSocket-Server. diff --git a/src/FamilyPlanner.Api/Features/Home/HomeAdminEndpoints.cs b/src/FamilyPlanner.Api/Features/Home/HomeAdminEndpoints.cs index 8472d4e..70edfc0 100644 --- a/src/FamilyPlanner.Api/Features/Home/HomeAdminEndpoints.cs +++ b/src/FamilyPlanner.Api/Features/Home/HomeAdminEndpoints.cs @@ -394,8 +394,10 @@ internal static class HomeAdminEndpoints 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)); + // Vallox MV kennt weder Kanal noch Anmeldung: nichts davon speichern (auch kein altes Passwort eines umgewidmeten Geräts). + var isVallox = request.Driver == HomeDriverType.Vallox; + var user = isVallox || string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim(); + var hasPassword = !isVallox && !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“)."); @@ -408,10 +410,10 @@ internal static class HomeAdminEndpoints device.Driver = request.Driver; device.Generation = request.Generation; device.Host = host; - device.Channel = request.Channel; + device.Channel = isVallox ? 0 : request.Channel; device.AuthUser = user; device.SortOrder = request.SortOrder; - if (request.ClearPassword) + if (request.ClearPassword || isVallox) { device.AuthPasswordProtected = null; } @@ -424,7 +426,9 @@ internal static class HomeAdminEndpoints } 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")}"; + d.Driver == HomeDriverType.Vallox + ? $"{HomeRules.DisplayName(d.Kind)}, Vallox MV, {d.Host}" + : $"{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 grants) { diff --git a/src/FamilyPlanner.Api/Features/Home/HomeDriver.cs b/src/FamilyPlanner.Api/Features/Home/HomeDriver.cs index f9d3059..70b61aa 100644 --- a/src/FamilyPlanner.Api/Features/Home/HomeDriver.cs +++ b/src/FamilyPlanner.Api/Features/Home/HomeDriver.cs @@ -1,4 +1,5 @@ using FamilyPlanner.Domain.Home; +using FamilyPlanner.Shared.Home; namespace FamilyPlanner.Api.Features.Home; @@ -12,7 +13,8 @@ public sealed record HomeDeviceState( int? CoverPosition = null, double? PowerWatts = null, double? TemperatureC = null, - double? HumidityPercent = null); + double? HumidityPercent = null, + HomeVentilationDto? Ventilation = null); /// Kennung des Geräts aus dem Verbindungstest. public sealed record HomeDeviceInfo(string? Model, string? Firmware); @@ -27,7 +29,7 @@ public enum HomeErrorKind /// Benutzername/Passwort fehlen oder stimmen nicht. Unauthorized, - /// Antwort unbrauchbar oder zu groß (kein Shelly, falsche Generation/Kanal). + /// Antwort unbrauchbar oder zu groß (kein Shelly/Vallox, falsche Generation/Kanal). BadResponse, /// Die Adresse liegt (nach Namensauflösung) nicht im Heimnetz – aus Sicherheitsgründen gesperrt. diff --git a/src/FamilyPlanner.Api/Features/Home/HomeServices.cs b/src/FamilyPlanner.Api/Features/Home/HomeServices.cs index 442672c..4240964 100644 --- a/src/FamilyPlanner.Api/Features/Home/HomeServices.cs +++ b/src/FamilyPlanner.Api/Features/Home/HomeServices.cs @@ -198,7 +198,7 @@ public sealed class HomeDeviceService( 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); + new(id, true, s.IsOn, s.CoverState, s.CoverPosition, s.PowerWatts, s.TemperatureC, s.HumidityPercent, null, s.Ventilation); private static HomeDeviceStateDto Offline(Guid id, string message) => new(id, false, null, null, null, null, null, null, message); diff --git a/src/FamilyPlanner.Api/Features/Home/ValloxDriver.cs b/src/FamilyPlanner.Api/Features/Home/ValloxDriver.cs new file mode 100644 index 0000000..4a9ddee --- /dev/null +++ b/src/FamilyPlanner.Api/Features/Home/ValloxDriver.cs @@ -0,0 +1,140 @@ +using System.Net.WebSockets; +using FamilyPlanner.Domain.Home; + +namespace FamilyPlanner.Api.Features.Home; + +/// Eine Binär-Nachricht an eine Vallox-Anlage senden und die erste Antwort lesen (eigene Verbindung je Anfrage, keine Wiederholungen). +public interface IValloxTransport +{ + Task RequestAsync(string host, byte[] payload, CancellationToken ct); +} + +/// +/// WebSocket-Zugriff auf ws://host/. Die Verbindung läuft über denselben Handler wie der home-HttpClient (, ADR-0023): +/// jede aufgelöste IP wird beim Verbinden geprüft, kein Proxy, keine Weiterleitungen; Zeitlimit wie bei Shelly, Antwort höchstens . +/// Bewusst ein eigener statt eines : dieser würde die 101-Antwort (den offenen Strom) puffern wollen. +/// +public sealed class ValloxWebSocketTransport : IValloxTransport, IDisposable +{ + private readonly HttpMessageInvoker _invoker = new(HomeHttp.CreateHandler(), disposeHandler: true); + + public void Dispose() => _invoker.Dispose(); + + public async Task RequestAsync(string host, byte[] payload, CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(HomeHttp.RequestTimeout); + var token = timeout.Token; + + var uri = new UriBuilder(new Uri(host, UriKind.Absolute)) { Path = "/" }; + uri.Scheme = uri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws"; + + using var socket = new ClientWebSocket(); + socket.Options.KeepAliveInterval = TimeSpan.Zero; + try + { + await socket.ConnectAsync(uri.Uri, _invoker, token); + await socket.SendAsync(payload, WebSocketMessageType.Binary, endOfMessage: true, token); + + using var response = new MemoryStream(); + var buffer = new byte[4 * 1024]; + while (true) + { + var result = await socket.ReceiveAsync(buffer, token); + if (result.MessageType == WebSocketMessageType.Close) + { + throw new HomeException(HomeErrorKind.BadResponse, "Die Lüftungsanlage hat die Verbindung ohne Antwort beendet."); + } + + response.Write(buffer, 0, result.Count); + if (response.Length > ValloxProtocol.MaxResponseBytes) + { + throw new HomeException(HomeErrorKind.BadResponse, "Die Antwort des Geräts ist zu groß."); + } + + if (result.EndOfMessage) + { + break; + } + } + + try + { + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, token); + } + catch (Exception ex) when (ex is WebSocketException or OperationCanceledException) + { + // Antwort liegt vor; ein unsauberes Schließen ist egal. + } + + return response.ToArray(); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new HomeException(HomeErrorKind.Timeout, "Das Gerät antwortet nicht rechtzeitig."); + } + catch (Exception ex) when (ex is WebSocketException or HttpRequestException && HomeHttp.IsBlocked(ex)) + { + throw new HomeException(HomeErrorKind.Blocked, "Diese Adresse liegt nicht im Heimnetz und wird aus Sicherheitsgründen nicht angesprochen.", ex); + } + catch (Exception ex) when (ex is WebSocketException or HttpRequestException) + { + throw new HomeException( + HomeErrorKind.Unreachable, + "Die Lüftungsanlage ist nicht erreichbar. Bitte Adresse prüfen und ob die Weboberfläche der Anlage im Heimnetz aktiv ist.", + ex); + } + } +} + +/// +/// Treiber für Vallox-Lüftungsanlagen der MV-Reihe (z. B. ValloPlus 350 MV): liest Profil, Temperaturen, Feuchte, CO₂ und Lüfterstufe aus der Datentabelle +/// und aktiviert die Profile Zuhause, Abwesend, Stoßlüften und Kamin (ADR-0025). Das Gerät hat keine Anmeldung; Kanal und Generation spielen keine Rolle. +/// +public sealed class ValloxDriver(IValloxTransport transport) : IHomeDeviceDriver +{ + public HomeDriverType Type => HomeDriverType.Vallox; + + public async Task GetStateAsync(HomeDeviceConnection c, CancellationToken ct) + { + var metrics = await ReadAsync(c, ct); + return new HomeDeviceState( + TemperatureC: metrics.ExtractAirC, + HumidityPercent: metrics.HumidityPercent, + Ventilation: metrics.ToDto()); + } + + public async Task ExecuteAsync(HomeDeviceConnection c, HomeCommand command, CancellationToken ct) + { + EnsureVentilation(c); + if (!HomeRules.IsCommandAllowed(HomeDeviceKind.Ventilation, command)) + { + throw new HomeException(HomeErrorKind.NotSupported, "Dieser Befehl passt nicht zu diesem Gerät."); + } + + // Stoßlüften/Kamin laufen so lange, wie am Gerät eingestellt – die Dauer steht in der Tabelle. + var current = ValloxProtocol.NeedsDuration(command) ? await ReadAsync(c, ct) : null; + await transport.RequestAsync(c.Host, ValloxProtocol.WriteRequest(ValloxProtocol.ProfileWrites(command, current)), ct); + } + + public async Task TestAsync(HomeDeviceConnection c, CancellationToken ct) + { + var metrics = await ReadAsync(c, ct); + return new HomeDeviceInfo(ValloxProtocol.ModelName(metrics.ModelIndex), null); + } + + private async Task ReadAsync(HomeDeviceConnection c, CancellationToken ct) + { + EnsureVentilation(c); + var response = await transport.RequestAsync(c.Host, ValloxProtocol.ReadTablesRequest(), ct); + return ValloxProtocol.ParseTables(response); + } + + private static void EnsureVentilation(HomeDeviceConnection c) + { + if (c.Kind != HomeDeviceKind.Ventilation) + { + throw new HomeException(HomeErrorKind.NotSupported, "Der Vallox-Treiber steuert nur Lüftungsanlagen."); + } + } +} diff --git a/src/FamilyPlanner.Api/Features/Home/ValloxProtocol.cs b/src/FamilyPlanner.Api/Features/Home/ValloxProtocol.cs new file mode 100644 index 0000000..cf27720 --- /dev/null +++ b/src/FamilyPlanner.Api/Features/Home/ValloxProtocol.cs @@ -0,0 +1,202 @@ +using System.Buffers.Binary; +using FamilyPlanner.Domain.Home; +using FamilyPlanner.Shared.Home; + +namespace FamilyPlanner.Api.Features.Home; + +/// Werte aus der Datentabelle einer Vallox-Anlage (Rohwerte bereits umgerechnet; null = Fühler fehlt/kein Wert). +public sealed record ValloxMetrics( + HomeVentilationProfile Profile, + int? MinutesLeft, + double? OutdoorAirC, + double? SupplyAirC, + double? ExtractAirC, + double? ExhaustAirC, + int? FanSpeedPercent, + double? HumidityPercent, + int? Co2Ppm, + int BoostMinutes, + int FireplaceMinutes, + int? ModelIndex) +{ + public HomeVentilationDto ToDto() => + new(Profile, MinutesLeft, OutdoorAirC, SupplyAirC, ExtractAirC, ExhaustAirC, FanSpeedPercent, Co2Ppm); +} + +/// +/// Binärprotokoll der Weboberfläche von Vallox-Anlagen der MV-Reihe (WebSocket ws://host/, Nachrichten als Binär-Frames). Nachgebaut nach +/// vallox_websocket_api (auch von Home Assistant genutzt): +/// +/// Anfrage: 16-Bit-Wörter little-endian – Länge (Anzahl der folgenden Wörter inkl. Prüfsumme), Befehl, Nutzdaten, Prüfsumme (Summe aller Wörter davor, mod 2^16). +/// READ_TABLES (246) liefert die komplette Datentabelle als 16-Bit-Wörter big-endian; die Position eines Werts ergibt sich aus den Adressbereichen der Firmware. +/// WRITE_DATA (249) schreibt Paare aus Adresse und Wert (nach Adresse sortiert). +/// +/// Die hier genutzten Werte liegen in den ersten, bei allen bekannten Firmware-Ständen (2.0.16, 2.0.22) gleich aufgebauten Bereichen. +/// +public static class ValloxProtocol +{ + public const ushort CommandWriteData = 249; + public const ushort CommandReadTables = 246; + + /// Größte erlaubte Antwort (die Tabelle hat knapp 700 Wörter). + public const int MaxResponseBytes = 64 * 1024; + + /// Am Gerät nicht eingestellte Dauer: Standardwerte der Vallox-Werkseinstellung (Minuten). + public const int DefaultBoostMinutes = 30; + public const int DefaultFireplaceMinutes = 15; + + /// Kein Wert / Fühler fehlt. + private const ushort NoValue = 0xFFFF; + + /// Profil läuft ohne Zeitbegrenzung. + private const ushort NoTimeout = 0xFFFF; + + // Adressen (Modbus-Nummern aus VlxDevConstants) für das Schreiben. + internal const ushort AddressState = 4609; + internal const ushort AddressBoostTimer = 4612; + internal const ushort AddressFireplaceTimer = 4613; + internal const ushort AddressExtraTimer = 4614; + + // Positionen in der READ_TABLES-Antwort (Wort-Index). + internal const int OffsetMachineModel = 17; // A_CYC_MACHINE_MODEL (g_cyclone_general_info 0–35 → 0) + internal const int OffsetFanSpeed = 64; // A_CYC_FAN_SPEED 4353 (g_cyclone_hw_state 4352–4394 → 63) + internal const int OffsetExtractAir = 65; // A_CYC_TEMP_EXTRACT_AIR 4354 + internal const int OffsetExhaustAir = 66; // A_CYC_TEMP_EXHAUST_AIR 4355 + internal const int OffsetOutdoorAir = 67; // A_CYC_TEMP_OUTDOOR_AIR 4356 + internal const int OffsetSupplyAir = 69; // A_CYC_TEMP_SUPPLY_AIR 4358 + internal const int OffsetHumidity = 74; // A_CYC_RH_VALUE 4363 + internal const int OffsetCo2 = 75; // A_CYC_CO2_VALUE 4364 + internal const int OffsetState = 107; // A_CYC_STATE 4609 (g_cyclone_sw_state 4608–4632 → 106) + internal const int OffsetBoostTimer = 110; // A_CYC_BOOST_TIMER 4612 + internal const int OffsetFireplaceTimer = 111; // A_CYC_FIREPLACE_TIMER 4613 + internal const int OffsetExtraTimer = 112; // A_CYC_EXTRA_TIMER 4614 + internal const int OffsetBoostTime = 246; // A_CYC_BOOST_TIME 20544 (g_cyclone_settings 20480–20555 → 182) + internal const int OffsetFireplaceTime = 247; // A_CYC_FIREPLACE_TIME 20545 + + /// Mindestlänge der Tabelle (in Wörtern), damit alle genutzten Werte enthalten sind. + internal const int MinTableWords = OffsetFireplaceTime + 1; + + /// Modellnamen nach A_CYC_MACHINE_MODEL (Reihenfolge wie in der Vallox-Weboberfläche). + private static readonly string?[] Models = + [ + null, "Vallox 096 MV", "Vallox 110 MV", "Vallox 145 MV", "Vallox 245 MV", "ValloPlus 270 MV", "ValloPlus 350 MV", "ValloPlus 510 MV", + "ValloPlus 850 MV", "Vallox TSK Multi 50 MV", "Vallox TSK Multi 80 MV", "ValloMulti 200 MV", "ValloMulti 300 MV", "DV96 Adroit", "DV110 Adroit", + "DV145 Adroit", "DV245 Adroit", "DV TSK Multi 50 Adroit", "DV TSK Multi 80 Adroit", "ValloPlus 350 MV-E", "Aito kotilämpökone", "Vallox 90 MV", + "Vallox 90K MV", "ValloPlus 240 MV", "ValloPlus 240-K MV", "ValloPlus 240-E MV", "DV90 Adroit", "DV90K Adroit", "Vallox 101 MV", + "ValloPlus 270 MV-E", "ValloPlus 510 MV-E", "ValloPlus 850 MV-E", "ValloMulti 200 MV-E", "ValloMulti 300 MV-E", "Vallox 51 MV", "Vallox 51K MV", + "ValloPlus 180 MV", "ValloPlus 180-K MV", "ValloPlus 180-E MV", "DV51 Adroit", "DV51K Adroit", + ]; + + /// Anfrage „komplette Datentabelle lesen“: 03 00 F6 00 00 00 F9 00. + public static byte[] ReadTablesRequest() => Frame(CommandReadTables, [0]); + + /// Schreibanfrage für Adresse/Wert-Paare (werden nach Adresse sortiert, wie die Weboberfläche es tut). + public static byte[] WriteRequest(IEnumerable<(ushort Address, ushort Value)> items) + { + var words = items.OrderBy(i => i.Address).SelectMany(i => new[] { i.Address, i.Value }).ToArray(); + if (words.Length == 0) + { + throw new ArgumentException("Mindestens ein Wert.", nameof(items)); + } + + return Frame(CommandWriteData, words); + } + + private static byte[] Frame(ushort command, ReadOnlySpan payload) + { + // Länge = Befehl + Nutzdaten + Prüfsumme (in Wörtern). + var words = new ushort[payload.Length + 3]; + words[0] = (ushort)(payload.Length + 2); + words[1] = command; + payload.CopyTo(words.AsSpan(2)); + + var checksum = 0; + for (var i = 0; i < words.Length - 1; i++) + { + checksum += words[i]; + } + + words[^1] = (ushort)(checksum & 0xFFFF); + + var bytes = new byte[words.Length * 2]; + for (var i = 0; i < words.Length; i++) + { + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(i * 2), words[i]); + } + + return bytes; + } + + /// Wertet die Antwort auf aus; wirft , wenn sie nicht wie eine Vallox-Tabelle aussieht. + public static ValloxMetrics ParseTables(ReadOnlySpan response) + { + if (response.Length % 2 != 0 || response.Length / 2 < MinTableWords) + { + throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät antwortet nicht wie eine Vallox-Lüftungsanlage. Bitte Adresse prüfen."); + } + + var data = response.ToArray(); + ushort Word(int offset) => BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(offset * 2, 2)); + + var boost = Word(OffsetBoostTimer); + var fireplace = Word(OffsetFireplaceTimer); + var extra = Word(OffsetExtraTimer); + var (profile, timer) = boost > 0 ? (HomeVentilationProfile.Boost, boost) + : fireplace > 0 ? (HomeVentilationProfile.Fireplace, fireplace) + : extra > 0 ? (HomeVentilationProfile.Extra, extra) + : (Word(OffsetState) switch + { + 0 => HomeVentilationProfile.Home, + 1 => HomeVentilationProfile.Away, + 2 => HomeVentilationProfile.Auto, + _ => HomeVentilationProfile.Unknown, + }, (ushort)0); + + var model = Word(OffsetMachineModel); + return new ValloxMetrics( + profile, + timer is > 0 and not NoTimeout ? timer : null, + Celsius(Word(OffsetOutdoorAir)), + Celsius(Word(OffsetSupplyAir)), + Celsius(Word(OffsetExtractAir)), + Celsius(Word(OffsetExhaustAir)), + Word(OffsetFanSpeed) is var fan and <= 100 ? fan : null, + Word(OffsetHumidity) is var rh and > 0 and <= 100 ? rh : null, + Word(OffsetCo2) is var co2 and > 0 and < 10_000 ? co2 : null, + Duration(Word(OffsetBoostTime), DefaultBoostMinutes), + Duration(Word(OffsetFireplaceTime), DefaultFireplaceMinutes), + model == NoValue ? null : model); + } + + /// Was für einen Profil-Befehl geschrieben wird (wie die Vallox-Weboberfläche bzw. vallox_websocket_api set_profile). + public static IReadOnlyList<(ushort Address, ushort Value)> ProfileWrites(HomeCommand command, ValloxMetrics? current) => command switch + { + HomeCommand.ProfileHome => [(AddressState, 0), (AddressBoostTimer, 0), (AddressFireplaceTimer, 0), (AddressExtraTimer, 0)], + HomeCommand.ProfileAway => [(AddressState, 1), (AddressBoostTimer, 0), (AddressFireplaceTimer, 0), (AddressExtraTimer, 0)], + HomeCommand.ProfileBoost => + [(AddressBoostTimer, (ushort)(current?.BoostMinutes ?? DefaultBoostMinutes)), (AddressFireplaceTimer, 0), (AddressExtraTimer, 0)], + HomeCommand.ProfileFireplace => + [(AddressBoostTimer, 0), (AddressFireplaceTimer, (ushort)(current?.FireplaceMinutes ?? DefaultFireplaceMinutes)), (AddressExtraTimer, 0)], + _ => throw new HomeException(HomeErrorKind.NotSupported, "Dieser Befehl passt nicht zu diesem Gerät."), + }; + + /// Braucht der Befehl die am Gerät eingestellte Dauer (vorher lesen)? + public static bool NeedsDuration(HomeCommand command) => command is HomeCommand.ProfileBoost or HomeCommand.ProfileFireplace; + + public static string ModelName(int? index) => + index is { } i && i >= 0 && i < Models.Length && Models[i] is { } name ? name : index is { } n ? $"Vallox (Modell-Nr. {n})" : "Vallox"; + + /// Kelvin × 100 → °C mit einer Nachkommastelle; 0/FFFF und Unplausibles = kein Fühler. + internal static double? Celsius(ushort raw) + { + if (raw is 0 or NoValue) + { + return null; + } + + var celsius = Math.Round(raw / 100.0 - 273.15, 1, MidpointRounding.AwayFromZero); + return celsius is >= -60 and <= 120 ? celsius : null; + } + + private static int Duration(ushort raw, int fallback) => raw is 0 or NoValue ? fallback : raw; +} diff --git a/src/FamilyPlanner.Api/Program.cs b/src/FamilyPlanner.Api/Program.cs index b36eaa1..275e9ce 100644 --- a/src/FamilyPlanner.Api/Program.cs +++ b/src/FamilyPlanner.Api/Program.cs @@ -68,6 +68,8 @@ builder.Services.AddHttpClient(HomeHttp.ClientName, client => client.Timeout = T #pragma warning restore EXTEXP0001 builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Lüftungsanlagen (ADR-0025) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/FamilyPlanner.Domain/Home/HomeEntities.cs b/src/FamilyPlanner.Domain/Home/HomeEntities.cs index a73cbd1..72e60cc 100644 --- a/src/FamilyPlanner.Domain/Home/HomeEntities.cs +++ b/src/FamilyPlanner.Domain/Home/HomeEntities.cs @@ -19,12 +19,30 @@ public enum HomeDeviceKind /// Nur lesend: Temperatur/Luftfeuchte (und ggf. Leistung). Sensor = 4, + + /// Lüftungsanlage mit Wärmerückgewinnung (z. B. Vallox MV): Temperaturen lesen, Profil wählen. + Ventilation = 5, } /// Treiber, der das Gerät anspricht. Weitere Treiber (MQTT, Home Assistant …) kommen als neuer Wert plus IHomeDeviceDriver. public enum HomeDriverType { Shelly = 0, + + /// Vallox-Lüftungsanlagen der MV-Reihe (z. B. ValloPlus 350 MV) über das WebSocket-Protokoll ihrer Weboberfläche. + Vallox = 1, +} + +/// Profil einer Lüftungsanlage. , und laufen mit Zeitbegrenzung. +public enum HomeVentilationProfile +{ + Unknown = 0, + Home = 1, + Away = 2, + Boost = 3, + Fireplace = 4, + Extra = 5, + Auto = 6, } /// Was darf eine Freigabe? = Zustand sehen, = zusätzlich schalten. @@ -44,6 +62,18 @@ public enum HomeCommand Open = 3, Close = 4, Stop = 5, + + /// Lüftung: Profil „Zuhause“. + ProfileHome = 6, + + /// Lüftung: Profil „Abwesend“. + ProfileAway = 7, + + /// Lüftung: „Stoßlüften“ für die am Gerät eingestellte Dauer. + ProfileBoost = 8, + + /// Lüftung: „Kamin“ (Überdruck) für die am Gerät eingestellte Dauer. + ProfileFireplace = 9, } public sealed class HomeRoom : AuditableEntity, ISoftDeletable @@ -79,7 +109,7 @@ public sealed class HomeDevice : AuditableEntity, ISoftDeletable, IHasRowVersion public HomeDriverType Driver { get; set; } - /// Shelly: 1 = Gen1 (REST), 2 = Gen2+ (RPC). + /// Shelly: 1 = Gen1 (REST), 2 = Gen2+ (RPC). Vallox: immer 1 (MV-WebSocket-Protokoll). public int Generation { get; set; } = 2; /// Normalisierte Basisadresse im Heimnetz, z. B. http://192.168.1.50 (). diff --git a/src/FamilyPlanner.Domain/Home/HomeRules.cs b/src/FamilyPlanner.Domain/Home/HomeRules.cs index f626dc2..e40b7f2 100644 --- a/src/FamilyPlanner.Domain/Home/HomeRules.cs +++ b/src/FamilyPlanner.Domain/Home/HomeRules.cs @@ -39,6 +39,7 @@ public static class HomeRules HomeDeviceKind.Light => "Licht", HomeDeviceKind.Cover => "Rollo", HomeDeviceKind.Sensor => "Sensor", + HomeDeviceKind.Ventilation => "Lüftungsanlage", _ => kind.ToString(), }; @@ -50,15 +51,40 @@ public static class HomeRules HomeCommand.Open => "geöffnet", HomeCommand.Close => "geschlossen", HomeCommand.Stop => "gestoppt", + HomeCommand.ProfileHome => "auf „Zuhause“ gestellt", + HomeCommand.ProfileAway => "auf „Abwesend“ gestellt", + HomeCommand.ProfileBoost => "auf „Stoßlüften“ gestellt", + HomeCommand.ProfileFireplace => "auf „Kamin“ gestellt", _ => command.ToString(), }; + public static string DisplayName(HomeVentilationProfile profile) => profile switch + { + HomeVentilationProfile.Home => "Zuhause", + HomeVentilationProfile.Away => "Abwesend", + HomeVentilationProfile.Boost => "Stoßlüften", + HomeVentilationProfile.Fireplace => "Kamin", + HomeVentilationProfile.Extra => "Extra", + HomeVentilationProfile.Auto => "Automatik", + _ => "Unbekannt", + }; + + /// Die Profile, die sich an einer Lüftungsanlage wählen lassen, mit dem zugehörigen Befehl. + public static IReadOnlyList<(HomeVentilationProfile Profile, HomeCommand Command)> VentilationProfiles { get; } = + [ + (HomeVentilationProfile.Home, HomeCommand.ProfileHome), + (HomeVentilationProfile.Away, HomeCommand.ProfileAway), + (HomeVentilationProfile.Boost, HomeCommand.ProfileBoost), + (HomeVentilationProfile.Fireplace, HomeCommand.ProfileFireplace), + ]; + /// Passt der Befehl zur Geräteart? Sensoren nehmen keine Befehle an. 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, + HomeDeviceKind.Ventilation => command is HomeCommand.ProfileHome or HomeCommand.ProfileAway or HomeCommand.ProfileBoost or HomeCommand.ProfileFireplace, _ => false, }; @@ -116,9 +142,22 @@ public static class HomeRules public static bool IsValidGeneration(HomeDriverType driver, int generation) => driver switch { HomeDriverType.Shelly => generation is 1 or 2, + HomeDriverType.Vallox => generation == 1, _ => false, }; + /// Passen Treiber und Geräteart zusammen? Vallox steuert nur Lüftungsanlagen, Shelly alles außer Lüftungsanlagen. + public static bool IsValidDriverForKind(HomeDriverType driver, HomeDeviceKind kind) => driver switch + { + HomeDriverType.Shelly => kind != HomeDeviceKind.Ventilation, + HomeDriverType.Vallox => kind == HomeDeviceKind.Ventilation, + _ => false, + }; + + /// Der Treiber, der zu einer Geräteart gehört (die Oberfläche lässt ihn nicht separat wählen). + public static HomeDriverType DefaultDriver(HomeDeviceKind kind) => + kind == HomeDeviceKind.Ventilation ? HomeDriverType.Vallox : HomeDriverType.Shelly; + public static bool IsValidIcon(string? icon) => string.IsNullOrEmpty(icon) || Icons.ContainsKey(icon); /// Kürzt Leerraum; null, wenn leer oder zu lang. diff --git a/src/FamilyPlanner.Shared/Home/HomeContracts.cs b/src/FamilyPlanner.Shared/Home/HomeContracts.cs index 29ebcb4..2f24282 100644 --- a/src/FamilyPlanner.Shared/Home/HomeContracts.cs +++ b/src/FamilyPlanner.Shared/Home/HomeContracts.cs @@ -78,7 +78,22 @@ public sealed record HomeDeviceStateDto( double? PowerWatts, double? TemperatureC, double? HumidityPercent, - string? Message); + string? Message, + HomeVentilationDto? Ventilation = null); + +/// +/// Zustand einer Lüftungsanlage (nur bei ). Temperaturen in °C; null = Fühler fehlt oder liefert nichts. +/// ist die Restlaufzeit bei Stoßlüften/Kamin/Extra. +/// +public sealed record HomeVentilationDto( + HomeVentilationProfile Profile, + int? MinutesLeft, + double? OutdoorAirC, + double? SupplyAirC, + double? ExtractAirC, + double? ExhaustAirC, + int? FanSpeedPercent, + int? Co2Ppm); public sealed record HomeStatesDto(IReadOnlyList States, DateTimeOffset At); @@ -142,7 +157,9 @@ public sealed class SaveHomeDeviceRequestValidator : AbstractValidator 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."); + .WithMessage(r => r.Driver == HomeDriverType.Vallox ? "Vallox-Geräte nutzen immer Protokollversion 1." : "Bitte Generation 1 (Gen1) oder 2 (Gen2/Plus/Pro/Gen3) wählen."); + RuleFor(r => r.Driver).Must((r, d) => !Enum.IsDefined(r.Kind) || !Enum.IsDefined(d) || HomeRules.IsValidDriverForKind(d, r.Kind)) + .WithMessage("Lüftungsanlagen werden über den Vallox-Treiber angesprochen, alle anderen Geräte über Shelly."); RuleFor(r => r.Channel).Must(HomeRules.IsValidChannel) .WithMessage($"Der Kanal muss zwischen 0 und {HomeRules.MaxChannel} liegen."); RuleFor(r => r.Host).Custom((host, context) => @@ -184,7 +201,9 @@ public sealed class TestHomeDeviceRequestValidator : AbstractValidator 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."); + .WithMessage(r => r.Driver == HomeDriverType.Vallox ? "Vallox-Geräte nutzen immer Protokollversion 1." : "Bitte Generation 1 (Gen1) oder 2 (Gen2/Plus/Pro/Gen3) wählen."); + RuleFor(r => r.Driver).Must((r, d) => !Enum.IsDefined(r.Kind) || !Enum.IsDefined(d) || HomeRules.IsValidDriverForKind(d, r.Kind)) + .WithMessage("Lüftungsanlagen werden über den Vallox-Treiber angesprochen, alle anderen Geräte über Shelly."); RuleFor(r => r.Channel).Must(HomeRules.IsValidChannel).WithMessage($"Der Kanal muss zwischen 0 und {HomeRules.MaxChannel} liegen."); RuleFor(r => r.Host).Custom((host, context) => { @@ -198,7 +217,7 @@ public sealed class TestHomeDeviceRequestValidator : AbstractValidatorErgebnis eines Verbindungstests (feste deutsche Meldung; Modell/Firmware aus Shelly.GetDeviceInfo bzw. /shelly). +/// Ergebnis eines Verbindungstests (feste deutsche Meldung; Modell/Firmware aus Shelly.GetDeviceInfo bzw. /shelly, bei Vallox das Modell aus der Datentabelle). public sealed record HomeTestResultDto(bool Success, string Message, string? Model, string? Firmware, long DurationMs); public sealed record SetHomeAccessRequest(IReadOnlyList Grants); diff --git a/src/FamilyPlanner.Web.Client/Components/House/DeviceCard.razor b/src/FamilyPlanner.Web.Client/Components/House/DeviceCard.razor index 4316639..aa47951 100644 --- a/src/FamilyPlanner.Web.Client/Components/House/DeviceCard.razor +++ b/src/FamilyPlanner.Web.Client/Components/House/DeviceCard.razor @@ -26,7 +26,7 @@ } - else if (Device.Kind != HomeDeviceKind.Sensor) + else if (Device.Kind is not (HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation)) { var isOn = State?.IsOn is true; + @if (Device.Kind == HomeDeviceKind.Ventilation) + { + var values = HouseUi.VentilationValues(State); + if (values.Count > 0) + { +
+ @foreach (var (label, value) in values) + { +
+
@label
+
@value
+
+ } +
+ } + + var active = State is { Online: true } ? State.Ventilation?.Profile : null; +
+ @foreach (var (profile, command) in HomeRules.VentilationProfiles) + { + var isActive = active == profile; + + @HomeRules.DisplayName(profile) + + } +
+ } + @if (!Device.CanControl && Device.Kind != HomeDeviceKind.Sensor) { Nur ansehen – du darfst dieses Gerät nicht schalten. diff --git a/src/FamilyPlanner.Web.Client/Components/House/HouseDeviceDialog.razor b/src/FamilyPlanner.Web.Client/Components/House/HouseDeviceDialog.razor index 665f7d1..eefc5ba 100644 --- a/src/FamilyPlanner.Web.Client/Components/House/HouseDeviceDialog.razor +++ b/src/FamilyPlanner.Web.Client/Components/House/HouseDeviceDialog.razor @@ -43,33 +43,46 @@ Verbindung - - Gen2 und neuer (Plus, Pro, Gen3, Gen4) - Gen1 (älter) - + @if (IsVallox) + { + + Vallox MV (z. B. ValloPlus 350 MV): Die Anlage wird über ihre Weboberfläche im Heimnetz angesprochen (WebSocket, Port 80). + Eine Anmeldung gibt es dort nicht – Zugriff haben nur Personen mit Freigabe für dieses Gerät. + + } + else + { + + Gen2 und neuer (Plus, Pro, Gen3, Gen4) + Gen1 (älter) + + } - - - Anmeldung am Gerät (optional) - - - @if (Device?.HasPassword == true) + @if (!IsVallox) { - + + + Anmeldung am Gerät (optional) + + + @if (Device?.HasPassword == true) + { + + } }
@@ -129,6 +142,7 @@ 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).", + HomeDeviceKind.Ventilation => "Lüftungsanlage Vallox MV: Profil anzeigen und wählen (Zuhause, Abwesend, Stoßlüften, Kamin), Temperaturen und Luftfeuchte.", _ => "Nur Anzeige: Temperatur und Luftfeuchte.", }; @@ -140,11 +154,26 @@ } (_name, _kind, _icon, _roomId, _generation, _host, _channel, _authUser) = (d.Name, d.Kind, d.Icon, d.RoomId, d.Generation, d.Host, d.Channel, d.AuthUser); + if (d.Driver == HomeDriverType.Vallox) + { + _generation = 2; // Vorgabe, falls die Art auf ein Shelly-Gerät geändert wird + } } + /// Lüftungsanlagen laufen immer über den Vallox-Treiber – ohne Generation, Kanal und Anmeldung. + private bool IsVallox => _kind == HomeDeviceKind.Ventilation; + + private int EffectiveGeneration => IsVallox ? 1 : _generation; + + private int EffectiveChannel => IsVallox ? 0 : _channel; + + private string? EffectiveUser => IsVallox || string.IsNullOrWhiteSpace(_authUser) ? null : _authUser.Trim(); + + private string? EffectivePassword => IsVallox || string.IsNullOrEmpty(_password) ? null : _password; + 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, + _name.Trim(), _kind, string.IsNullOrEmpty(_icon) ? null : _icon, _roomId, HomeRules.DefaultDriver(_kind), EffectiveGeneration, _host.Trim(), EffectiveChannel, + EffectiveUser, EffectivePassword, _clearPassword && !IsVallox, Device?.SortOrder ?? 0, Device?.Version); private async Task SaveAsync() @@ -183,8 +212,7 @@ { _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); + Device?.Id, _kind, HomeRules.DefaultDriver(_kind), EffectiveGeneration, _host.Trim(), EffectiveChannel, EffectiveUser, EffectivePassword); var validation = new TestHomeDeviceRequestValidator().Validate(request); if (!validation.IsValid) { diff --git a/src/FamilyPlanner.Web.Client/Components/House/HouseDevicesAdmin.razor b/src/FamilyPlanner.Web.Client/Components/House/HouseDevicesAdmin.razor index 583a2d3..5e56945 100644 --- a/src/FamilyPlanner.Web.Client/Components/House/HouseDevicesAdmin.razor +++ b/src/FamilyPlanner.Web.Client/Components/House/HouseDevicesAdmin.razor @@ -42,7 +42,14 @@ else @RoomName(context.RoomId) @context.Host - Gen@(context.Generation) · Kanal @context.Channel · @(context.HasPassword ? "mit Passwort" : "ohne Passwort") + @if (context.Driver == HomeDriverType.Vallox) + { + Vallox MV + } + else + { + Gen@(context.Generation) · Kanal @context.Channel · @(context.HasPassword ? "mit Passwort" : "ohne Passwort") + } @DescribeGrants(context) diff --git a/src/FamilyPlanner.Web.Client/Components/House/HouseLogView.razor b/src/FamilyPlanner.Web.Client/Components/House/HouseLogView.razor index f8f8358..cf03cf8 100644 --- a/src/FamilyPlanner.Web.Client/Components/House/HouseLogView.razor +++ b/src/FamilyPlanner.Web.Client/Components/House/HouseLogView.razor @@ -55,6 +55,10 @@ HomeCommand.Open => "Öffnen", HomeCommand.Close => "Schließen", HomeCommand.Stop => "Stoppen", + HomeCommand.ProfileHome => "Profil Zuhause", + HomeCommand.ProfileAway => "Profil Abwesend", + HomeCommand.ProfileBoost => "Stoßlüften", + HomeCommand.ProfileFireplace => "Kamin", _ => command.ToString(), }; diff --git a/src/FamilyPlanner.Web.Client/Components/House/HouseUi.cs b/src/FamilyPlanner.Web.Client/Components/House/HouseUi.cs index 5b83947..58c4ceb 100644 --- a/src/FamilyPlanner.Web.Client/Components/House/HouseUi.cs +++ b/src/FamilyPlanner.Web.Client/Components/House/HouseUi.cs @@ -41,10 +41,20 @@ public static class HouseUi HomeDeviceKind.Socket => Icons.Material.Filled.Power, HomeDeviceKind.Cover => Icons.Material.Filled.Blinds, HomeDeviceKind.Sensor => Icons.Material.Filled.Thermostat, + HomeDeviceKind.Ventilation => Icons.Material.Filled.Air, _ => Icons.Material.Filled.ToggleOn, }, }; + public static string ProfileIcon(HomeVentilationProfile profile) => profile switch + { + HomeVentilationProfile.Home => Icons.Material.Filled.Home, + HomeVentilationProfile.Away => Icons.Material.Filled.DirectionsWalk, + HomeVentilationProfile.Boost => Icons.Material.Filled.Speed, + HomeVentilationProfile.Fireplace => Icons.Material.Filled.Fireplace, + _ => Icons.Material.Filled.Air, + }; + public static Status StatusOf(HomeDeviceDto device, HomeDeviceStateDto? state) { if (state is null) @@ -66,7 +76,7 @@ public static class HouseUi "closed" => Status.Closed, _ => Status.Value, }, - HomeDeviceKind.Sensor => Status.Value, + HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation => Status.Value, _ => state.IsOn is true ? Status.On : state.IsOn is false ? Status.Off : Status.Unknown, }; } @@ -80,11 +90,63 @@ public static class HouseUi 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), + _ => device.Kind switch + { + HomeDeviceKind.Cover => $"Gestoppt{(state?.CoverPosition is { } p ? $" ({p} % offen)" : string.Empty)}", + HomeDeviceKind.Ventilation => VentilationText(state?.Ventilation), + _ => SensorText(state), + }, }; + /// Profil der Lüftung als Text, bei zeitbegrenzten Profilen mit Restlaufzeit. + public static string VentilationText(HomeVentilationDto? ventilation) => ventilation switch + { + null => "Keine Werte", + { MinutesLeft: { } minutes } => $"Profil: {HomeRules.DisplayName(ventilation.Profile)} · noch {minutes} min", + _ => $"Profil: {HomeRules.DisplayName(ventilation.Profile)}", + }; + + /// Messwerte einer Lüftungsanlage (Beschriftung, Wert) in fester Reihenfolge; fehlende Fühler werden ausgelassen. + public static IReadOnlyList<(string Label, string Value)> VentilationValues(HomeDeviceStateDto? state) + { + var values = new List<(string, string)>(); + if (state is not { Online: true, Ventilation: { } v }) + { + return values; + } + + void Temperature(string label, double? celsius) + { + if (celsius is { } c) + { + values.Add((label, $"{c.ToString("0.0", German)} °C")); + } + } + + Temperature("Außenluft", v.OutdoorAirC); + Temperature("Zuluft", v.SupplyAirC); + Temperature("Abluft", v.ExtractAirC); + Temperature("Fortluft", v.ExhaustAirC); + if (state.HumidityPercent is { } h) + { + values.Add(("Luftfeuchte", $"{h.ToString("0", German)} %")); + } + + if (v.Co2Ppm is { } co2) + { + values.Add(("CO₂", $"{co2.ToString(German)} ppm")); + } + + if (v.FanSpeedPercent is { } fan) + { + values.Add(("Lüfter", $"{fan.ToString(German)} %")); + } + + return values; + } + + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + /// Kurzer Text für das Symbol auf dem Plan (zusätzlich zur Farbe). public static string Badge(Status status) => status switch { @@ -119,9 +181,9 @@ public static class HouseUi return null; } - if (device.Kind == HomeDeviceKind.Sensor) + if (device.Kind is HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation) { - return null; // steht schon im Status + return null; // steht schon im Status bzw. in der Werteliste } return state.PowerWatts is { } w and > 0.5 ? $"{w.ToString("0.#", CultureInfo.GetCultureInfo("de-DE"))} W" : null; diff --git a/src/FamilyPlanner.Web/wwwroot/app.css b/src/FamilyPlanner.Web/wwwroot/app.css index 78e4e2a..1262d4b 100644 --- a/src/FamilyPlanner.Web/wwwroot/app.css +++ b/src/FamilyPlanner.Web/wwwroot/app.css @@ -2123,6 +2123,35 @@ a.fp-learning-title:hover { color: var(--mud-palette-text-secondary); } +/* Lüftungsanlage: Messwerte und Profilwahl (ADR-0025) */ +.fp-vent-values { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 4px 12px; + margin: 10px 0 0; +} + +.fp-vent-value dt { + font-size: .75rem; + color: var(--mud-palette-text-secondary); +} + +.fp-vent-value dd { + margin: 0; + font-weight: 500; +} + +.fp-vent-profiles { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.fp-vent-profile { + min-height: 40px; +} + /* 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); } diff --git a/tests/FamilyPlanner.UnitTests/Home/HomeRulesTests.cs b/tests/FamilyPlanner.UnitTests/Home/HomeRulesTests.cs index 21f9265..fc66788 100644 --- a/tests/FamilyPlanner.UnitTests/Home/HomeRulesTests.cs +++ b/tests/FamilyPlanner.UnitTests/Home/HomeRulesTests.cs @@ -97,11 +97,66 @@ public sealed class HomeRulesTests [InlineData(HomeDeviceKind.Sensor, HomeCommand.TurnOn, false)] [InlineData(HomeDeviceKind.Sensor, HomeCommand.Toggle, false)] [InlineData(HomeDeviceKind.Sensor, HomeCommand.Open, false)] + [InlineData(HomeDeviceKind.Sensor, HomeCommand.ProfileHome, false)] + [InlineData(HomeDeviceKind.Switch, HomeCommand.ProfileBoost, false)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileHome, true)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileAway, true)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileBoost, true)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileFireplace, true)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.TurnOn, false)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.Toggle, false)] + [InlineData(HomeDeviceKind.Ventilation, HomeCommand.Open, false)] public void Commands_must_fit_the_device_kind(HomeDeviceKind kind, HomeCommand command, bool allowed) { Assert.Equal(allowed, HomeRules.IsCommandAllowed(kind, command)); } + [Theory] + [InlineData(HomeDriverType.Shelly, HomeDeviceKind.Switch, true)] + [InlineData(HomeDriverType.Shelly, HomeDeviceKind.Sensor, true)] + [InlineData(HomeDriverType.Shelly, HomeDeviceKind.Ventilation, false)] + [InlineData(HomeDriverType.Vallox, HomeDeviceKind.Ventilation, true)] + [InlineData(HomeDriverType.Vallox, HomeDeviceKind.Switch, false)] + [InlineData(HomeDriverType.Vallox, HomeDeviceKind.Sensor, false)] + public void Driver_must_fit_the_device_kind(HomeDriverType driver, HomeDeviceKind kind, bool valid) + { + Assert.Equal(valid, HomeRules.IsValidDriverForKind(driver, kind)); + Assert.Equal(valid, HomeRules.DefaultDriver(kind) == driver); + } + + [Fact] + public void Vallox_uses_protocol_version_one_only() + { + Assert.True(HomeRules.IsValidGeneration(HomeDriverType.Vallox, 1)); + Assert.False(HomeRules.IsValidGeneration(HomeDriverType.Vallox, 2)); + Assert.False(HomeRules.IsValidGeneration(HomeDriverType.Vallox, 0)); + } + + [Fact] + public void Every_selectable_ventilation_profile_has_an_allowed_command_and_a_german_name() + { + Assert.Equal(4, HomeRules.VentilationProfiles.Count); + Assert.All(HomeRules.VentilationProfiles, p => + { + Assert.True(HomeRules.IsCommandAllowed(HomeDeviceKind.Ventilation, p.Command)); + Assert.NotEqual(p.Profile.ToString(), HomeRules.DisplayName(p.Profile)); + Assert.NotEqual(p.Command.ToString(), HomeRules.DisplayName(p.Command)); + }); + } + + [Fact] + public void Save_validator_requires_the_matching_driver() + { + var shellyVentilation = new SaveHomeDeviceRequest("Lüftung", HomeDeviceKind.Ventilation, null, null, HomeDriverType.Shelly, 2, "192.168.1.60", 0, null, null, false); + var vallox = shellyVentilation with { Driver = HomeDriverType.Vallox, Generation = 1 }; + var valloxGen2 = vallox with { Generation = 2 }; + + Assert.Contains(new SaveHomeDeviceRequestValidator().Validate(shellyVentilation).Errors, e => e.PropertyName == nameof(SaveHomeDeviceRequest.Driver)); + Assert.True(new SaveHomeDeviceRequestValidator().Validate(vallox).IsValid); + Assert.Contains(new SaveHomeDeviceRequestValidator().Validate(valloxGen2).Errors, e => e.ErrorMessage.Contains("Vallox", StringComparison.Ordinal)); + Assert.False(new TestHomeDeviceRequestValidator().Validate(new TestHomeDeviceRequest(null, HomeDeviceKind.Switch, HomeDriverType.Vallox, 1, "192.168.1.60", 0, null, null)).IsValid); + } + private static readonly Guid Me = Guid.NewGuid(); [Fact] diff --git a/tests/FamilyPlanner.UnitTests/Home/ValloxDriverTests.cs b/tests/FamilyPlanner.UnitTests/Home/ValloxDriverTests.cs new file mode 100644 index 0000000..0321651 --- /dev/null +++ b/tests/FamilyPlanner.UnitTests/Home/ValloxDriverTests.cs @@ -0,0 +1,331 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using FamilyPlanner.Api.Features.Home; +using FamilyPlanner.Domain.Home; + +namespace FamilyPlanner.UnitTests.Home; + +/// Vallox-MV-Protokoll (ADR-0025): Rahmen und Prüfsumme, Auswertung der Datentabelle, Profil-Befehle, Treiber gegen Fake-Transport und echten WebSocket. +public sealed class ValloxDriverTests +{ + /// Kelvin × 100, wie die Anlage Temperaturen meldet. + private static ushort Kelvin(double celsius) => (ushort)Math.Round(celsius * 100 + 27315); + + /// Eine Datentabelle wie von READ_TABLES (big-endian), Werte an den bekannten Positionen. + private static byte[] Table(Action? fill = null, int words = 690) + { + var table = new ushort[words]; + table[ValloxProtocol.OffsetMachineModel] = 6; // ValloPlus 350 MV + table[ValloxProtocol.OffsetFanSpeed] = 40; + table[ValloxProtocol.OffsetExtractAir] = Kelvin(21.7); + table[ValloxProtocol.OffsetExhaustAir] = Kelvin(7.1); + table[ValloxProtocol.OffsetOutdoorAir] = Kelvin(5.2); + table[ValloxProtocol.OffsetSupplyAir] = Kelvin(18.9); + table[ValloxProtocol.OffsetHumidity] = 45; + table[ValloxProtocol.OffsetCo2] = 620; + table[ValloxProtocol.OffsetState] = 0; + table[ValloxProtocol.OffsetBoostTime] = 45; + table[ValloxProtocol.OffsetFireplaceTime] = 20; + fill?.Invoke(table); + + var bytes = new byte[table.Length * 2]; + for (var i = 0; i < table.Length; i++) + { + BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(i * 2), table[i]); + } + + return bytes; + } + + private static List<(ushort Address, ushort Value)> DecodeWrite(byte[] request) + { + var words = Enumerable.Range(0, request.Length / 2).Select(i => BinaryPrimitives.ReadUInt16LittleEndian(request.AsSpan(i * 2))).ToArray(); + Assert.Equal(words.Length - 1, words[0]); // Länge = Wörter nach dem Längenfeld + Assert.Equal(ValloxProtocol.CommandWriteData, words[1]); + Assert.Equal((ushort)(words[..^1].Sum(w => w) & 0xFFFF), words[^1]); + return Enumerable.Range(0, (words.Length - 3) / 2).Select(i => (words[2 + i * 2], words[3 + i * 2])).ToList(); + } + + private static List<(ushort Address, ushort Value)> W(params (int Address, int Value)[] items) => + items.Select(i => ((ushort)i.Address, (ushort)i.Value)).ToList(); + + private sealed class FakeTransport(Func respond) : IValloxTransport + { + public List Requests { get; } = []; + + public Task RequestAsync(string host, byte[] payload, CancellationToken ct) + { + Requests.Add(payload); + return Task.FromResult(respond(payload)); + } + } + + private static readonly HomeDeviceConnection Vallox = new("http://192.168.1.60", 1, 0, HomeDeviceKind.Ventilation, null, null); + + private static bool IsRead(byte[] payload) => payload.SequenceEqual(ValloxProtocol.ReadTablesRequest()); + + // ---------- Protokoll ---------- + + [Fact] + public void Read_tables_request_matches_the_reference_implementation() + { + // vallox_websocket_api: length=3, command=246 (READ_TABLES), items=0, checksum=249 – alles little-endian. + Assert.Equal(new byte[] { 0x03, 0x00, 0xF6, 0x00, 0x00, 0x00, 0xF9, 0x00 }, ValloxProtocol.ReadTablesRequest()); + } + + [Fact] + public void Write_request_has_length_command_sorted_pairs_and_checksum() + { + var request = ValloxProtocol.WriteRequest([(4613, 15), (4612, 0)]); + + // Länge 6 (Befehl + 2 Paare + Prüfsumme), 249, (4612,0), (4613,15), Prüfsumme = 6 + 249 + 4612 + 0 + 4613 + 15 = 9495 = 0x2517 + Assert.Equal(new byte[] { 0x06, 0x00, 0xF9, 0x00, 0x04, 0x12, 0x00, 0x00, 0x05, 0x12, 0x0F, 0x00, 0x17, 0x25 }, request); + } + + [Fact] + public void Table_values_are_converted() + { + var metrics = ValloxProtocol.ParseTables(Table()); + + Assert.Equal(HomeVentilationProfile.Home, metrics.Profile); + Assert.Null(metrics.MinutesLeft); + Assert.Equal(5.2, metrics.OutdoorAirC); + Assert.Equal(18.9, metrics.SupplyAirC); + Assert.Equal(21.7, metrics.ExtractAirC); + Assert.Equal(7.1, metrics.ExhaustAirC); + Assert.Equal(40, metrics.FanSpeedPercent); + Assert.Equal(45, metrics.HumidityPercent); + Assert.Equal(620, metrics.Co2Ppm); + Assert.Equal(45, metrics.BoostMinutes); + Assert.Equal(20, metrics.FireplaceMinutes); + Assert.Equal("ValloPlus 350 MV", ValloxProtocol.ModelName(metrics.ModelIndex)); + } + + [Theory] + [InlineData(0, 0, 0, 0, HomeVentilationProfile.Home, null)] + [InlineData(1, 0, 0, 0, HomeVentilationProfile.Away, null)] + [InlineData(2, 0, 0, 0, HomeVentilationProfile.Auto, null)] + [InlineData(7, 0, 0, 0, HomeVentilationProfile.Unknown, null)] + [InlineData(0, 25, 0, 0, HomeVentilationProfile.Boost, 25)] + [InlineData(1, 0, 12, 0, HomeVentilationProfile.Fireplace, 12)] + [InlineData(0, 0, 0, 90, HomeVentilationProfile.Extra, 90)] + [InlineData(0, 0xFFFF, 0, 0, HomeVentilationProfile.Boost, null)] // ohne Zeitbegrenzung + [InlineData(1, 5, 10, 0, HomeVentilationProfile.Boost, 5)] // Stoßlüften hat Vorrang (wie die Weboberfläche) + public void Profile_follows_the_timers_then_the_state(int state, int boost, int fireplace, int extra, HomeVentilationProfile expected, int? minutes) + { + var metrics = ValloxProtocol.ParseTables(Table(t => + { + t[ValloxProtocol.OffsetState] = (ushort)state; + t[ValloxProtocol.OffsetBoostTimer] = (ushort)boost; + t[ValloxProtocol.OffsetFireplaceTimer] = (ushort)fireplace; + t[ValloxProtocol.OffsetExtraTimer] = (ushort)extra; + })); + + Assert.Equal(expected, metrics.Profile); + Assert.Equal(minutes, metrics.MinutesLeft); + } + + [Fact] + public void Missing_sensors_are_null_and_durations_fall_back_to_the_defaults() + { + var metrics = ValloxProtocol.ParseTables(Table(t => + { + t[ValloxProtocol.OffsetOutdoorAir] = 0; + t[ValloxProtocol.OffsetSupplyAir] = 0xFFFF; + t[ValloxProtocol.OffsetExhaustAir] = 50; // −272,65 °C: unplausibel + t[ValloxProtocol.OffsetHumidity] = 0; + t[ValloxProtocol.OffsetCo2] = 0xFFFF; + t[ValloxProtocol.OffsetFanSpeed] = 0xFFFF; + t[ValloxProtocol.OffsetBoostTime] = 0; + t[ValloxProtocol.OffsetFireplaceTime] = 0xFFFF; + t[ValloxProtocol.OffsetMachineModel] = 0xFFFF; + })); + + Assert.Null(metrics.OutdoorAirC); + Assert.Null(metrics.SupplyAirC); + Assert.Null(metrics.ExhaustAirC); + Assert.Null(metrics.HumidityPercent); + Assert.Null(metrics.Co2Ppm); + Assert.Null(metrics.FanSpeedPercent); + Assert.Equal(ValloxProtocol.DefaultBoostMinutes, metrics.BoostMinutes); + Assert.Equal(ValloxProtocol.DefaultFireplaceMinutes, metrics.FireplaceMinutes); + Assert.Equal("Vallox", ValloxProtocol.ModelName(metrics.ModelIndex)); + } + + [Theory] + [InlineData(0)] + [InlineData(8)] // z. B. eine Fehlerantwort des Geräts + [InlineData(494)] // ein Wort zu kurz + [InlineData(1001)] // ungerade Länge + public void Short_or_odd_responses_are_rejected(int bytes) + { + var ex = Assert.Throws(() => ValloxProtocol.ParseTables(new byte[bytes])); + Assert.Equal(HomeErrorKind.BadResponse, ex.Kind); + } + + [Fact] + public void Unknown_model_numbers_are_named_by_number() + { + Assert.Equal("Vallox (Modell-Nr. 250)", ValloxProtocol.ModelName(250)); + } + + [Fact] + public void Profile_writes_match_set_profile_of_the_reference_implementation() + { + var metrics = ValloxProtocol.ParseTables(Table()); + + Assert.Equal(W((4609, 0), (4612, 0), (4613, 0), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileHome, null).ToList()); + Assert.Equal(W((4609, 1), (4612, 0), (4613, 0), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileAway, null).ToList()); + Assert.Equal(W((4612, 45), (4613, 0), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileBoost, metrics).ToList()); + Assert.Equal(W((4612, 0), (4613, 20), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileFireplace, metrics).ToList()); + Assert.Throws(() => ValloxProtocol.ProfileWrites(HomeCommand.TurnOn, metrics)); + } + + // ---------- Treiber ---------- + + [Fact] + public async Task State_contains_profile_and_temperatures() + { + var transport = new FakeTransport(_ => Table(t => t[ValloxProtocol.OffsetBoostTimer] = 18)); + var state = await new ValloxDriver(transport).GetStateAsync(Vallox, TestContext.Current.CancellationToken); + + Assert.True(IsRead(Assert.Single(transport.Requests))); + Assert.Equal(21.7, state.TemperatureC); // Abluft = Raumluft + Assert.Equal(45, state.HumidityPercent); + Assert.Null(state.IsOn); + var ventilation = Assert.IsType(state.Ventilation); + Assert.Equal(HomeVentilationProfile.Boost, ventilation.Profile); + Assert.Equal(18, ventilation.MinutesLeft); + Assert.Equal(5.2, ventilation.OutdoorAirC); + Assert.Equal(620, ventilation.Co2Ppm); + } + + [Fact] + public async Task Home_and_away_are_written_without_reading_first() + { + var transport = new FakeTransport(_ => []); + var driver = new ValloxDriver(transport); + + await driver.ExecuteAsync(Vallox, HomeCommand.ProfileAway, TestContext.Current.CancellationToken); + + Assert.Equal(W((4609, 1), (4612, 0), (4613, 0), (4614, 0)), DecodeWrite(Assert.Single(transport.Requests))); + } + + [Theory] + [InlineData(HomeCommand.ProfileBoost, 4612, 45)] + [InlineData(HomeCommand.ProfileFireplace, 4613, 20)] + public async Task Timed_profiles_use_the_duration_configured_on_the_unit(HomeCommand command, int timerAddress, int minutes) + { + var transport = new FakeTransport(p => IsRead(p) ? Table() : []); + + await new ValloxDriver(transport).ExecuteAsync(Vallox, command, TestContext.Current.CancellationToken); + + Assert.Equal(2, transport.Requests.Count); + Assert.True(IsRead(transport.Requests[0])); + var writes = DecodeWrite(transport.Requests[1]); + Assert.Contains(((ushort)timerAddress, (ushort)minutes), writes); + Assert.All(writes.Where(w => w.Address != timerAddress), w => Assert.Equal(0, w.Value)); // andere Timer beenden + } + + [Fact] + public async Task Test_reports_the_model() + { + var info = await new ValloxDriver(new FakeTransport(_ => Table())).TestAsync(Vallox, TestContext.Current.CancellationToken); + + Assert.Equal("ValloPlus 350 MV", info.Model); + } + + [Fact] + public async Task Other_device_kinds_and_commands_are_refused_without_contacting_the_unit() + { + var transport = new FakeTransport(_ => Table()); + var driver = new ValloxDriver(transport); + + var wrongKind = await Assert.ThrowsAsync(() => driver.GetStateAsync(Vallox with { Kind = HomeDeviceKind.Switch }, TestContext.Current.CancellationToken)); + var wrongCommand = await Assert.ThrowsAsync(() => driver.ExecuteAsync(Vallox, HomeCommand.Toggle, TestContext.Current.CancellationToken)); + + Assert.Equal(HomeErrorKind.NotSupported, wrongKind.Kind); + Assert.Equal(HomeErrorKind.NotSupported, wrongCommand.Kind); + Assert.Empty(transport.Requests); + } + + // ---------- echter WebSocket (Loopback) ---------- + + private static int FreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + /// Ein minimaler Vallox-Ersatz: nimmt eine WebSocket-Verbindung an, merkt sich die Anfrage und antwortet mit . + private static async Task<(string Host, Task Received)> StartFakeUnitAsync(byte[] reply) + { + var port = FreePort(); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + + var received = Task.Run(async () => + { + try + { + var context = await listener.GetContextAsync(); + var ws = (await context.AcceptWebSocketAsync(subProtocol: null)).WebSocket; + var buffer = new byte[4096]; + var result = await ws.ReceiveAsync(buffer, TestContext.Current.CancellationToken); + await ws.SendAsync(reply, WebSocketMessageType.Binary, endOfMessage: true, TestContext.Current.CancellationToken); + try + { + await ws.ReceiveAsync(new byte[16], TestContext.Current.CancellationToken); // Schließen des Clients abwarten + } + catch (WebSocketException) + { + } + + return buffer[..result.Count]; + } + finally + { + listener.Stop(); + } + }); + + await Task.Yield(); + return ($"http://127.0.0.1:{port}", received); + } + + [Fact] + public async Task Transport_talks_binary_websocket_to_the_unit() + { + var (host, received) = await StartFakeUnitAsync(Table()); + using var transport = new ValloxWebSocketTransport(); + + var response = await transport.RequestAsync(host, ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken); + + Assert.Equal(ValloxProtocol.ReadTablesRequest(), await received.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Equal(HomeVentilationProfile.Home, ValloxProtocol.ParseTables(response).Profile); + } + + [Fact] + public async Task Transport_refuses_addresses_outside_the_home_network() + { + using var transport = new ValloxWebSocketTransport(); + + var ex = await Assert.ThrowsAsync(() => transport.RequestAsync("http://8.8.8.8", ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(HomeErrorKind.Blocked, ex.Kind); + } + + [Fact] + public async Task Transport_reports_an_unreachable_unit() + { + using var transport = new ValloxWebSocketTransport(); + + var ex = await Assert.ThrowsAsync(() => transport.RequestAsync($"http://127.0.0.1:{FreePort()}", ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(HomeErrorKind.Unreachable, ex.Kind); + } +} diff --git a/tests/FamilyPlanner.Web.Tests/House/FakeHomeApi.cs b/tests/FamilyPlanner.Web.Tests/House/FakeHomeApi.cs index 98eeb0b..ad9782d 100644 --- a/tests/FamilyPlanner.Web.Tests/House/FakeHomeApi.cs +++ b/tests/FamilyPlanner.Web.Tests/House/FakeHomeApi.cs @@ -62,6 +62,9 @@ internal sealed class FakeHomeApi : IHomeApi public static HomeDeviceStateDto Sensor(Guid id, double temperature, double humidity) => new(id, true, null, null, null, null, temperature, humidity, null); + public static HomeDeviceStateDto Ventilation(Guid id, HomeVentilationProfile profile, int? minutesLeft = null) => + new(id, true, null, null, null, null, 21.7, 45, null, new HomeVentilationDto(profile, minutesLeft, 5.2, 18.9, 21.7, 7.1, 40, 620)); + public Task> GetOverviewAsync(CancellationToken ct = default) { OverviewCalls++; @@ -202,5 +205,8 @@ internal sealed class FakeHomeApi : IHomeApi public static HomeAdminDeviceDto AdminDevice(string name, HomeDeviceKind kind = HomeDeviceKind.Switch, Guid? room = null, IReadOnlyList? 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 HomeAdminDeviceDto ValloxDevice(string name) => + new(Guid.NewGuid(), name, HomeDeviceKind.Ventilation, null, null, HomeDriverType.Vallox, 1, "http://192.168.1.60", 0, null, false, 0, [1], []); + public static UserDirectoryEntry User(string name, string role = Roles.Child) => new(Guid.NewGuid(), name, "#43A047", null, role); } diff --git a/tests/FamilyPlanner.Web.Tests/House/HouseAdminTests.cs b/tests/FamilyPlanner.Web.Tests/House/HouseAdminTests.cs index 1153741..4260804 100644 --- a/tests/FamilyPlanner.Web.Tests/House/HouseAdminTests.cs +++ b/tests/FamilyPlanner.Web.Tests/House/HouseAdminTests.cs @@ -176,6 +176,30 @@ public sealed class HouseAdminTests : BunitContext Assert.True(_api.Devices[0].HasPassword); } + [Fact] + public void Ventilation_dialog_hides_shelly_fields_and_saves_with_the_vallox_driver() + { + var device = FakeHomeApi.ValloxDevice("Lüftung"); + _api.Devices.Add(device); + var cut = Open(); + Assert.Contains("Vallox MV", cut.Find(".fp-device-table tbody tr").TextContent); + + cut.Find("button[aria-label='Lüftung bearbeiten']").Click(); + cut.WaitForElement("#house-device-form"); + + Assert.Single(cut.FindAll(".fp-device-vallox-hint")); + Assert.Empty(cut.FindAll(".fp-device-generation, #device-channel, #device-user, #device-password")); + cut.Find(".fp-device-test").Click(); + cut.WaitForAssertion(() => Assert.Single(_api.DraftTests)); + var draft = _api.DraftTests.Single(); + Assert.Equal((HomeDriverType.Vallox, 1, 0, null, null), (draft.Driver, draft.Generation, draft.Channel, draft.AuthUser, draft.Password)); + + cut.Find("#house-device-form").Submit(); + cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices)); + var saved = _api.SavedDevices.Single(); + Assert.Equal((HomeDeviceKind.Ventilation, HomeDriverType.Vallox, 1, "http://192.168.1.60", 0, null), (saved.Kind, saved.Driver, saved.Generation, saved.Host, saved.Channel, saved.AuthUser)); + } + [Fact] public void Dialog_test_button_sends_the_draft_with_the_device_id_and_shows_the_result() { diff --git a/tests/FamilyPlanner.Web.Tests/House/HousePageTests.cs b/tests/FamilyPlanner.Web.Tests/House/HousePageTests.cs index 6958206..b187847 100644 --- a/tests/FamilyPlanner.Web.Tests/House/HousePageTests.cs +++ b/tests/FamilyPlanner.Web.Tests/House/HousePageTests.cs @@ -204,6 +204,57 @@ public sealed class HousePageTests : BunitContext Assert.Empty(cut.FindAll(".fp-device-readonly")); } + [Fact] + public void Ventilation_shows_profile_temperatures_and_profile_buttons() + { + var d = FakeHomeApi.Device("Lüftung", HomeDeviceKind.Ventilation); + _api.States[d.Id] = FakeHomeApi.Ventilation(d.Id, HomeVentilationProfile.Boost, minutesLeft: 25); + + var cut = Open(Overview([d])); + + Assert.Equal("Profil: Stoßlüften · noch 25 min", cut.Find(".fp-device-status-text").TextContent.Trim()); + var values = cut.FindAll(".fp-vent-value").Select(v => v.TextContent.Replace("\n", " ").Trim()).ToList(); + Assert.Contains(values, v => v.Contains("Außenluft", StringComparison.Ordinal) && v.Contains("5,2 °C", StringComparison.Ordinal)); + Assert.Contains(values, v => v.Contains("Zuluft", StringComparison.Ordinal) && v.Contains("18,9 °C", StringComparison.Ordinal)); + Assert.Contains(values, v => v.Contains("Abluft", StringComparison.Ordinal) && v.Contains("21,7 °C", StringComparison.Ordinal)); + Assert.Contains(values, v => v.Contains("Fortluft", StringComparison.Ordinal) && v.Contains("7,1 °C", StringComparison.Ordinal)); + Assert.Contains(values, v => v.Contains("45 %", StringComparison.Ordinal)); + Assert.Contains(values, v => v.Contains("620 ppm", StringComparison.Ordinal)); + Assert.Empty(cut.FindAll(".fp-device-toggle")); + + var buttons = cut.FindAll(".fp-vent-profile"); + Assert.Equal(["Zuhause", "Abwesend", "Stoßlüften", "Kamin"], buttons.Select(b => b.TextContent.Trim())); + Assert.Equal("true", cut.Find(".fp-vent-profile-boost").GetAttribute("aria-pressed")); + Assert.Equal("false", cut.Find(".fp-vent-profile-home").GetAttribute("aria-pressed")); + } + + [Fact] + public void Choosing_a_ventilation_profile_sends_the_command() + { + var d = FakeHomeApi.Device("Lüftung", HomeDeviceKind.Ventilation); + _api.States[d.Id] = FakeHomeApi.Ventilation(d.Id, HomeVentilationProfile.Home); + var cut = Open(Overview([d])); + + cut.Find(".fp-vent-profile-fireplace").Click(); + cut.WaitForAssertion(() => Assert.Equal([(d.Id, HomeCommand.ProfileFireplace)], _api.Commands)); + cut.Find(".fp-vent-profile-away").Click(); + + cut.WaitForAssertion(() => Assert.Equal(HomeCommand.ProfileAway, _api.Commands[1].Command)); + } + + [Fact] + public void View_only_ventilation_cannot_change_the_profile() + { + var d = FakeHomeApi.Device("Lüftung", HomeDeviceKind.Ventilation, canControl: false); + _api.States[d.Id] = FakeHomeApi.Ventilation(d.Id, HomeVentilationProfile.Away); + + var cut = Open(Overview([d])); + + Assert.All(cut.FindAll(".fp-vent-profile"), b => Assert.True(b.HasAttribute("disabled"))); + Assert.Contains("Nur ansehen", cut.Find(".fp-device-readonly").TextContent); + Assert.Equal("Profil: Abwesend", cut.Find(".fp-device-status-text").TextContent.Trim()); + } + [Fact] public void Without_plans_there_are_no_tabs() {