Files
Rbm.FamStack/tests/FamilyPlanner.UnitTests/Home/ValloxDriverTests.cs
T
openclaw 70f1890641 feat: Phase 19 Haus - Vallox MV ventilation unit (profile, temperatures, profile switching)
- New device kind Ventilation with driver Vallox (WebSocket protocol of the unit's web UI:
  READ_TABLES/WRITE_DATA, checksum, big-endian table); no migration (enums stored as text)
- Read profile incl. remaining minutes, outdoor/supply/extract/exhaust air temperatures,
  humidity, CO2, fan speed; activate Home/Away/Boost/Fireplace (timed profiles use the
  duration configured on the unit)
- Same SSRF protection as Shelly (shared connection handler), permissions, action log, audit
- Admin dialog without generation/channel/credentials for ventilation, device card with
  values and profile buttons
- Tests: unit (protocol vs. reference bytes, driver, loopback WebSocket, SSRF, rules), bUnit
- ADR-0025, roadmap phase 19
2026-10-16 20:04:14 +02:00

332 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using FamilyPlanner.Api.Features.Home;
using FamilyPlanner.Domain.Home;
namespace FamilyPlanner.UnitTests.Home;
/// <summary>Vallox-MV-Protokoll (ADR-0025): Rahmen und Prüfsumme, Auswertung der Datentabelle, Profil-Befehle, Treiber gegen Fake-Transport und echten WebSocket.</summary>
public sealed class ValloxDriverTests
{
/// <summary>Kelvin × 100, wie die Anlage Temperaturen meldet.</summary>
private static ushort Kelvin(double celsius) => (ushort)Math.Round(celsius * 100 + 27315);
/// <summary>Eine Datentabelle wie von READ_TABLES (big-endian), Werte an den bekannten Positionen.</summary>
private static byte[] Table(Action<ushort[]>? fill = null, int words = 690)
{
var table = new ushort[words];
table[ValloxProtocol.OffsetMachineModel] = 6; // ValloPlus 350 MV
table[ValloxProtocol.OffsetFanSpeed] = 40;
table[ValloxProtocol.OffsetExtractAir] = Kelvin(21.7);
table[ValloxProtocol.OffsetExhaustAir] = Kelvin(7.1);
table[ValloxProtocol.OffsetOutdoorAir] = Kelvin(5.2);
table[ValloxProtocol.OffsetSupplyAir] = Kelvin(18.9);
table[ValloxProtocol.OffsetHumidity] = 45;
table[ValloxProtocol.OffsetCo2] = 620;
table[ValloxProtocol.OffsetState] = 0;
table[ValloxProtocol.OffsetBoostTime] = 45;
table[ValloxProtocol.OffsetFireplaceTime] = 20;
fill?.Invoke(table);
var bytes = new byte[table.Length * 2];
for (var i = 0; i < table.Length; i++)
{
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(i * 2), table[i]);
}
return bytes;
}
private static List<(ushort Address, ushort Value)> DecodeWrite(byte[] request)
{
var words = Enumerable.Range(0, request.Length / 2).Select(i => BinaryPrimitives.ReadUInt16LittleEndian(request.AsSpan(i * 2))).ToArray();
Assert.Equal(words.Length - 1, words[0]); // Länge = Wörter nach dem Längenfeld
Assert.Equal(ValloxProtocol.CommandWriteData, words[1]);
Assert.Equal((ushort)(words[..^1].Sum(w => w) & 0xFFFF), words[^1]);
return Enumerable.Range(0, (words.Length - 3) / 2).Select(i => (words[2 + i * 2], words[3 + i * 2])).ToList();
}
private static List<(ushort Address, ushort Value)> W(params (int Address, int Value)[] items) =>
items.Select(i => ((ushort)i.Address, (ushort)i.Value)).ToList();
private sealed class FakeTransport(Func<byte[], byte[]> respond) : IValloxTransport
{
public List<byte[]> Requests { get; } = [];
public Task<byte[]> RequestAsync(string host, byte[] payload, CancellationToken ct)
{
Requests.Add(payload);
return Task.FromResult(respond(payload));
}
}
private static readonly HomeDeviceConnection Vallox = new("http://192.168.1.60", 1, 0, HomeDeviceKind.Ventilation, null, null);
private static bool IsRead(byte[] payload) => payload.SequenceEqual(ValloxProtocol.ReadTablesRequest());
// ---------- Protokoll ----------
[Fact]
public void Read_tables_request_matches_the_reference_implementation()
{
// vallox_websocket_api: length=3, command=246 (READ_TABLES), items=0, checksum=249 – alles little-endian.
Assert.Equal(new byte[] { 0x03, 0x00, 0xF6, 0x00, 0x00, 0x00, 0xF9, 0x00 }, ValloxProtocol.ReadTablesRequest());
}
[Fact]
public void Write_request_has_length_command_sorted_pairs_and_checksum()
{
var request = ValloxProtocol.WriteRequest([(4613, 15), (4612, 0)]);
// Länge 6 (Befehl + 2 Paare + Prüfsumme), 249, (4612,0), (4613,15), Prüfsumme = 6 + 249 + 4612 + 0 + 4613 + 15 = 9495 = 0x2517
Assert.Equal(new byte[] { 0x06, 0x00, 0xF9, 0x00, 0x04, 0x12, 0x00, 0x00, 0x05, 0x12, 0x0F, 0x00, 0x17, 0x25 }, request);
}
[Fact]
public void Table_values_are_converted()
{
var metrics = ValloxProtocol.ParseTables(Table());
Assert.Equal(HomeVentilationProfile.Home, metrics.Profile);
Assert.Null(metrics.MinutesLeft);
Assert.Equal(5.2, metrics.OutdoorAirC);
Assert.Equal(18.9, metrics.SupplyAirC);
Assert.Equal(21.7, metrics.ExtractAirC);
Assert.Equal(7.1, metrics.ExhaustAirC);
Assert.Equal(40, metrics.FanSpeedPercent);
Assert.Equal(45, metrics.HumidityPercent);
Assert.Equal(620, metrics.Co2Ppm);
Assert.Equal(45, metrics.BoostMinutes);
Assert.Equal(20, metrics.FireplaceMinutes);
Assert.Equal("ValloPlus 350 MV", ValloxProtocol.ModelName(metrics.ModelIndex));
}
[Theory]
[InlineData(0, 0, 0, 0, HomeVentilationProfile.Home, null)]
[InlineData(1, 0, 0, 0, HomeVentilationProfile.Away, null)]
[InlineData(2, 0, 0, 0, HomeVentilationProfile.Auto, null)]
[InlineData(7, 0, 0, 0, HomeVentilationProfile.Unknown, null)]
[InlineData(0, 25, 0, 0, HomeVentilationProfile.Boost, 25)]
[InlineData(1, 0, 12, 0, HomeVentilationProfile.Fireplace, 12)]
[InlineData(0, 0, 0, 90, HomeVentilationProfile.Extra, 90)]
[InlineData(0, 0xFFFF, 0, 0, HomeVentilationProfile.Boost, null)] // ohne Zeitbegrenzung
[InlineData(1, 5, 10, 0, HomeVentilationProfile.Boost, 5)] // Stoßlüften hat Vorrang (wie die Weboberfläche)
public void Profile_follows_the_timers_then_the_state(int state, int boost, int fireplace, int extra, HomeVentilationProfile expected, int? minutes)
{
var metrics = ValloxProtocol.ParseTables(Table(t =>
{
t[ValloxProtocol.OffsetState] = (ushort)state;
t[ValloxProtocol.OffsetBoostTimer] = (ushort)boost;
t[ValloxProtocol.OffsetFireplaceTimer] = (ushort)fireplace;
t[ValloxProtocol.OffsetExtraTimer] = (ushort)extra;
}));
Assert.Equal(expected, metrics.Profile);
Assert.Equal(minutes, metrics.MinutesLeft);
}
[Fact]
public void Missing_sensors_are_null_and_durations_fall_back_to_the_defaults()
{
var metrics = ValloxProtocol.ParseTables(Table(t =>
{
t[ValloxProtocol.OffsetOutdoorAir] = 0;
t[ValloxProtocol.OffsetSupplyAir] = 0xFFFF;
t[ValloxProtocol.OffsetExhaustAir] = 50; // −272,65 °C: unplausibel
t[ValloxProtocol.OffsetHumidity] = 0;
t[ValloxProtocol.OffsetCo2] = 0xFFFF;
t[ValloxProtocol.OffsetFanSpeed] = 0xFFFF;
t[ValloxProtocol.OffsetBoostTime] = 0;
t[ValloxProtocol.OffsetFireplaceTime] = 0xFFFF;
t[ValloxProtocol.OffsetMachineModel] = 0xFFFF;
}));
Assert.Null(metrics.OutdoorAirC);
Assert.Null(metrics.SupplyAirC);
Assert.Null(metrics.ExhaustAirC);
Assert.Null(metrics.HumidityPercent);
Assert.Null(metrics.Co2Ppm);
Assert.Null(metrics.FanSpeedPercent);
Assert.Equal(ValloxProtocol.DefaultBoostMinutes, metrics.BoostMinutes);
Assert.Equal(ValloxProtocol.DefaultFireplaceMinutes, metrics.FireplaceMinutes);
Assert.Equal("Vallox", ValloxProtocol.ModelName(metrics.ModelIndex));
}
[Theory]
[InlineData(0)]
[InlineData(8)] // z. B. eine Fehlerantwort des Geräts
[InlineData(494)] // ein Wort zu kurz
[InlineData(1001)] // ungerade Länge
public void Short_or_odd_responses_are_rejected(int bytes)
{
var ex = Assert.Throws<HomeException>(() => ValloxProtocol.ParseTables(new byte[bytes]));
Assert.Equal(HomeErrorKind.BadResponse, ex.Kind);
}
[Fact]
public void Unknown_model_numbers_are_named_by_number()
{
Assert.Equal("Vallox (Modell-Nr. 250)", ValloxProtocol.ModelName(250));
}
[Fact]
public void Profile_writes_match_set_profile_of_the_reference_implementation()
{
var metrics = ValloxProtocol.ParseTables(Table());
Assert.Equal(W((4609, 0), (4612, 0), (4613, 0), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileHome, null).ToList());
Assert.Equal(W((4609, 1), (4612, 0), (4613, 0), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileAway, null).ToList());
Assert.Equal(W((4612, 45), (4613, 0), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileBoost, metrics).ToList());
Assert.Equal(W((4612, 0), (4613, 20), (4614, 0)), ValloxProtocol.ProfileWrites(HomeCommand.ProfileFireplace, metrics).ToList());
Assert.Throws<HomeException>(() => ValloxProtocol.ProfileWrites(HomeCommand.TurnOn, metrics));
}
// ---------- Treiber ----------
[Fact]
public async Task State_contains_profile_and_temperatures()
{
var transport = new FakeTransport(_ => Table(t => t[ValloxProtocol.OffsetBoostTimer] = 18));
var state = await new ValloxDriver(transport).GetStateAsync(Vallox, TestContext.Current.CancellationToken);
Assert.True(IsRead(Assert.Single(transport.Requests)));
Assert.Equal(21.7, state.TemperatureC); // Abluft = Raumluft
Assert.Equal(45, state.HumidityPercent);
Assert.Null(state.IsOn);
var ventilation = Assert.IsType<FamilyPlanner.Shared.Home.HomeVentilationDto>(state.Ventilation);
Assert.Equal(HomeVentilationProfile.Boost, ventilation.Profile);
Assert.Equal(18, ventilation.MinutesLeft);
Assert.Equal(5.2, ventilation.OutdoorAirC);
Assert.Equal(620, ventilation.Co2Ppm);
}
[Fact]
public async Task Home_and_away_are_written_without_reading_first()
{
var transport = new FakeTransport(_ => []);
var driver = new ValloxDriver(transport);
await driver.ExecuteAsync(Vallox, HomeCommand.ProfileAway, TestContext.Current.CancellationToken);
Assert.Equal(W((4609, 1), (4612, 0), (4613, 0), (4614, 0)), DecodeWrite(Assert.Single(transport.Requests)));
}
[Theory]
[InlineData(HomeCommand.ProfileBoost, 4612, 45)]
[InlineData(HomeCommand.ProfileFireplace, 4613, 20)]
public async Task Timed_profiles_use_the_duration_configured_on_the_unit(HomeCommand command, int timerAddress, int minutes)
{
var transport = new FakeTransport(p => IsRead(p) ? Table() : []);
await new ValloxDriver(transport).ExecuteAsync(Vallox, command, TestContext.Current.CancellationToken);
Assert.Equal(2, transport.Requests.Count);
Assert.True(IsRead(transport.Requests[0]));
var writes = DecodeWrite(transport.Requests[1]);
Assert.Contains(((ushort)timerAddress, (ushort)minutes), writes);
Assert.All(writes.Where(w => w.Address != timerAddress), w => Assert.Equal(0, w.Value)); // andere Timer beenden
}
[Fact]
public async Task Test_reports_the_model()
{
var info = await new ValloxDriver(new FakeTransport(_ => Table())).TestAsync(Vallox, TestContext.Current.CancellationToken);
Assert.Equal("ValloPlus 350 MV", info.Model);
}
[Fact]
public async Task Other_device_kinds_and_commands_are_refused_without_contacting_the_unit()
{
var transport = new FakeTransport(_ => Table());
var driver = new ValloxDriver(transport);
var wrongKind = await Assert.ThrowsAsync<HomeException>(() => driver.GetStateAsync(Vallox with { Kind = HomeDeviceKind.Switch }, TestContext.Current.CancellationToken));
var wrongCommand = await Assert.ThrowsAsync<HomeException>(() => driver.ExecuteAsync(Vallox, HomeCommand.Toggle, TestContext.Current.CancellationToken));
Assert.Equal(HomeErrorKind.NotSupported, wrongKind.Kind);
Assert.Equal(HomeErrorKind.NotSupported, wrongCommand.Kind);
Assert.Empty(transport.Requests);
}
// ---------- echter WebSocket (Loopback) ----------
private static int FreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
/// <summary>Ein minimaler Vallox-Ersatz: nimmt eine WebSocket-Verbindung an, merkt sich die Anfrage und antwortet mit <paramref name="reply"/>.</summary>
private static async Task<(string Host, Task<byte[]> Received)> StartFakeUnitAsync(byte[] reply)
{
var port = FreePort();
var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
listener.Start();
var received = Task.Run(async () =>
{
try
{
var context = await listener.GetContextAsync();
var ws = (await context.AcceptWebSocketAsync(subProtocol: null)).WebSocket;
var buffer = new byte[4096];
var result = await ws.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
await ws.SendAsync(reply, WebSocketMessageType.Binary, endOfMessage: true, TestContext.Current.CancellationToken);
try
{
await ws.ReceiveAsync(new byte[16], TestContext.Current.CancellationToken); // Schließen des Clients abwarten
}
catch (WebSocketException)
{
}
return buffer[..result.Count];
}
finally
{
listener.Stop();
}
});
await Task.Yield();
return ($"http://127.0.0.1:{port}", received);
}
[Fact]
public async Task Transport_talks_binary_websocket_to_the_unit()
{
var (host, received) = await StartFakeUnitAsync(Table());
using var transport = new ValloxWebSocketTransport();
var response = await transport.RequestAsync(host, ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken);
Assert.Equal(ValloxProtocol.ReadTablesRequest(), await received.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
Assert.Equal(HomeVentilationProfile.Home, ValloxProtocol.ParseTables(response).Profile);
}
[Fact]
public async Task Transport_refuses_addresses_outside_the_home_network()
{
using var transport = new ValloxWebSocketTransport();
var ex = await Assert.ThrowsAsync<HomeException>(() => transport.RequestAsync("http://8.8.8.8", ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken));
Assert.Equal(HomeErrorKind.Blocked, ex.Kind);
}
[Fact]
public async Task Transport_reports_an_unreachable_unit()
{
using var transport = new ValloxWebSocketTransport();
var ex = await Assert.ThrowsAsync<HomeException>(() => transport.RequestAsync($"http://127.0.0.1:{FreePort()}", ValloxProtocol.ReadTablesRequest(), TestContext.Current.CancellationToken));
Assert.Equal(HomeErrorKind.Unreachable, ex.Kind);
}
}