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:
openclaw committed 2026-10-16 20:04:14 +02:00
1 parent d1b1197a42
commit 70f1890641
23 files changed
+1167 -48

No files matched your search

+2 -2
View File
@@ -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) |
+8
View File
@@ -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
+46
View File
@@ -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;
}
+2
View File
@@ -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>();
+31 -1
View File
@@ -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>
+23 -4
View File
@@ -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,33 +43,46 @@
</MudSelect>
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Verbindung</MudText>
<MudSelect T="int" @bind-Value="_generation" Label="Shelly-Generation" Variant="Variant.Outlined" Class="fp-device-generation"
HelperText="Gen2 und neuer (Plus, Pro, Gen3, Gen4) sprechen RPC; ältere Geräte (z. B. Shelly 1, 2.5, Plug S) Gen1."
Error="@HasError("Generation")" ErrorText="@ErrorFor("Generation")">
<MudSelectItem T="int" Value="2">Gen2 und neuer (Plus, Pro, Gen3, Gen4)</MudSelectItem>
<MudSelectItem T="int" Value="1">Gen1 (älter)</MudSelectItem>
</MudSelect>
@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")" />
<MudNumericField T="int" @bind-Value="_channel" Label="Kanal / Relais-Nr." InputId="device-channel" Variant="Variant.Outlined" Min="0" Max="@HomeRules.MaxChannel"
HelperText="Beginnt bei 0 (bei Geräten mit zwei Relais: 0 und 1)."
Error="@HasError("Channel")" ErrorText="@ErrorFor("Channel")" />
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Anmeldung am Gerät (optional)</MudText>
<MudTextField @bind-Value="_authUser" Label="Benutzername" InputId="device-user" Variant="Variant.Outlined" autocomplete="off"
HelperText="Bei Shelly Gen2+ immer „admin“. Leer lassen, wenn das Gerät keinen Schutz hat."
Error="@HasError("AuthUser")" ErrorText="@ErrorFor("AuthUser")" />
<MudTextField @bind-Value="_password" Label="@(Device?.HasPassword == true ? "Passwort (leer lassen = unverändert)" : "Passwort")" InputId="device-password"
Variant="Variant.Outlined" InputType="InputType.Password" Disabled="_clearPassword" autocomplete="new-password"
HelperText="Wird verschlüsselt gespeichert und nie wieder angezeigt."
Error="@HasError("Password")" ErrorText="@ErrorFor("Password")" />
@if (Device?.HasPassword == true)
@if (!IsVallox)
{
<MudCheckBox T="bool" @bind-Value="_clearPassword" Label="Gespeichertes Passwort entfernen" Class="fp-device-clearpw" />
<MudNumericField T="int" @bind-Value="_channel" Label="Kanal / Relais-Nr." InputId="device-channel" Variant="Variant.Outlined" Min="0" Max="@HomeRules.MaxChannel"
HelperText="Beginnt bei 0 (bei Geräten mit zwei Relais: 0 und 1)."
Error="@HasError("Channel")" ErrorText="@ErrorFor("Channel")" />
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Anmeldung am Gerät (optional)</MudText>
<MudTextField @bind-Value="_authUser" Label="Benutzername" InputId="device-user" Variant="Variant.Outlined" autocomplete="off"
HelperText="Bei Shelly Gen2+ immer „admin“. Leer lassen, wenn das Gerät keinen Schutz hat."
Error="@HasError("AuthUser")" ErrorText="@ErrorFor("AuthUser")" />
<MudTextField @bind-Value="_password" Label="@(Device?.HasPassword == true ? "Passwort (leer lassen = unverändert)" : "Passwort")" InputId="device-password"
Variant="Variant.Outlined" InputType="InputType.Password" Disabled="_clearPassword" autocomplete="new-password"
HelperText="Wird verschlüsselt gespeichert und nie wieder angezeigt."
Error="@HasError("Password")" ErrorText="@ErrorFor("Password")" />
@if (Device?.HasPassword == true)
{
<MudCheckBox T="bool" @bind-Value="_clearPassword" Label="Gespeichertes Passwort entfernen" Class="fp-device-clearpw" />
}
}
<div>
@@ -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
<MudText Typo="Typo.caption">Gen@(context.Generation) · Kanal @context.Channel · @(context.HasPassword ? "mit Passwort" : "ohne Passwort")</MudText>
@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;
+29
View File
@@ -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()
{