Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a1aa24cd | ||
|
|
82350b7e1b |
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, Vallox), /api/home/* und /api/admin/home/* (Phase 17/19)
|
||||
Home/… # Haus: Treiber-Abstraktion (IHomeDeviceDriver, Shelly, Vallox, SMA Modbus), /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/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 |
|
||||
| Haus ✅ (Phase 17) | `HomeRoom` (Name, SortOrder, Soft Delete), `HomeDevice` (Name, Kind Schalter/Steckdose/Licht/Rollo/Sensor/Lüftungsanlage/PV-Anlage, Icon, RoomId, Driver (Shelly, Vallox – ADR-0025, SmaModbus – ADR-0026), Generation 1/2, Host (nur Heimnetz, normalisiert), `SecondaryHost` (optional, zweiter SMA-Wechselrichter), 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) |
|
||||
|
||||
|
||||
@@ -235,6 +235,14 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- [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)
|
||||
|
||||
## Phase 20 – PV-Anlage (Haus, ADR-0026)
|
||||
- [x] Geräteart „PV-Anlage“ (`HomeDeviceKind.Solar`) mit Treiber `SmaModbus`: SMA-Wechselrichter per Modbus TCP (Port 502, Unit-ID 3), **nur lesend** (Funktionscode 0x03), eigener Client ohne NuGet-Paket; EF-Migration `HomeDeviceSecondaryHost` (optionale zweite Adresse)
|
||||
- [x] An der echten Anlage gelesen (STP 6.0-3AV-40 + SBS3.7-10, Treiber live gegen beide Wechselrichter, 140–840 ms, parallele Zugriffe ok): AC-Leistung, Tagesertrag, Akku-Ladezustand/-Leistung/-Temperatur, Netzbezug/Einspeisung (Energy Meter); Hausverbrauch als AC-Bilanz **berechnet** (rechnerisch plausibel, noch nicht mit Sunny Portal verglichen, ebenso die Richtung von Bezug/Einspeisung und das Nachtverhalten); Reihenfolge der Adressen egal (Geräteklasse wird erkannt), Ausfall eines Wechselrichters → Warnung statt Totalausfall
|
||||
- [x] Rechte, Aktionsprotokoll, SSRF-Schutz (gemeinsamer Verbindungsaufbau `HomeHttp.ConnectSocketAsync`) wie bei den anderen Geräten; je Wechselrichter ein Zugriff gleichzeitig
|
||||
- [x] Hausverwaltung: Dialog mit zweiter Adresse, Verbindungstest mit Modellnamen; Gerätekarte mit PV, Hausverbrauch, Netz (Bezug/Einspeisung als Text), Akku, Ertrag
|
||||
- [x] Tests: Unit (`SmaModbusDriverTests`: Rahmen, NaN, Bilanz mit den Messwerten der Anlage, Teilausfall, nur Funktion 3, echter Modbus-Server über Loopback, SSRF; `HomeRulesTests`), bUnit (Gerätekarte, Verwaltungsdialog); Hilfsmittel `tools/sma-modbus-probe.ps1`
|
||||
- [ ] Integrationstest (Docker war nicht gestartet) und Aspire-Start mit der echten Anlage; Verlauf/Tageskurve, Ertrag je Monat, Anzeige auf der Startseite – bewusst nicht umgesetzt (ADR-0026)
|
||||
|
||||
## 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,55 @@
|
||||
# ADR-0026: Haus – PV-Anlage (SMA, Modbus TCP, nur lesend)
|
||||
|
||||
* Status: akzeptiert (Phase 20)
|
||||
* Datum: 2026-10-17
|
||||
|
||||
## Kontext
|
||||
Die Anlage besteht aus einem **SMA Sunny Tripower STP 6.0-3AV-40** (PV-Wechselrichter), einem **SMA Sunny Boy Storage SBS3.7-10** (Akku-Wechselrichter) und einem SMA-Zähler (Energy Meter / Home Manager).
|
||||
Angezeigt werden sollen aktuelle PV-Leistung, Akkustand und Energieflüsse – ähnlich der Lüftung (ADR-0025). Als Schnittstelle bietet SMA **Modbus TCP** (offiziell dokumentiert, im Heimnetz ohne Anmeldung)
|
||||
neben der Weboberfläche (`/dyn/getValues.json`, undokumentiert, mit Anmeldung) und Speedwire (UDP-Multicast, im Docker-Bridge-Netz nicht nutzbar).
|
||||
|
||||
## Entscheidung
|
||||
|
||||
### 1. Modell
|
||||
* `HomeDeviceKind.Solar` („PV-Anlage“) und `HomeDriverType.SmaModbus` hinter `IHomeDeviceDriver`; Rechte, Pläne, Freigaben, Protokoll, Audit unverändert (ADR-0023). Enums sind Text → keine Migration dafür.
|
||||
* **Ein Gerät, zwei Adressen:** `HomeDevice.SecondaryHost` (nullable, wie `Host` normalisiert, nur Heimnetz; Migration `HomeDeviceSecondaryHost`). Welcher Wechselrichter welche Rolle hat, entscheidet die **Geräteklasse** (Register 30051: 8001 PV, 8007 Batterie, 8009 Hybrid), nicht die Reihenfolge.
|
||||
* Der Treiber ist **nur lesend**: `ExecuteAsync` wirft `NotSupported`; es gibt keinen Schreibpfad, `IsCommandAllowed` lässt für `Solar` keinen Befehl zu. Modbus von SMA hat keine Anmeldung – ein Schreibzugriff wäre unbemerkt gefährlich.
|
||||
* Kein Kanal, keine Anmeldung, Generation immer 1 (wie Vallox). Eine Adresse ohne Port bedeutet Modbus-Standardport **502** (nicht 80).
|
||||
|
||||
### 2. Register (Unit-ID 3, Funktion 0x03, Big-Endian, Wortreihenfolge hoch/niedrig)
|
||||
Mit STP 6.0-3AV-40 und SBS3.7-10 an der echten Anlage geprüft (`tools/sma-modbus-probe.ps1`). „Kein Wert“ ist bei U32 `0xFFFFFFFF`, bei S32 `0x80000000`, bei U64 alles `0xFF`.
|
||||
|
||||
| Register | Größe | Inhalt |
|
||||
|---|---|---|
|
||||
| 30051 / 30053 | U32 | Geräteklasse / Gerätetyp (9346 = STP 6.0-3AV-40, 9356 = SBS3.7-10) |
|
||||
| 30775 | S32 | Wirkleistung AC gesamt (W); beim SBS negativ = lädt |
|
||||
| 30517 | U64 | Tagesertrag (Wh) |
|
||||
| 30845 / 30849 | U32 / S32 | Akku-Ladezustand (%) / Akku-Temperatur (0,1 °C) – nur Akku-Wechselrichter |
|
||||
| 31393 / 31395 | U32 | Akku-Lade- / Entladeleistung (W) |
|
||||
| 30865 / 30867 | S32 | Netzbezug / Einspeisung (W) – vom Zähler, beide Wechselrichter liefern sie |
|
||||
|
||||
Nicht verwendet: 30529 (Gesamtertrag) ist ein U32, kein U64 (das 64-Bit-Lesen der Probe war falsch); MPP-/DC-Werte (der STP hat zwei MPP-Eingänge, die AC-Leistung ist die verlässlichere Gesamtgröße).
|
||||
|
||||
### 3. Zusammenführung (`SmaModbusProtocol.Combine`)
|
||||
* PV-Leistung = Summe der AC-Leistung der Nicht-Akku-Wechselrichter, nie negativ (nachts melden Wechselrichter kleine negative Werte → 0).
|
||||
* Netz = Bezug − Einspeisung vom **ersten** Wechselrichter, der Zählerwerte liefert (beide liefern denselben Zähler; die Werte unterscheiden sich nur durch den Abfragezeitpunkt).
|
||||
* **Hausverbrauch** ist berechnet: Summe aller AC-Leistungen (Akku-Wechselrichter negativ beim Laden, positiv beim Entladen) + Netzbezug − Einspeisung, mindestens 0. Fehlen Zählerwerte, bleibt er **unbekannt** (`null`) statt geschätzt.
|
||||
* Akku: Ladezustand, Leistung (Laden − Entladen, positiv = lädt), Temperatur vom ersten Wechselrichter mit Ladezustand. Tagesertrag zählt nur die PV-Wechselrichter.
|
||||
* Jeder Wert ist optional. Fällt ein Wechselrichter aus, bleibt der andere sichtbar und `HomeSolarDto.Warning` nennt es („Der zweite Wechselrichter ist nicht erreichbar.“); fallen alle aus, ist das Gerät offline.
|
||||
|
||||
### 4. Netzwerk, SSRF, Last
|
||||
* Der Verbindungsaufbau nutzt `HomeHttp.ConnectSocketAsync` (aus dem `ConnectCallback` herausgelöst): jede aufgelöste Adresse wird geprüft (nur Heimnetz, DNS-Rebinding), verbunden wird mit der geprüften Adresse.
|
||||
* Kurze Verbindung je Durchgang (alle Register über eine Verbindung), Zeitlimit 4 s insgesamt, keine Wiederholungen, Antwortrahmen werden streng geprüft (Transaktion, Unit, Länge, Funktionscode).
|
||||
* **Je Wechselrichter höchstens ein Zugriff gleichzeitig** (Semaphore im Transport) plus der bestehende 3-s-Server-Cache der Gerätezustände – viele offene Browser verursachen so nur einen Lesezugriff je Wechselrichter.
|
||||
|
||||
### 5. Oberfläche
|
||||
* Hausverwaltung: Art „PV-Anlage“ – Name, Raum, Adresse, optional zweite Adresse; Hinweis, dass Modbus am Wechselrichter aktiviert sein muss. „Verbindung testen“ zeigt die Modelle („STP 6.0-3AV-40 + SBS3.7-10“).
|
||||
* Gerätekarte: Status „PV 775 W · Akku 82 %“, Liste mit PV-Erzeugung, Hausverbrauch, Netz (**Bezug/Einspeisung als Text**, nie nur Vorzeichen oder Farbe), Akku (Laden/Entladen als Text), Akku-Temperatur, Ertrag heute. Aktualisierung alle 10 s wie alle Geräte.
|
||||
|
||||
## Konsequenzen
|
||||
* **Voraussetzung am Wechselrichter:** Modbus TCP muss in der Weboberfläche jedes Wechselrichters aktiviert werden (Geräteparameter → Externe Kommunikation → Modbus, Port 502).
|
||||
* Andere SMA-Modelle/Firmware können Register anders belegen; der Verbindungstest erkennt „kein SMA“ über die Geräteklasse. Hybrid-Wechselrichter (Klasse 8009) werden wie PV-Wechselrichter gezählt (AC-Gesamtleistung), ihre Akku-Werte kommen aus ihren eigenen Registern.
|
||||
* Der Akku-Wechselrichter bilanziert seine Leistung an der AC-Seite; die Akku-Leistung (31393/31395) liegt DC-seitig leicht daneben (228 W gegenüber 229 W AC) – für die Anzeige irrelevant.
|
||||
* Bewusst nicht umgesetzt: Verlauf/Tageskurve, Monats-/Jahresertrag, Anzeige auf der Startseite, Steuern des Akkus, SunSpec-Profil (Unit-ID 126), Speedwire, Energiefluss-Grafik. Ein Verlauf bräuchte einen Hintergrunddienst und eine eigene Tabelle.
|
||||
* Die Register sind mit der Probe an der echten Anlage geprüft (die Messwerte stecken in den Tests), der Treiber ist live gegen beide Wechselrichter gelaufen (140–840 ms je Abruf, drei parallele Abrufe ok, Reihenfolge der Adressen egal). Noch offen: Vergleich von **Hausverbrauch** und der **Richtung von Bezug/Einspeisung** (30865/30867) mit Sunny Portal bzw. Home Manager, das **Nachtverhalten** (ein erreichbarer PV-Wechselrichter ohne Wert zeigt 0 W) sowie der Praxistest der fertigen Oberfläche mit Aspire und der Integrationstest (Docker war nicht gestartet).
|
||||
* Fällt ein konfigurierter Wechselrichter aus, bleibt der Hausverbrauch **unbekannt**: ohne den Akku-Wechselrichter fehlte dessen Entladung in der Bilanz (live beobachtet: 96 W statt rund 600 W).
|
||||
@@ -150,7 +150,8 @@ internal static class HomeAdminEndpoints
|
||||
password = protector.Unprotect(secret);
|
||||
}
|
||||
|
||||
var connection = new HomeDeviceConnection(host!, request.Generation, request.Channel, request.Kind, string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim(), password);
|
||||
var (secondaryHost, _) = HomeRules.NormalizeOptionalHost(request.SecondaryHost);
|
||||
var connection = new HomeDeviceConnection(host!, request.Generation, request.Channel, request.Kind, string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim(), password, secondaryHost);
|
||||
return TypedResults.Ok(await devices.TestAsync(connection, request.Driver, ct));
|
||||
}
|
||||
|
||||
@@ -394,8 +395,14 @@ internal static class HomeAdminEndpoints
|
||||
return Invalid(nameof(SaveHomeDeviceRequest.RoomId), "Der Raum existiert nicht mehr.");
|
||||
}
|
||||
|
||||
// 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 (secondaryHost, secondaryError) = HomeRules.NormalizeOptionalHost(request.SecondaryHost);
|
||||
if (secondaryError is not null)
|
||||
{
|
||||
return Invalid(nameof(SaveHomeDeviceRequest.SecondaryHost), secondaryError);
|
||||
}
|
||||
|
||||
// Vallox MV und SMA kennen weder Kanal noch Anmeldung: nichts davon speichern (auch kein altes Passwort eines umgewidmeten Geräts).
|
||||
var isVallox = request.Driver is HomeDriverType.Vallox or HomeDriverType.SmaModbus;
|
||||
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)
|
||||
@@ -410,6 +417,7 @@ internal static class HomeAdminEndpoints
|
||||
device.Driver = request.Driver;
|
||||
device.Generation = request.Generation;
|
||||
device.Host = host;
|
||||
device.SecondaryHost = request.Driver == HomeDriverType.SmaModbus ? secondaryHost : null;
|
||||
device.Channel = isVallox ? 0 : request.Channel;
|
||||
device.AuthUser = user;
|
||||
device.SortOrder = request.SortOrder;
|
||||
@@ -428,7 +436,9 @@ internal static class HomeAdminEndpoints
|
||||
private static string Describe(HomeDevice d) =>
|
||||
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")}";
|
||||
: d.Driver == HomeDriverType.SmaModbus
|
||||
? $"{HomeRules.DisplayName(d.Kind)}, SMA Modbus, {d.Host}{(d.SecondaryHost is null ? string.Empty : ", " + d.SecondaryHost)}"
|
||||
: $"{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)
|
||||
{
|
||||
@@ -457,7 +467,7 @@ internal static class HomeAdminEndpoints
|
||||
private static HomeAdminDeviceDto ToDto(HomeDevice d, IEnumerable<HomeDeviceAccess> grants) => new(
|
||||
d.Id, d.Name, d.Kind, d.Icon, d.RoomId, d.Driver, d.Generation, d.Host, d.Channel, d.AuthUser,
|
||||
!string.IsNullOrEmpty(d.AuthPasswordProtected), d.SortOrder, d.RowVersion,
|
||||
grants.Select(g => new HomeGrantDto(g.Role, g.UserId, g.Level)).ToList());
|
||||
grants.Select(g => new HomeGrantDto(g.Role, g.UserId, g.Level)).ToList(), d.SecondaryHost);
|
||||
|
||||
private static HomeAdminPlanDto ToDto(HomePlan p, IEnumerable<HomeDevicePlacement> placements) => new(
|
||||
p.Id, p.Name, p.SortOrder, p.ImageVersion, placements.Select(x => new HomePinDto(x.DeviceId, x.X, x.Y)).ToList());
|
||||
|
||||
@@ -4,7 +4,7 @@ using FamilyPlanner.Shared.Home;
|
||||
namespace FamilyPlanner.Api.Features.Home;
|
||||
|
||||
/// <summary>Was ein Treiber zum Ansprechen eines Geräts braucht (Passwort bereits entschlüsselt, nur im Arbeitsspeicher).</summary>
|
||||
public sealed record HomeDeviceConnection(string Host, int Generation, int Channel, HomeDeviceKind Kind, string? User, string? Password);
|
||||
public sealed record HomeDeviceConnection(string Host, int Generation, int Channel, HomeDeviceKind Kind, string? User, string? Password, string? SecondaryHost = null);
|
||||
|
||||
/// <summary>Gelesener Zustand; nicht jedes Gerät liefert jede Größe.</summary>
|
||||
public sealed record HomeDeviceState(
|
||||
@@ -14,7 +14,8 @@ public sealed record HomeDeviceState(
|
||||
double? PowerWatts = null,
|
||||
double? TemperatureC = null,
|
||||
double? HumidityPercent = null,
|
||||
HomeVentilationDto? Ventilation = null);
|
||||
HomeVentilationDto? Ventilation = null,
|
||||
HomeSolarDto? Solar = null);
|
||||
|
||||
/// <summary>Kennung des Geräts aus dem Verbindungstest.</summary>
|
||||
public sealed record HomeDeviceInfo(string? Model, string? Firmware);
|
||||
|
||||
@@ -38,9 +38,16 @@ internal static partial class HomeHttp
|
||||
ConnectCallback = ConnectAsync,
|
||||
};
|
||||
|
||||
private static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct)
|
||||
private static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct) =>
|
||||
new NetworkStream(await ConnectSocketAsync(context.DnsEndPoint.Host, context.DnsEndPoint.Port, ct), ownsSocket: true);
|
||||
|
||||
/// <summary>
|
||||
/// Baut eine TCP-Verbindung zu einem Gerät im Heimnetz auf und prüft dabei <b>jede</b> aufgelöste Adresse (SSRF-/DNS-Rebinding-Schutz). Auch für Nicht-HTTP-Protokolle
|
||||
/// (Modbus TCP): verbunden wird mit der geprüften Adresse, der Name wird nicht ein zweites Mal aufgelöst. Nicht erlaubte Adressen lösen eine
|
||||
/// <see cref="HttpRequestException"/> mit <see cref="HomeBlockedAddressException"/> aus (siehe <see cref="IsBlocked"/>).
|
||||
/// </summary>
|
||||
internal static async Task<Socket> ConnectSocketAsync(string host, int port, CancellationToken ct)
|
||||
{
|
||||
var host = context.DnsEndPoint.Host;
|
||||
var addresses = IPAddress.TryParse(host.Trim('[', ']'), out var literal)
|
||||
? [literal]
|
||||
: await Dns.GetHostAddressesAsync(host, ct);
|
||||
@@ -58,8 +65,8 @@ internal static partial class HomeHttp
|
||||
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), ct);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
await socket.ConnectAsync(new IPEndPoint(address, port), ct);
|
||||
return socket;
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException)
|
||||
{
|
||||
|
||||
@@ -97,7 +97,8 @@ public sealed class HomeDeviceService(
|
||||
device.Channel,
|
||||
device.Kind,
|
||||
device.AuthUser,
|
||||
device.AuthPasswordProtected is { Length: > 0 } secret ? protector.Unprotect(secret) : null);
|
||||
device.AuthPasswordProtected is { Length: > 0 } secret ? protector.Unprotect(secret) : null,
|
||||
device.SecondaryHost);
|
||||
|
||||
public async Task<IReadOnlyList<HomeDeviceStateDto>> ReadStatesAsync(IReadOnlyList<HomeDevice> devices, CancellationToken ct)
|
||||
{
|
||||
@@ -198,7 +199,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, s.Ventilation);
|
||||
new(id, true, s.IsOn, s.CoverState, s.CoverPosition, s.PowerWatts, s.TemperatureC, s.HumidityPercent, null, s.Ventilation, s.Solar);
|
||||
|
||||
private static HomeDeviceStateDto Offline(Guid id, string message) =>
|
||||
new(id, false, null, null, null, null, null, null, message);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Sockets;
|
||||
using FamilyPlanner.Domain.Home;
|
||||
using FamilyPlanner.Shared.Home;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Home;
|
||||
|
||||
/// <summary>Liest Register eines SMA-Wechselrichters (nur lesend) über eine Verbindung, in der Reihenfolge der Anfragen.</summary>
|
||||
public interface ISmaModbusTransport
|
||||
{
|
||||
Task<IReadOnlyList<ModbusResult>> ReadAsync(string host, byte unitId, IReadOnlyList<ModbusRead> reads, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP (Funktion 0x03). Der Verbindungsaufbau läuft über <see cref="HomeHttp.ConnectSocketAsync"/> – dieselbe Prüfung wie bei allen Geräten (nur Heimnetz, jede aufgelöste Adresse).
|
||||
/// Je Wechselrichter läuft höchstens ein Zugriff gleichzeitig (mehrere offene Browser sollen das Gerät nicht überlasten), kurze Verbindung je Durchgang,
|
||||
/// Zeitlimit insgesamt wie bei den anderen Geräten, keine Wiederholungen.
|
||||
/// </summary>
|
||||
public sealed class SmaModbusTcpTransport : ISmaModbusTransport, IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _gates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private ushort _transaction;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var gate in _gates.Values)
|
||||
{
|
||||
gate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ModbusResult>> ReadAsync(string host, byte unitId, IReadOnlyList<ModbusRead> reads, CancellationToken ct)
|
||||
{
|
||||
var uri = new Uri(host, UriKind.Absolute);
|
||||
var port = uri.IsDefaultPort ? SmaModbusProtocol.DefaultPort : uri.Port; // „http://host“ ohne Port heißt hier Modbus-Standardport 502
|
||||
var gate = _gates.GetOrAdd($"{uri.Host}:{port}", _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(HomeHttp.RequestTimeout);
|
||||
var token = timeout.Token;
|
||||
try
|
||||
{
|
||||
await gate.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
using var socket = await HomeHttp.ConnectSocketAsync(uri.Host, port, token);
|
||||
await using var stream = new NetworkStream(socket, ownsSocket: false);
|
||||
var results = new List<ModbusResult>(reads.Count);
|
||||
foreach (var read in reads)
|
||||
{
|
||||
results.Add(await ReadOneAsync(stream, unitId, read, token));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new HomeException(HomeErrorKind.Timeout, "Der Wechselrichter antwortet nicht rechtzeitig.");
|
||||
}
|
||||
catch (HttpRequestException ex) when (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 HttpRequestException or SocketException or IOException)
|
||||
{
|
||||
throw new HomeException(
|
||||
HomeErrorKind.Unreachable,
|
||||
"Der Wechselrichter ist nicht erreichbar. Bitte Adresse prüfen und ob Modbus TCP (Port 502) am Wechselrichter aktiviert ist.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ModbusResult> ReadOneAsync(NetworkStream stream, byte unitId, ModbusRead read, CancellationToken ct)
|
||||
{
|
||||
var transaction = unchecked(++_transaction);
|
||||
await stream.WriteAsync(SmaModbusProtocol.ReadRequest(transaction, unitId, read), ct);
|
||||
|
||||
var header = new byte[9];
|
||||
await stream.ReadExactlyAsync(header, ct);
|
||||
var parsed = SmaModbusProtocol.ParseHeader(header, transaction, unitId)
|
||||
?? throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät antwortet nicht wie ein SMA-Wechselrichter (Modbus).");
|
||||
if (parsed.IsException)
|
||||
{
|
||||
return new ModbusResult(null, parsed.Byte8);
|
||||
}
|
||||
|
||||
if (parsed.Remaining != read.Count * 2)
|
||||
{
|
||||
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät antwortet nicht wie ein SMA-Wechselrichter (Modbus).");
|
||||
}
|
||||
|
||||
var data = new byte[parsed.Remaining];
|
||||
await stream.ReadExactlyAsync(data, ct);
|
||||
return new ModbusResult(data, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Treiber für SMA-Wechselrichter per Modbus TCP, nur lesend (ADR-0026): ein Gerät „PV-Anlage“ mit einer oder zwei Adressen (z. B. Sunny Tripower + Sunny Boy Storage).
|
||||
/// Jeder Wechselrichter wird einzeln gelesen; fällt einer aus, bleibt der Rest sichtbar und <see cref="HomeSolarDto.Warning"/> nennt es.
|
||||
/// </summary>
|
||||
public sealed class SmaModbusDriver(ISmaModbusTransport transport) : IHomeDeviceDriver
|
||||
{
|
||||
public HomeDriverType Type => HomeDriverType.SmaModbus;
|
||||
|
||||
public async Task<HomeDeviceState> GetStateAsync(HomeDeviceConnection c, CancellationToken ct)
|
||||
{
|
||||
EnsureSolar(c);
|
||||
var hosts = Hosts(c);
|
||||
var outcomes = await Task.WhenAll(hosts.Select(h => TryReadAsync(h, SmaModbusProtocol.StateReads, ct)));
|
||||
|
||||
var readings = outcomes.Where(o => o.Results is not null).Select(o => SmaModbusProtocol.Parse(o.Results!)).ToList();
|
||||
if (readings.Count == 0)
|
||||
{
|
||||
throw outcomes[0].Error!;
|
||||
}
|
||||
|
||||
string? warning = null;
|
||||
if (outcomes.Any(o => o.Results is null))
|
||||
{
|
||||
warning = hosts.Count == 1
|
||||
? null
|
||||
: outcomes[0].Results is null ? "Der erste Wechselrichter ist nicht erreichbar." : "Der zweite Wechselrichter ist nicht erreichbar.";
|
||||
}
|
||||
|
||||
var solar = SmaModbusProtocol.Combine(readings, warning);
|
||||
return new HomeDeviceState(PowerWatts: solar.PvPowerW, Solar: solar);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(HomeDeviceConnection c, HomeCommand command, CancellationToken ct) =>
|
||||
throw new HomeException(HomeErrorKind.NotSupported, "PV-Anlagen werden nur angezeigt und nicht gesteuert.");
|
||||
|
||||
public async Task<HomeDeviceInfo> TestAsync(HomeDeviceConnection c, CancellationToken ct)
|
||||
{
|
||||
EnsureSolar(c);
|
||||
var models = new List<string>();
|
||||
foreach (var host in Hosts(c))
|
||||
{
|
||||
var results = await transport.ReadAsync(host, SmaModbusProtocol.UnitId, SmaModbusProtocol.IdentityReads, ct);
|
||||
var deviceClass = SmaModbusProtocol.U32(results[0].Data);
|
||||
if (deviceClass is not (SmaModbusProtocol.ClassPvInverter or SmaModbusProtocol.ClassBatteryInverter or SmaModbusProtocol.ClassHybridInverter))
|
||||
{
|
||||
throw new HomeException(HomeErrorKind.BadResponse, "Das Gerät ist kein SMA-Wechselrichter oder Modbus ist nicht für SMA freigegeben (Unit-ID 3).");
|
||||
}
|
||||
|
||||
models.Add(SmaModbusProtocol.ModelName(SmaModbusProtocol.U32(results[1].Data)));
|
||||
}
|
||||
|
||||
return new HomeDeviceInfo(string.Join(" + ", models), null);
|
||||
}
|
||||
|
||||
private async Task<(IReadOnlyList<ModbusResult>? Results, HomeException? Error)> TryReadAsync(string host, IReadOnlyList<ModbusRead> reads, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (await transport.ReadAsync(host, SmaModbusProtocol.UnitId, reads, ct), null);
|
||||
}
|
||||
catch (HomeException ex)
|
||||
{
|
||||
return (null, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> Hosts(HomeDeviceConnection c) =>
|
||||
c.SecondaryHost is { Length: > 0 } second ? [c.Host, second] : [c.Host];
|
||||
|
||||
private static void EnsureSolar(HomeDeviceConnection c)
|
||||
{
|
||||
if (c.Kind != HomeDeviceKind.Solar)
|
||||
{
|
||||
throw new HomeException(HomeErrorKind.NotSupported, "Der SMA-Treiber liest nur PV-Anlagen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Buffers.Binary;
|
||||
using FamilyPlanner.Shared.Home;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Home;
|
||||
|
||||
/// <summary>Ein zu lesender Registerbereich (Modbus-Funktion 0x03, „Read Holding Registers“).</summary>
|
||||
public sealed record ModbusRead(int Address, int Count);
|
||||
|
||||
/// <summary>Ergebnis eines Lesezugriffs: <see cref="Data"/> (Big-Endian, 2 Byte je Register) oder <see cref="ExceptionCode"/> des Geräts (z. B. 2 = Adresse nicht vorhanden).</summary>
|
||||
public sealed record ModbusResult(byte[]? Data, int? ExceptionCode);
|
||||
|
||||
/// <summary>Was ein SMA-Wechselrichter in einem Durchgang geliefert hat; <c>null</c> = Register nicht vorhanden oder „kein Wert“ (NaN).</summary>
|
||||
public sealed record SmaReading(
|
||||
uint? DeviceClass,
|
||||
uint? DeviceType,
|
||||
int? AcPowerW,
|
||||
ulong? DailyYieldWh,
|
||||
uint? BatteryPercent,
|
||||
int? BatteryTemperatureDeciC,
|
||||
uint? BatteryChargeW,
|
||||
uint? BatteryDischargeW,
|
||||
int? GridImportW,
|
||||
int? GridExportW);
|
||||
|
||||
/// <summary>
|
||||
/// SMA-Modbus-Profil (ADR-0026): Registerbelegung, Rahmenbau, Dekodierung und Zusammenführung mehrerer Wechselrichter zu einer <see cref="HomeSolarDto"/>.
|
||||
/// Es wird ausschließlich gelesen (Funktionscode 0x03); eine Schreibfunktion gibt es bewusst nicht.
|
||||
/// Die Register sind mit einem STP 6.0-3AV-40 (Klasse 8001) und einem SBS3.7-10 (Klasse 8007) an der echten Anlage geprüft.
|
||||
/// </summary>
|
||||
public static class SmaModbusProtocol
|
||||
{
|
||||
public const int DefaultPort = 502;
|
||||
|
||||
/// <summary>Unit-ID der SMA-Geräte (SunSpec nutzt dagegen 126).</summary>
|
||||
public const byte UnitId = 3;
|
||||
|
||||
public const uint ClassPvInverter = 8001;
|
||||
public const uint ClassBatteryInverter = 8007;
|
||||
public const uint ClassHybridInverter = 8009;
|
||||
|
||||
/// <summary>Größter Rahmen, den wir annehmen (Kopf 9 Byte + höchstens 8 Register).</summary>
|
||||
public const int MaxFrameBytes = 9 + 16;
|
||||
|
||||
// Reihenfolge = Reihenfolge der Ergebnisse in Parse().
|
||||
public static IReadOnlyList<ModbusRead> StateReads { get; } =
|
||||
[
|
||||
new(30051, 2), // 0 Geräteklasse (U32)
|
||||
new(30053, 2), // 1 Gerätetyp (U32)
|
||||
new(30775, 2), // 2 Wirkleistung AC gesamt (S32, W; negativ = Akku lädt aus dem Netz)
|
||||
new(30517, 4), // 3 Tagesertrag (U64, Wh)
|
||||
new(30845, 2), // 4 Akku-Ladezustand (U32, %)
|
||||
new(30849, 2), // 5 Akku-Temperatur (S32, 0,1 °C)
|
||||
new(31393, 2), // 6 Akku-Ladeleistung (U32, W)
|
||||
new(31395, 2), // 7 Akku-Entladeleistung (U32, W)
|
||||
new(30865, 4), // 8 Netzbezug (S32, W) + 9 Einspeisung (S32, W) – kommen vom Energy Meter / Home Manager
|
||||
];
|
||||
|
||||
/// <summary>Nur Klasse und Typ – für den Verbindungstest.</summary>
|
||||
public static IReadOnlyList<ModbusRead> IdentityReads { get; } = [StateReads[0], StateReads[1]];
|
||||
|
||||
/// <summary>Anfrage-Rahmen: MBAP-Kopf (Transaktion, Protokoll 0, Länge, Unit) + Funktion 0x03, Adresse, Anzahl.</summary>
|
||||
public static byte[] ReadRequest(ushort transaction, byte unit, ModbusRead read)
|
||||
{
|
||||
var frame = new byte[12];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(0), transaction);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), 6);
|
||||
frame[6] = unit;
|
||||
frame[7] = 0x03;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(8), (ushort)read.Address);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(10), (ushort)read.Count);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wertet den MBAP-Kopf (7 Byte) plus Funktionscode und das nächste Byte aus (9 Byte). Liefert die Länge der noch folgenden Daten
|
||||
/// und ob es eine Ausnahme-Antwort ist; <c>null</c> bei einem Kopf, der nicht zur Anfrage passt.
|
||||
/// </summary>
|
||||
public static (int Remaining, bool IsException, int Byte8)? ParseHeader(ReadOnlySpan<byte> header, ushort transaction, byte unit)
|
||||
{
|
||||
if (header.Length < 9 || BinaryPrimitives.ReadUInt16BigEndian(header) != transaction
|
||||
|| BinaryPrimitives.ReadUInt16BigEndian(header[2..]) != 0 || header[6] != unit)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var length = BinaryPrimitives.ReadUInt16BigEndian(header[4..]);
|
||||
if (length is < 3 or > 2 + 1 + 16)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var function = header[7];
|
||||
if (function == 0x83)
|
||||
{
|
||||
return (0, true, header[8]);
|
||||
}
|
||||
|
||||
if (function != 0x03)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Länge zählt Unit + Funktion + Byteanzahl + Daten; das Byteanzahl-Byte haben wir schon gelesen.
|
||||
var dataBytes = header[8];
|
||||
return length - 3 == dataBytes ? (dataBytes, false, dataBytes) : null;
|
||||
}
|
||||
|
||||
/// <summary>Alle Ergebnisse eines <see cref="StateReads"/>-Durchgangs in einen <see cref="SmaReading"/>.</summary>
|
||||
public static SmaReading Parse(IReadOnlyList<ModbusResult> results)
|
||||
{
|
||||
byte[]? Data(int index) => index < results.Count ? results[index].Data : null;
|
||||
var grid = Data(8);
|
||||
return new SmaReading(
|
||||
U32(Data(0)),
|
||||
U32(Data(1)),
|
||||
S32(Data(2)),
|
||||
U64(Data(3)),
|
||||
U32(Data(4)),
|
||||
S32(Data(5)),
|
||||
U32(Data(6)),
|
||||
U32(Data(7)),
|
||||
S32(grid, 0),
|
||||
S32(grid, 4));
|
||||
}
|
||||
|
||||
/// <summary>Unsigned 32 Bit; <c>0xFFFFFFFF</c> heißt bei SMA „kein Wert“.</summary>
|
||||
public static uint? U32(byte[]? data, int offset = 0) =>
|
||||
data is not null && data.Length >= offset + 4 && BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset)) is var v && v != uint.MaxValue ? v : null;
|
||||
|
||||
/// <summary>Signed 32 Bit; <c>0x80000000</c> heißt „kein Wert“.</summary>
|
||||
public static int? S32(byte[]? data, int offset = 0) =>
|
||||
data is not null && data.Length >= offset + 4 && BinaryPrimitives.ReadInt32BigEndian(data.AsSpan(offset)) is var v && v != int.MinValue ? v : null;
|
||||
|
||||
/// <summary>Unsigned 64 Bit; alles <c>0xFF</c> heißt „kein Wert“.</summary>
|
||||
public static ulong? U64(byte[]? data, int offset = 0) =>
|
||||
data is not null && data.Length >= offset + 8 && BinaryPrimitives.ReadUInt64BigEndian(data.AsSpan(offset)) is var v && v != ulong.MaxValue ? v : null;
|
||||
|
||||
public static bool IsBatteryInverter(SmaReading r) => r.DeviceClass == ClassBatteryInverter;
|
||||
|
||||
/// <summary>Bekannte Gerätetypen (Register 30053) → Modellname; unbekannte werden mit ihrer Kennung angezeigt.</summary>
|
||||
public static string ModelName(uint? type) => type switch
|
||||
{
|
||||
9346 => "STP 6.0-3AV-40",
|
||||
9356 => "SBS3.7-10",
|
||||
{ } other => $"SMA-Gerät {other}",
|
||||
_ => "SMA-Gerät",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Führt die Wechselrichter zu einem Anlagenzustand zusammen. Leistungen sind an der AC-Sammelschiene bilanziert:
|
||||
/// Hausverbrauch = Summe der AC-Leistungen (PV positiv, Akku-Wechselrichter negativ beim Laden) + Netzbezug − Einspeisung.
|
||||
/// Netzbezug und Einspeisung liefert der Zähler über jeden Wechselrichter; der erste vorhandene Wert zählt.
|
||||
/// Mit <paramref name="warning"/> (ein Wechselrichter fehlt) bleibt der Hausverbrauch unbekannt, weil die Bilanz unvollständig wäre.
|
||||
/// </summary>
|
||||
public static HomeSolarDto Combine(IReadOnlyList<SmaReading> readings, string? warning)
|
||||
{
|
||||
// Ein erreichbarer Wechselrichter (Klasse bekannt) ohne Leistungswert speist nichts ein (nachts „kein Wert“): 0 W statt einer fehlenden Zeile.
|
||||
double? Ac(SmaReading r) => r.AcPowerW is { } w ? w : r.DeviceClass is not null ? 0 : null;
|
||||
var pvValues = readings.Where(r => !IsBatteryInverter(r) && Ac(r) is not null).Select(r => Ac(r)!.Value).ToList();
|
||||
double? pv = pvValues.Count == 0 ? null : Math.Max(0, pvValues.Sum());
|
||||
|
||||
var daily = readings.Where(r => !IsBatteryInverter(r) && r.DailyYieldWh is not null).Select(r => (double)r.DailyYieldWh!.Value).ToList();
|
||||
double? dailyKwh = daily.Count == 0 ? null : Math.Round(daily.Sum() / 1000, 1);
|
||||
|
||||
var battery = readings.FirstOrDefault(r => r.BatteryPercent is not null);
|
||||
int? percent = battery?.BatteryPercent is { } p ? (int)Math.Min(p, 100) : null;
|
||||
double? batteryPower = battery is { BatteryChargeW: not null } or { BatteryDischargeW: not null }
|
||||
? (double)(battery.BatteryChargeW ?? 0) - (battery.BatteryDischargeW ?? 0)
|
||||
: null;
|
||||
double? batteryTemp = battery?.BatteryTemperatureDeciC is { } t ? t / 10.0 : null;
|
||||
|
||||
var meter = readings.FirstOrDefault(r => r.GridImportW is not null || r.GridExportW is not null);
|
||||
double? grid = meter is null ? null : (meter.GridImportW ?? 0) - (meter.GridExportW ?? 0);
|
||||
|
||||
// Fehlt ein konfigurierter Wechselrichter (warning), fehlt auch sein Anteil an der Bilanz (z. B. die Akku-Entladung): dann lieber unbekannt als falsch.
|
||||
var ac = readings.Where(r => Ac(r) is not null).Select(r => Ac(r)!.Value).ToList();
|
||||
double? house = warning is null && grid is { } g && ac.Count > 0 ? Math.Max(0, ac.Sum() + g) : null;
|
||||
|
||||
return new HomeSolarDto(pv, house, grid, percent, batteryPower, batteryTemp, dailyKwh, warning);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ 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.AddSingleton<ISmaModbusTransport, SmaModbusTcpTransport>(); // PV-Anlage, nur lesend (ADR-0026)
|
||||
builder.Services.AddScoped<IHomeDeviceDriver, SmaModbusDriver>();
|
||||
builder.Services.AddScoped<HomeAccessService>();
|
||||
builder.Services.AddScoped<HomeDeviceService>();
|
||||
builder.Services.AddScoped<HomeCameraAccessService>();
|
||||
|
||||
@@ -22,6 +22,9 @@ public enum HomeDeviceKind
|
||||
|
||||
/// <summary>Lüftungsanlage mit Wärmerückgewinnung (z. B. Vallox MV): Temperaturen lesen, Profil wählen.</summary>
|
||||
Ventilation = 5,
|
||||
|
||||
/// <summary>PV-Anlage (z. B. SMA Sunny Tripower + Speicher-Wechselrichter): nur lesend – PV-Leistung, Netz, Hausverbrauch, Akku.</summary>
|
||||
Solar = 6,
|
||||
}
|
||||
|
||||
/// <summary>Treiber, der das Gerät anspricht. Weitere Treiber (MQTT, Home Assistant …) kommen als neuer Wert plus <c>IHomeDeviceDriver</c>.</summary>
|
||||
@@ -31,6 +34,9 @@ public enum HomeDriverType
|
||||
|
||||
/// <summary>Vallox-Lüftungsanlagen der MV-Reihe (z. B. ValloPlus 350 MV) über das WebSocket-Protokoll ihrer Weboberfläche.</summary>
|
||||
Vallox = 1,
|
||||
|
||||
/// <summary>SMA-Wechselrichter (Sunny Tripower, Sunny Boy Storage) über Modbus TCP, nur lesend (ADR-0026).</summary>
|
||||
SmaModbus = 2,
|
||||
}
|
||||
|
||||
/// <summary>Profil einer Lüftungsanlage. <see cref="Boost"/>, <see cref="Fireplace"/> und <see cref="Extra"/> laufen mit Zeitbegrenzung.</summary>
|
||||
@@ -115,6 +121,9 @@ public sealed class HomeDevice : AuditableEntity, ISoftDeletable, IHasRowVersion
|
||||
/// <summary>Normalisierte Basisadresse im Heimnetz, z. B. <c>http://192.168.1.50</c> (<see cref="HomeRules.NormalizeHost"/>).</summary>
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optionale zweite Adresse (nur PV-Anlage: der zweite Wechselrichter), normalisiert wie <see cref="Host"/>.</summary>
|
||||
public string? SecondaryHost { get; set; }
|
||||
|
||||
/// <summary>Kanal bzw. Relais-/Cover-Id (0-basiert).</summary>
|
||||
public int Channel { get; set; }
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ public static class HomeRules
|
||||
HomeDeviceKind.Cover => "Rollo",
|
||||
HomeDeviceKind.Sensor => "Sensor",
|
||||
HomeDeviceKind.Ventilation => "Lüftungsanlage",
|
||||
HomeDeviceKind.Solar => "PV-Anlage",
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
@@ -142,21 +143,26 @@ 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,
|
||||
HomeDriverType.Vallox or HomeDriverType.SmaModbus => generation == 1,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Passen Treiber und Geräteart zusammen? Vallox steuert nur Lüftungsanlagen, Shelly alles außer Lüftungsanlagen.</summary>
|
||||
/// <summary>Passen Treiber und Geräteart zusammen? Vallox steuert nur Lüftungsanlagen, SMA nur PV-Anlagen, Shelly alles andere.</summary>
|
||||
public static bool IsValidDriverForKind(HomeDriverType driver, HomeDeviceKind kind) => driver switch
|
||||
{
|
||||
HomeDriverType.Shelly => kind != HomeDeviceKind.Ventilation,
|
||||
HomeDriverType.Shelly => kind is not (HomeDeviceKind.Ventilation or HomeDeviceKind.Solar),
|
||||
HomeDriverType.Vallox => kind == HomeDeviceKind.Ventilation,
|
||||
HomeDriverType.SmaModbus => kind == HomeDeviceKind.Solar,
|
||||
_ => 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 HomeDriverType DefaultDriver(HomeDeviceKind kind) => kind switch
|
||||
{
|
||||
HomeDeviceKind.Ventilation => HomeDriverType.Vallox,
|
||||
HomeDeviceKind.Solar => HomeDriverType.SmaModbus,
|
||||
_ => HomeDriverType.Shelly,
|
||||
};
|
||||
|
||||
public static bool IsValidIcon(string? icon) => string.IsNullOrEmpty(icon) || Icons.ContainsKey(icon);
|
||||
|
||||
@@ -213,6 +219,10 @@ public static class HomeRules
|
||||
return (uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'), null);
|
||||
}
|
||||
|
||||
/// <summary>Wie <see cref="NormalizeHost"/>, aber leer ist erlaubt (liefert dann <c>null</c> ohne Fehler) – für die optionale zweite Adresse.</summary>
|
||||
public static (string? Host, string? Error) NormalizeOptionalHost(string? input) =>
|
||||
string.IsNullOrWhiteSpace(input) ? (null, null) : NormalizeHost(input);
|
||||
|
||||
/// <summary>
|
||||
/// Prüft und normalisiert die Adresse eines Kamerastroms. Wie <see cref="NormalizeHost"/> (nur http/https, nur Heimnetz, keine Zugangsdaten, keine Link-Local-/Metadaten-Adressen),
|
||||
/// aber mit Pfad und Query (z. B. <c>http://localhost:8090/video.mjpg?oids=1&size=1280x720</c>); ohne Schema gilt http. Ein Fragment ist nicht erlaubt.
|
||||
|
||||
@@ -22,6 +22,7 @@ internal sealed class HomeDeviceConfiguration : IEntityTypeConfiguration<HomeDev
|
||||
builder.Property(d => d.Kind).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(d => d.Driver).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(d => d.Host).HasMaxLength(HomeDevice.HostMaxLength).IsRequired();
|
||||
builder.Property(d => d.SecondaryHost).HasMaxLength(HomeDevice.HostMaxLength);
|
||||
builder.Property(d => d.AuthUser).HasMaxLength(HomeDevice.AuthUserMaxLength);
|
||||
builder.Property(d => d.AuthPasswordProtected).HasMaxLength(HomeDevice.ProtectedPasswordMaxLength);
|
||||
builder.Property(d => d.Icon).HasMaxLength(HomeDevice.IconMaxLength);
|
||||
|
||||
+3342
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class HomeDeviceSecondaryHost : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SecondaryHost",
|
||||
table: "HomeDevices",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SecondaryHost",
|
||||
table: "HomeDevices");
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -17,7 +17,7 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.12")
|
||||
.HasAnnotation("ProductVersion", "10.0.13")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
@@ -1162,6 +1162,10 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<string>("SecondaryHost")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ public sealed record HomeDeviceStateDto(
|
||||
double? TemperatureC,
|
||||
double? HumidityPercent,
|
||||
string? Message,
|
||||
HomeVentilationDto? Ventilation = null);
|
||||
HomeVentilationDto? Ventilation = null,
|
||||
HomeSolarDto? Solar = null);
|
||||
|
||||
/// <summary>
|
||||
/// Zustand einer Lüftungsanlage (nur bei <see cref="HomeDeviceKind.Ventilation"/>). Temperaturen in °C; <c>null</c> = Fühler fehlt oder liefert nichts.
|
||||
@@ -95,6 +96,22 @@ public sealed record HomeVentilationDto(
|
||||
int? FanSpeedPercent,
|
||||
int? Co2Ppm);
|
||||
|
||||
/// <summary>
|
||||
/// Zustand einer PV-Anlage (nur bei <see cref="HomeDeviceKind.Solar"/>), alles in W bzw. % – <c>null</c> = der Wert fehlt (Gerät/Zähler liefert ihn nicht, Wechselrichter nicht erreichbar).
|
||||
/// <see cref="GridPowerW"/> positiv = Netzbezug, negativ = Einspeisung. <see cref="BatteryPowerW"/> positiv = Laden, negativ = Entladen.
|
||||
/// <see cref="HouseLoadW"/> ist berechnet (PV + Akku-Entladung + Netzbezug − Einspeisung) und nie negativ.
|
||||
/// <see cref="Warning"/> nennt einen teilweise ausgefallenen Wechselrichter (feste deutsche Meldung).
|
||||
/// </summary>
|
||||
public sealed record HomeSolarDto(
|
||||
double? PvPowerW,
|
||||
double? HouseLoadW,
|
||||
double? GridPowerW,
|
||||
int? BatteryPercent,
|
||||
double? BatteryPowerW,
|
||||
double? BatteryTemperatureC,
|
||||
double? DailyYieldKwh,
|
||||
string? Warning);
|
||||
|
||||
public sealed record HomeStatesDto(IReadOnlyList<HomeDeviceStateDto> States, DateTimeOffset At);
|
||||
|
||||
public sealed record HomeCommandRequest(HomeCommand Command);
|
||||
@@ -127,7 +144,8 @@ public sealed record HomeAdminDeviceDto(
|
||||
bool HasPassword,
|
||||
int SortOrder,
|
||||
byte[] Version,
|
||||
IReadOnlyList<HomeGrantDto> Grants);
|
||||
IReadOnlyList<HomeGrantDto> Grants,
|
||||
string? SecondaryHost = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gerät anlegen/ändern. <see cref="Password"/> <c>null</c> lässt ein vorhandenes Passwort unverändert, ein Wert ersetzt es,
|
||||
@@ -146,7 +164,8 @@ public sealed record SaveHomeDeviceRequest(
|
||||
string? Password,
|
||||
bool ClearPassword,
|
||||
int SortOrder = 0,
|
||||
byte[]? Version = null);
|
||||
byte[]? Version = null,
|
||||
string? SecondaryHost = null);
|
||||
|
||||
public sealed class SaveHomeDeviceRequestValidator : AbstractValidator<SaveHomeDeviceRequest>
|
||||
{
|
||||
@@ -157,9 +176,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(r => r.Driver == HomeDriverType.Vallox ? "Vallox-Geräte nutzen immer Protokollversion 1." : "Bitte Generation 1 (Gen1) oder 2 (Gen2/Plus/Pro/Gen3) wählen.");
|
||||
.WithMessage(r => r.Driver is HomeDriverType.Vallox or HomeDriverType.SmaModbus ? "Vallox- und SMA-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.");
|
||||
.WithMessage("Lüftungsanlagen werden über den Vallox-Treiber, PV-Anlagen über den SMA-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) =>
|
||||
@@ -169,6 +188,13 @@ public sealed class SaveHomeDeviceRequestValidator : AbstractValidator<SaveHomeD
|
||||
context.AddFailure(nameof(SaveHomeDeviceRequest.Host), error);
|
||||
}
|
||||
});
|
||||
RuleFor(r => r.SecondaryHost).Custom((host, context) =>
|
||||
{
|
||||
if (HomeRules.NormalizeOptionalHost(host).Error is { } error)
|
||||
{
|
||||
context.AddFailure(nameof(SaveHomeDeviceRequest.SecondaryHost), error);
|
||||
}
|
||||
});
|
||||
RuleFor(r => r.Icon).Must(HomeRules.IsValidIcon).WithMessage("Unbekanntes Symbol.");
|
||||
RuleFor(r => r.AuthUser).MaximumLength(HomeDevice.AuthUserMaxLength)
|
||||
.WithMessage($"Der Benutzername darf höchstens {HomeDevice.AuthUserMaxLength} Zeichen haben.");
|
||||
@@ -192,7 +218,8 @@ public sealed record TestHomeDeviceRequest(
|
||||
string Host,
|
||||
int Channel,
|
||||
string? AuthUser,
|
||||
string? Password);
|
||||
string? Password,
|
||||
string? SecondaryHost = null);
|
||||
|
||||
public sealed class TestHomeDeviceRequestValidator : AbstractValidator<TestHomeDeviceRequest>
|
||||
{
|
||||
@@ -201,9 +228,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(r => r.Driver == HomeDriverType.Vallox ? "Vallox-Geräte nutzen immer Protokollversion 1." : "Bitte Generation 1 (Gen1) oder 2 (Gen2/Plus/Pro/Gen3) wählen.");
|
||||
.WithMessage(r => r.Driver is HomeDriverType.Vallox or HomeDriverType.SmaModbus ? "Vallox- und SMA-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.");
|
||||
.WithMessage("Lüftungsanlagen werden über den Vallox-Treiber, PV-Anlagen über den SMA-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) =>
|
||||
{
|
||||
@@ -212,6 +239,13 @@ public sealed class TestHomeDeviceRequestValidator : AbstractValidator<TestHomeD
|
||||
context.AddFailure(nameof(TestHomeDeviceRequest.Host), error);
|
||||
}
|
||||
});
|
||||
RuleFor(r => r.SecondaryHost).Custom((host, context) =>
|
||||
{
|
||||
if (HomeRules.NormalizeOptionalHost(host).Error is { } error)
|
||||
{
|
||||
context.AddFailure(nameof(TestHomeDeviceRequest.SecondaryHost), error);
|
||||
}
|
||||
});
|
||||
RuleFor(r => r.AuthUser).MaximumLength(HomeDevice.AuthUserMaxLength).WithMessage("Der Benutzername ist zu lang.");
|
||||
RuleFor(r => r.Password).Must(p => p is null || p.Length <= HomeDevice.PasswordMaxLength).WithMessage("Das Passwort ist zu lang.");
|
||||
}
|
||||
|
||||
@@ -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 is not (HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation))
|
||||
else if (Device.Kind is not (HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation or HomeDeviceKind.Solar))
|
||||
{
|
||||
var isOn = State?.IsOn is true;
|
||||
<MudButton Variant="@(isOn ? Variant.Filled : Variant.Outlined)" Color="@(isOn ? Color.Success : Color.Default)" Class="fp-device-toggle"
|
||||
@@ -69,7 +69,29 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Device.CanControl && Device.Kind != HomeDeviceKind.Sensor)
|
||||
@if (Device.Kind == HomeDeviceKind.Solar)
|
||||
{
|
||||
var solarValues = HouseUi.SolarValues(State);
|
||||
if (solarValues.Count > 0)
|
||||
{
|
||||
<dl class="fp-vent-values fp-solar-values" aria-label="@($"Messwerte {Device.Name}")">
|
||||
@foreach (var (label, value) in solarValues)
|
||||
{
|
||||
<div class="fp-vent-value">
|
||||
<dt>@label</dt>
|
||||
<dd>@value</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
|
||||
@if (State?.Solar?.Warning is { Length: > 0 } warning)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-device-message" role="status">@warning</MudText>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!Device.CanControl && Device.Kind is not (HomeDeviceKind.Sensor or HomeDeviceKind.Solar))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-device-readonly">Nur ansehen – du darfst dieses Gerät nicht schalten.</MudText>
|
||||
}
|
||||
|
||||
@@ -43,7 +43,14 @@
|
||||
</MudSelect>
|
||||
|
||||
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Verbindung</MudText>
|
||||
@if (IsVallox)
|
||||
@if (IsSolar)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="fp-device-solar-hint">
|
||||
SMA-Wechselrichter (z. B. Sunny Tripower und Sunny Boy Storage): Die Werte werden per Modbus TCP gelesen, nur lesend.
|
||||
Modbus muss im Wechselrichter aktiviert sein (Port 502). Netzbezug/Einspeisung kommen vom SMA Energy Meter bzw. Home Manager.
|
||||
</MudAlert>
|
||||
}
|
||||
else 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).
|
||||
@@ -61,10 +68,18 @@
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="_host" Label="Adresse im Heimnetz" InputId="device-host" Variant="Variant.Outlined"
|
||||
Placeholder="@(IsVallox ? "192.168.1.60 oder vallox.local" : "192.168.1.50 oder shelly-flur.local")" MaxLength="HomeDevice.HostMaxLength"
|
||||
Placeholder="@(IsSolar ? "192.168.1.40" : 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 (IsSolar)
|
||||
{
|
||||
<MudTextField @bind-Value="_secondaryHost" Label="Zweiter Wechselrichter (optional)" InputId="device-secondary-host" Variant="Variant.Outlined"
|
||||
Placeholder="192.168.1.41" MaxLength="HomeDevice.HostMaxLength"
|
||||
HelperText="z. B. der Speicher-Wechselrichter. Die Reihenfolge spielt keine Rolle, die Geräteart wird am Gerät erkannt."
|
||||
Error="@HasError("SecondaryHost")" ErrorText="@ErrorFor("SecondaryHost")" />
|
||||
}
|
||||
|
||||
@if (!IsVallox)
|
||||
{
|
||||
<MudNumericField T="int" @bind-Value="_channel" Label="Kanal / Relais-Nr." InputId="device-channel" Variant="Variant.Outlined" Min="0" Max="@HomeRules.MaxChannel"
|
||||
@@ -126,6 +141,7 @@
|
||||
private Guid? _roomId;
|
||||
private int _generation = 2;
|
||||
private string _host = string.Empty;
|
||||
private string? _secondaryHost;
|
||||
private int _channel;
|
||||
private string? _authUser;
|
||||
private string? _password;
|
||||
@@ -142,6 +158,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.Solar => "PV-Anlage (SMA): PV-Leistung, Hausverbrauch, Netz und Akku anzeigen. Nur lesend.",
|
||||
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.",
|
||||
};
|
||||
@@ -154,14 +171,19 @@
|
||||
}
|
||||
|
||||
(_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)
|
||||
_secondaryHost = d.SecondaryHost;
|
||||
if (d.Driver is HomeDriverType.Vallox or HomeDriverType.SmaModbus)
|
||||
{
|
||||
_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 bool IsSolar => _kind == HomeDeviceKind.Solar;
|
||||
|
||||
/// <summary>Lüftungsanlagen (Vallox) und PV-Anlagen (SMA) haben eigene Treiber – ohne Generation, Kanal und Anmeldung.</summary>
|
||||
private bool IsVallox => _kind is HomeDeviceKind.Ventilation or HomeDeviceKind.Solar;
|
||||
|
||||
private string? EffectiveSecondaryHost => IsSolar && !string.IsNullOrWhiteSpace(_secondaryHost) ? _secondaryHost.Trim() : null;
|
||||
|
||||
private int EffectiveGeneration => IsVallox ? 1 : _generation;
|
||||
|
||||
@@ -174,7 +196,7 @@
|
||||
private SaveHomeDeviceRequest BuildRequest() => new(
|
||||
_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);
|
||||
Device?.SortOrder ?? 0, Device?.Version, EffectiveSecondaryHost);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
@@ -212,7 +234,7 @@
|
||||
{
|
||||
_testResult = null;
|
||||
var request = new TestHomeDeviceRequest(
|
||||
Device?.Id, _kind, HomeRules.DefaultDriver(_kind), EffectiveGeneration, _host.Trim(), EffectiveChannel, EffectiveUser, EffectivePassword);
|
||||
Device?.Id, _kind, HomeRules.DefaultDriver(_kind), EffectiveGeneration, _host.Trim(), EffectiveChannel, EffectiveUser, EffectivePassword, EffectiveSecondaryHost);
|
||||
var validation = new TestHomeDeviceRequestValidator().Validate(request);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -42,10 +42,18 @@ else
|
||||
<MudTd DataLabel="Raum">@RoomName(context.RoomId)</MudTd>
|
||||
<MudTd DataLabel="Verbindung">
|
||||
@context.Host
|
||||
@if (context.SecondaryHost is { Length: > 0 } second)
|
||||
{
|
||||
<br />@second
|
||||
}
|
||||
@if (context.Driver == HomeDriverType.Vallox)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Vallox MV</MudText>
|
||||
}
|
||||
else if (context.Driver == HomeDriverType.SmaModbus)
|
||||
{
|
||||
<MudText Typo="Typo.caption">SMA Modbus TCP</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption">Gen@(context.Generation) · Kanal @context.Channel · @(context.HasPassword ? "mit Passwort" : "ohne Passwort")</MudText>
|
||||
|
||||
@@ -42,6 +42,7 @@ public static class HouseUi
|
||||
HomeDeviceKind.Cover => Icons.Material.Filled.Blinds,
|
||||
HomeDeviceKind.Sensor => Icons.Material.Filled.Thermostat,
|
||||
HomeDeviceKind.Ventilation => Icons.Material.Filled.Air,
|
||||
HomeDeviceKind.Solar => Icons.Material.Filled.SolarPower,
|
||||
_ => Icons.Material.Filled.ToggleOn,
|
||||
},
|
||||
};
|
||||
@@ -76,7 +77,7 @@ public static class HouseUi
|
||||
"closed" => Status.Closed,
|
||||
_ => Status.Value,
|
||||
},
|
||||
HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation => Status.Value,
|
||||
HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation or HomeDeviceKind.Solar => Status.Value,
|
||||
_ => state.IsOn is true ? Status.On : state.IsOn is false ? Status.Off : Status.Unknown,
|
||||
};
|
||||
}
|
||||
@@ -94,6 +95,7 @@ public static class HouseUi
|
||||
{
|
||||
HomeDeviceKind.Cover => $"Gestoppt{(state?.CoverPosition is { } p ? $" ({p} % offen)" : string.Empty)}",
|
||||
HomeDeviceKind.Ventilation => VentilationText(state?.Ventilation),
|
||||
HomeDeviceKind.Solar => SolarText(state?.Solar),
|
||||
_ => SensorText(state),
|
||||
},
|
||||
};
|
||||
@@ -106,6 +108,87 @@ public static class HouseUi
|
||||
_ => $"Profil: {HomeRules.DisplayName(ventilation.Profile)}",
|
||||
};
|
||||
|
||||
/// <summary>Kurzstatus einer PV-Anlage: aktuelle PV-Leistung und Akku-Ladezustand.</summary>
|
||||
public static string SolarText(HomeSolarDto? solar)
|
||||
{
|
||||
if (solar is null)
|
||||
{
|
||||
return "Keine Werte";
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
if (solar.PvPowerW is { } pv)
|
||||
{
|
||||
parts.Add($"PV {Watts(pv)}");
|
||||
}
|
||||
|
||||
if (solar.BatteryPercent is { } percent)
|
||||
{
|
||||
parts.Add($"Akku {percent.ToString(German)} %");
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? "Keine Werte" : string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
/// <summary>Messwerte einer PV-Anlage (Beschriftung, Wert) in fester Reihenfolge; fehlende Werte werden ausgelassen. Netz und Akku nennen die Richtung als Text.</summary>
|
||||
public static IReadOnlyList<(string Label, string Value)> SolarValues(HomeDeviceStateDto? state)
|
||||
{
|
||||
var values = new List<(string, string)>();
|
||||
if (state is not { Online: true, Solar: { } s })
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
if (s.PvPowerW is { } pv)
|
||||
{
|
||||
values.Add(("PV-Erzeugung", Watts(pv)));
|
||||
}
|
||||
|
||||
if (s.HouseLoadW is { } house)
|
||||
{
|
||||
values.Add(("Hausverbrauch", Watts(house)));
|
||||
}
|
||||
|
||||
if (s.GridPowerW is { } grid)
|
||||
{
|
||||
values.Add(("Netz", grid switch
|
||||
{
|
||||
> 5 => $"Bezug {Watts(grid)}",
|
||||
< -5 => $"Einspeisung {Watts(-grid)}",
|
||||
_ => "Ausgeglichen",
|
||||
}));
|
||||
}
|
||||
|
||||
if (s.BatteryPercent is { } percent)
|
||||
{
|
||||
var power = s.BatteryPowerW switch
|
||||
{
|
||||
> 5 => $" · lädt mit {Watts(s.BatteryPowerW.Value)}",
|
||||
< -5 => $" · entlädt mit {Watts(-s.BatteryPowerW.Value)}",
|
||||
null => string.Empty,
|
||||
_ => " · Ruhezustand",
|
||||
};
|
||||
values.Add(("Akku", $"{percent.ToString(German)} %{power}"));
|
||||
}
|
||||
|
||||
if (s.BatteryTemperatureC is { } temperature)
|
||||
{
|
||||
values.Add(("Akku-Temperatur", $"{temperature.ToString("0.0", German)} °C"));
|
||||
}
|
||||
|
||||
if (s.DailyYieldKwh is { } yield)
|
||||
{
|
||||
values.Add(("Ertrag heute", $"{yield.ToString("0.0", German)} kWh"));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/// <summary>Leistung lesbar: unter 1 kW in W, darüber in kW mit zwei Nachkommastellen.</summary>
|
||||
public static string Watts(double watts) => Math.Abs(watts) >= 1000
|
||||
? $"{(watts / 1000).ToString("0.00", German)} kW"
|
||||
: $"{watts.ToString("0", German)} W";
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -181,7 +264,7 @@ public static class HouseUi
|
||||
return null;
|
||||
}
|
||||
|
||||
if (device.Kind is HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation)
|
||||
if (device.Kind is HomeDeviceKind.Sensor or HomeDeviceKind.Ventilation or HomeDeviceKind.Solar)
|
||||
{
|
||||
return null; // steht schon im Status bzw. in der Werteliste
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ public sealed class HomeRulesTests
|
||||
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.TurnOn, false)]
|
||||
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.Toggle, false)]
|
||||
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.Open, false)]
|
||||
[InlineData(HomeDeviceKind.Solar, HomeCommand.TurnOn, false)]
|
||||
[InlineData(HomeDeviceKind.Solar, HomeCommand.Toggle, false)]
|
||||
[InlineData(HomeDeviceKind.Solar, HomeCommand.ProfileHome, false)]
|
||||
public void Commands_must_fit_the_device_kind(HomeDeviceKind kind, HomeCommand command, bool allowed)
|
||||
{
|
||||
Assert.Equal(allowed, HomeRules.IsCommandAllowed(kind, command));
|
||||
@@ -118,6 +121,10 @@ public sealed class HomeRulesTests
|
||||
[InlineData(HomeDriverType.Vallox, HomeDeviceKind.Ventilation, true)]
|
||||
[InlineData(HomeDriverType.Vallox, HomeDeviceKind.Switch, false)]
|
||||
[InlineData(HomeDriverType.Vallox, HomeDeviceKind.Sensor, false)]
|
||||
[InlineData(HomeDriverType.Shelly, HomeDeviceKind.Solar, false)]
|
||||
[InlineData(HomeDriverType.SmaModbus, HomeDeviceKind.Solar, true)]
|
||||
[InlineData(HomeDriverType.SmaModbus, HomeDeviceKind.Switch, false)]
|
||||
[InlineData(HomeDriverType.SmaModbus, HomeDeviceKind.Ventilation, false)]
|
||||
public void Driver_must_fit_the_device_kind(HomeDriverType driver, HomeDeviceKind kind, bool valid)
|
||||
{
|
||||
Assert.Equal(valid, HomeRules.IsValidDriverForKind(driver, kind));
|
||||
@@ -349,4 +356,27 @@ public sealed class HomeRulesTests
|
||||
Assert.False(new HomeCommandRequestValidator().Validate(new HomeCommandRequest((HomeCommand)42)).IsValid);
|
||||
Assert.True(new HomeCommandRequestValidator().Validate(new HomeCommandRequest(HomeCommand.Toggle)).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Solar_devices_use_the_sma_driver_and_an_optional_second_address()
|
||||
{
|
||||
var request = new SaveHomeDeviceRequest("PV", HomeDeviceKind.Solar, null, null, HomeDriverType.SmaModbus, 1, "10.20.31.107", 0, null, null, false, SecondaryHost: "10.20.31.106");
|
||||
var validator = new SaveHomeDeviceRequestValidator();
|
||||
|
||||
Assert.True(validator.Validate(request).IsValid);
|
||||
Assert.True(validator.Validate(request with { SecondaryHost = null }).IsValid);
|
||||
Assert.True(validator.Validate(request with { SecondaryHost = " " }).IsValid);
|
||||
Assert.False(validator.Validate(request with { SecondaryHost = "8.8.8.8" }).IsValid); // nur Heimnetz
|
||||
Assert.False(validator.Validate(request with { Driver = HomeDriverType.Shelly, Generation = 2 }).IsValid);
|
||||
Assert.Equal("PV-Anlage", HomeRules.DisplayName(HomeDeviceKind.Solar));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optional_host_may_be_empty_but_is_checked_when_given()
|
||||
{
|
||||
Assert.Equal((null, null), HomeRules.NormalizeOptionalHost(null));
|
||||
Assert.Equal((null, null), HomeRules.NormalizeOptionalHost(" "));
|
||||
Assert.Equal("http://10.20.31.106", HomeRules.NormalizeOptionalHost("10.20.31.106").Host);
|
||||
Assert.NotNull(HomeRules.NormalizeOptionalHost("example.com").Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FamilyPlanner.Api.Features.Home;
|
||||
using FamilyPlanner.Domain.Home;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Home;
|
||||
|
||||
/// <summary>SMA-Modbus (ADR-0026): Rahmen, Dekodierung, Zusammenführung zweier Wechselrichter, Treiber gegen Fake-Transport und einen echten lokalen Modbus-Server.</summary>
|
||||
public sealed class SmaModbusDriverTests
|
||||
{
|
||||
private const uint NaN32 = uint.MaxValue;
|
||||
private const uint NaNS32 = 0x80000000;
|
||||
|
||||
/// <summary>Registerinhalt eines Wechselrichters als Adresse → Bytes; Messwerte stammen von der echten Anlage (STP 6.0-3AV-40 und SBS3.7-10).</summary>
|
||||
private sealed class Inverter(Dictionary<int, byte[]> registers)
|
||||
{
|
||||
public Dictionary<int, byte[]> Registers { get; } = registers;
|
||||
}
|
||||
|
||||
private static byte[] U32(uint value)
|
||||
{
|
||||
var b = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b, value);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static byte[] U64(ulong value)
|
||||
{
|
||||
var b = new byte[8];
|
||||
BinaryPrimitives.WriteUInt64BigEndian(b, value);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] parts) => parts.SelectMany(p => p).ToArray();
|
||||
|
||||
private static Inverter Stp(uint acPower = 775, uint gridImport = 44, uint gridExport = 0) => new(new()
|
||||
{
|
||||
[30051] = U32(8001),
|
||||
[30053] = U32(9346),
|
||||
[30775] = U32(acPower),
|
||||
[30517] = U64(9524),
|
||||
[30845] = U32(NaN32),
|
||||
[30849] = U32(NaNS32),
|
||||
[31393] = U32(NaN32),
|
||||
[31395] = U32(NaN32),
|
||||
[30865] = Concat(U32(gridImport), U32(gridExport)),
|
||||
});
|
||||
|
||||
private static Inverter Sbs() => new(new()
|
||||
{
|
||||
[30051] = U32(8007),
|
||||
[30053] = U32(9356),
|
||||
[30775] = U32(unchecked((uint)-229)),
|
||||
[30517] = U64(737),
|
||||
[30845] = U32(82),
|
||||
[30849] = U32(220),
|
||||
[31393] = U32(228),
|
||||
[31395] = U32(0),
|
||||
[30865] = Concat(U32(0), U32(13)),
|
||||
});
|
||||
|
||||
private static IReadOnlyList<ModbusResult> Answer(Inverter inverter, IReadOnlyList<ModbusRead> reads) =>
|
||||
reads.Select(r => inverter.Registers.TryGetValue(r.Address, out var data) && data.Length == r.Count * 2
|
||||
? new ModbusResult(data, null)
|
||||
: new ModbusResult(null, 2)).ToList();
|
||||
|
||||
private sealed class FakeTransport(Dictionary<string, Inverter?> inverters) : ISmaModbusTransport
|
||||
{
|
||||
public List<(string Host, int Reads)> Calls { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<ModbusResult>> ReadAsync(string host, byte unitId, IReadOnlyList<ModbusRead> reads, CancellationToken ct)
|
||||
{
|
||||
Calls.Add((host, reads.Count));
|
||||
Assert.Equal(SmaModbusProtocol.UnitId, unitId);
|
||||
return inverters[host] is { } inverter
|
||||
? Task.FromResult(Answer(inverter, reads))
|
||||
: throw new HomeException(HomeErrorKind.Unreachable, "Der Wechselrichter ist nicht erreichbar.");
|
||||
}
|
||||
}
|
||||
|
||||
private static HomeDeviceConnection Connection(string host = "http://stp", string? second = "http://sbs", HomeDeviceKind kind = HomeDeviceKind.Solar) =>
|
||||
new(host, 1, 0, kind, null, null, second);
|
||||
|
||||
// ---------- Protokoll ----------
|
||||
|
||||
[Fact]
|
||||
public void Read_request_is_a_function_3_frame_for_unit_3()
|
||||
{
|
||||
var frame = SmaModbusProtocol.ReadRequest(0x0102, SmaModbusProtocol.UnitId, new ModbusRead(30775, 2));
|
||||
|
||||
Assert.Equal(new byte[] { 0x01, 0x02, 0, 0, 0, 6, 3, 3, 0x78, 0x37, 0, 2 }, frame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nan_sentinels_decode_to_null()
|
||||
{
|
||||
Assert.Null(SmaModbusProtocol.U32(U32(NaN32)));
|
||||
Assert.Null(SmaModbusProtocol.S32(U32(NaNS32)));
|
||||
Assert.Null(SmaModbusProtocol.U64(U64(ulong.MaxValue)));
|
||||
Assert.Equal(82u, SmaModbusProtocol.U32(U32(82)));
|
||||
Assert.Equal(-229, SmaModbusProtocol.S32(U32(unchecked((uint)-229))));
|
||||
Assert.Equal(9524ul, SmaModbusProtocol.U64(U64(9524)));
|
||||
Assert.Null(SmaModbusProtocol.U32(null));
|
||||
Assert.Null(SmaModbusProtocol.U32([1, 2]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_must_match_transaction_unit_and_length()
|
||||
{
|
||||
byte[] Header(ushort tx = 7, byte unit = 3, ushort length = 7, byte function = 3, byte count = 4)
|
||||
{
|
||||
var h = new byte[9];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(h, tx);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(h.AsSpan(4), length);
|
||||
h[6] = unit;
|
||||
h[7] = function;
|
||||
h[8] = count;
|
||||
return h;
|
||||
}
|
||||
|
||||
Assert.Equal((4, false, 4), SmaModbusProtocol.ParseHeader(Header(), 7, 3));
|
||||
Assert.Null(SmaModbusProtocol.ParseHeader(Header(tx: 8), 7, 3));
|
||||
Assert.Null(SmaModbusProtocol.ParseHeader(Header(unit: 1), 7, 3));
|
||||
Assert.Null(SmaModbusProtocol.ParseHeader(Header(length: 9), 7, 3)); // Länge passt nicht zur Byteanzahl
|
||||
Assert.Null(SmaModbusProtocol.ParseHeader(Header(function: 6), 7, 3)); // nur Lesen
|
||||
Assert.Null(SmaModbusProtocol.ParseHeader(Header(length: 200), 7, 3));
|
||||
Assert.Equal((0, true, 2), SmaModbusProtocol.ParseHeader(Header(length: 3, function: 0x83, count: 2), 7, 3));
|
||||
}
|
||||
|
||||
// ---------- Zusammenführung ----------
|
||||
|
||||
[Fact]
|
||||
public void Combine_balances_both_inverters_with_the_real_plant_values()
|
||||
{
|
||||
var stp = SmaModbusProtocol.Parse(Answer(Stp(), SmaModbusProtocol.StateReads));
|
||||
var sbs = SmaModbusProtocol.Parse(Answer(Sbs(), SmaModbusProtocol.StateReads));
|
||||
|
||||
var solar = SmaModbusProtocol.Combine([stp, sbs], null);
|
||||
|
||||
Assert.Equal(775, solar.PvPowerW);
|
||||
Assert.Equal(44, solar.GridPowerW); // erster vorhandener Zählerwert: Bezug 44 W
|
||||
Assert.Equal(775 - 229 + 44, solar.HouseLoadW); // AC-Bilanz: Akku lädt mit 229 W
|
||||
Assert.Equal(82, solar.BatteryPercent);
|
||||
Assert.Equal(228, solar.BatteryPowerW);
|
||||
Assert.Equal(22.0, solar.BatteryTemperatureC);
|
||||
Assert.Equal(9.5, solar.DailyYieldKwh); // der Akku-Wechselrichter zählt nicht zum PV-Ertrag
|
||||
Assert.Null(solar.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Order_of_the_inverters_does_not_matter()
|
||||
{
|
||||
var stp = SmaModbusProtocol.Parse(Answer(Stp(), SmaModbusProtocol.StateReads));
|
||||
var sbs = SmaModbusProtocol.Parse(Answer(Sbs(), SmaModbusProtocol.StateReads));
|
||||
|
||||
var solar = SmaModbusProtocol.Combine([sbs, stp], null);
|
||||
|
||||
Assert.Equal(775, solar.PvPowerW);
|
||||
Assert.Equal(-13, solar.GridPowerW); // jetzt zählt der Zählerwert des SBS: Einspeisung 13 W
|
||||
Assert.Equal(82, solar.BatteryPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Night_values_give_no_pv_and_never_negative_numbers()
|
||||
{
|
||||
var stp = Stp(acPower: unchecked((uint)-3), gridImport: 410);
|
||||
var reading = SmaModbusProtocol.Parse(Answer(stp, SmaModbusProtocol.StateReads));
|
||||
|
||||
var solar = SmaModbusProtocol.Combine([reading], null);
|
||||
|
||||
Assert.Equal(0, solar.PvPowerW); // -3 W nachts → 0
|
||||
Assert.Equal(407, solar.HouseLoadW);
|
||||
Assert.Null(solar.BatteryPercent);
|
||||
Assert.Null(solar.BatteryPowerW);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_meter_values_the_house_load_is_unknown_not_guessed()
|
||||
{
|
||||
var inverter = Stp();
|
||||
inverter.Registers[30865] = Concat(U32(NaNS32), U32(NaNS32));
|
||||
var reading = SmaModbusProtocol.Parse(Answer(inverter, SmaModbusProtocol.StateReads));
|
||||
|
||||
var solar = SmaModbusProtocol.Combine([reading], null);
|
||||
|
||||
Assert.Equal(775, solar.PvPowerW);
|
||||
Assert.Null(solar.GridPowerW);
|
||||
Assert.Null(solar.HouseLoadW);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_registers_are_tolerated()
|
||||
{
|
||||
var inverter = Stp();
|
||||
inverter.Registers.Remove(30845);
|
||||
inverter.Registers.Remove(31393);
|
||||
|
||||
var reading = SmaModbusProtocol.Parse(Answer(inverter, SmaModbusProtocol.StateReads));
|
||||
|
||||
Assert.Null(reading.BatteryPercent);
|
||||
Assert.Equal(775, reading.AcPowerW);
|
||||
}
|
||||
|
||||
// ---------- Treiber ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_reads_both_inverters_and_exposes_the_solar_state()
|
||||
{
|
||||
var transport = new FakeTransport(new() { ["http://stp"] = Stp(), ["http://sbs"] = Sbs() });
|
||||
var driver = new SmaModbusDriver(transport);
|
||||
|
||||
var state = await driver.GetStateAsync(Connection(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(775, state.PowerWatts);
|
||||
Assert.Equal(82, state.Solar!.BatteryPercent);
|
||||
Assert.Equal(2, transport.Calls.Count);
|
||||
Assert.All(transport.Calls, c => Assert.Equal(SmaModbusProtocol.StateReads.Count, c.Reads));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_inverter_down_keeps_the_rest_and_warns()
|
||||
{
|
||||
var transport = new FakeTransport(new() { ["http://stp"] = Stp(), ["http://sbs"] = null });
|
||||
|
||||
var state = await new SmaModbusDriver(transport).GetStateAsync(Connection(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(775, state.Solar!.PvPowerW);
|
||||
Assert.Null(state.Solar.BatteryPercent);
|
||||
Assert.Equal("Der zweite Wechselrichter ist nicht erreichbar.", state.Solar.Warning);
|
||||
Assert.Null(state.Solar.HouseLoadW); // ohne den Akku-Wechselrichter wäre die Bilanz falsch (live beobachtet: 96 W statt ~600 W)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reachable_pv_inverter_without_a_value_at_night_shows_zero_not_a_missing_row()
|
||||
{
|
||||
var stp = Stp();
|
||||
stp.Registers[30775] = U32(NaNS32);
|
||||
stp.Registers[30517] = U64(ulong.MaxValue);
|
||||
var reading = SmaModbusProtocol.Parse(Answer(stp, SmaModbusProtocol.StateReads));
|
||||
|
||||
var solar = SmaModbusProtocol.Combine([reading], null);
|
||||
|
||||
Assert.Equal(0, solar.PvPowerW);
|
||||
Assert.Equal(44, solar.HouseLoadW); // nur Netzbezug
|
||||
Assert.Null(solar.DailyYieldKwh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_inverters_down_is_an_error()
|
||||
{
|
||||
var transport = new FakeTransport(new() { ["http://stp"] = null, ["http://sbs"] = null });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HomeException>(() => new SmaModbusDriver(transport).GetStateAsync(Connection(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(HomeErrorKind.Unreachable, ex.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_address_needs_no_second_inverter_and_no_warning()
|
||||
{
|
||||
var transport = new FakeTransport(new() { ["http://stp"] = Stp() });
|
||||
|
||||
var state = await new SmaModbusDriver(transport).GetStateAsync(Connection(second: null), CancellationToken.None);
|
||||
|
||||
Assert.Equal(775, state.Solar!.PvPowerW);
|
||||
Assert.Null(state.Solar.Warning);
|
||||
Assert.Single(transport.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_is_read_only_and_only_for_solar_devices()
|
||||
{
|
||||
var driver = new SmaModbusDriver(new FakeTransport([]));
|
||||
|
||||
var command = await Assert.ThrowsAsync<HomeException>(() => driver.ExecuteAsync(Connection(), HomeCommand.TurnOn, CancellationToken.None));
|
||||
var kind = await Assert.ThrowsAsync<HomeException>(() => driver.GetStateAsync(Connection(kind: HomeDeviceKind.Switch), CancellationToken.None));
|
||||
|
||||
Assert.Equal(HomeErrorKind.NotSupported, command.Kind);
|
||||
Assert.Equal(HomeErrorKind.NotSupported, kind.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_names_the_models_and_rejects_foreign_devices()
|
||||
{
|
||||
var transport = new FakeTransport(new() { ["http://stp"] = Stp(), ["http://sbs"] = Sbs() });
|
||||
var info = await new SmaModbusDriver(transport).TestAsync(Connection(), CancellationToken.None);
|
||||
Assert.Equal("STP 6.0-3AV-40 + SBS3.7-10", info.Model);
|
||||
|
||||
var foreign = new Inverter(new() { [30051] = U32(1234), [30053] = U32(1) });
|
||||
var ex = await Assert.ThrowsAsync<HomeException>(() =>
|
||||
new SmaModbusDriver(new FakeTransport(new() { ["http://x"] = foreign })).TestAsync(Connection("http://x", null), CancellationToken.None));
|
||||
Assert.Equal(HomeErrorKind.BadResponse, ex.Kind);
|
||||
}
|
||||
|
||||
// ---------- Echter TCP-Zugriff ----------
|
||||
|
||||
/// <summary>Ein kleiner Modbus-TCP-Server auf Loopback, der die Register eines <see cref="Inverter"/> liefert; protokolliert die Funktionscodes.</summary>
|
||||
private sealed class ModbusServer : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Inverter _inverter;
|
||||
|
||||
public ModbusServer(Inverter inverter)
|
||||
{
|
||||
_inverter = inverter;
|
||||
_listener.Start();
|
||||
_ = Task.Run(AcceptAsync);
|
||||
}
|
||||
|
||||
public List<byte> Functions { get; } = [];
|
||||
|
||||
public string Host => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_listener.Stop();
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
|
||||
_ = Task.Run(() => ServeAsync(client));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
|
||||
{
|
||||
// beendet
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
using var _ = client;
|
||||
var stream = client.GetStream();
|
||||
var request = new byte[12];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await stream.ReadExactlyAsync(request, _cts.Token);
|
||||
lock (Functions)
|
||||
{
|
||||
Functions.Add(request[7]);
|
||||
}
|
||||
|
||||
var address = BinaryPrimitives.ReadUInt16BigEndian(request.AsSpan(8));
|
||||
var count = BinaryPrimitives.ReadUInt16BigEndian(request.AsSpan(10));
|
||||
byte[] response;
|
||||
if (_inverter.Registers.TryGetValue(address, out var data) && data.Length == count * 2)
|
||||
{
|
||||
response = new byte[9 + data.Length];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(4), (ushort)(3 + data.Length));
|
||||
response[7] = 3;
|
||||
response[8] = (byte)data.Length;
|
||||
data.CopyTo(response, 9);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = new byte[9];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(4), 3);
|
||||
response[7] = 0x83;
|
||||
response[8] = 2;
|
||||
}
|
||||
|
||||
request.AsSpan(0, 2).CopyTo(response); // Transaktion
|
||||
response[6] = request[6]; // Unit
|
||||
await stream.WriteAsync(response, _cts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or OperationCanceledException or EndOfStreamException)
|
||||
{
|
||||
// Verbindung beendet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_tcp_transport_reads_registers_and_only_uses_function_3()
|
||||
{
|
||||
using var server = new ModbusServer(Stp());
|
||||
using var transport = new SmaModbusTcpTransport();
|
||||
var driver = new SmaModbusDriver(transport);
|
||||
|
||||
var state = await driver.GetStateAsync(Connection(server.Host, null), CancellationToken.None);
|
||||
|
||||
Assert.Equal(775, state.Solar!.PvPowerW);
|
||||
Assert.Equal(9.5, state.Solar.DailyYieldKwh);
|
||||
Assert.Equal(44, state.Solar.GridPowerW);
|
||||
Assert.Equal(SmaModbusProtocol.StateReads.Count, server.Functions.Count);
|
||||
Assert.All(server.Functions, f => Assert.Equal(3, f)); // nur „Read Holding Registers“
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_tcp_transport_reports_unreachable_for_a_closed_port()
|
||||
{
|
||||
var port = FreePort();
|
||||
using var transport = new SmaModbusTcpTransport();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HomeException>(() =>
|
||||
transport.ReadAsync($"http://127.0.0.1:{port}", SmaModbusProtocol.UnitId, SmaModbusProtocol.IdentityReads, CancellationToken.None));
|
||||
|
||||
Assert.Equal(HomeErrorKind.Unreachable, ex.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_tcp_transport_refuses_addresses_outside_the_home_network()
|
||||
{
|
||||
using var transport = new SmaModbusTcpTransport();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HomeException>(() =>
|
||||
transport.ReadAsync("http://8.8.8.8:502", SmaModbusProtocol.UnitId, SmaModbusProtocol.IdentityReads, CancellationToken.None));
|
||||
|
||||
Assert.Equal(HomeErrorKind.Blocked, ex.Kind);
|
||||
}
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ internal sealed class FakeHomeApi : IHomeApi
|
||||
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 static HomeDeviceStateDto Solar(Guid id, string? warning = null) =>
|
||||
new(id, true, null, null, null, 775, null, null, null, null, new HomeSolarDto(775, 590, 44, 82, 228, 22.0, 9.5, warning));
|
||||
|
||||
public Task<ApiResult<HomeOverviewDto>> GetOverviewAsync(CancellationToken ct = default)
|
||||
{
|
||||
OverviewCalls++;
|
||||
@@ -205,6 +208,9 @@ 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 SolarDevice(string name) =>
|
||||
new(Guid.NewGuid(), name, HomeDeviceKind.Solar, null, null, HomeDriverType.SmaModbus, 1, "http://10.20.31.107", 0, null, false, 0, [1], [], "http://10.20.31.106");
|
||||
|
||||
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], []);
|
||||
|
||||
|
||||
@@ -200,6 +200,33 @@ public sealed class HouseAdminTests : BunitContext
|
||||
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 Solar_dialog_has_a_second_address_and_saves_with_the_sma_driver()
|
||||
{
|
||||
var device = FakeHomeApi.SolarDevice("PV-Anlage");
|
||||
_api.Devices.Add(device);
|
||||
var cut = Open<HouseDevicesAdmin>();
|
||||
Assert.Contains("SMA Modbus TCP", cut.Find(".fp-device-table tbody tr").TextContent);
|
||||
Assert.Contains("10.20.31.106", cut.Find(".fp-device-table tbody tr").TextContent);
|
||||
|
||||
cut.Find("button[aria-label='PV-Anlage bearbeiten']").Click();
|
||||
cut.WaitForElement("#house-device-form");
|
||||
|
||||
Assert.Single(cut.FindAll(".fp-device-solar-hint"));
|
||||
Assert.Empty(cut.FindAll(".fp-device-generation, #device-channel, #device-user, #device-password"));
|
||||
Assert.Equal("http://10.20.31.106", cut.Find("#device-secondary-host").GetAttribute("value"));
|
||||
|
||||
cut.Find("#device-secondary-host").Change("10.20.31.108");
|
||||
cut.Find(".fp-device-test").Click();
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.DraftTests));
|
||||
Assert.Equal((HomeDriverType.SmaModbus, "10.20.31.108"), (_api.DraftTests.Single().Driver, _api.DraftTests.Single().SecondaryHost));
|
||||
|
||||
cut.Find("#house-device-form").Submit();
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices));
|
||||
var saved = _api.SavedDevices.Single();
|
||||
Assert.Equal((HomeDeviceKind.Solar, HomeDriverType.SmaModbus, "http://10.20.31.107", "10.20.31.108"), (saved.Kind, saved.Driver, saved.Host, saved.SecondaryHost));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialog_test_button_sends_the_draft_with_the_device_id_and_shows_the_result()
|
||||
{
|
||||
|
||||
@@ -228,6 +228,48 @@ public sealed class HousePageTests : BunitContext
|
||||
Assert.Equal("false", cut.Find(".fp-vent-profile-home").GetAttribute("aria-pressed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Solar_shows_values_with_direction_as_text_and_has_no_controls()
|
||||
{
|
||||
var d = FakeHomeApi.Device("PV-Anlage", HomeDeviceKind.Solar);
|
||||
_api.States[d.Id] = FakeHomeApi.Solar(d.Id);
|
||||
|
||||
var cut = Open(Overview([d]));
|
||||
|
||||
Assert.Equal("PV 775 W · Akku 82 %", cut.Find(".fp-device-status-text").TextContent.Trim());
|
||||
var values = cut.FindAll(".fp-solar-values .fp-vent-value").Select(v => v.TextContent.Replace("\n", " ").Trim()).ToList();
|
||||
Assert.Contains(values, v => v.Contains("PV-Erzeugung", StringComparison.Ordinal) && v.Contains("775 W", StringComparison.Ordinal));
|
||||
Assert.Contains(values, v => v.Contains("Hausverbrauch", StringComparison.Ordinal) && v.Contains("590 W", StringComparison.Ordinal));
|
||||
Assert.Contains(values, v => v.Contains("Netz", StringComparison.Ordinal) && v.Contains("Bezug 44 W", StringComparison.Ordinal));
|
||||
Assert.Contains(values, v => v.Contains("Akku", StringComparison.Ordinal) && v.Contains("82 % · lädt mit 228 W", StringComparison.Ordinal));
|
||||
Assert.Contains(values, v => v.Contains("22,0 °C", StringComparison.Ordinal));
|
||||
Assert.Contains(values, v => v.Contains("9,5 kWh", StringComparison.Ordinal));
|
||||
Assert.Empty(cut.FindAll(".fp-device-toggle, .fp-device-open, .fp-vent-profile, .fp-device-readonly"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Solar_with_a_failed_inverter_shows_the_warning()
|
||||
{
|
||||
var d = FakeHomeApi.Device("PV-Anlage", HomeDeviceKind.Solar);
|
||||
_api.States[d.Id] = FakeHomeApi.Solar(d.Id, warning: "Der zweite Wechselrichter ist nicht erreichbar.");
|
||||
|
||||
var cut = Open(Overview([d]));
|
||||
|
||||
Assert.Contains("zweite Wechselrichter", cut.Find(".fp-device-message").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Solar_offline_shows_the_reason_and_no_values()
|
||||
{
|
||||
var offline = FakeHomeApi.Device("PV aus", HomeDeviceKind.Solar);
|
||||
_api.States[offline.Id] = FakeHomeApi.Offline(offline.Id, "Der Wechselrichter ist nicht erreichbar.");
|
||||
var cut = Open(Overview([offline]));
|
||||
|
||||
Assert.Equal("Offline", cut.Find(".fp-device-status-text").TextContent.Trim());
|
||||
Assert.Contains("nicht erreichbar", cut.Find(".fp-device-message").TextContent);
|
||||
Assert.Empty(cut.FindAll(".fp-solar-values"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_a_ventilation_profile_sends_the_command()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Liest ausgewählte SMA-Modbus-Register (nur Lesen, Funktionscode 0x03) und zeigt Rohwerte + Deutung.
|
||||
Hilfsmittel für ADR-0026: prüft die Registerbelegung an der echten Anlage, bevor der Treiber sie fest verwendet.
|
||||
|
||||
.EXAMPLE
|
||||
./tools/sma-modbus-probe.ps1 -Ip 192.168.1.40
|
||||
./tools/sma-modbus-probe.ps1 -Ip 192.168.1.41 -UnitId 3 | Tee-Object probe-sbs.txt
|
||||
|
||||
Modbus TCP muss im Wechselrichter aktiviert sein (Weboberfläche -> Geräteparameter -> Externe Kommunikation -> Modbus,
|
||||
Port 502). Das Skript schreibt nichts, es sendet ausschließlich "Read Holding Registers".
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Ip,
|
||||
[int]$Port = 502,
|
||||
[byte]$UnitId = 3,
|
||||
[int]$TimeoutMs = 4000
|
||||
)
|
||||
|
||||
# Adresse, Wörter, Typ, Name (Typ: U32/S32/U64/S16/U16)
|
||||
$registers = @(
|
||||
@(30051, 2, 'U32', 'Geräteklasse (8001=PV-WR, 8007=Batterie-WR, 8009=Hybrid)'),
|
||||
@(30053, 2, 'U32', 'Gerätetyp (Kennung)'),
|
||||
@(30201, 2, 'U32', 'Zustand (307=OK, 303=Aus, 455=Warnung ...)'),
|
||||
@(30769, 2, 'S32', 'DC-Strom MPP A (mA)'),
|
||||
@(30771, 2, 'S32', 'DC-Spannung MPP A (0,01 V)'),
|
||||
@(30773, 2, 'S32', 'DC-Leistung MPP A (W)'),
|
||||
@(30775, 2, 'S32', 'Wirkleistung AC gesamt (W)'),
|
||||
@(30517, 4, 'U64', 'Tagesertrag (Wh)'),
|
||||
@(30529, 2, 'U32', 'Gesamtertrag (Wh)'),
|
||||
@(30845, 2, 'U32', 'Akku-Ladezustand (%)'),
|
||||
@(30849, 2, 'S32', 'Akku-Temperatur (0,1 °C)'),
|
||||
@(31393, 2, 'U32', 'Akku-Ladeleistung (W)'),
|
||||
@(31395, 2, 'U32', 'Akku-Entladeleistung (W)'),
|
||||
@(30865, 2, 'S32', 'Netz: Bezug (W) - Zähler nötig'),
|
||||
@(30867, 2, 'S32', 'Netz: Einspeisung (W) - Zähler nötig')
|
||||
)
|
||||
|
||||
$client = [System.Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
if (-not $client.ConnectAsync($Ip, $Port).Wait($TimeoutMs)) { throw "Keine Verbindung zu ${Ip}:${Port} (Zeitüberschreitung). Ist Modbus TCP aktiviert?" }
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$transaction = 0
|
||||
|
||||
function Read-Registers([int]$address, [int]$count) {
|
||||
$script:transaction++
|
||||
# MBAP: Transaktion(2) Protokoll(2)=0 Länge(2) Unit(1) | PDU: FC(1)=3 Adresse(2) Anzahl(2)
|
||||
$req = [byte[]]@(
|
||||
($script:transaction -shr 8), ($script:transaction -band 0xFF), 0, 0, 0, 6, $UnitId,
|
||||
3, ($address -shr 8), ($address -band 0xFF), ($count -shr 8), ($count -band 0xFF))
|
||||
$stream.Write($req, 0, $req.Length)
|
||||
|
||||
$header = [byte[]]::new(9)
|
||||
$read = 0
|
||||
while ($read -lt 9) { $n = $stream.Read($header, $read, 9 - $read); if ($n -le 0) { throw 'Verbindung beendet' }; $read += $n }
|
||||
if ($header[7] -band 0x80) {
|
||||
$code = $header[8]
|
||||
return @{ Error = "Modbus-Ausnahme $code (2 = Adresse nicht vorhanden)" }
|
||||
}
|
||||
$data = [byte[]]::new($header[8])
|
||||
$read = 0
|
||||
while ($read -lt $data.Length) { $n = $stream.Read($data, $read, $data.Length - $read); if ($n -le 0) { throw 'Verbindung beendet' }; $read += $n }
|
||||
return @{ Data = $data }
|
||||
}
|
||||
|
||||
foreach ($r in $registers) {
|
||||
$address, $count, $type, $name = $r
|
||||
try { $res = Read-Registers $address $count } catch { Write-Output ("{0} {1,-48} FEHLER: {2}" -f $address, $name, $_.Exception.Message); break }
|
||||
if ($res.Error) { Write-Output ("{0} {1,-48} {2}" -f $address, $name, $res.Error); continue }
|
||||
$b = $res.Data
|
||||
$hex = ($b | ForEach-Object { $_.ToString('X2') }) -join ' '
|
||||
$value = switch ($type) {
|
||||
'U32' { $v = [uint64]0; $b | ForEach-Object { $v = ($v -shl 8) -bor $_ }; if ($v -eq 4294967295) { 'NaN (kein Wert)' } else { $v } }
|
||||
'S32' { $v = [int64]0; $b | ForEach-Object { $v = ($v -shl 8) -bor $_ }; if ($v -eq 2147483648 -or $v -eq 0x80000000) { 'NaN (kein Wert)' } elseif ($v -ge 2147483648) { $v - 4294967296 } else { $v } }
|
||||
'U64' { $v = [System.Numerics.BigInteger]::Zero; $b | ForEach-Object { $v = ($v * 256) + $_ }; if ($v -eq [System.Numerics.BigInteger]::Parse('18446744073709551615')) { 'NaN (kein Wert)' } else { $v } }
|
||||
default { $hex }
|
||||
}
|
||||
Write-Output ("{0} {1,-48} {2,-18} [{3}]" -f $address, $name, $value, $hex)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
Reference in new issue
Block a user