feat: Phase 19 Haus - Vallox MV ventilation unit (profile, temperatures, profile switching)
- New device kind Ventilation with driver Vallox (WebSocket protocol of the unit's web UI: READ_TABLES/WRITE_DATA, checksum, big-endian table); no migration (enums stored as text) - Read profile incl. remaining minutes, outdoor/supply/extract/exhaust air temperatures, humidity, CO2, fan speed; activate Home/Away/Boost/Fireplace (timed profiles use the duration configured on the unit) - Same SSRF protection as Shelly (shared connection handler), permissions, action log, audit - Admin dialog without generation/channel/credentials for ventilation, device card with values and profile buttons - Tests: unit (protocol vs. reference bytes, driver, loopback WebSocket, SSRF, rules), bUnit - ADR-0025, roadmap phase 19
This commit is contained in:
1 parent
d1b1197a42
commit
70f1890641
23 files changed
+1146
-27
No files matched your search
@@ -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) |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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://<gerät>/`), 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.
|
||||
@@ -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<HomeDeviceAccess> grants)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Kennung des Geräts aus dem Verbindungstest.</summary>
|
||||
public sealed record HomeDeviceInfo(string? Model, string? Firmware);
|
||||
@@ -27,7 +29,7 @@ public enum HomeErrorKind
|
||||
/// <summary>Benutzername/Passwort fehlen oder stimmen nicht.</summary>
|
||||
Unauthorized,
|
||||
|
||||
/// <summary>Antwort unbrauchbar oder zu groß (kein Shelly, falsche Generation/Kanal).</summary>
|
||||
/// <summary>Antwort unbrauchbar oder zu groß (kein Shelly/Vallox, falsche Generation/Kanal).</summary>
|
||||
BadResponse,
|
||||
|
||||
/// <summary>Die Adresse liegt (nach Namensauflösung) nicht im Heimnetz – aus Sicherheitsgründen gesperrt.</summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Net.WebSockets;
|
||||
using FamilyPlanner.Domain.Home;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Home;
|
||||
|
||||
/// <summary>Eine Binär-Nachricht an eine Vallox-Anlage senden und die erste Antwort lesen (eigene Verbindung je Anfrage, keine Wiederholungen).</summary>
|
||||
public interface IValloxTransport
|
||||
{
|
||||
Task<byte[]> RequestAsync(string host, byte[] payload, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket-Zugriff auf <c>ws://host/</c>. Die Verbindung läuft über denselben Handler wie der <c>home</c>-HttpClient (<see cref="HomeHttp.CreateHandler"/>, ADR-0023):
|
||||
/// jede aufgelöste IP wird beim Verbinden geprüft, kein Proxy, keine Weiterleitungen; Zeitlimit wie bei Shelly, Antwort höchstens <see cref="ValloxProtocol.MaxResponseBytes"/>.
|
||||
/// Bewusst ein eigener <see cref="HttpMessageInvoker"/> statt eines <see cref="HttpClient"/>: dieser würde die 101-Antwort (den offenen Strom) puffern wollen.
|
||||
/// </summary>
|
||||
public sealed class ValloxWebSocketTransport : IValloxTransport, IDisposable
|
||||
{
|
||||
private readonly HttpMessageInvoker _invoker = new(HomeHttp.CreateHandler(), disposeHandler: true);
|
||||
|
||||
public void Dispose() => _invoker.Dispose();
|
||||
|
||||
public async Task<byte[]> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ValloxDriver(IValloxTransport transport) : IHomeDeviceDriver
|
||||
{
|
||||
public HomeDriverType Type => HomeDriverType.Vallox;
|
||||
|
||||
public async Task<HomeDeviceState> 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<HomeDeviceInfo> TestAsync(HomeDeviceConnection c, CancellationToken ct)
|
||||
{
|
||||
var metrics = await ReadAsync(c, ct);
|
||||
return new HomeDeviceInfo(ValloxProtocol.ModelName(metrics.ModelIndex), null);
|
||||
}
|
||||
|
||||
private async Task<ValloxMetrics> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Buffers.Binary;
|
||||
using FamilyPlanner.Domain.Home;
|
||||
using FamilyPlanner.Shared.Home;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Home;
|
||||
|
||||
/// <summary>Werte aus der Datentabelle einer Vallox-Anlage (Rohwerte bereits umgerechnet; <c>null</c> = Fühler fehlt/kein Wert).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binärprotokoll der Weboberfläche von Vallox-Anlagen der MV-Reihe (WebSocket <c>ws://host/</c>, Nachrichten als Binär-Frames). Nachgebaut nach
|
||||
/// <see href="https://github.com/yozik04/vallox_websocket_api">vallox_websocket_api</see> (auch von Home Assistant genutzt):
|
||||
/// <list type="bullet">
|
||||
/// <item>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).</item>
|
||||
/// <item><c>READ_TABLES</c> (246) liefert die komplette Datentabelle als 16-Bit-Wörter <b>big-endian</b>; die Position eines Werts ergibt sich aus den Adressbereichen der Firmware.</item>
|
||||
/// <item><c>WRITE_DATA</c> (249) schreibt Paare aus Adresse und Wert (nach Adresse sortiert).</item>
|
||||
/// </list>
|
||||
/// Die hier genutzten Werte liegen in den ersten, bei allen bekannten Firmware-Ständen (2.0.16, 2.0.22) gleich aufgebauten Bereichen.
|
||||
/// </summary>
|
||||
public static class ValloxProtocol
|
||||
{
|
||||
public const ushort CommandWriteData = 249;
|
||||
public const ushort CommandReadTables = 246;
|
||||
|
||||
/// <summary>Größte erlaubte Antwort (die Tabelle hat knapp 700 Wörter).</summary>
|
||||
public const int MaxResponseBytes = 64 * 1024;
|
||||
|
||||
/// <summary>Am Gerät nicht eingestellte Dauer: Standardwerte der Vallox-Werkseinstellung (Minuten).</summary>
|
||||
public const int DefaultBoostMinutes = 30;
|
||||
public const int DefaultFireplaceMinutes = 15;
|
||||
|
||||
/// <summary>Kein Wert / Fühler fehlt.</summary>
|
||||
private const ushort NoValue = 0xFFFF;
|
||||
|
||||
/// <summary>Profil läuft ohne Zeitbegrenzung.</summary>
|
||||
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
|
||||
|
||||
/// <summary>Mindestlänge der Tabelle (in Wörtern), damit alle genutzten Werte enthalten sind.</summary>
|
||||
internal const int MinTableWords = OffsetFireplaceTime + 1;
|
||||
|
||||
/// <summary>Modellnamen nach <c>A_CYC_MACHINE_MODEL</c> (Reihenfolge wie in der Vallox-Weboberfläche).</summary>
|
||||
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",
|
||||
];
|
||||
|
||||
/// <summary>Anfrage „komplette Datentabelle lesen“: <c>03 00 F6 00 00 00 F9 00</c>.</summary>
|
||||
public static byte[] ReadTablesRequest() => Frame(CommandReadTables, [0]);
|
||||
|
||||
/// <summary>Schreibanfrage für Adresse/Wert-Paare (werden nach Adresse sortiert, wie die Weboberfläche es tut).</summary>
|
||||
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<ushort> 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;
|
||||
}
|
||||
|
||||
/// <summary>Wertet die Antwort auf <see cref="ReadTablesRequest"/> aus; wirft <see cref="HomeException"/>, wenn sie nicht wie eine Vallox-Tabelle aussieht.</summary>
|
||||
public static ValloxMetrics ParseTables(ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>Was für einen Profil-Befehl geschrieben wird (wie die Vallox-Weboberfläche bzw. vallox_websocket_api <c>set_profile</c>).</summary>
|
||||
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."),
|
||||
};
|
||||
|
||||
/// <summary>Braucht der Befehl die am Gerät eingestellte Dauer (vorher lesen)?</summary>
|
||||
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";
|
||||
|
||||
/// <summary>Kelvin × 100 → °C mit einer Nachkommastelle; 0/FFFF und Unplausibles = kein Fühler.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -68,6 +68,8 @@ builder.Services.AddHttpClient(HomeHttp.ClientName, client => client.Timeout = T
|
||||
#pragma warning restore EXTEXP0001
|
||||
builder.Services.AddSingleton<IHomeSecretProtector, HomeSecretProtector>();
|
||||
builder.Services.AddScoped<IHomeDeviceDriver, ShellyDriver>();
|
||||
builder.Services.AddSingleton<IValloxTransport, ValloxWebSocketTransport>(); // Lüftungsanlagen (ADR-0025)
|
||||
builder.Services.AddScoped<IHomeDeviceDriver, ValloxDriver>();
|
||||
builder.Services.AddScoped<HomeAccessService>();
|
||||
builder.Services.AddScoped<HomeDeviceService>();
|
||||
builder.Services.AddScoped<HomeCameraAccessService>();
|
||||
|
||||
@@ -19,12 +19,30 @@ public enum HomeDeviceKind
|
||||
|
||||
/// <summary>Nur lesend: Temperatur/Luftfeuchte (und ggf. Leistung).</summary>
|
||||
Sensor = 4,
|
||||
|
||||
/// <summary>Lüftungsanlage mit Wärmerückgewinnung (z. B. Vallox MV): Temperaturen lesen, Profil wählen.</summary>
|
||||
Ventilation = 5,
|
||||
}
|
||||
|
||||
/// <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>Vallox-Lüftungsanlagen der MV-Reihe (z. B. ValloPlus 350 MV) über das WebSocket-Protokoll ihrer Weboberfläche.</summary>
|
||||
Vallox = 1,
|
||||
}
|
||||
|
||||
/// <summary>Profil einer Lüftungsanlage. <see cref="Boost"/>, <see cref="Fireplace"/> und <see cref="Extra"/> laufen mit Zeitbegrenzung.</summary>
|
||||
public enum HomeVentilationProfile
|
||||
{
|
||||
Unknown = 0,
|
||||
Home = 1,
|
||||
Away = 2,
|
||||
Boost = 3,
|
||||
Fireplace = 4,
|
||||
Extra = 5,
|
||||
Auto = 6,
|
||||
}
|
||||
|
||||
/// <summary>Was darf eine Freigabe? <see cref="View"/> = Zustand sehen, <see cref="Control"/> = zusätzlich schalten.</summary>
|
||||
@@ -44,6 +62,18 @@ public enum HomeCommand
|
||||
Open = 3,
|
||||
Close = 4,
|
||||
Stop = 5,
|
||||
|
||||
/// <summary>Lüftung: Profil „Zuhause“.</summary>
|
||||
ProfileHome = 6,
|
||||
|
||||
/// <summary>Lüftung: Profil „Abwesend“.</summary>
|
||||
ProfileAway = 7,
|
||||
|
||||
/// <summary>Lüftung: „Stoßlüften“ für die am Gerät eingestellte Dauer.</summary>
|
||||
ProfileBoost = 8,
|
||||
|
||||
/// <summary>Lüftung: „Kamin“ (Überdruck) für die am Gerät eingestellte Dauer.</summary>
|
||||
ProfileFireplace = 9,
|
||||
}
|
||||
|
||||
public sealed class HomeRoom : AuditableEntity, ISoftDeletable
|
||||
@@ -79,7 +109,7 @@ public sealed class HomeDevice : AuditableEntity, ISoftDeletable, IHasRowVersion
|
||||
|
||||
public HomeDriverType Driver { get; set; }
|
||||
|
||||
/// <summary>Shelly: 1 = Gen1 (REST), 2 = Gen2+ (RPC).</summary>
|
||||
/// <summary>Shelly: 1 = Gen1 (REST), 2 = Gen2+ (RPC). Vallox: immer 1 (MV-WebSocket-Protokoll).</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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/// <summary>Die Profile, die sich an einer Lüftungsanlage wählen lassen, mit dem zugehörigen Befehl.</summary>
|
||||
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),
|
||||
];
|
||||
|
||||
/// <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,
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Passen Treiber und Geräteart zusammen? Vallox steuert nur Lüftungsanlagen, Shelly alles außer Lüftungsanlagen.</summary>
|
||||
public static bool IsValidDriverForKind(HomeDriverType driver, HomeDeviceKind kind) => driver switch
|
||||
{
|
||||
HomeDriverType.Shelly => kind != HomeDeviceKind.Ventilation,
|
||||
HomeDriverType.Vallox => kind == HomeDeviceKind.Ventilation,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Der Treiber, der zu einer Geräteart gehört (die Oberfläche lässt ihn nicht separat wählen).</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Kürzt Leerraum; <c>null</c>, wenn leer oder zu lang.</summary>
|
||||
|
||||
@@ -78,7 +78,22 @@ public sealed record HomeDeviceStateDto(
|
||||
double? PowerWatts,
|
||||
double? TemperatureC,
|
||||
double? HumidityPercent,
|
||||
string? Message);
|
||||
string? Message,
|
||||
HomeVentilationDto? Ventilation = null);
|
||||
|
||||
/// <summary>
|
||||
/// Zustand einer Lüftungsanlage (nur bei <see cref="HomeDeviceKind.Ventilation"/>). Temperaturen in °C; <c>null</c> = Fühler fehlt oder liefert nichts.
|
||||
/// <see cref="MinutesLeft"/> ist die Restlaufzeit bei Stoßlüften/Kamin/Extra.
|
||||
/// </summary>
|
||||
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<HomeDeviceStateDto> States, DateTimeOffset At);
|
||||
|
||||
@@ -142,7 +157,9 @@ public sealed class SaveHomeDeviceRequestValidator : AbstractValidator<SaveHomeD
|
||||
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.");
|
||||
.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<TestHomeD
|
||||
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.");
|
||||
.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 : AbstractValidator<TestHomeD
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis eines Verbindungstests (feste deutsche Meldung; Modell/Firmware aus <c>Shelly.GetDeviceInfo</c> bzw. <c>/shelly</c>).</summary>
|
||||
/// <summary>Ergebnis eines Verbindungstests (feste deutsche Meldung; Modell/Firmware aus <c>Shelly.GetDeviceInfo</c> bzw. <c>/shelly</c>, bei Vallox das Modell aus der Datentabelle).</summary>
|
||||
public sealed record HomeTestResultDto(bool Success, string Message, string? Model, string? Firmware, long DurationMs);
|
||||
|
||||
public sealed record SetHomeAccessRequest(IReadOnlyList<HomeGrantDto> Grants);
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<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)
|
||||
else if (Device.Kind is not (HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation))
|
||||
{
|
||||
var isOn = State?.IsOn is true;
|
||||
<MudButton Variant="@(isOn ? Variant.Filled : Variant.Outlined)" Color="@(isOn ? Color.Success : Color.Default)" Class="fp-device-toggle"
|
||||
@@ -39,6 +39,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Device.Kind == HomeDeviceKind.Ventilation)
|
||||
{
|
||||
var values = HouseUi.VentilationValues(State);
|
||||
if (values.Count > 0)
|
||||
{
|
||||
<dl class="fp-vent-values" aria-label="@($"Messwerte {Device.Name}")">
|
||||
@foreach (var (label, value) in values)
|
||||
{
|
||||
<div class="fp-vent-value">
|
||||
<dt>@label</dt>
|
||||
<dd>@value</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
|
||||
var active = State is { Online: true } ? State.Ventilation?.Profile : null;
|
||||
<div class="fp-vent-profiles" role="group" aria-label="@($"Profil {Device.Name}")">
|
||||
@foreach (var (profile, command) in HomeRules.VentilationProfiles)
|
||||
{
|
||||
var isActive = active == profile;
|
||||
<MudButton Variant="@(isActive ? Variant.Filled : Variant.Outlined)" Color="@(isActive ? Color.Primary : Color.Default)" Size="Size.Small"
|
||||
StartIcon="@HouseUi.ProfileIcon(profile)" Disabled="@(!CanOperate)" Class="@($"fp-vent-profile fp-vent-profile-{profile.ToString().ToLowerInvariant()}")"
|
||||
OnClick="() => OnCommand.InvokeAsync(command)" aria-pressed="@(isActive ? "true" : "false")">
|
||||
@HomeRules.DisplayName(profile)
|
||||
</MudButton>
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -43,18 +43,30 @@
|
||||
</MudSelect>
|
||||
|
||||
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Verbindung</MudText>
|
||||
@if (IsVallox)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="fp-device-vallox-hint">
|
||||
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.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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"
|
||||
Placeholder="@(IsVallox ? "192.168.1.60 oder vallox.local" : "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")" />
|
||||
|
||||
@if (!IsVallox)
|
||||
{
|
||||
<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")" />
|
||||
@@ -71,6 +83,7 @@
|
||||
{
|
||||
<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">
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lüftungsanlagen laufen immer über den Vallox-Treiber – ohne Generation, Kanal und Anmeldung.</summary>
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -42,7 +42,14 @@ else
|
||||
<MudTd DataLabel="Raum">@RoomName(context.RoomId)</MudTd>
|
||||
<MudTd DataLabel="Verbindung">
|
||||
@context.Host
|
||||
@if (context.Driver == HomeDriverType.Vallox)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Vallox MV</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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">
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Profil der Lüftung als Text, bei zeitbegrenzten Profilen mit Restlaufzeit.</summary>
|
||||
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)}",
|
||||
};
|
||||
|
||||
/// <summary>Messwerte einer Lüftungsanlage (Beschriftung, Wert) in fester Reihenfolge; fehlende Fühler werden ausgelassen.</summary>
|
||||
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");
|
||||
|
||||
/// <summary>Kurzer Text für das Symbol auf dem Plan (zusätzlich zur Farbe).</summary>
|
||||
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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Vallox-MV-Protokoll (ADR-0025): Rahmen und Prüfsumme, Auswertung der Datentabelle, Profil-Befehle, Treiber gegen Fake-Transport und echten WebSocket.</summary>
|
||||
public sealed class ValloxDriverTests
|
||||
{
|
||||
/// <summary>Kelvin × 100, wie die Anlage Temperaturen meldet.</summary>
|
||||
private static ushort Kelvin(double celsius) => (ushort)Math.Round(celsius * 100 + 27315);
|
||||
|
||||
/// <summary>Eine Datentabelle wie von READ_TABLES (big-endian), Werte an den bekannten Positionen.</summary>
|
||||
private static byte[] Table(Action<ushort[]>? 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<byte[], byte[]> respond) : IValloxTransport
|
||||
{
|
||||
public List<byte[]> Requests { get; } = [];
|
||||
|
||||
public Task<byte[]> 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<HomeException>(() => 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<HomeException>(() => 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<FamilyPlanner.Shared.Home.HomeVentilationDto>(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<HomeException>(() => driver.GetStateAsync(Vallox with { Kind = HomeDeviceKind.Switch }, TestContext.Current.CancellationToken));
|
||||
var wrongCommand = await Assert.ThrowsAsync<HomeException>(() => 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;
|
||||
}
|
||||
|
||||
/// <summary>Ein minimaler Vallox-Ersatz: nimmt eine WebSocket-Verbindung an, merkt sich die Anfrage und antwortet mit <paramref name="reply"/>.</summary>
|
||||
private static async Task<(string Host, Task<byte[]> 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<HomeException>(() => 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<HomeException>(() => transport.RequestAsync($"http://127.0.0.1:{FreePort()}", ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(HomeErrorKind.Unreachable, ex.Kind);
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResult<HomeOverviewDto>> 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<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 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);
|
||||
}
|
||||
@@ -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<HouseDevicesAdmin>();
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user