This commit is contained in:
fuenmao
2026-11-11 16:11:41 +08:00
parent 3ca90c4cf3
commit 61e25175db
39 changed files with 2176 additions and 31 deletions
+1
View File
@@ -25,6 +25,7 @@
</ItemGroup>
<ItemGroup>
<None Include="..\..\docs\protocols\ib43\examples\*.json" Link="Ib43Examples\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
<ProjectReference Include="..\Evergreen.Health.Application\Evergreen.Health.Application.csproj" />
</ItemGroup>
@@ -0,0 +1,160 @@
using Evergreen.Health.Application.Health.Devices.Curtain;
using Evergreen.Health.Application.Health.Devices.Lighting;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class BedPresenceAutomationTests
{
private const string Binding = "a98788f2aeeb40b68f145a182d99f832";
private const string Light = "light.test_bedroom";
private const string Curtain = "cover.test_curtain";
private static BedPresenceSample Sample(BedPresence presence, DateTimeOffset at, Guid connection) =>
new(Binding, "evergreen-test", "IB43SIM001", BedSide.Left, true, presence, at, connection);
[Fact]
public void Edge_requires_fresh_empty_baseline_on_the_same_bed_and_connection()
{
var edge = new BedPresenceEdge();
var at = DateTimeOffset.UtcNow;
var connection = Guid.NewGuid();
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at));
Assert.True(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at.AddSeconds(-1), connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at.AddSeconds(61), connection), at.AddSeconds(61)));
Assert.False(edge.Observe(Sample(BedPresence.Empty, at, connection), at.AddSeconds(61)));
Assert.False(edge.Observe(Sample(BedPresence.Occupied, at, Guid.NewGuid()), at));
}
[Fact]
public async Task Preview_requires_configured_simulation_identity_and_never_executes_action()
{
var options = Options.Create(new BedPresenceAutomationOptions
{ Mode = BedAutomationMode.Preview, BindingId = Binding, ActionKey = "curtain-close", TargetEntityId = Curtain });
var action = new NeverExecuteAction();
var logger = new PreviewLogger();
using var automation = new BedPresenceAutomation(options, [action], TimeProvider.System, logger);
await automation.StartAsync(default);
try
{
var connection = Guid.NewGuid();
var at = DateTimeOffset.UtcNow;
var occupied = Sample(BedPresence.Occupied, at, connection);
var empty = occupied with { Presence = BedPresence.Empty };
// 这些样本即使构成上床边沿也不能进入规则。
Assert.False(options.Value.Matches(empty with { Side = BedSide.Right }));
Assert.False(options.Value.Matches(empty with { Simulation = false }));
Assert.False(options.Value.Matches(empty with { BindingId = "someone-else" }));
automation.Observe(occupied, default);
automation.Observe(empty, default);
automation.Observe(occupied, default);
await logger.Preview.Task.WaitAsync(TimeSpan.FromSeconds(3));
Assert.Contains("Action=curtain-close", logger.Preview.Task.Result);
Assert.Contains(Curtain, logger.Preview.Task.Result);
Assert.Equal(0, action.Calls);
}
finally { await automation.StopAsync(default); }
}
[Theory]
[InlineData("closed", "already-closed")]
[InlineData("closing", "already-closing")]
[InlineData("opening", "state-not-open")]
public async Task Curtain_action_leaves_closed_or_moving_curtain_alone(string state, string expected)
{
var (action, gateway) = CurtainFixture(state, 2, "closed");
Assert.Equal(expected, await action.ExecuteAsync(Curtain, default));
Assert.Empty(gateway.Calls);
}
[Theory]
[InlineData("closed", "readback-confirmed")]
[InlineData("closing", "sent-readback-unconfirmed")]
public async Task Curtain_action_sends_one_close_and_waits_for_closed_readback(string reported, string expected)
{
var (action, gateway) = CurtainFixture("open", 2, reported);
Assert.Equal("target-not-configured", await action.ExecuteAsync("cover.other", default));
Assert.Equal(expected, await action.ExecuteAsync(Curtain, default));
var command = Assert.Single(gateway.Calls);
Assert.Equal(Curtain, command.EntityId);
Assert.Equal("close_cover", command.Service);
}
[Fact]
public async Task Curtain_action_requires_close_capability_before_sending()
{
var (action, gateway) = CurtainFixture("open", 1, "closed");
await Assert.ThrowsAsync<InvalidOperationException>(() => action.ExecuteAsync(Curtain, default));
Assert.Empty(gateway.Calls);
}
private static (BedPresenceCurtainCloseAction, FakeHaGateway) CurtainFixture(string state, int features, string closeResult)
{
var gateway = new FakeHaGateway { CoverCloseResult = closeResult };
gateway.States[Curtain] = new() { EntityId = Curtain, State = state,
Attributes = JsonSerializer.SerializeToElement(new { supported_features = features }) };
var options = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = Curtain } });
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero });
return (new(new CurtainModule(runner, options), runner, options), gateway);
}
[Fact]
public async Task Light_action_starts_only_target_and_verifies_brightness_without_restarting_active_fade()
{
var gateway = new FakeHaGateway();
var time = new ManualTestTimeProvider();
gateway.States[Light] = new() { EntityId = Light, State = "off", Attributes = JsonSerializer.SerializeToElement(new { }) };
gateway.States["light.other"] = new() { EntityId = "light.other", State = "off" };
var options = Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = Light } });
var lights = new HomeAssistantLightTransitionService(gateway, new LightOffDispatcher([]), options,
NullLogger<HomeAssistantLightTransitionService>.Instance, new(), time);
var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
var action = new BedPresenceSleepLightAction(lights, new DeviceActionRunner(gateway, time, execution), execution, options);
try
{
Assert.Equal("target-not-configured", await action.ExecuteAsync("light.other", default));
Assert.Empty(gateway.Calls);
Assert.Equal("readback-confirmed", await action.ExecuteAsync(Light, default));
Assert.Equal("already-running", await action.ExecuteAsync(Light, default));
var call = Assert.Single(gateway.Calls);
Assert.Equal(Light, call.EntityId);
Assert.Equal(50, call.Data!["brightness_pct"]);
Assert.Equal("off", gateway.States["light.other"].State);
}
finally { await lights.StopAsync(default); }
}
private sealed class NeverExecuteAction : IBedPresenceAction
{
public int Calls;
public string Key => "curtain-close";
public string Description => "关闭窗帘";
public bool SupportsTarget(string entityId) => entityId == Curtain;
public Task<string> ExecuteAsync(string entityId, CancellationToken token)
{ Interlocked.Increment(ref Calls); throw new InvalidOperationException("Preview cannot execute"); }
}
private sealed class PreviewLogger : ILogger<BedPresenceAutomation>
{
public TaskCompletionSource<string> Preview { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
var message = formatter(state, exception);
if (message.StartsWith("Bed presence preview:", StringComparison.Ordinal)) Preview.TrySetResult(message);
}
}
}
@@ -147,6 +147,52 @@ public sealed class DeviceArchitectureTests
Assert.Equal("light.bedroom", device.PrimaryControlEntityId);
}
[Fact]
public async Task Configured_inventory_filters_retired_devices_even_if_ha_cache_still_reports_them()
{
var services = new ServiceCollection();
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
var gateway = new FakeHaGateway { ReadsToFail = 1 };
services.AddSingleton<IHomeAssistantGateway>(gateway);
services.AddSingleton(Options.Create(new HomeAssistantOptions
{
OnlyConfiguredDevices = true,
Entities = new()
{
Climate = "climate.keep", Temperature = "sensor.keep_temperature", Curtain = "cover.keep",
Dehumidifier = "humidifier.keep", FragranceSwitch = "switch.keep_fragrance", Speaker = "media_player.keep"
},
DisplayOnlyDevices = [new() { EntityId = "switch.wake_fragrance", Name = "香氛机 唤醒", Category = "Fragrance" }]
}));
using var provider = services.BuildServiceProvider();
var entities = new[] { "climate.keep", "sensor.keep_temperature", "cover.keep", "humidifier.keep",
"switch.keep_fragrance", "media_player.keep", "switch.wake_fragrance", "light.removed", "switch.removed_outlet", "sensor.removed_weight" };
var registry = new HomeAssistantRegistries
{
Devices = entities.Select(id => new HomeAssistantDeviceRegistryItem { Id = id, Name = id }).ToList(),
Entities = entities.Select(id => new HomeAssistantEntityRegistryItem { DeviceId = id, EntityId = id }).ToList()
};
var state = entities.ToDictionary(id => id, id => new HomeAssistantState { EntityId = id, State = "on" });
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
foreach (var inventory in new[] { catalog.BuildDiscoveredDevices(registry, state), catalog.BuildDiscoveredDevices(null, state) })
{
Assert.Equal(7, inventory.Count);
Assert.DoesNotContain(inventory.SelectMany(device => device.Entities), entity => entity.EntityId.Contains("removed"));
var wake = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.wake_fragrance"));
Assert.Null(wake.PrimaryControlEntityId);
Assert.True(wake.Online);
var sleep = Assert.Single(inventory, device => device.Entities.Any(entity => entity.EntityId == "switch.keep_fragrance"));
Assert.Equal("switch.keep_fragrance", sleep.PrimaryControlEntityId);
}
var modules = provider.GetRequiredService<DeviceModuleRegistry>();
Assert.DoesNotContain(modules.Modules, module => module.Key is "lighting" or "outlet");
Assert.Throws<InvalidOperationException>(() => modules.ForParameter("brightness"));
var lights = provider.GetRequiredService<HomeAssistantLightTransitionService>();
await Assert.ThrowsAsync<InvalidOperationException>(() => lights.StartSleepAsync(2700, default));
Assert.Equal(1, gateway.ReadsToFail);
Assert.Empty(gateway.Calls);
}
[Fact]
public async Task ShutdownContinuesAfterAnIndividualDeviceFailure()
{
@@ -218,7 +264,7 @@ public sealed class DeviceArchitectureTests
services.AddHealthDevices();
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
Assert.NotNull(provider.GetRequiredService<HealthSceneCoordinator>());
Assert.Equal(2, provider.GetServices<IHostedService>().Count());
Assert.Equal(4, provider.GetServices<IHostedService>().Count());
Assert.DoesNotContain(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
Assert.Empty(provider.GetServices<ILightOffHandler>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<DeviceModuleRegistry>().ForParameter("mattress_left_heating"));
@@ -15,6 +15,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
public ConcurrentQueue<DeviceCommand> Calls { get; } = new();
public string? FailEntity { get; set; }
public int ReadsToFail { get; set; }
public string? CoverCloseResult { get; set; }
public TimeProvider Time { get; set; } = TimeProvider.System;
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
@@ -34,6 +35,7 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
var state = service switch
{
"turn_on" => "on", "turn_off" => "off",
"close_cover" when CoverCloseResult is not null => CoverCloseResult,
"select_option" => data["option"]!.ToString()!,
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
};
@@ -0,0 +1,48 @@
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace Admin.NET.Test.Health;
[CollectionDefinition("Ib43Environment", DisableParallelization = true)]
public sealed class Ib43EnvironmentCollection { }
[Collection("Ib43Environment")]
public sealed class Ib43ConfigurationTests
{
[Fact]
public void Environment_overrides_defaults_after_binding_and_before_validation()
{
var names = new[] { "Ib43__Enabled", "Ib43__Password" };
var original = names.Select(Environment.GetEnvironmentVariable).ToArray();
try
{
Environment.SetEnvironmentVariable(names[0], "true");
Environment.SetEnvironmentVariable(names[1], "test-only-secret");
var services = new ServiceCollection();
services.AddOptions<Ib43Options>().Configure(options =>
{
options.Enabled = false; options.Password = "";
options.Devices = [new() { ProductKey = "test", SerialNumber = "sim" }];
}).PostConfigure(Ib43Configuration.ApplyEnvironmentOverrides)
.Validate(options => options.IsValid());
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<Ib43Options>>().Value;
Assert.True(options.Enabled);
Assert.Equal("test-only-secret", options.Password);
Assert.Single(options.Devices);
Environment.SetEnvironmentVariable(names[0], null);
Environment.SetEnvironmentVariable(names[1], null);
var defaults = new Ib43Options();
Ib43Configuration.ApplyEnvironmentOverrides(defaults);
Assert.False(defaults.Enabled);
Assert.Empty(defaults.Password);
}
finally
{
for (var i = 0; i < names.Length; i++) Environment.SetEnvironmentVariable(names[i], original[i]);
}
}
}
@@ -0,0 +1,164 @@
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Devices.Curtain;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using SqlSugar;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using MQTTnet.Server;
using System.Net;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class Ib43MqttIntegrationTests
{
private const string LeftBinding = "a98788f2aeeb40b68f145a182d99f832";
private const string TargetCurtain = "cover.test_curtain";
[Fact]
public async Task Real_MQTT_receives_supplier_examples_and_resubscribes_after_broker_restart()
{
// 只监听 loopback,随机端口;不访问用户的 EMQX、数据库或家具。
using var reservation = new TcpListener(IPAddress.Loopback, 0);
reservation.Start();
var port = ((IPEndPoint)reservation.LocalEndpoint).Port;
reservation.Stop();
var factory = new MqttFactory();
using var broker = factory.CreateMqttServer(new MqttServerOptionsBuilder()
.WithDefaultEndpoint().WithDefaultEndpointPort(port)
.WithDefaultEndpointBoundIPAddress(IPAddress.Loopback).Build());
await broker.StartAsync();
var binding = new Ib43Binding { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
var options = Options.Create(new Ib43Options { Enabled = true, Host = "127.0.0.1", Port = port,
Username = "local-test", Password = "local-test-only", Devices = [binding] });
var store = new Ib43StateStore(options, new(), TimeProvider.System);
using var sink = new DatabaseSink(binding);
var gateway = new FakeHaGateway { CoverCloseResult = "closed" };
gateway.States[TargetCurtain] = new() { EntityId = TargetCurtain, State = "open",
Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) };
var haOptions = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = TargetCurtain } });
var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
var runner = new DeviceActionRunner(gateway, TimeProvider.System, execution);
var curtainAction = new BedPresenceCurtainCloseAction(new CurtainModule(runner, haOptions), runner, haOptions);
using var automation = new BedPresenceAutomation(Options.Create(new BedPresenceAutomationOptions
{ Mode = BedAutomationMode.Execute, BindingId = LeftBinding, ActionKey = "curtain-close", TargetEntityId = TargetCurtain }),
[curtainAction], TimeProvider.System, NullLogger<BedPresenceAutomation>.Instance);
using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store,
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, automation);
using var publisher = factory.CreateMqttClient();
try
{
await automation.StartAsync(default);
await service.StartAsync(default);
await WaitFor(() => store.Snapshot()[0].Transport == "connected");
await publisher.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port)
.WithClientId("ib43-local-simulator").Build());
foreach (var name in new[] { "startup.pdf-example.json", "realtime.pdf-example.json", "sleep-event.pdf-keys.json" })
{
var json = JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", name)));
json["params"]["deviceName"] = binding.SerialNumber;
if (json["params"]["productKey"] != null) json["params"]["productKey"] = binding.ProductKey;
await publisher.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
.WithPayload(json.ToJsonString()).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
}
await WaitFor(() => store.Snapshot()[0].Readings.TryGetValue("left.heartRate", out var value) && value.Value == 74);
Assert.Equal(80d, store.Snapshot()[0].Readings["right.heartRate"].Value);
Assert.Equal(60, store.Snapshot()[0].Fields["characteristic"].Value.GetProperty("reportIerVal").GetInt32());
Assert.All(store.Diagnostics(), entry => Assert.Empty(entry.Issues));
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 2);
Assert.Equal(74d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
Assert.Equal(80d, (await sink.Repository.Latest(2, "right-test"))["heartRate"].Value);
Assert.Empty(gateway.Calls);
// 左侧空床后上床才触发。连续重复在床不会重启,右侧的变化不触发左侧规则。
await PublishPresence(publisher, binding, 0, 0);
await PublishPresence(publisher, binding, 0, 1);
await PublishPresence(publisher, binding, 1, 1);
await PublishPresence(publisher, binding, 1, 1);
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 6 && gateway.Calls.Count == 1);
var close = Assert.Single(gateway.Calls);
Assert.Equal(TargetCurtain, close.EntityId);
Assert.Equal("close_cover", close.Service);
Assert.Equal("closed", gateway.States[TargetCurtain].State);
Assert.Equal(1d, (await sink.Repository.Latest(1, LeftBinding))["presence"].Value);
await broker.StopAsync();
await WaitFor(() => store.Snapshot()[0].Transport != "connected");
await broker.StartAsync();
await WaitFor(() => store.Snapshot()[0].Transport == "connected");
using var publisherAfterRestart = factory.CreateMqttClient();
await publisherAfterRestart.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port).Build());
await publisherAfterRestart.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
.WithPayload("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[75,16,0,81,18,0]}}")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
await WaitFor(() => store.Snapshot()[0].Readings["left.heartRate"].Value == 75);
Assert.Equal(1d, store.Snapshot()[0].Readings["left.presence"].Value);
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 7);
Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
Assert.Single(gateway.Calls);
}
finally
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await service.StopAsync(timeout.Token);
await automation.StopAsync(timeout.Token);
await broker.StopAsync();
}
}
private sealed class DatabaseSink : IIb43ReportSink, IDisposable
{
private readonly SqlSugarClient _db;
public readonly Ib43DataRepository Repository;
public int SavedReports;
public DatabaseSink(Ib43Binding binding)
{
_db = new(new ConnectionConfig { ConnectionString = "DataSource=:memory:", DbType = DbType.Sqlite,
IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute,
ConfigureExternalServices = new ConfigureExternalServices { EntityService = (p, c) =>
{
if (p.DeclaringType == typeof(HealthIb43Observation) && p.Name == "Id") c.DataType = "INTEGER";
} } });
_db.CodeFirst.InitTables<HealthIb43Binding, HealthIb43Observation>();
Repository = new(_db);
var key = Ib43DataRepository.DeviceKey(binding.ProductKey, binding.SerialNumber);
foreach (var (side, user) in new[] { ("left", 1L), ("right", 2L) })
_db.Insertable(new HealthIb43Binding { Id = side == "left" ? LeftBinding : "right-test", DeviceKey = key,
ProductKey = binding.ProductKey, SerialNumber = binding.SerialNumber, Side = side,
ActiveSlot = key + "." + side, UserId = user, Simulation = true }).ExecuteCommand();
}
public async Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
{
var count = await Repository.Save(reportId, binding, report);
Interlocked.Increment(ref SavedReports);
return count;
}
public void Dispose() => _db.Dispose();
}
private static Task<MqttClientPublishResult> PublishPresence(IMqttClient publisher, Ib43Binding binding, int left, int right)
{
var payload = JsonSerializer.Serialize(new
{
method = "thing.property.post",
@params = new { deviceName = binding.SerialNumber, SleepReportNew = new { ibNew = new[] { left, 0, 0, 16, 74, 0, right, 0, 0, 17, 80, 0 } } }
});
return publisher.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic).WithPayload(payload)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
}
private static async Task WaitFor(Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(12));
while (!predicate()) await Task.Delay(20, timeout.Token);
}
}
@@ -0,0 +1,208 @@
using Admin.NET.Core;
using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Evergreen.Health.Application.Health.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using SqlSugar;
using System.Security.Claims;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class Ib43PersistenceTests : IDisposable
{
private readonly SqlSugarClient _db;
private readonly string _databasePath = Path.Combine(Path.GetTempPath(), "ib43-test-" + Guid.NewGuid().ToString("N") + ".db");
private readonly Ib43DataRepository _repository;
private readonly Clock _clock = new();
private readonly Ib43Binding _device = new() { ProductKey = "test", SerialNumber = "sim", Simulation = true };
private readonly HttpContextAccessor _context = new() { HttpContext = new DefaultHttpContext() };
private readonly HealthIb43Service _service;
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1000000);
public override DateTimeOffset GetUtcNow() => Now;
}
public Ib43PersistenceTests()
{
_db = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
DbType = DbType.Sqlite, IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute,
ConfigureExternalServices = new ConfigureExternalServices { EntityService = (property, column) =>
{
// SQLite 的 rowid 必须声明 INTEGER;MySQL 生产表保持 bigint identity。
if (property.DeclaringType == typeof(HealthIb43Observation) && property.Name == "Id") column.DataType = "INTEGER";
if (Nullable.GetUnderlyingType(property.PropertyType) != null) column.IsNullable = true;
} } });
_db.CodeFirst.InitTables<HealthIb43Binding, HealthIb43Observation, HealthUser>();
_db.Ado.ExecuteCommand("CREATE TABLE SysUser (Id INTEGER PRIMARY KEY, AccountType INTEGER, Status INTEGER)");
_db.Ado.ExecuteCommand("INSERT INTO SysUser VALUES (99,999,1)");
foreach (var id in new[] { 1L, 2L, 3L })
_db.Insertable(new HealthUser { Id = id, Phone = id.ToString(), DisplayName = "test", PasswordHash = "unused",
CreateTime = DateTime.UtcNow, CreateUserName = "test", UpdateUserName = "test" }).ExecuteCommand();
_repository = new(_db);
var options = Options.Create(new Ib43Options { Devices = [_device] });
_service = new(_repository, _context, options, new(options, new(), _clock), _clock);
}
private void User(long id) => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("health_user_id", id.ToString())], "test"));
private void Admin() => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimConst.UserId, "99"), new Claim(ClaimConst.AccountType, "999")], "test"));
private async Task<Ib43BedOutput> Assign(string side, long user)
{
Admin();
return await _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = side, UserId = user, BedName = "测试床" });
}
private Ib43Report Report(string fields) => new Ib43Parser().Parse(_device.PostTopic,
"{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"sim\"," + fields + "}}", _device, _clock.Now);
[Fact]
public async Task Stored_sides_are_isolated_and_survive_new_store_instance()
{
var left = await Assign("left", 1); var right = await Assign("right", 2);
await _repository.Save("a", _device, Report("\"HR\":[72,16,0,80,18,1]"));
User(1);
var current = await _service.Current(left.Id);
Assert.Equal(72d, current.Readings["heartRate"].Value);
Assert.Equal("unknown", current.Readings["presence"].Freshness);
Assert.Single(await _service.Bindings());
await ExpectRejection(() => _service.Current(right.Id));
await ExpectRejection(() => _service.History(right.Id, 0, 2000000));
_db.Ado.Close();
using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute });
var freshRepository = new Ib43DataRepository(reopened);
Assert.Equal(80d, (await freshRepository.Latest(2, right.Id))["heartRate"].Value);
var rows = await _db.Queryable<HealthIb43Observation>().Where(o => o.UserId == 1).ToListAsync();
Assert.DoesNotContain("right", rows[0].ReadingsJson);
Assert.DoesNotContain("80", rows[0].ReadingsJson);
}
[Fact]
public async Task Partial_updates_and_zero_are_distinct_and_each_field_has_own_age()
{
var bed = await Assign("left", 1);
await _repository.Save("a", _device, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
_clock.Now += TimeSpan.FromSeconds(151);
await _repository.Save("b", _device, Report("\"HR\":[0,0,0,80,18,0]"));
User(1);
var current = await _service.Current(bed.Id);
Assert.Null(current.Readings["heartRate"].Value);
Assert.Equal("recent", current.Readings["heartRate"].Freshness);
Assert.Equal(1d, current.Readings["presence"].Value);
Assert.Equal("stale", current.Readings["presence"].Freshness);
Assert.Equal(2, (await _service.History(bed.Id, 0, 2000000)).Items.Count);
}
[Fact]
public async Task Reassignment_does_not_transfer_previous_users_data_or_delayed_reports()
{
var oldBed = await Assign("left", 1);
var pending = Report("\"HR\":[72,16,0,80,18,0]");
_clock.Now += TimeSpan.FromSeconds(1);
Admin(); await _service.Release(oldBed.Id);
var newBed = await Assign("left", 3);
await _repository.Save("delayed", _device, pending);
User(3);
Assert.Equal("unknown", (await _service.Current(newBed.Id)).Readings["heartRate"].Freshness);
Assert.Empty((await _service.History(newBed.Id, 0, 2000000)).Items);
await ExpectRejection(() => _service.Current(oldBed.Id));
await _repository.Save("new", _device, Report("\"HR\":[90,20,0,0,0,0]"));
Assert.Equal(90d, (await _service.Current(newBed.Id)).Readings["heartRate"].Value);
User(1);
Assert.Equal(72d, (await _service.Current(oldBed.Id)).Readings["heartRate"].Value);
Assert.False((await _service.Current(oldBed.Id)).Active);
}
[Fact]
public async Task Retries_are_idempotent_and_history_cursor_does_not_skip_same_timestamp()
{
var bed = await Assign("left", 1);
var report = Report("\"HR\":[72,16,0,80,18,0]");
await _repository.Save("a", _device, report);
await _repository.Save("a", _device, report);
await _repository.Save("b", _device, report);
User(1);
var page = await _service.History(bed.Id, 0, 2000000, take: 1);
Assert.NotNull(page.NextBeforeId);
var next = await _service.History(bed.Id, 0, 2000000, page.NextBeforeId, 1);
Assert.Single(next.Items); Assert.Null(next.NextBeforeId);
Assert.NotEqual(page.Items[0].Id, next.Items[0].Id);
await ExpectRejection(() => _service.History(bed.Id, 0, 8L * 86400000));
await ExpectRejection(() => _service.History(bed.Id, 0, 2000000, take: 201));
}
[Fact]
public async Task Anonymous_disabled_and_health_users_cannot_provision_or_read_others()
{
await ExpectRejection(() => _service.Bindings());
var bed = await Assign("left", 1);
User(1);
await ExpectRejection(() => _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = "right", UserId = 1, BedName = "test" }));
await ExpectRejection(() => _service.Release(bed.Id));
await _db.Updateable<HealthUser>().SetColumns(u => u.Status == HealthUserStatus.Disabled).Where(u => u.Id == 1).ExecuteCommandAsync();
await ExpectRejection(() => _service.Bindings());
await ExpectRejection(() => _service.Current(bed.Id));
}
[Fact]
public async Task Duplicate_active_slot_is_rejected_and_unbound_invalid_reports_are_not_saved()
{
await _repository.Save("unbound", _device, Report("\"HR\":[72,16,0,80,18,0]"));
Assert.Equal(0, await _db.Queryable<HealthIb43Observation>().CountAsync());
await Assign("left", 1);
await ExpectRejection(() => Assign("left", 2));
await _repository.Save("bad", _device, Report("\"HR\":[72]"));
Assert.Equal(0, await _db.Queryable<HealthIb43Observation>().CountAsync());
await Assign("right", 2);
}
private static async Task ExpectRejection(Func<Task> action)
{
var error = await Assert.ThrowsAnyAsync<Exception>(action);
Assert.Equal("Furion.FriendlyException.AppFriendlyException", error.GetType().FullName);
}
[Fact]
public async Task Local_maintenance_previews_then_binds_with_audit_and_is_idempotent()
{
_db.CodeFirst.InitTables<HealthIb43MaintenanceAudit>();
var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] };
var tool = new Ib43LocalBinding(_db, _clock);
await tool.Run("1", options, "test-machine/test-operator", false);
Assert.Equal(0, await _db.Queryable<HealthIb43Binding>().CountAsync());
var first = await tool.Run("1", options, "test-machine/test-operator", true);
var repeat = await tool.Run("1", options, "test-machine/test-operator", true);
Assert.Equal(first.Id, repeat.Id);
Assert.Equal(0, first.AssignedBy);
Assert.Equal("left", first.Side);
var audit = await _db.Queryable<HealthIb43MaintenanceAudit>().SingleAsync();
Assert.Equal(first.Id, audit.BindingId);
Assert.Equal("test-machine/test-operator", audit.Actor);
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("2", options, "test", true));
Assert.Single(await _db.Queryable<HealthIb43Binding>().ToListAsync());
options.Devices[0].Simulation = false;
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("1", options, "test", true));
}
[Fact]
public async Task Local_maintenance_refuses_unknown_user_and_rolls_back_when_audit_fails()
{
var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] };
var tool = new Ib43LocalBinding(_db, _clock);
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("missing", options, "test", true));
// 故意不建审计表,验证绑定不会单独提交。
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("1", options, "test", true));
Assert.Equal(0, await _db.Queryable<HealthIb43Binding>().CountAsync());
}
public void Dispose()
{
_db.Dispose();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
File.Delete(_databasePath);
}
}
+205
View File
@@ -0,0 +1,205 @@
using Evergreen.Health.Application.Health.Integrations.Ib43;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Xunit;
namespace Admin.NET.Test.Health;
public sealed class Ib43Tests
{
private static Ib43Binding Binding() => new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001" };
private static string Report(string fields) => "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\"," + fields + "}}";
private static Ib43Report Parse(string fields) => new Ib43Parser().Parse(Binding().PostTopic, Report(fields), Binding(), DateTimeOffset.UtcNow);
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private static Ib43StateStore Store(Clock clock) => new(Options.Create(new Ib43Options { Devices = [Binding()] }), new(), clock);
[Theory]
[InlineData("startup.pdf-example.json", "startup")]
[InlineData("realtime.pdf-example.json", "periodic")]
[InlineData("sleep-event.pdf-keys.json", "event")]
public void Archived_supplier_examples_parse_without_issues(string file, string kind)
{
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Ib43Examples", file));
using var doc = JsonDocument.Parse(json);
var p = doc.RootElement.GetProperty("params");
var binding = new Ib43Binding { SerialNumber = p.GetProperty("deviceName").GetString(),
ProductKey = p.TryGetProperty("productKey", out var pk) ? pk.GetString() : "example" };
var report = new Ib43Parser().Parse(binding.PostTopic, json, binding, DateTimeOffset.UtcNow);
Assert.True(report.Accepted);
Assert.Empty(report.Issues);
Assert.Contains(kind, report.Kinds);
if (kind == "startup") Assert.Equal(60, report.Fields["characteristic"].GetProperty("reportIerVal").GetInt32());
if (kind == "periodic") Assert.Equal(-52, report.Fields["airbagsPressure"][3].GetInt32());
if (kind == "event") Assert.Equal(80d, report.Readings["right.heartRate"].Value);
}
[Fact]
public void Periodic_HR_uses_confirmed_order_and_does_not_infer_presence()
{
var r = Parse("\"HR\":[72,16,1,80,18,0],\"snoreStatus\":[3,9],\"airbagsPerson\":[1,0]");
Assert.Equal(72d, r.Readings["left.heartRate"].Value);
Assert.Equal(18d, r.Readings["right.respiration"].Value);
Assert.Equal(1d, r.Readings["left.snoring"].Value);
Assert.Equal(0d, r.Readings["right.snoring"].Value);
Assert.DoesNotContain("left.presence", r.Readings.Keys);
}
[Theory]
[InlineData("SleepReportNew", "ibNew")]
[InlineData(" SleepReportNew", " ibNew")]
[InlineData(" SleepReportNew", "ibNew")]
public void Event_has_its_own_order_and_preserves_sides(string outer, string inner)
{
var r = Parse($"\"{outer}\":{{\"{inner}\":[1,0,0,16,74,0,0,1,0,17,80,0]}}");
Assert.Equal(74d, r.Readings["left.heartRate"].Value);
Assert.Equal(17d, r.Readings["right.respiration"].Value);
Assert.Equal(1d, r.Readings["left.presence"].Value);
Assert.Equal(0d, r.Readings["right.presence"].Value);
}
[Fact]
public void Partial_updates_preserve_presence_but_zero_clears_previous_vitals()
{
var clock = new Clock(); var store = Store(clock);
store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
var first = store.Snapshot()[0].Readings["left.presence"].ReceivedAt;
clock.Now += TimeSpan.FromSeconds(5);
store.Receive(Binding().PostTopic, Report("\"HR\":[0,0,0,80,18,1]"));
var state = store.Snapshot()[0];
Assert.Equal(1d, state.Readings["left.presence"].Value);
Assert.Equal(first, state.Readings["left.presence"].ReceivedAt);
Assert.Null(state.Readings["left.heartRate"].Value);
Assert.Equal(80d, state.Readings["right.heartRate"].Value);
}
[Theory]
[InlineData("\"HR\":[1,2]", "HR:invalid-length")]
[InlineData("\"HR\":[72,16,0,80,\"18\",0]", "HR:invalid-number")]
[InlineData("\"HR\":[72,16,4,80,18,0]", "left.snoring:invalid-enum")]
[InlineData("\"motorStatus\":[0,0,0,9]", "motorStatus:invalid-enum")]
[InlineData("\"SleepReportNew\":{},\" SleepReportNew\":{}", "SleepReportNew:ambiguous-alias")]
[InlineData("\"SleepReportNew\":{\"ibNew\":[],\" ibNew\":[]}", "ibNew:ambiguous-alias")]
public void Malformed_fields_record_issues(string fields, string issue)
=> Assert.Contains(issue, Parse(fields).Issues);
[Theory]
[InlineData("{", "invalid-json")]
[InlineData("[]", "invalid-envelope")]
[InlineData("{\"method\":\"x\",\"params\":{}}", "invalid-envelope")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"other\"}}", "identity-mismatch")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"productKey\":\"other\"}}", "identity-mismatch")]
[InlineData("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[],\"HR\":[]}}", "duplicate-json-key")]
public void Rejects_invalid_envelopes_and_identity(string raw, string issue)
{
var r = new Ib43Parser().Parse(Binding().PostTopic, raw, Binding(), DateTimeOffset.UtcNow);
Assert.False(r.Accepted); Assert.Contains(issue, r.Issues); Assert.Empty(r.Readings);
}
[Fact]
public void Old_events_are_not_erased_by_timeout_and_transport_is_independent()
{
var clock = new Clock(); var store = Store(clock);
Assert.Equal("unknown", store.Snapshot()[0].Freshness);
store.SetTransport("connected");
store.Receive(Binding().PostTopic, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}"));
clock.Now += TimeSpan.FromSeconds(151);
store.Receive(Binding().PostTopic, Report("\"HR\":[0]"));
store.Receive(Binding().PostTopic, Report("\"firmwareVer\":\"new\""));
var snapshot = store.Snapshot()[0];
Assert.Equal("stale", snapshot.Freshness);
Assert.Equal("connected", snapshot.Transport);
Assert.Equal(1d, snapshot.Readings["left.presence"].Value);
store.SetTransport("disconnected");
Assert.Equal("disconnected", store.Snapshot()[0].Transport);
}
[Fact]
public void Unbound_retained_and_oversized_reports_never_update_live_state_and_history_is_bounded()
{
var store = Store(new()); var payload = Report("\"HR\":[72,16,0,80,18,0]");
Assert.False(store.Receive("/sys/other/IB43SIM001/thing/property/post", payload).Accepted);
Assert.False(store.Receive(Binding().PostTopic, payload, true).Accepted);
Assert.False(store.Receive(Binding().PostTopic, new string('x', 65537)).Accepted);
Assert.Equal("unknown", store.Snapshot()[0].Freshness);
for (var i = 0; i < 100; i++) store.Receive(Binding().PostTopic, "bad-json");
Assert.Equal(32, store.Diagnostics().Length);
Assert.Equal("bad-json", store.Diagnostics()[0].Raw);
}
[Theory]
[InlineData(0, 1, 0)]
[InlineData(1, 0, 1)]
public void Presence_mapping_requires_explicit_configuration(int index, int left, int right)
{
var binding = Binding(); binding.PersonLeftIndex = index;
var r = new Ib43Parser().Parse(binding.PostTopic, Report("\"airbagsPerson\":[1,0]"), binding, DateTimeOffset.UtcNow);
Assert.Equal((double)left, r.Readings["left.presence"].Value);
Assert.Equal((double)right, r.Readings["right.presence"].Value);
}
[Fact]
public void Enabled_configuration_requires_credentials_and_exact_unique_topics()
{
var options = new Ib43Options(); Assert.True(options.IsValid());
options.Enabled = true; Assert.False(options.IsValid());
options.Password = "test-only"; options.Devices = [Binding()]; Assert.True(options.IsValid());
options.Devices.Add(Binding()); Assert.False(options.IsValid());
options.Devices = [new() { ProductKey = "+", SerialNumber = "#" }]; Assert.False(options.IsValid());
}
[Fact]
public async Task Disabled_service_never_creates_connection()
{
var factory = new FakeFactory();
using var service = new Ib43MqttService(Options.Create(new Ib43Options()), factory, Store(new()),
TimeProvider.System, NullLogger<Ib43MqttService>.Instance);
await service.StartAsync(default); await service.StopAsync(default);
Assert.Equal(0, factory.Count);
}
[Fact]
public async Task Service_retries_failed_subscription_then_receives_and_stops()
{
var factory = new FakeFactory(); var store = Store(new());
using var service = new Ib43MqttService(Options.Create(new Ib43Options { Enabled = true,
Password = "test-only", Devices = [Binding()] }), factory, store,
TimeProvider.System, NullLogger<Ib43MqttService>.Instance);
await service.StartAsync(default);
try
{
await factory.Connected.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, factory.Count);
Assert.Equal(72d, store.Snapshot()[0].Readings["left.heartRate"].Value);
}
finally { await service.StopAsync(default); }
Assert.Equal("disconnected", store.Snapshot()[0].Transport);
Assert.True(factory.Last.Disposed);
}
private sealed class FakeFactory : IIb43MqttTransportFactory
{
public int Count;
public FakeTransport Last;
public TaskCompletionSource Connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IIb43MqttTransport Create() => Last = new FakeTransport(++Count == 1, Connected);
}
private sealed class FakeTransport(bool fail, TaskCompletionSource connected) : IIb43MqttTransport
{
public bool Disposed;
public bool IsConnected => !Disposed;
public event Func<string, string, bool, Task> Message;
public event Action<string> OversizedMessage { add { } remove { } }
public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
{
if (fail) throw new InvalidOperationException("subscription denied");
if (Message != null) await Message.Invoke(options.Devices[0].PostTopic, Report("\"HR\":[72,16,0,80,18,0]"), false);
connected.TrySetResult();
}
public void Dispose() => Disposed = true;
}
}
@@ -162,6 +162,7 @@ public sealed class OutletIntegrationTests
{
var services = new ServiceCollection();
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
services.AddSingleton(Settings());
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
var module = provider.GetRequiredService<OutletModule>();
Assert.Same(module, provider.GetRequiredService<DeviceModuleRegistry>().Modules.Single(item => item.Key == "outlet"));