- 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
332 lines
14 KiB
C#
332 lines
14 KiB
C#
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);
|
||
}
|
||
}
|