Compare commits
4
Commits
2d44cc3966
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cfd9e24d7 | ||
|
|
718b138033 | ||
|
|
61e25175db | ||
|
|
3ca90c4cf3 |
@@ -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,319 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
using Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
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 const string Dehumidifier = "humidifier.test";
|
||||
private const string Humidity = "sensor.test_humidity";
|
||||
private const string Climate = "climate.test";
|
||||
private const string Temperature = "sensor.test_temperature";
|
||||
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); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stopping_and_reopening_rule_cancels_the_old_action_waiting_for_persistence()
|
||||
{
|
||||
var time = new ManualTestTimeProvider();
|
||||
var options = Options.Create(new BedPresenceAutomationOptions
|
||||
{ Mode = BedAutomationMode.Managed, ActionKey = "curtain-close", TargetEntityId = Curtain });
|
||||
var action = new NeverExecuteAction();
|
||||
var executionLog = new PausedExecutionLog();
|
||||
using var automation = new BedPresenceAutomation(options, [action], time, NullLogger<BedPresenceAutomation>.Instance, executionLog);
|
||||
await automation.StartAsync(default);
|
||||
try
|
||||
{
|
||||
var bed = new BedAutomationBinding(Binding, 1, "evergreen-test", "IB43SIM001", BedSide.Left, true);
|
||||
var connection = Guid.NewGuid();
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
automation.Observe(Sample(BedPresence.Empty, time.GetUtcNow(), connection), default);
|
||||
automation.Observe(Sample(BedPresence.Occupied, time.GetUtcNow(), connection), default);
|
||||
await executionLog.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
automation.Disable(Binding, 1);
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
executionLog.Release.TrySetResult("old-execution");
|
||||
Assert.Equal("cancelled-unconfirmed", await executionLog.Completed.Task.WaitAsync(TimeSpan.FromSeconds(3)));
|
||||
Assert.Equal(0, action.Calls);
|
||||
Assert.True(automation.IsEnabled(Binding, 1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionLog.Release.TrySetResult(null);
|
||||
await automation.StopAsync(default);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PausedExecutionLog : IBedPresenceExecutionLog
|
||||
{
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource<string?> Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource<string> Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt,
|
||||
CancellationToken token)
|
||||
{ Started.TrySetResult(); return Release.Task; }
|
||||
public Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
{ Completed.TrySetResult(result); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("26", "°C", 0, false, "preview-climate-cool")]
|
||||
[InlineData("21", "°C", 0, false, "preview-climate-heat")]
|
||||
[InlineData("82.4", "°F", 0, false, "preview-climate-cool")]
|
||||
[InlineData("22", "°C", 0, false, "preview-temperature-within-range")]
|
||||
[InlineData("25", "°C", 0, false, "preview-temperature-within-range")]
|
||||
[InlineData(null, "°C", 0, false, "preview-temperature-unavailable")]
|
||||
[InlineData("26", "°C", 0, true, "preview-temperature-unavailable")]
|
||||
[InlineData("26", "°C", 31, false, "preview-temperature-stale")]
|
||||
[InlineData("26", "°C", null, false, "preview-temperature-stale")]
|
||||
[InlineData("26", "%", 0, false, "preview-temperature-invalid")]
|
||||
[InlineData("NaN", "°C", 0, false, "preview-temperature-invalid")]
|
||||
public async Task Climate_preview_uses_the_default_range_and_never_sends_commands(
|
||||
string? state, string unit, int? ageMinutes, bool readFails, string expected)
|
||||
{
|
||||
var clock = new ManualTestTimeProvider();
|
||||
var gateway = new FakeHaGateway { ReadsToFail = readFails ? 1 : 0 };
|
||||
if (state is not null)
|
||||
gateway.States[Temperature] = new()
|
||||
{
|
||||
EntityId = Temperature, State = state,
|
||||
LastReported = ageMinutes is { } age ? clock.GetUtcNow().AddMinutes(-age) : null,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = unit })
|
||||
};
|
||||
var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = Climate, Temperature = Temperature } });
|
||||
var preview = new BedPresenceClimatePreview(gateway, new IndoorEnvironmentReader(options, clock), options);
|
||||
Assert.True(preview.PreviewOnly);
|
||||
Assert.Equal("target-not-configured", await preview.ExecuteAsync("climate.other", default));
|
||||
Assert.Equal(expected, await preview.ExecuteAsync(Climate, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("64", "%", 0, "preview-would-start")]
|
||||
[InlineData("55", "%", 0, "preview-humidity-normal")]
|
||||
[InlineData("45", "%", 0, "preview-humidity-normal")]
|
||||
[InlineData(null, "%", 0, "preview-humidity-unavailable")]
|
||||
[InlineData("unavailable", "%", 0, "preview-humidity-unavailable")]
|
||||
[InlineData("64", "%", 31, "preview-humidity-stale")]
|
||||
[InlineData("64", "%", -2, "preview-humidity-stale")]
|
||||
[InlineData("64", "%", null, "preview-humidity-stale")]
|
||||
[InlineData("64", "°C", 0, "preview-humidity-invalid")]
|
||||
[InlineData("NaN", "%", 0, "preview-humidity-invalid")]
|
||||
[InlineData("101", "%", 0, "preview-humidity-invalid")]
|
||||
public async Task Dehumidifier_preview_records_humidity_decision_without_sending_commands(
|
||||
string? state, string unit, int? ageMinutes, string expected)
|
||||
{
|
||||
var clock = new ManualTestTimeProvider();
|
||||
var gateway = new FakeHaGateway();
|
||||
if (state is not null)
|
||||
gateway.States[Humidity] = new()
|
||||
{
|
||||
EntityId = Humidity, State = state,
|
||||
LastUpdated = ageMinutes is { } age ? clock.GetUtcNow().AddMinutes(-age) : null,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = unit })
|
||||
};
|
||||
var options = Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
Entities = new() { Dehumidifier = Dehumidifier, Humidity = Humidity },
|
||||
SleepScene = new() { TargetHumidity = 55 }
|
||||
});
|
||||
var preview = new BedPresenceDehumidifierPreview(gateway, options, clock);
|
||||
Assert.True(preview.PreviewOnly);
|
||||
Assert.Equal("target-not-configured", await preview.ExecuteAsync("humidifier.other", default));
|
||||
Assert.Equal(expected, await preview.ExecuteAsync(Dehumidifier, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("64", 0, "off", "on", "readback-confirmed", 1)]
|
||||
[InlineData("64", 0, "off", "off", "sent-readback-unconfirmed", 1)]
|
||||
[InlineData("55", 0, "off", "on", "humidity-normal", 0)]
|
||||
[InlineData("64", 31, "off", "on", "humidity-stale", 0)]
|
||||
[InlineData(null, 0, "off", "on", "humidity-unavailable", 0)]
|
||||
[InlineData("101", 0, "off", "on", "humidity-invalid", 0)]
|
||||
[InlineData("64", 0, "on", "on", "dehumidifier-already-on", 0)]
|
||||
[InlineData("64", 0, "unavailable", "on", "dehumidifier-unavailable", 0)]
|
||||
[InlineData("64", 0, "unknown-state", "on", "dehumidifier-state-not-off", 0)]
|
||||
public async Task Dehumidifier_rule_requires_fresh_high_humidity_and_off_state_and_never_resends(
|
||||
string? humidity, int ageMinutes, string power, string readback, string result, int commandCount)
|
||||
{
|
||||
var clock = new ManualTestTimeProvider();
|
||||
var gateway = new FakeHaGateway { HumidifierTurnOnResult = readback };
|
||||
gateway.States[Dehumidifier] = new() { EntityId = Dehumidifier, State = power };
|
||||
if (humidity is not null)
|
||||
gateway.States[Humidity] = new() { EntityId = Humidity, State = humidity,
|
||||
LastReported = clock.GetUtcNow().AddMinutes(-ageMinutes),
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) };
|
||||
var options = Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
Entities = new() { Dehumidifier = Dehumidifier, Humidity = Humidity },
|
||||
SleepScene = new() { TargetHumidity = 55 }
|
||||
});
|
||||
var runner = new DeviceActionRunner(gateway, clock, new() { VerificationDelay = TimeSpan.Zero });
|
||||
var action = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, options), runner, options, clock);
|
||||
|
||||
Assert.Equal("target-not-configured", await action.ExecuteAsync("humidifier.other", default));
|
||||
Assert.Equal(result, await action.ExecuteAsync(Dehumidifier, default));
|
||||
Assert.Equal(commandCount, gateway.Calls.Count);
|
||||
Assert.All(gateway.Calls, command =>
|
||||
{
|
||||
Assert.Equal(Dehumidifier, command.EntityId);
|
||||
Assert.Equal("humidifier", command.Domain);
|
||||
Assert.Equal("turn_on", command.Service);
|
||||
Assert.Equal("entity_id", Assert.Single(command.Data!).Key);
|
||||
});
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public sealed class ClimateAdviceBindingTests
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
}
|
||||
};
|
||||
var service = new MattressComfortService(gateway,
|
||||
var service = new EnvironmentAdviceService(gateway,
|
||||
new IndoorEnvironmentReader(options, TimeProvider.System), options);
|
||||
var output = service.Get(null, states);
|
||||
Assert.Equal(mode, output.ClimateAdvice.Mode);
|
||||
|
||||
@@ -3,6 +3,7 @@ using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
@@ -16,11 +17,25 @@ namespace Admin.NET.Test.Health;
|
||||
public sealed class ClimateCurtainIntegrationTests
|
||||
{
|
||||
private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain";
|
||||
private static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId } };
|
||||
private const string RoomTemperatureId = "sensor.test_room_temperature";
|
||||
private static HomeAssistantOptions Settings() => new()
|
||||
{ Entities = new() { Climate = ClimateId, Curtain = CurtainId, Temperature = RoomTemperatureId } };
|
||||
private static DeviceActionRunner Runner(Gateway gateway) => new(gateway, TimeProvider.System,
|
||||
new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
|
||||
private static ClimateModule Climate(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
|
||||
private static CurtainModule Curtain(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
|
||||
private static BedPresenceClimateHeatAction HeatAutomation(Gateway gateway)
|
||||
{
|
||||
var runner = Runner(gateway);
|
||||
var options = Options.Create(Settings());
|
||||
return new(new ClimateModule(runner, options), new IndoorEnvironmentReader(options, TimeProvider.System), runner, options);
|
||||
}
|
||||
|
||||
private static HomeAssistantState RoomTemperature(string celsius = "20", int ageMinutes = 0) => new()
|
||||
{
|
||||
EntityId = RoomTemperatureId, State = celsius, LastReported = DateTimeOffset.UtcNow.AddMinutes(-ageMinutes),
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
};
|
||||
private static HomeAssistantState ClimateState(string state = "off", int features = 393,
|
||||
double minimum = 5, double maximum = 35, double step = .5) => new()
|
||||
{
|
||||
@@ -43,6 +58,64 @@ public sealed class ClimateCurtainIntegrationTests
|
||||
TargetTemperatureCelsius = target, Reason = "测试室温建议" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_sends_each_command_once_and_confirms_heat_and_22_degrees()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature());
|
||||
var action = HeatAutomation(gateway);
|
||||
Assert.Equal("target-not-configured", await action.ExecuteAsync("climate.other", default));
|
||||
Assert.Equal("readback-confirmed", await action.ExecuteAsync(ClimateId, default));
|
||||
Assert.Collection(gateway.Calls,
|
||||
mode => { Assert.Equal("set_hvac_mode", mode.Service); Assert.Equal("heat", mode.Data!["hvac_mode"]); },
|
||||
temperature => { Assert.Equal("set_temperature", temperature.Service); Assert.Equal(22m, temperature.Data!["temperature"]); });
|
||||
Assert.Equal("heat", gateway.States[ClimateId].State);
|
||||
Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("22", 0, "off", 5, "temperature-not-low")]
|
||||
[InlineData("27", 0, "off", 5, "temperature-not-low")]
|
||||
[InlineData("20", 31, "off", 5, "temperature-stale")]
|
||||
[InlineData("NaN", 0, "off", 5, "temperature-invalid")]
|
||||
[InlineData(null, 0, "off", 5, "temperature-unavailable")]
|
||||
[InlineData("20", 0, "cool", 5, "climate-not-off")]
|
||||
[InlineData("20", 0, "unavailable", 5, "climate-unavailable")]
|
||||
[InlineData("20", 0, "off", 24, "climate-heat-unsupported")]
|
||||
public async Task Bed_presence_heat_checks_room_power_and_target_before_sending(
|
||||
string? roomTemperature, int ageMinutes, string climateState, int minimum, string result)
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(climateState, minimum: minimum));
|
||||
if (roomTemperature is not null) gateway.States[RoomTemperatureId] = RoomTemperature(roomTemperature, ageMinutes);
|
||||
Assert.Equal(result, await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_leaves_the_temperature_alone_when_heat_mode_cannot_be_confirmed()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature()) { ApplyCommands = false };
|
||||
Assert.Equal("climate-heat-mode-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_does_not_set_temperature_after_the_user_changes_the_mode()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature());
|
||||
gateway.BeforeRead = read => { if (read == 3) gateway.States[ClimateId] = ClimateState("off"); };
|
||||
Assert.Equal("climate-heat-state-changed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bed_presence_heat_requires_both_mode_and_target_in_the_final_readback()
|
||||
{
|
||||
var gateway = new Gateway(ClimateState(), RoomTemperature()) { ModeAfterTemperature = "off" };
|
||||
Assert.Equal("climate-heat-target-unconfirmed", await HeatAutomation(gateway).ExecuteAsync(ClimateId, default));
|
||||
Assert.Equal(22, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
|
||||
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(23.5)]
|
||||
@@ -383,9 +456,12 @@ public sealed class ClimateCurtainIntegrationTests
|
||||
public int Reads { get; private set; }
|
||||
public bool ApplyCommands { get; set; } = true;
|
||||
public bool BecomeUnavailableAfterSend { get; set; }
|
||||
public string? ModeAfterTemperature { get; set; }
|
||||
public Action<int>? BeforeRead { get; set; }
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested(); Reads++;
|
||||
BeforeRead?.Invoke(Reads);
|
||||
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
|
||||
}
|
||||
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
@@ -397,7 +473,14 @@ public sealed class ClimateCurtainIntegrationTests
|
||||
if (!ApplyCommands) return Task.CompletedTask;
|
||||
var previous = States[entityId];
|
||||
var attributes = previous.Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
|
||||
var current = service switch { "set_hvac_mode" => (string)data["hvac_mode"]!, "turn_on" => "heat", "turn_off" => "off", _ => previous.State };
|
||||
var current = service switch
|
||||
{
|
||||
"set_hvac_mode" => (string)data["hvac_mode"]!,
|
||||
"set_temperature" when ModeAfterTemperature is not null => ModeAfterTemperature,
|
||||
"turn_on" => "heat",
|
||||
"turn_off" => "off",
|
||||
_ => previous.State
|
||||
};
|
||||
if (data.TryGetValue("temperature", out var temperature)) attributes["temperature"] = temperature;
|
||||
if (data.TryGetValue("fan_mode", out var fan)) attributes["fan_mode"] = fan;
|
||||
States[entityId] = new() { EntityId = entityId, State = BecomeUnavailableAfterSend ? "unavailable" : current,
|
||||
|
||||
@@ -4,7 +4,6 @@ using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
@@ -28,11 +27,8 @@ public sealed class DeviceArchitectureTests
|
||||
Entities = new()
|
||||
{
|
||||
AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep",
|
||||
Temperature = "sensor.room_temperature", MattressLeftMode = "select.left_mode", MattressRightMode = "select.right_mode",
|
||||
MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat",
|
||||
MattressLeftMassagePart = "select.left_massage", MattressRightMassagePart = "select.right_massage",
|
||||
MattressMassageLevel = "select.massage_level", MattressFirmness = new() { ["left_shoulder"] = "select.left_shoulder" }
|
||||
}, SleepScene = new() { EnableMattress = true }
|
||||
Temperature = "sensor.room_temperature"
|
||||
}
|
||||
};
|
||||
private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new()
|
||||
{
|
||||
@@ -40,25 +36,6 @@ public sealed class DeviceArchitectureTests
|
||||
LastReported = observed, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(18.4, "很软")]
|
||||
[InlineData(18.5, "稍软")]
|
||||
[InlineData(24.0, "稍硬")]
|
||||
[InlineData(28.0, "很硬")]
|
||||
[InlineData(31.1, "很硬")]
|
||||
[InlineData(32.0, "最硬")]
|
||||
public void BmiUsesStableFirmnessBoundaries(double bmi, string expected)
|
||||
=> Assert.Equal(expected, BmiFirmnessPolicy.Firmness((decimal)bmi));
|
||||
|
||||
[Fact]
|
||||
public void MissingOrInvalidBodyDataNeverProducesFirmness()
|
||||
{
|
||||
Assert.Null(BmiFirmnessPolicy.Calculate(null, 80));
|
||||
Assert.Null(BmiFirmnessPolicy.Calculate(0, 80));
|
||||
Assert.Null(BmiFirmnessPolicy.Firmness(null));
|
||||
Assert.Equal(31.1m, BmiFirmnessPolicy.Calculate(180, 100.8m));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(21.9, "heat")]
|
||||
[InlineData(22.0, "off")]
|
||||
@@ -131,10 +108,6 @@ public sealed class DeviceArchitectureTests
|
||||
Assert.Throws<InvalidOperationException>(() => light.BuildParameterCommand("color_temp", 6500));
|
||||
Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId);
|
||||
Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service);
|
||||
var mattress = new MattressModule(runner, Options.Create(Configuration()));
|
||||
Assert.Equal("全身", mattress.BuildParameterCommand("mattress_massage_right_part", "全身").Data!["option"]);
|
||||
Assert.Equal("轻柔", mattress.BuildParameterCommand("mattress_massage_level", "轻柔").Data!["option"]);
|
||||
Assert.Throws<InvalidOperationException>(() => mattress.BuildParameterCommand("mattress_right_mode", "随意模式"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -163,15 +136,108 @@ public sealed class DeviceArchitectureTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallbackOnlyContainsConfiguredModulesAndIncludesMattressEntities()
|
||||
public void FallbackOnlyContainsConfiguredModules()
|
||||
{
|
||||
var runner = Runner(new());
|
||||
var options = Options.Create(Configuration());
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))]));
|
||||
var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary<string, HomeAssistantState>()));
|
||||
Assert.Equal("mattress", device.ModuleKey);
|
||||
Assert.Contains(device.Entities, entity => entity.EntityId == "select.right_massage");
|
||||
Assert.Null(device.PrimaryControlEntityId);
|
||||
Assert.Equal("lighting", device.ModuleKey);
|
||||
Assert.Contains(device.Entities, entity => entity.EntityId == "light.bedroom");
|
||||
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 FragranceManualControlsUseTheirOwnDeviceAndKeepWakeOutOfSleepScenes()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
|
||||
var gateway = new FakeHaGateway();
|
||||
services.AddSingleton<IHomeAssistantGateway>(gateway);
|
||||
services.AddSingleton(Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
OnlyConfiguredDevices = true,
|
||||
Entities = new()
|
||||
{
|
||||
FragranceSwitch = "switch.sleep_fragrance", FragranceDuration = "number.sleep_duration",
|
||||
FragranceQuickRelease = "button.sleep_release",
|
||||
WakeFragranceSwitch = "switch.wake_fragrance", WakeFragranceDuration = "number.wake_duration",
|
||||
WakeFragranceQuickRelease = "button.wake_release"
|
||||
}
|
||||
}));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
|
||||
var catalog = provider.GetRequiredService<HomeAssistantDeviceCatalog>();
|
||||
var parameters = provider.GetRequiredService<DeviceParameterController>();
|
||||
var sleep = Assert.Single(registry.Modules, module => module.Key == "fragrance");
|
||||
var wake = Assert.Single(registry.Modules, module => module.Key == "wake-fragrance");
|
||||
foreach (var id in sleep.EntityIds.Concat(wake.EntityIds))
|
||||
gateway.States[id] = new() { EntityId = id, State = "off" };
|
||||
var inventory = catalog.BuildDiscoveredDevices(null, gateway.States);
|
||||
var sleepDevice = Assert.Single(inventory, device => device.ModuleKey == sleep.Key);
|
||||
var wakeDevice = Assert.Single(inventory, device => device.ModuleKey == wake.Key);
|
||||
Assert.Equal("switch.wake_fragrance", wakeDevice.PrimaryControlEntityId);
|
||||
await wake.SetPowerAsync(true, default);
|
||||
await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_duration", 5, null, default);
|
||||
await parameters.ExecuteAsync(wakeDevice, gateway.States, "wake_fragrance_quick_release", true, null, default);
|
||||
await parameters.ExecuteAsync(sleepDevice, gateway.States, "fragrance_duration", 3, null, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => parameters.ExecuteAsync(
|
||||
wakeDevice, gateway.States, "fragrance_quick_release", true, null, default));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => parameters.ExecuteAsync(
|
||||
sleepDevice, gateway.States, "wake_fragrance_quick_release", true, null, default));
|
||||
Assert.Equal(new[] { "switch.wake_fragrance", "number.wake_duration", "button.wake_release", "number.sleep_duration" },
|
||||
gateway.Calls.Select(command => command.EntityId));
|
||||
Assert.Null(wake.BuildPreview(new(), "balanced"));
|
||||
Assert.Empty(wake.GetShutdownCommands());
|
||||
var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
|
||||
await wake.ExecuteSleepAsync(context, new() { EnableFragrance = true }, "balanced", default);
|
||||
Assert.Equal(4, gateway.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -218,17 +284,15 @@ public sealed class DeviceArchitectureTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualLightOffUsesSceneLinkageToStopBothMassageSides()
|
||||
public async Task ManualLightOffOnlyControlsTheLight()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = Runner(gateway);
|
||||
var options = Options.Create(Configuration());
|
||||
var mattress = new MattressModule(runner, options);
|
||||
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([mattress]));
|
||||
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]));
|
||||
await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
|
||||
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
||||
Assert.Equal("无", gateway.States["select.right_massage"].State);
|
||||
Assert.Equal("无", gateway.States["select.left_massage"].State);
|
||||
Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -247,8 +311,10 @@ 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.Contains(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
|
||||
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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -15,6 +15,8 @@ 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 string? HumidifierTurnOnResult { get; set; }
|
||||
public TimeProvider Time { get; set; } = TimeProvider.System;
|
||||
|
||||
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
@@ -33,7 +35,9 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway
|
||||
if (entity == FailEntity) throw new IOException("模拟设备命令失败");
|
||||
var state = service switch
|
||||
{
|
||||
"turn_on" when domain == "humidifier" && HumidifierTurnOnResult is not null => HumidifierTurnOnResult,
|
||||
"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,384 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Devices.Audio;
|
||||
using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
using Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
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.Collections.Concurrent;
|
||||
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";
|
||||
private const string TargetDehumidifier = "humidifier.test";
|
||||
private const string HumiditySensor = "sensor.test_humidity";
|
||||
private const string TargetClimate = "climate.test";
|
||||
private const string TemperatureSensor = "sensor.test_temperature";
|
||||
private const string FragranceButton = "button.test_fragrance";
|
||||
private const string WakeFragranceButton = "button.test_wake_fragrance";
|
||||
private const string Speaker = "media_player.test_speaker";
|
||||
private const string TrackUrl = "https://example.test/sleep.mp3";
|
||||
|
||||
[Fact]
|
||||
public async Task Real_MQTT_resubscribes_and_only_fresh_empty_to_occupied_triggers_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" };
|
||||
var openCurtain = new HomeAssistantState { EntityId = TargetCurtain, State = "open",
|
||||
Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) };
|
||||
var stoppedDehumidifier = new HomeAssistantState { EntityId = TargetDehumidifier, State = "off" };
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
gateway.States[HumiditySensor] = new() { EntityId = HumiditySensor, State = "64", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) };
|
||||
gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "27", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) };
|
||||
gateway.States[FragranceButton] = new() { EntityId = FragranceButton, State = "unknown" };
|
||||
gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unknown" };
|
||||
gateway.States[Speaker] = new() { EntityId = Speaker, State = "idle" };
|
||||
var haOptions = Options.Create(new HomeAssistantOptions
|
||||
{ Entities = new() { Curtain = TargetCurtain, Dehumidifier = TargetDehumidifier, Humidity = HumiditySensor,
|
||||
Climate = TargetClimate, Temperature = TemperatureSensor, FragranceQuickRelease = FragranceButton,
|
||||
WakeFragranceQuickRelease = WakeFragranceButton, Speaker = Speaker } });
|
||||
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);
|
||||
var preview = new BedPresenceDehumidifierPreview(gateway, haOptions, TimeProvider.System);
|
||||
var dehumidifier = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, haOptions),
|
||||
runner, haOptions, TimeProvider.System);
|
||||
var room = new IndoorEnvironmentReader(haOptions, TimeProvider.System);
|
||||
var climate = new BedPresenceClimatePreview(gateway, room, haOptions);
|
||||
var heat = new BedPresenceClimateHeatAction(new ClimateModule(runner, haOptions), room, runner, haOptions);
|
||||
var fragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: false);
|
||||
var wakeFragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: true);
|
||||
var audio = new FakeAudioGateway();
|
||||
var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
|
||||
{
|
||||
Tracks = new() { ["balanced"] = [new() { Url = TrackUrl, DurationSeconds = 30 }] }
|
||||
}), NullLogger<TcmMusicTransitionService>.Instance, new ManualTestTimeProvider());
|
||||
var speaker = new BedPresenceSleepMusicAction(gateway, music, haOptions);
|
||||
var executionLog = new RecordingExecutionLog();
|
||||
using var automation = new BedPresenceAutomation(Options.Create(new BedPresenceAutomationOptions
|
||||
{
|
||||
Mode = BedAutomationMode.Managed, ActionKey = curtainAction.Key, TargetEntityId = TargetCurtain,
|
||||
AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ActionKey = preview.Key, TargetEntityId = TargetDehumidifier },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = dehumidifier.Key, TargetEntityId = TargetDehumidifier },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = climate.Key, TargetEntityId = TargetClimate },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = heat.Key, TargetEntityId = TargetClimate },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = fragrance.Key, TargetEntityId = FragranceButton },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = wakeFragrance.Key, TargetEntityId = WakeFragranceButton },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = speaker.Key, TargetEntityId = Speaker }]
|
||||
}), [curtainAction, preview, dehumidifier, climate, heat, fragrance, wakeFragrance, speaker], TimeProvider.System,
|
||||
NullLogger<BedPresenceAutomation>.Instance, executionLog);
|
||||
var observedPresence = new ObservedPresenceSink(automation);
|
||||
using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store,
|
||||
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, observedPresence);
|
||||
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, 1, 0);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 4);
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1));
|
||||
Assert.Empty(gateway.Calls);
|
||||
var bed = new BedAutomationBinding(LeftBinding, 1, binding.ProductKey, binding.SerialNumber, BedSide.Left, true);
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
|
||||
// 左侧空床后上床才触发。连续重复在床不会重启,右侧的变化不触发左侧规则。
|
||||
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) == 8 && gateway.Calls.Count == 1);
|
||||
Assert.Equal("readback-confirmed", await executionLog.WaitForResult(curtainAction.Key));
|
||||
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);
|
||||
automation.Disable(LeftBinding, 1);
|
||||
|
||||
Assert.True(automation.TryEnable(bed, preview.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1));
|
||||
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) == 11);
|
||||
Assert.Equal("preview-would-start", await executionLog.WaitForResult(preview.Key));
|
||||
Assert.Single(gateway.Calls);
|
||||
Assert.Equal("off", gateway.States[TargetDehumidifier].State);
|
||||
Assert.True(automation.Disable(LeftBinding, 1, preview.Key));
|
||||
|
||||
Assert.True(automation.TryEnable(bed, dehumidifier.Key));
|
||||
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) == 14);
|
||||
Assert.Equal("readback-confirmed", await executionLog.WaitForResult(dehumidifier.Key));
|
||||
Assert.Equal("on", gateway.States[TargetDehumidifier].State);
|
||||
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
var oldConnection = observedPresence.LastLeft!.ConnectionId;
|
||||
|
||||
// 保持两条实物规则开启,断开前的空床基线不能与新连接的首条在床组成上床事件。
|
||||
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());
|
||||
Assert.True(automation.IsEnabled(LeftBinding, 1));
|
||||
Assert.True(automation.IsEnabled(LeftBinding, 1, dehumidifier.Key));
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var firstOnNewConnection = observedPresence.LastLeft!;
|
||||
Assert.NotEqual(oldConnection, firstOnNewConnection.ConnectionId);
|
||||
Assert.Equal(3, executionLog.Started.Count);
|
||||
Assert.Equal(2, gateway.Calls.Count);
|
||||
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var freshTransition = observedPresence.LastLeft!;
|
||||
Assert.Equal(firstOnNewConnection.ConnectionId, freshTransition.ConnectionId);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 19 && executionLog.Completed.Count >= 5);
|
||||
|
||||
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) == 20);
|
||||
Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
|
||||
var newExecutions = executionLog.Started.Skip(3).ToArray();
|
||||
Assert.Equal(new[] { curtainAction.Key, dehumidifier.Key },
|
||||
newExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.All(newExecutions, entry => Assert.Equal(freshTransition.ReceivedAt, entry.TriggeredAt));
|
||||
Assert.All(executionLog.Completed.Skip(3), result => Assert.Equal("readback-confirmed", result));
|
||||
Assert.Equal(5, executionLog.Completed.Count);
|
||||
Assert.Equal(new[] { "close_cover", "turn_on" },
|
||||
gateway.Calls.Skip(2).Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal));
|
||||
|
||||
Assert.True(automation.Disable(LeftBinding, 1));
|
||||
Assert.True(automation.Disable(LeftBinding, 1, dehumidifier.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, climate.Key));
|
||||
Assert.True(automation.TryEnable(bed, climate.Key));
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 23);
|
||||
Assert.Equal("preview-climate-cool", await executionLog.WaitForResult(climate.Key));
|
||||
Assert.Equal(climate.Key, Assert.Single(executionLog.Started.Skip(5)).Action);
|
||||
Assert.Equal(4, gateway.Calls.Count);
|
||||
|
||||
Assert.True(automation.Disable(LeftBinding, 1, climate.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, heat.Key));
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "20", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) };
|
||||
gateway.States[TargetClimate] = new() { EntityId = TargetClimate, State = "off",
|
||||
Attributes = JsonSerializer.SerializeToElement(new { hvac_modes = new[] { "off", "heat", "cool" }, supported_features = 1,
|
||||
temperature_unit = "°C", min_temp = 16, max_temp = 30, target_temp_step = 1, temperature = 24 }) };
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
Assert.True(automation.TryEnable(bed, dehumidifier.Key));
|
||||
Assert.True(automation.TryEnable(bed, heat.Key));
|
||||
foreach (var action in new[] { fragrance.Key, wakeFragrance.Key, speaker.Key })
|
||||
{
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, action));
|
||||
Assert.True(automation.TryEnable(bed, action));
|
||||
}
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var combinedTransition = observedPresence.LastLeft!;
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 26 && executionLog.Completed.Count >= 12);
|
||||
var combinedExecutions = executionLog.Started.Skip(6).ToArray();
|
||||
Assert.Equal(new[] { heat.Key, curtainAction.Key, dehumidifier.Key, fragrance.Key, speaker.Key, wakeFragrance.Key },
|
||||
combinedExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.All(combinedExecutions, entry => Assert.Equal(combinedTransition.ReceivedAt, entry.TriggeredAt));
|
||||
var combinedResults = executionLog.Completed.Skip(6).ToArray();
|
||||
Assert.Equal(6, combinedResults.Length);
|
||||
Assert.Equal(3, combinedResults.Count(result => result == "readback-confirmed"));
|
||||
Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(fragrance.Key));
|
||||
Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(wakeFragrance.Key));
|
||||
Assert.Equal("speaker-playback-sent", await executionLog.WaitForResult(speaker.Key));
|
||||
|
||||
var combinedCommands = gateway.Calls.Skip(4).ToArray();
|
||||
Assert.Equal(new[] { "close_cover", "press", "press", "set_hvac_mode", "set_temperature", "turn_on" },
|
||||
combinedCommands.Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal));
|
||||
Assert.Equal(new[] { FragranceButton, WakeFragranceButton }, combinedCommands.Where(command => command.Service == "press")
|
||||
.Select(command => command.EntityId).OrderBy(id => id, StringComparer.Ordinal));
|
||||
Assert.Equal(TrackUrl, Assert.Single(audio.Tracks));
|
||||
Assert.Equal(50, Assert.Single(audio.Volumes));
|
||||
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" },
|
||||
combinedCommands.Where(command => command.EntityId == TargetClimate).Select(command => command.Service));
|
||||
Assert.Equal("closed", gateway.States[TargetCurtain].State);
|
||||
Assert.Equal("on", gateway.States[TargetDehumidifier].State);
|
||||
Assert.Equal("heat", gateway.States[TargetClimate].State);
|
||||
Assert.Equal(22, gateway.States[TargetClimate].GetDoubleAttribute("temperature"));
|
||||
|
||||
Assert.Equal("speaker-not-idle", await speaker.ExecuteAsync(Speaker, default));
|
||||
Assert.Single(audio.Tracks);
|
||||
gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unavailable" };
|
||||
Assert.Equal("fragrance-release-unavailable", await wakeFragrance.ExecuteAsync(WakeFragranceButton, default));
|
||||
Assert.Equal("target-not-configured", await fragrance.ExecuteAsync(WakeFragranceButton, default));
|
||||
Assert.Equal(10, gateway.Calls.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await service.StopAsync(timeout.Token);
|
||||
await automation.StopAsync(timeout.Token);
|
||||
await music.StopAsync(timeout.Token);
|
||||
await broker.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ObservedPresenceSink(IBedPresenceSink inner) : IBedPresenceSink
|
||||
{
|
||||
private BedPresenceSample? _lastLeft;
|
||||
public BedPresenceSample? LastLeft => Volatile.Read(ref _lastLeft);
|
||||
|
||||
public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
|
||||
{
|
||||
inner.Observe(sample, connectionToken);
|
||||
if (sample.Side == BedSide.Left) Volatile.Write(ref _lastLeft, sample);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutionLog : IBedPresenceExecutionLog
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _results = new();
|
||||
public ConcurrentQueue<(string Action, DateTimeOffset TriggeredAt)> Started { get; } = new();
|
||||
public ConcurrentQueue<string> Completed { get; } = new();
|
||||
public Task<string> WaitForResult(string action) => Result(action).Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
|
||||
private TaskCompletionSource<string> Result(string action) =>
|
||||
_results.GetOrAdd(action, _ => new(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
public Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token)
|
||||
{
|
||||
Assert.Equal(LeftBinding, bindingId);
|
||||
Assert.Equal(1L, userId);
|
||||
Started.Enqueue((action, triggeredAt));
|
||||
return Task.FromResult<string?>(action);
|
||||
}
|
||||
public Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
{
|
||||
Completed.Enqueue(result);
|
||||
Result(id).TrySetResult(result);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
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,345 @@
|
||||
using Admin.NET.Core;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Services;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SqlSugar;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class Ib43PersistenceTests : IDisposable
|
||||
{
|
||||
private const string PreviewAction = "dehumidifier-humidity-preview";
|
||||
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 readonly BedPresenceAutomation _automation;
|
||||
|
||||
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.CodeFirst.InitTables<HealthIb43AutomationExecution>();
|
||||
_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);
|
||||
_automation = CreateAutomation(_repository, PreviewAction);
|
||||
_service = CreateService(_repository, _automation);
|
||||
}
|
||||
|
||||
private BedPresenceAutomation CreateAutomation(Ib43DataRepository repository, string secondaryAction) =>
|
||||
new(Options.Create(new BedPresenceAutomationOptions
|
||||
{
|
||||
Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey, SerialNumber = _device.SerialNumber,
|
||||
ActionKey = "curtain-close", TargetEntityId = "cover.test", TargetLabel = "测试窗帘",
|
||||
AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey,
|
||||
SerialNumber = _device.SerialNumber, ActionKey = secondaryAction, TargetEntityId = "humidifier.test" }]
|
||||
}), [new TestAction("curtain-close", "cover.test"), new TestAction(secondaryAction, "humidifier.test")],
|
||||
_clock, NullLogger<BedPresenceAutomation>.Instance, new TestExecutionLog(repository));
|
||||
|
||||
private HealthIb43Service CreateService(Ib43DataRepository repository, BedPresenceAutomation automation)
|
||||
{
|
||||
var options = Options.Create(new Ib43Options { Enabled = true, Devices = [_device] });
|
||||
return new(repository, _context, options, new(options, new(), _clock), _clock, automation);
|
||||
}
|
||||
|
||||
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 Owner_controls_only_the_configured_bed_and_can_read_the_durable_execution_result()
|
||||
{
|
||||
var left = await Assign("left", 1);
|
||||
var right = await Assign("right", 2);
|
||||
await _automation.StartAsync(default);
|
||||
try
|
||||
{
|
||||
User(1);
|
||||
var state = await _service.AutomationStatus(left.Id);
|
||||
Assert.True(state.Available);
|
||||
Assert.False(state.Enabled);
|
||||
Assert.Equal(new[] { "curtain-close", PreviewAction }, state.Rules.Select(rule => rule.ActionKey));
|
||||
await _service.StartAutomation(left.Id);
|
||||
Assert.True((await _service.AutomationStatus(left.Id)).Enabled);
|
||||
await _service.StartAutomation(left.Id, PreviewAction);
|
||||
Assert.All((await _service.AutomationStatus(left.Id)).Rules, rule => Assert.True(rule.Enabled));
|
||||
await ExpectRejection(() => _service.StartAutomation(left.Id, "unknown"));
|
||||
await ExpectRejection(() => _service.StopAutomation(left.Id, "unknown"));
|
||||
|
||||
var id = await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default);
|
||||
Assert.NotNull(id);
|
||||
await _repository.CompleteAutomation(id!, "readback-confirmed", _clock.Now.ToUnixTimeMilliseconds());
|
||||
Assert.Equal("readback-confirmed", Assert.Single((await _service.AutomationStatus(left.Id)).Records).Result);
|
||||
|
||||
User(2);
|
||||
await ExpectRejection(() => _service.AutomationStatus(left.Id));
|
||||
await ExpectRejection(() => _service.StartAutomation(left.Id));
|
||||
await ExpectRejection(() => _service.StopAutomation(left.Id));
|
||||
await ExpectRejection(() => _service.StartAutomation(left.Id, PreviewAction));
|
||||
await ExpectRejection(() => _service.StopAutomation(left.Id, PreviewAction));
|
||||
Assert.False((await _service.AutomationStatus(right.Id)).Available);
|
||||
await ExpectRejection(() => _service.StartAutomation(right.Id));
|
||||
Assert.True(_automation.IsEnabled(left.Id, 1));
|
||||
|
||||
User(1);
|
||||
await _service.StopAutomation(left.Id);
|
||||
Assert.False((await _service.AutomationStatus(left.Id)).Enabled);
|
||||
Assert.True(_automation.IsEnabled(left.Id, 1, PreviewAction));
|
||||
await _service.StartAutomation(left.Id);
|
||||
Admin(); await _service.Release(left.Id);
|
||||
Assert.False(_automation.IsEnabled(left.Id, 1));
|
||||
Assert.False(_automation.IsEnabled(left.Id, 1, PreviewAction));
|
||||
Assert.Null(await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default));
|
||||
User(1);
|
||||
state = await _service.AutomationStatus(left.Id);
|
||||
Assert.False(state.Available);
|
||||
Assert.Single(state.Records);
|
||||
}
|
||||
finally { await _automation.StopAsync(default); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restarted_backend_services_leave_rules_stopped_and_return_the_same_saved_execution_history()
|
||||
{
|
||||
const string dehumidifier = "dehumidifier-humidity-start";
|
||||
var left = await Assign("left", 1);
|
||||
User(1);
|
||||
Ib43AutomationOutput before;
|
||||
using (var automation = CreateAutomation(_repository, dehumidifier))
|
||||
{
|
||||
await automation.StartAsync(default);
|
||||
try
|
||||
{
|
||||
var service = CreateService(_repository, automation);
|
||||
await service.StartAutomation(left.Id);
|
||||
await service.StartAutomation(left.Id, dehumidifier);
|
||||
foreach (var (action, target) in new[] { ("curtain-close", "测试窗帘"), (dehumidifier, "测试除湿机") })
|
||||
{
|
||||
var now = _clock.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var id = await _repository.BeginAutomation(left.Id, 1, action, target, now, default);
|
||||
Assert.NotNull(id);
|
||||
await _repository.CompleteAutomation(id!, "readback-confirmed", now);
|
||||
_clock.Now = _clock.Now.AddSeconds(1);
|
||||
}
|
||||
before = await service.AutomationStatus(left.Id);
|
||||
Assert.Equal(2, before.Rules.Count);
|
||||
Assert.All(before.Rules, rule => Assert.True(rule.Enabled));
|
||||
Assert.Equal(2, before.Records.Count);
|
||||
}
|
||||
finally { await automation.StopAsync(default); }
|
||||
}
|
||||
|
||||
_db.Ado.Close();
|
||||
using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath,
|
||||
DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute });
|
||||
var repository = new Ib43DataRepository(reopened);
|
||||
using var restarted = CreateAutomation(repository, dehumidifier);
|
||||
await restarted.StartAsync(default);
|
||||
try
|
||||
{
|
||||
var after = await CreateService(repository, restarted).AutomationStatus(left.Id);
|
||||
Assert.False(after.Enabled);
|
||||
Assert.Equal(before.Rules.Select(rule => rule with { Enabled = false }), after.Rules);
|
||||
Assert.Equal(before.Records.ToArray(), after.Records.ToArray());
|
||||
}
|
||||
finally { await restarted.StopAsync(default); }
|
||||
}
|
||||
|
||||
private sealed class TestAction(string key, string target) : IBedPresenceAction
|
||||
{
|
||||
public string Key => key;
|
||||
public string Description => "测试";
|
||||
public bool SupportsTarget(string entityId) => entityId == target;
|
||||
public Task<string> ExecuteAsync(string entityId, CancellationToken token) => throw new InvalidOperationException("No real action in this test");
|
||||
}
|
||||
|
||||
private sealed class TestExecutionLog(Ib43DataRepository repository) : IBedPresenceExecutionLog
|
||||
{
|
||||
public Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token)
|
||||
=> repository.BeginAutomation(bindingId, userId, action, target, triggeredAt.ToUnixTimeMilliseconds(), token);
|
||||
public Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
=> repository.CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
_automation.Dispose();
|
||||
_db.Dispose();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
File.Delete(_databasePath);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Admin.NET.Test.Health;
|
||||
|
||||
public sealed class RetiredDeviceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("user")]
|
||||
public void ExplicitRetiredBindingExcludesEntireDeviceEvenWhenBindingIsDisabled(string? disabledBy)
|
||||
{
|
||||
var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
ExcludedDeviceEntityIds = ["number.old_heat"]
|
||||
}), new DeviceModuleRegistry([]));
|
||||
var registry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "retired", Name = "智能床垫" }, new() { Id = "new", Name = "智能床垫" }],
|
||||
Entities = [new() { DeviceId = "retired", EntityId = "NUMBER.OLD_HEAT", DisabledBy = disabledBy },
|
||||
new() { DeviceId = "retired", EntityId = "switch.old" },
|
||||
new() { DeviceId = "new", EntityId = "sensor.presence" }]
|
||||
};
|
||||
var states = new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["switch.old"] = new() { EntityId = "switch.old", State = "on" },
|
||||
["sensor.presence"] = new() { EntityId = "sensor.presence", State = "on" }
|
||||
};
|
||||
var remaining = Assert.Single(catalog.BuildDiscoveredDevices(registry, states));
|
||||
Assert.Equal("new", remaining.DeviceId);
|
||||
Assert.Equal("", remaining.ModuleKey);
|
||||
registry.Devices.RemoveAt(1);
|
||||
Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
|
||||
Assert.Empty(catalog.BuildDiscoveredDevices(null, states));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrimaryLightCommandsStillTrackKnownOffStateWithoutMattressRules()
|
||||
{
|
||||
var state = new SceneLightSafetyState();
|
||||
var gateway = new FakeHaGateway();
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [new PrimaryLightStateRule(state)]);
|
||||
await runner.SendAsync(new("关灯", "light.room", "light", "turn_off")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default);
|
||||
Assert.NotNull(state.OffAt);
|
||||
await runner.SendAsync(new("开灯", "light.room", "light", "turn_on")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOn }, default);
|
||||
Assert.Null(state.OffAt);
|
||||
Assert.All(gateway.Calls, call => Assert.Equal("light.room", call.EntityId));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Integrations.Tuya;
|
||||
@@ -21,16 +20,13 @@ public sealed class SafetyAndReservedWorkflowTests
|
||||
SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence);
|
||||
private static HomeAssistantOptions Configuration() => new()
|
||||
{
|
||||
Entities = new() { AmbientLight = "light.bedroom", MattressRightMassagePart = "select.right",
|
||||
MattressLeftMassagePart = "select.left", MattressLeftHeatingLevel = "number.heat" }
|
||||
Entities = new() { AmbientLight = "light.bedroom" }
|
||||
};
|
||||
private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new()
|
||||
{
|
||||
EntityId = "light.bedroom", State = state, LastUpdated = updated,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { brightness })
|
||||
};
|
||||
private static DeviceCommand Massage() => new("按摩", "select.right", "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "全身" }) { Intent = DeviceCommandIntent.StartMassage };
|
||||
|
||||
[Fact]
|
||||
public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork()
|
||||
@@ -71,7 +67,7 @@ public sealed class SafetyAndReservedWorkflowTests
|
||||
Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase);
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)));
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59)));
|
||||
Assert.Equal(2, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count);
|
||||
Assert.Equal(1, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -93,7 +89,7 @@ public sealed class SafetyAndReservedWorkflowTests
|
||||
workflow.Start(); workflow.Observe(Reading(Now), Now);
|
||||
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29)));
|
||||
var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
|
||||
Assert.Contains(actions, action => action.Kind == SleepWorkflowActionKind.StartGentleRightMassage);
|
||||
Assert.Single(actions);
|
||||
Assert.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent);
|
||||
Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50)));
|
||||
var music = Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddSeconds(50)));
|
||||
@@ -122,55 +118,15 @@ public sealed class SafetyAndReservedWorkflowTests
|
||||
Assert.True(gate.CanRemind("another", Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("off", 255)]
|
||||
[InlineData("on", 0)]
|
||||
[InlineData("unavailable", 255)]
|
||||
public async Task OffOrUnconfirmedLampBlocksMassage(string state, double brightness)
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States["light.bedroom"] = Light(state, brightness, Now);
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
|
||||
Assert.Empty(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownLampOffRejectsOlderCloudOnCacheButAllowsFreshOnReading()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var state = new SceneLightSafetyState(); state.MarkOff(Now);
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), state);
|
||||
gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(-1));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => guard.ValidateAsync(Massage(), default));
|
||||
gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(1));
|
||||
await guard.ValidateAsync(Massage(), default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateMassageAfterAnOffCommandCannotUndoShutdown()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
|
||||
gateway.States["light.bedroom"] = Light("on", 100, DateTimeOffset.UtcNow);
|
||||
await runner.SendAsync(new("关灯", "light.bedroom", "light", "turn_off")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
|
||||
Assert.DoesNotContain(gateway.Calls, command => command.EntityId == "select.right");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NaturalFadeToZeroStopsBothMassageSides()
|
||||
public async Task NaturalFadeToZeroOnlyControlsTheLight()
|
||||
{
|
||||
var time = new ManualTestTimeProvider();
|
||||
var gateway = new FakeHaGateway { Time = time };
|
||||
gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow());
|
||||
var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero });
|
||||
var mattress = new MattressModule(runner, Options.Create(Configuration()));
|
||||
var safety = new SceneLightSafetyState();
|
||||
var light = new HomeAssistantLightTransitionService(gateway, new([mattress]), Options.Create(Configuration()),
|
||||
var light = new HomeAssistantLightTransitionService(gateway, new([]), Options.Create(Configuration()),
|
||||
NullLogger<HomeAssistantLightTransitionService>.Instance, safety, time);
|
||||
await light.StartSleepAsync(2700, default);
|
||||
for (var step = 1; step <= 50; step++)
|
||||
@@ -179,10 +135,9 @@ public sealed class SafetyAndReservedWorkflowTests
|
||||
var expected = 50 - step;
|
||||
await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running));
|
||||
}
|
||||
await WaitUntilAsync(() => gateway.States.GetValueOrDefault("select.left")?.State == "无");
|
||||
Assert.Equal("off", gateway.States["light.bedroom"].State);
|
||||
Assert.Equal("无", gateway.States["select.right"].State);
|
||||
Assert.NotNull(safety.OffAt);
|
||||
Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
|
||||
await light.StopAsync(default);
|
||||
}
|
||||
|
||||
@@ -195,14 +150,6 @@ public sealed class SafetyAndReservedWorkflowTests
|
||||
Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManuallyEnabledHeatIsIncludedEvenIfAutomaticMattressStartIsDisabled()
|
||||
{
|
||||
var gateway = new FakeHaGateway();
|
||||
var module = new MattressModule(new(gateway, TimeProvider.System, new()), Options.Create(Configuration()));
|
||||
Assert.Contains(module.GetShutdownCommands(), command => command.EntityId == "number.heat" && command.ExpectedState == "0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized()
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
@@ -26,9 +25,8 @@ public sealed class SceneLightingIntegrationTests
|
||||
private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" };
|
||||
private static HomeAssistantOptions Settings() => new()
|
||||
{
|
||||
Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature,
|
||||
MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat" },
|
||||
AdditionalSleepLights = [StripSettings()], SleepScene = new() { EnableMattress = true }
|
||||
Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature },
|
||||
AdditionalSleepLights = [StripSettings()]
|
||||
};
|
||||
private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new()
|
||||
{
|
||||
@@ -62,7 +60,7 @@ public sealed class SceneLightingIntegrationTests
|
||||
public DeviceActionRunner Runner { get; }
|
||||
public DeviceModuleRegistry Modules { get; }
|
||||
public HealthSceneCoordinator Coordinator { get; }
|
||||
public Fixture(bool mattress = false)
|
||||
public Fixture()
|
||||
{
|
||||
Gateway = new() { Time = Time };
|
||||
Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip);
|
||||
@@ -77,7 +75,6 @@ public sealed class SceneLightingIntegrationTests
|
||||
new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)),
|
||||
new ClimateModule(Runner, Options)
|
||||
};
|
||||
if (mattress) modules.Add(new MattressModule(Runner, Options));
|
||||
Modules = new(modules);
|
||||
Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic());
|
||||
}
|
||||
@@ -89,10 +86,10 @@ public sealed class SceneLightingIntegrationTests
|
||||
LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
||||
};
|
||||
else Gateway.States.TryRemove(Temperature, out _);
|
||||
var comfort = new MattressComfortService(Gateway, new IndoorEnvironmentReader(Options, Time), Options)
|
||||
var comfort = new EnvironmentAdviceService(Gateway, new IndoorEnvironmentReader(Options, Time), Options)
|
||||
.Get(null, Gateway.States);
|
||||
var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 };
|
||||
MattressComfortService.ApplyTo(config, comfort);
|
||||
EnvironmentAdviceService.ApplyTo(config, comfort);
|
||||
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
|
||||
return new(config, "balanced");
|
||||
}
|
||||
@@ -134,7 +131,7 @@ public sealed class SceneLightingIntegrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsMassageShutdownHook()
|
||||
public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsLightOffEvent()
|
||||
{
|
||||
var f = new Fixture();
|
||||
try
|
||||
@@ -171,21 +168,21 @@ public sealed class SceneLightingIntegrationTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(15, "heat", 1)]
|
||||
[InlineData(30, "cool", 0)]
|
||||
[InlineData(22, "off", 0)]
|
||||
[InlineData(24, "off", 0)]
|
||||
[InlineData(25, "off", 0)]
|
||||
public async Task EnvironmentSensorDrivesActualSceneClimateAndBothHeatingLevels(double room, string mode, int heating)
|
||||
[InlineData(15, "heat")]
|
||||
[InlineData(30, "cool")]
|
||||
[InlineData(22, "off")]
|
||||
[InlineData(24, "off")]
|
||||
[InlineData(25, "off")]
|
||||
public async Task EnvironmentSensorDrivesActualSceneClimateWithoutRetiredHeating(double room, string mode)
|
||||
{
|
||||
var f = new Fixture(mattress: true);
|
||||
var f = new Fixture();
|
||||
try
|
||||
{
|
||||
var prepared = f.Prepare(room);
|
||||
var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time);
|
||||
Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode);
|
||||
Assert.False(result.ClimateAdvice.PendingIntegration);
|
||||
foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Assert.Equal(heating.ToString(), f.Gateway.States[id].State);
|
||||
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId is "number.left_heat" or "number.right_heat");
|
||||
if (mode == "off")
|
||||
{
|
||||
Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius);
|
||||
@@ -211,11 +208,10 @@ public sealed class SceneLightingIntegrationTests
|
||||
var prepared = f.Prepare(null);
|
||||
var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default);
|
||||
Assert.Equal("unavailable", result.ClimateAdvice!.Mode);
|
||||
Assert.Equal(0, prepared.Config.MattressHeatingLevel);
|
||||
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
|
||||
f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31));
|
||||
var comfort = new MattressComfortService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States);
|
||||
Assert.Equal("unavailable", comfort.ClimateAdvice.Mode); Assert.Equal(0, comfort.HeatingLevel);
|
||||
var comfort = new EnvironmentAdviceService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States);
|
||||
Assert.Equal("unavailable", comfort.ClimateAdvice.Mode);
|
||||
}
|
||||
finally { await f.Lights.StopAsync(default); }
|
||||
}
|
||||
@@ -292,17 +288,27 @@ public sealed class SceneLightingIntegrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionRegistrationAddsConfiguredStripWithoutCreatingASecondDeviceControlImplementation()
|
||||
public async Task ProductionRegistrationAddsSceneStripWithIndependentManualPower()
|
||||
{
|
||||
var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient();
|
||||
services.AddSingleton<IOptions<HomeAssistantOptions>>(Options.Create(Settings()));
|
||||
services.AddHealthDevices(); services.AddSingleton<IHomeAssistantGateway>(new FakeHaGateway());
|
||||
var settings = Settings();
|
||||
settings.AdditionalSleepLights.Single().StartSleepOnManualPowerOn = false;
|
||||
var gateway = new FakeHaGateway();
|
||||
gateway.States[Strip] = Light(Strip);
|
||||
services.AddSingleton<IOptions<HomeAssistantOptions>>(Options.Create(settings));
|
||||
services.AddHealthDevices(); services.AddSingleton<IHomeAssistantGateway>(gateway);
|
||||
services.AddSingleton<IAudioPlaybackGateway>(new FakeAudioGateway());
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
|
||||
Assert.IsType<ConfiguredSleepLightModule>(registry.ForEntities([Strip]));
|
||||
var strip = Assert.IsType<ConfiguredSleepLightModule>(registry.ForEntities([Strip]));
|
||||
Assert.IsType<LightingModule>(registry.ForEntities([Primary]));
|
||||
Assert.IsType<ClimateModule>(registry.ForEntities([Climate]));
|
||||
await strip.SetPowerAsync(true, default);
|
||||
var command = Assert.Single(gateway.Calls);
|
||||
Assert.Equal(Strip, command.EntityId);
|
||||
Assert.Equal("turn_on", command.Service);
|
||||
Assert.Single(command.Data!); // 详情页开灯不套用舒睡模式或亮度。
|
||||
Assert.False(provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(Strip).GetStatus().Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -96,6 +96,43 @@ public sealed class StandardLightControlTests
|
||||
Assert.Single(gateway.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfiguredManualLightsAreDiscoveredIndependentlyWithInventoryFilteringEnabled()
|
||||
{
|
||||
const string auxiliary = "light.auxiliary", unconfigured = "light.unconfigured";
|
||||
var gateway = new FakeHaGateway();
|
||||
foreach (var id in new[] { LightA, LightB, auxiliary, unconfigured }) gateway.States[id] = State(id);
|
||||
var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
OnlyConfiguredDevices = true, ManualLightEntityIds = [LightA, LightB, LightB]
|
||||
}), new DeviceModuleRegistry([]));
|
||||
var haRegistry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "a", Name = "顶灯一" }, new() { Id = "b", Name = "顶灯二" }, new() { Id = "other" }],
|
||||
Entities = [new() { DeviceId = "a", EntityId = auxiliary }, new() { DeviceId = "a", EntityId = LightA },
|
||||
new() { DeviceId = "b", EntityId = LightB }, new() { DeviceId = "other", EntityId = unconfigured }]
|
||||
};
|
||||
var controller = Controller(gateway);
|
||||
foreach (var registry in new[] { haRegistry, null })
|
||||
{
|
||||
var devices = catalog.BuildDiscoveredDevices(registry, gateway.States);
|
||||
Assert.Equal(2, devices.Count);
|
||||
var first = Assert.Single(devices, item => item.PrimaryControlEntityId == LightA);
|
||||
var second = Assert.Single(devices, item => item.PrimaryControlEntityId == LightB);
|
||||
Assert.NotEqual(first.DeviceId, second.DeviceId);
|
||||
Assert.All(devices, item =>
|
||||
{
|
||||
Assert.Equal(nameof(HealthDeviceCategory.Lighting), item.Category);
|
||||
Assert.Equal("", item.ModuleKey);
|
||||
});
|
||||
await controller.ExecuteAsync(first, gateway.States, "light_brightness", 30, LightA, default);
|
||||
await controller.ExecuteAsync(second, gateway.States, "light_color_temperature", 3100, LightB, default);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => controller.ExecuteAsync(
|
||||
second, gateway.States, "light_brightness", 40, LightA, default));
|
||||
}
|
||||
Assert.Equal(new[] { LightA, LightB, LightA, LightB }, gateway.Calls.Select(call => call.EntityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoLightsUseIndependentRangesAndEffectLists()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -96,10 +96,10 @@ public sealed class SystemServiceFilteringTests
|
||||
{
|
||||
var options = Options.Create(new HomeAssistantOptions
|
||||
{
|
||||
Entities = new() { MattressLeftHeatingLevel = "number.heating" }
|
||||
Entities = new() { AmbientLight = "light.bedroom" }
|
||||
});
|
||||
var runner = new DeviceActionRunner(new FakeHaGateway(), TimeProvider.System, new());
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
|
||||
var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))]));
|
||||
var registry = new HomeAssistantRegistries
|
||||
{
|
||||
Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }],
|
||||
@@ -108,9 +108,9 @@ public sealed class SystemServiceFilteringTests
|
||||
var states = new Dictionary<string, HomeAssistantState>
|
||||
{
|
||||
["sensor.backup"] = new() { EntityId = "sensor.backup", State = "idle" },
|
||||
["number.heating"] = new() { EntityId = "number.heating", State = "0" }
|
||||
["light.bedroom"] = new() { EntityId = "light.bedroom", State = "0" }
|
||||
};
|
||||
Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
|
||||
Assert.Equal("mattress", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey);
|
||||
Assert.Equal("lighting", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ using AspNetCoreRateLimit;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Furion;
|
||||
using Furion.Logging;
|
||||
using Furion.SpecificationDocument;
|
||||
@@ -24,6 +26,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
@@ -82,6 +85,12 @@ public class Startup : AppStartup
|
||||
services.AddHttpClient<HomeAssistantClient>();
|
||||
services.AddHttpClient<MiAirXClient>();
|
||||
services.AddHealthDevices();
|
||||
services.AddOptions<Ib43Options>().BindConfiguration("Ib43")
|
||||
.PostConfigure(Ib43Configuration.ApplyEnvironmentOverrides)
|
||||
.Validate(options => options.IsValid(), "IB43 enabled configuration requires credentials and valid unique device bindings")
|
||||
.ValidateOnStart();
|
||||
services.AddOptions<BedPresenceAutomationOptions>().BindConfiguration("BedPresenceAutomation")
|
||||
.PostConfigure(options => new ConfigurationBuilder().AddEnvironmentVariables("BedPresenceAutomation__").Build().Bind(options));
|
||||
// 任务队列
|
||||
services.AddTaskQueue();
|
||||
// 脱敏检测
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"BedPresenceAutomation": {
|
||||
"Mode": "Managed",
|
||||
"BindingId": "",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "curtain-close",
|
||||
"TargetEntityId": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
|
||||
"TargetLabel": "美百帘窗帘",
|
||||
"AdditionalRules": [
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "dehumidifier-humidity-start",
|
||||
"TargetEntityId": "humidifier.xiaomi_cn_972783525_60l",
|
||||
"TargetLabel": "米家变频净化除湿机 Max"
|
||||
},
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "dehumidifier-humidity-preview",
|
||||
"TargetEntityId": "humidifier.xiaomi_cn_972783525_60l",
|
||||
"TargetLabel": "米家变频净化除湿机 Max"
|
||||
},
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "climate-temperature-preview",
|
||||
"TargetEntityId": "climate.fawad_cn_974720212_3010",
|
||||
"TargetLabel": "空调温控器2"
|
||||
},
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "climate-heat-start",
|
||||
"TargetEntityId": "climate.fawad_cn_974720212_3010",
|
||||
"TargetLabel": "空调温控器2"
|
||||
},
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "fragrance-quick-release",
|
||||
"TargetEntityId": "button.xiaomi_cn_1206553658_xw002_fragrance_delivery_a_4_1",
|
||||
"TargetLabel": "米家智能香氛机"
|
||||
},
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "wake-fragrance-quick-release",
|
||||
"TargetEntityId": "button.xiaomi_cn_1238447032_xw002_fragrance_delivery_a_4_1",
|
||||
"TargetLabel": "香氛机 唤醒"
|
||||
},
|
||||
{
|
||||
"Mode": "Managed",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "speaker-sleep-music",
|
||||
"TargetEntityId": "media_player.xiaomi_cn_1221084723_l17a",
|
||||
"TargetLabel": "Xiaomi Sound Pro"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,33 +15,18 @@
|
||||
"FragranceAutoRelease": "switch.xiaomi_cn_1206553658_xw002_auto_fragrance_p_4_1",
|
||||
"FragranceAutoLight": "switch.xiaomi_cn_1206553658_xw002_auto_light_p_4_2",
|
||||
"FragranceLightAutoOff": "switch.xiaomi_cn_1206553658_xw002_light_auto_off_p_4_3",
|
||||
"AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light",
|
||||
"WakeFragranceSwitch": "switch.xiaomi_cn_1238447032_xw002_on_p_2_2",
|
||||
"WakeFragranceLight": "light.xiaomi_cn_1238447032_xw002_s_3_ambient_light",
|
||||
"WakeFragranceDuration": "number.xiaomi_cn_1238447032_xw002_fragrance_out_time_p_2_3",
|
||||
"WakeFragranceInterval": "number.xiaomi_cn_1238447032_xw002_fragrance_out_time_p_2_4",
|
||||
"WakeFragranceQuickRelease": "button.xiaomi_cn_1238447032_xw002_fragrance_delivery_a_4_1",
|
||||
"WakeFragranceAutoRelease": "switch.xiaomi_cn_1238447032_xw002_auto_fragrance_p_4_1",
|
||||
"WakeFragranceAutoLight": "switch.xiaomi_cn_1238447032_xw002_auto_light_p_4_2",
|
||||
"WakeFragranceLightAutoOff": "switch.xiaomi_cn_1238447032_xw002_light_auto_off_p_4_3",
|
||||
"Speaker": "media_player.xiaomi_cn_1221084723_l17a",
|
||||
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
|
||||
"Climate": "climate.fawad_cn_974720212_3010",
|
||||
"Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
|
||||
"Outlet": "switch.ym001_cn_2108613283_ymcz_on_p_2_1",
|
||||
"MattressLeftHeatingLevel": "number.qushui_cn_2108064504_zerox_left_level_p_6_1",
|
||||
"MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2",
|
||||
"MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1",
|
||||
"MattressRightMode": "select.qushui_cn_2108064504_zerox_right_mode_p_5_2",
|
||||
"MattressFirmness": {
|
||||
"left_shoulder": "select.qushui_cn_2108064504_zerox_left_shoulder_level_p_5_3",
|
||||
"right_shoulder": "select.qushui_cn_2108064504_zerox_right_shoulder_level_p_5_4",
|
||||
"left_back": "select.qushui_cn_2108064504_zerox_left_back_level_p_5_5",
|
||||
"right_back": "select.qushui_cn_2108064504_zerox_right_back_level_p_5_6",
|
||||
"left_waist": "select.qushui_cn_2108064504_zerox_left_waist_level_p_5_7",
|
||||
"right_waist": "select.qushui_cn_2108064504_zerox_right_waist_level_p_5_8",
|
||||
"left_lumbar": "select.qushui_cn_2108064504_zerox_left_lumbar_level_p_5_9",
|
||||
"right_lumbar": "select.qushui_cn_2108064504_zerox_right_lumbar_level_p_5_10",
|
||||
"left_hip": "select.qushui_cn_2108064504_zerox_left_hip_level_p_5_11",
|
||||
"right_hip": "select.qushui_cn_2108064504_zerox_right_hip_level_p_5_12",
|
||||
"left_leg": "select.qushui_cn_2108064504_zerox_left_leg_level_p_5_13",
|
||||
"right_leg": "select.qushui_cn_2108064504_zerox_right_leg_level_p_5_14"
|
||||
},
|
||||
"MattressLeftMassagePart": "select.qushui_cn_2108064504_zerox_left_part_p_4_1",
|
||||
"MattressRightMassagePart": "select.qushui_cn_2108064504_zerox_right_part_p_4_2",
|
||||
"MattressMassageLevel": "select.qushui_cn_2108064504_zerox_level_p_4_3",
|
||||
"Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1",
|
||||
"Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2",
|
||||
"Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3",
|
||||
@@ -49,26 +34,45 @@
|
||||
"Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7",
|
||||
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
|
||||
},
|
||||
"OnlyConfiguredDevices": true,
|
||||
"ManualLightEntityIds": [],
|
||||
"AdditionalSleepLights": [
|
||||
{
|
||||
"Key": "sleep-light-strip",
|
||||
"Name": "烛照智能灯带Ultra2.0",
|
||||
"EntityId": "light.zhunen_cn_2205208916_wy0a01_s_2_light",
|
||||
"SleepEffect": "助眠模式"
|
||||
"Key": "roof-light-strip-1",
|
||||
"Name": "超薄智能灯带",
|
||||
"EntityId": "light.hekai1_cn_2224036048_wy0a01_s_2_light",
|
||||
"SleepEffect": "助眠模式",
|
||||
"StartSleepOnManualPowerOn": false
|
||||
}
|
||||
],
|
||||
"SleepScene": {
|
||||
"TargetHumidity": 55,
|
||||
"DehumidifierMode": "睡眠",
|
||||
"FanLevel": "1挡",
|
||||
"LightBrightnessPercent": 20,
|
||||
"LightColorTemperatureKelvin": 2700,
|
||||
"EnableFragrance": true,
|
||||
"EnableSpeakerSleepMode": true,
|
||||
"EnableMattress": true,
|
||||
"MattressHeatingLevel": 1,
|
||||
"MattressMode": "智能适配",
|
||||
"EnableUv": false
|
||||
}
|
||||
},
|
||||
"ExcludedDeviceEntityIds": [
|
||||
"number.qushui_cn_2108064504_zerox_left_level_p_6_1",
|
||||
"number.qushui_cn_2108064504_zerox_right_level_p_6_2",
|
||||
"select.qushui_cn_2108064504_zerox_left_mode_p_5_1",
|
||||
"select.qushui_cn_2108064504_zerox_right_mode_p_5_2",
|
||||
"select.qushui_cn_2108064504_zerox_left_shoulder_level_p_5_3",
|
||||
"select.qushui_cn_2108064504_zerox_right_shoulder_level_p_5_4",
|
||||
"select.qushui_cn_2108064504_zerox_left_back_level_p_5_5",
|
||||
"select.qushui_cn_2108064504_zerox_right_back_level_p_5_6",
|
||||
"select.qushui_cn_2108064504_zerox_left_waist_level_p_5_7",
|
||||
"select.qushui_cn_2108064504_zerox_right_waist_level_p_5_8",
|
||||
"select.qushui_cn_2108064504_zerox_left_lumbar_level_p_5_9",
|
||||
"select.qushui_cn_2108064504_zerox_right_lumbar_level_p_5_10",
|
||||
"select.qushui_cn_2108064504_zerox_left_hip_level_p_5_11",
|
||||
"select.qushui_cn_2108064504_zerox_right_hip_level_p_5_12",
|
||||
"select.qushui_cn_2108064504_zerox_left_leg_level_p_5_13",
|
||||
"select.qushui_cn_2108064504_zerox_right_leg_level_p_5_14",
|
||||
"select.qushui_cn_2108064504_zerox_left_part_p_4_1",
|
||||
"select.qushui_cn_2108064504_zerox_right_part_p_4_2",
|
||||
"select.qushui_cn_2108064504_zerox_level_p_4_3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Ib43": {
|
||||
"Enabled": false,
|
||||
"Host": "192.168.2.2",
|
||||
"Port": 1883,
|
||||
"UseTls": false,
|
||||
"Username": "evergreen-backend",
|
||||
"Password": "",
|
||||
"ClientId": "evergreen-health-backend",
|
||||
"StaleAfterSeconds": 150,
|
||||
"Devices": [
|
||||
{
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Simulation": true,
|
||||
"PersonLeftIndex": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
|
||||
<ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Audio;
|
||||
|
||||
public sealed class BedPresenceSleepMusicAction(IHomeAssistantGateway gateway, TcmMusicTransitionService music,
|
||||
IOptions<HomeAssistantOptions> options) : IBedPresenceAction
|
||||
{
|
||||
private const string Playlist = "balanced";
|
||||
|
||||
public string Key => "speaker-sleep-music";
|
||||
public string Description => "用 Xiaomi Sound Pro 播放舒睡音乐";
|
||||
public string ConditionLabel => "音箱空闲时播放默认舒睡曲目并渐弱音量。";
|
||||
|
||||
public bool SupportsTarget(string entityId) => options.Value.Enabled &&
|
||||
entityId?.StartsWith("media_player.", StringComparison.Ordinal) == true && entityId == options.Value.Entities.Speaker;
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
if (!music.HasPlayableTracks(Playlist)) return "speaker-music-not-configured";
|
||||
var states = await gateway.GetStatesAsync(token);
|
||||
var speaker = states.FirstOrDefault(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
if (speaker is null || !DeviceActionRunner.IsAvailable(speaker)) return "speaker-state-unavailable";
|
||||
if (speaker.State is not ("idle" or "off" or "standby")) return "speaker-not-idle";
|
||||
return await music.TryStartIdlePlaybackAsync(Playlist, token) ? "speaker-playback-sent" : "speaker-not-idle";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Climate;
|
||||
|
||||
public sealed class BedPresenceClimateHeatAction(ClimateModule climate, IndoorEnvironmentReader environment,
|
||||
DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : IBedPresenceAction
|
||||
{
|
||||
private const int TargetTemperature = 22;
|
||||
|
||||
public string Key => "climate-heat-start";
|
||||
public string Description => "室温偏低时开启空调制热";
|
||||
public string ConditionLabel => $"室温低于 {TargetTemperature}℃ 且空调关机时,开启制热并设为 {TargetTemperature}℃。";
|
||||
public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(40);
|
||||
|
||||
public bool SupportsTarget(string entityId) => options.Value.Enabled &&
|
||||
entityId?.StartsWith("climate.", StringComparison.Ordinal) == true && entityId == climate.PrimaryEntityId &&
|
||||
options.Value.Entities.Temperature?.StartsWith("sensor.", StringComparison.Ordinal) == true;
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
var mode = climate.BuildParameterCommand("climate_mode", "heat");
|
||||
var temperature = climate.BuildParameterCommand("climate_temperature", TargetTemperature) with
|
||||
{
|
||||
ExpectedEntityState = "heat"
|
||||
};
|
||||
|
||||
var reason = await runner.SendIfAsync(mode, states => GetStartSkipReason(states, mode, temperature), token);
|
||||
if (reason is not null) return reason;
|
||||
|
||||
try { await runner.VerifyParameterAsync(mode, token); }
|
||||
catch (InvalidOperationException) { return "climate-heat-mode-unconfirmed"; }
|
||||
|
||||
try
|
||||
{
|
||||
reason = await runner.SendIfAsync(temperature, states =>
|
||||
CurrentState(states, entityId)?.State == "heat" ? null : "climate-heat-state-changed", token);
|
||||
if (reason is not null) return reason;
|
||||
await runner.VerifyParameterAsync(temperature, token);
|
||||
return "readback-confirmed";
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
return "climate-heat-target-unconfirmed";
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetStartSkipReason(IReadOnlyList<HomeAssistantState> states, DeviceCommand mode, DeviceCommand temperature)
|
||||
{
|
||||
var sample = environment.Read(states);
|
||||
if (sample.Celsius is not { } celsius)
|
||||
return sample.Issue switch
|
||||
{
|
||||
IndoorTemperatureIssue.Stale => "temperature-stale",
|
||||
IndoorTemperatureIssue.Invalid => "temperature-invalid",
|
||||
_ => "temperature-unavailable"
|
||||
};
|
||||
if (celsius >= TargetTemperature) return "temperature-not-low";
|
||||
|
||||
var state = CurrentState(states, mode.EntityId);
|
||||
if (state is null || !DeviceActionRunner.IsAvailable(state)) return "climate-unavailable";
|
||||
if (state.State != "off") return "climate-not-off";
|
||||
|
||||
// 启动前同时校验制热模式和目标温度。
|
||||
try
|
||||
{
|
||||
mode.ValidateCurrentState!(state);
|
||||
temperature.ValidateCurrentState!(state);
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException) { return "climate-heat-unsupported"; }
|
||||
}
|
||||
|
||||
private static HomeAssistantState? CurrentState(IReadOnlyList<HomeAssistantState> states, string entityId) =>
|
||||
states.FirstOrDefault(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Climate;
|
||||
|
||||
public sealed class BedPresenceClimatePreview(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
|
||||
IOptions<HomeAssistantOptions> options) : IBedPresenceAction
|
||||
{
|
||||
private HomeAssistantOptions Configuration => options.Value;
|
||||
|
||||
public string Key => "climate-temperature-preview";
|
||||
public string Description => "按室温判断空调模式";
|
||||
public bool PreviewOnly => true;
|
||||
public string ConditionLabel => $"室温高于 {RecommendedRoomRange.Default.Maximum}℃ 时计划制冷,低于 {RecommendedRoomRange.Default.Minimum}℃ 时计划制热。";
|
||||
|
||||
public bool SupportsTarget(string entityId) => Configuration.Enabled &&
|
||||
entityId?.StartsWith("climate.", StringComparison.Ordinal) == true && entityId == Configuration.Entities.Climate &&
|
||||
Configuration.Entities.Temperature?.StartsWith("sensor.", StringComparison.Ordinal) == true;
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
IReadOnlyList<HomeAssistantState> states;
|
||||
try { states = await gateway.GetStatesAsync(token); }
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
return "preview-temperature-unavailable";
|
||||
}
|
||||
|
||||
var sample = environment.Read(states);
|
||||
return RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.Default).Mode switch
|
||||
{
|
||||
"cool" => "preview-climate-cool",
|
||||
"heat" => "preview-climate-heat",
|
||||
"off" => "preview-temperature-within-range",
|
||||
_ => sample.Issue switch
|
||||
{
|
||||
IndoorTemperatureIssue.Stale => "preview-temperature-stale",
|
||||
IndoorTemperatureIssue.Invalid => "preview-temperature-invalid",
|
||||
_ => "preview-temperature-unavailable"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
|
||||
public enum DeviceCommandIntent { Normal, StartMassage, TurnRelaxationLightOn, TurnRelaxationLightOff }
|
||||
public enum DeviceCommandIntent { Normal = 0, TurnRelaxationLightOn = 2, TurnRelaxationLightOff = 3 }
|
||||
|
||||
/// <summary>实体由后端模块提供,不接受客户端任意实体或任意服务名。</summary>
|
||||
public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service,
|
||||
@@ -13,6 +13,7 @@ public sealed record DeviceCommand(string Name, string EntityId, string Domain,
|
||||
public Action<HomeAssistantState>? ValidateCurrentState { get; init; }
|
||||
// 不为空时 ExpectedState 表示此属性的期望值,而不是实体的顶层状态。
|
||||
public string? ExpectedAttribute { get; init; }
|
||||
public string? ExpectedEntityState { get; init; }
|
||||
public bool RetryOnVerificationMismatch { get; init; } = true;
|
||||
public Dictionary<string, object?> ToServiceData()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
|
||||
public sealed class BedPresenceCurtainCloseAction(CurtainModule curtain, DeviceActionRunner runner,
|
||||
IOptions<HomeAssistantOptions> options) : IBedPresenceAction
|
||||
{
|
||||
public string Key => "curtain-close";
|
||||
public string Description => "关闭窗帘";
|
||||
public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(55);
|
||||
|
||||
public bool SupportsTarget(string entityId) => options.Value.Enabled &&
|
||||
entityId?.StartsWith("cover.", StringComparison.Ordinal) == true && entityId == curtain.PrimaryEntityId;
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
var state = await runner.ReadAvailableStateAsync(entityId, token);
|
||||
if (state.State == "closed") return "already-closed";
|
||||
if (state.State == "closing") return "already-closing";
|
||||
if (state.State != "open") return "state-not-open";
|
||||
|
||||
var close = curtain.BuildParameterCommand("curtain_action", "close");
|
||||
var command = close with
|
||||
{
|
||||
ExpectedState = "closed",
|
||||
ValidateCurrentState = current =>
|
||||
{
|
||||
close.ValidateCurrentState?.Invoke(current);
|
||||
if (current.State != "open") throw new InvalidOperationException("窗帘状态已改变,跳过关帘");
|
||||
}
|
||||
};
|
||||
await runner.SendAsync(command, token);
|
||||
try
|
||||
{
|
||||
// 机械行程需要较长回读时间。沿用窗帘命令的只读回读配置,失败不重发关帘。
|
||||
await runner.VerifyParameterAsync(command, token, verificationAttempts: 20);
|
||||
return "readback-confirmed";
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return "sent-readback-unconfirmed";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
|
||||
/// <summary>窗帘开合不是电源开关;本版只提供单独手动控制,不擅自加入舒睡/全关。</summary>
|
||||
/// <summary>集中定义窗帘开合动作和能力校验,供手动控制与独立联动复用。</summary>
|
||||
public sealed class CurtainModule : HealthDeviceModule
|
||||
{
|
||||
private readonly string _entityId;
|
||||
@@ -70,7 +70,7 @@ public sealed class CurtainModule : HealthDeviceModule
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
context.Results.Add(new() { Name = "窗帘场景联动", EntityId = _entityId, Status = "Skipped",
|
||||
Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "本版窗帘只支持手动控制,舒睡不自动改变开合位置" });
|
||||
Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "一键舒睡保持窗帘当前位置" });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
|
||||
public sealed class BedPresenceDehumidifierPreview(IHomeAssistantGateway gateway, IOptions<HomeAssistantOptions> options,
|
||||
TimeProvider clock) : IBedPresenceAction
|
||||
{
|
||||
private HomeAssistantOptions Configuration => options.Value;
|
||||
private int Threshold => Configuration.SleepScene.TargetHumidity;
|
||||
|
||||
public string Key => "dehumidifier-humidity-preview";
|
||||
public string Description => "湿度偏高时开启除湿机";
|
||||
public bool PreviewOnly => true;
|
||||
public string ConditionLabel => $"室内相对湿度超过 {Threshold}% 时记录开机计划。";
|
||||
|
||||
public bool SupportsTarget(string entityId) => DehumidifierHumidityCondition.SupportsTarget(Configuration, entityId);
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
var states = await gateway.GetStatesAsync(token);
|
||||
var reason = DehumidifierHumidityCondition.SkipReason(states, Configuration, clock.GetUtcNow());
|
||||
return reason is null ? "preview-would-start" : $"preview-{reason}";
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
|
||||
public sealed class BedPresenceDehumidifierStartAction(DehumidifierModule dehumidifier, DeviceActionRunner runner,
|
||||
IOptions<HomeAssistantOptions> options, TimeProvider clock) : IBedPresenceAction
|
||||
{
|
||||
private HomeAssistantOptions Configuration => options.Value;
|
||||
|
||||
public string Key => "dehumidifier-humidity-start";
|
||||
public string Description => "湿度偏高时开启除湿机";
|
||||
public string ConditionLabel => $"室内相对湿度超过 {Configuration.SleepScene.TargetHumidity}% 且除湿机关机时,发送开机指令。";
|
||||
|
||||
public bool SupportsTarget(string entityId) => entityId == dehumidifier.PrimaryEntityId &&
|
||||
DehumidifierHumidityCondition.SupportsTarget(Configuration, entityId);
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
var command = dehumidifier.BuildStartCommand() with
|
||||
{
|
||||
ExpectedState = "on",
|
||||
RetryOnVerificationMismatch = false
|
||||
};
|
||||
var reason = await runner.SendIfAsync(command, states => GetSkipReason(states, entityId), token);
|
||||
if (reason is not null) return reason;
|
||||
|
||||
try
|
||||
{
|
||||
await runner.VerifyParameterAsync(command, token);
|
||||
return "readback-confirmed";
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return "sent-readback-unconfirmed";
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetSkipReason(IReadOnlyList<HomeAssistantState> states, string entityId)
|
||||
{
|
||||
var reason = DehumidifierHumidityCondition.SkipReason(states, Configuration, clock.GetUtcNow());
|
||||
if (reason is not null) return reason;
|
||||
|
||||
var state = states.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
if (state is null || !DeviceActionRunner.IsAvailable(state)) return "dehumidifier-unavailable";
|
||||
return state.State switch
|
||||
{
|
||||
"off" => null,
|
||||
"on" => "dehumidifier-already-on",
|
||||
_ => "dehumidifier-state-not-off"
|
||||
};
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
|
||||
internal static class DehumidifierHumidityCondition
|
||||
{
|
||||
private static readonly TimeSpan MaxHumidityAge = TimeSpan.FromMinutes(30);
|
||||
|
||||
public static bool SupportsTarget(HomeAssistantOptions configuration, string entityId) =>
|
||||
configuration.Enabled && configuration.SleepScene.TargetHumidity is >= 40 and <= 70 &&
|
||||
entityId?.StartsWith("humidifier.", StringComparison.Ordinal) == true &&
|
||||
entityId == configuration.Entities.Dehumidifier &&
|
||||
configuration.Entities.Humidity?.StartsWith("sensor.", StringComparison.Ordinal) == true;
|
||||
|
||||
public static string? SkipReason(IReadOnlyList<HomeAssistantState> states, HomeAssistantOptions configuration, DateTimeOffset now)
|
||||
{
|
||||
var humidity = states.FirstOrDefault(state =>
|
||||
string.Equals(state.EntityId, configuration.Entities.Humidity, StringComparison.OrdinalIgnoreCase));
|
||||
if (humidity is null || !DeviceActionRunner.IsAvailable(humidity)) return "humidity-unavailable";
|
||||
|
||||
var observedAt = humidity.LastReported ?? humidity.LastUpdated;
|
||||
if (observedAt is null || now - observedAt > MaxHumidityAge || observedAt > now.AddMinutes(1))
|
||||
return "humidity-stale";
|
||||
|
||||
if (humidity.GetStringAttribute("unit_of_measurement") != "%" ||
|
||||
!double.TryParse(humidity.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var percent) ||
|
||||
!double.IsFinite(percent) || percent is < 0 or > 100)
|
||||
return "humidity-invalid";
|
||||
|
||||
return percent > configuration.SleepScene.TargetHumidity ? null : "humidity-normal";
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,12 @@ public sealed class DehumidifierModule : HealthDeviceModule
|
||||
public override string PrimaryEntityId => _entities.Dehumidifier;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
internal DeviceCommand BuildStartCommand() => new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on");
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RunAsync(new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on"), cancellationToken);
|
||||
await context.RunAsync(BuildStartCommand(), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("humidity", config.TargetHumidity), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("dehumidifier_mode", config.DehumidifierMode), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("fan_level", config.FanLevel), cancellationToken);
|
||||
|
||||
+53
-6
@@ -12,8 +12,14 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
{
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private readonly DeviceModuleRegistry _modules;
|
||||
private readonly HashSet<string> _manualLightIds;
|
||||
public HomeAssistantDeviceCatalog(IOptions<HomeAssistantOptions> options, DeviceModuleRegistry modules)
|
||||
=> (_options, _modules) = (options.Value, modules);
|
||||
{
|
||||
(_options, _modules) = (options.Value, modules);
|
||||
_manualLightIds = _options.ManualLightEntityIds
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id) && id.StartsWith("light.", StringComparison.Ordinal))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public List<HomeAssistantDiscoveredDeviceOutput> BuildDiscoveredDevices(
|
||||
HomeAssistantRegistries? registries,
|
||||
@@ -29,12 +35,21 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
.GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 使用完整注册表识别退役设备;即使用于识别的实体被禁用,也不能重新发现同一设备。
|
||||
var excludedDevices = registries.Entities.Where(entity =>
|
||||
_options.ExcludedDeviceEntityIds.Contains(entity.EntityId, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(entity => entity.DeviceId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
// HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。
|
||||
foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) &&
|
||||
!string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase)))
|
||||
!string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase) && !excludedDevices.Contains(device.Id)))
|
||||
{
|
||||
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
|
||||
if (regEntities.Count == 0) continue;
|
||||
var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var module = _modules.ForEntities(entityIds);
|
||||
var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId));
|
||||
var manualLightId = regEntities.FirstOrDefault(entity => _manualLightIds.Contains(entity.EntityId))?.EntityId;
|
||||
if (_options.OnlyConfiguredDevices && module is null && displayOnly is null && manualLightId is null) continue;
|
||||
|
||||
var entityOutputs = new List<HomeAssistantEntityOutput>();
|
||||
foreach (var regEntity in regEntities)
|
||||
@@ -72,10 +87,11 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
? an
|
||||
: null;
|
||||
|
||||
var module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId));
|
||||
var category = module?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
|
||||
var category = module?.Category ?? (manualLightId is not null ? nameof(HealthDeviceCategory.Lighting) :
|
||||
displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs));
|
||||
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
||||
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
|
||||
var primaryControlEntityId = module is not null ? FindPrimaryControlEntityId(category, entityOutputs) :
|
||||
manualLightId ?? (displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs));
|
||||
|
||||
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
|
||||
{
|
||||
@@ -130,6 +146,38 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
||||
});
|
||||
}
|
||||
foreach (var id in _manualLightIds)
|
||||
{
|
||||
if (_modules.ForEntities([id]) is not null ||
|
||||
_options.ExcludedDeviceEntityIds.Contains(id, StringComparer.OrdinalIgnoreCase) ||
|
||||
!stateMap.TryGetValue(id, out var state)) continue;
|
||||
var name = state.GetStringAttribute("friendly_name") ?? id;
|
||||
var available = IsAvailable(state);
|
||||
list.Add(new()
|
||||
{
|
||||
DeviceId = $"fallback_{id}", Name = name, Category = nameof(HealthDeviceCategory.Lighting), Online = available,
|
||||
PrimaryControlEntityId = state.EntityId, Entities = [new()
|
||||
{
|
||||
EntityId = state.EntityId, Domain = "light", Name = name, State = state.State,
|
||||
Available = available, Attributes = ExtractSafeAttributes(state)
|
||||
}]
|
||||
});
|
||||
}
|
||||
foreach (var device in _options.DisplayOnlyDevices)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(device.EntityId) || _modules.ForEntities([device.EntityId]) is not null ||
|
||||
_manualLightIds.Contains(device.EntityId) ||
|
||||
_options.ExcludedDeviceEntityIds.Contains(device.EntityId, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
stateMap.TryGetValue(device.EntityId, out var state);
|
||||
var available = state is not null && IsAvailable(state);
|
||||
list.Add(new()
|
||||
{
|
||||
DeviceId = $"fallback_{device.EntityId}", Name = device.Name, Category = device.Category,
|
||||
Online = available, Entities = [new() { EntityId = device.EntityId, Domain = device.EntityId.Split('.')[0],
|
||||
Name = state?.GetStringAttribute("friendly_name") ?? device.Name, State = state?.State ?? "unavailable",
|
||||
Available = available, Attributes = ExtractSafeAttributes(state) }]
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -291,7 +339,6 @@ public sealed class HomeAssistantDeviceCatalog
|
||||
if (id.Contains("tvoc")) metrics["tvoc"] = entity.State;
|
||||
if (id.Contains("weight")) metrics["weight"] = entity.State;
|
||||
if (id.Contains("body_fat") || id.Contains("fat_rate")) metrics["bodyFatRate"] = entity.State;
|
||||
if (id.Contains("firmness") || id.Contains("soft")) metrics["firmness"] = entity.State;
|
||||
if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State;
|
||||
}
|
||||
|
||||
|
||||
+18
-10
@@ -13,22 +13,30 @@ public sealed class IndoorEnvironmentReader
|
||||
public IndoorEnvironmentReader(IOptions<HomeAssistantOptions> options, TimeProvider time)
|
||||
=> (_entities, _time) = (options.Value.Entities, time);
|
||||
|
||||
public IndoorTemperatureSample Read(IReadOnlyDictionary<string, HomeAssistantState> states)
|
||||
public IndoorTemperatureSample Read(IReadOnlyDictionary<string, HomeAssistantState> states) =>
|
||||
ReadState(string.IsNullOrWhiteSpace(_entities.Temperature) ? null : states.GetValueOrDefault(_entities.Temperature));
|
||||
|
||||
public IndoorTemperatureSample Read(IReadOnlyList<HomeAssistantState> states) =>
|
||||
ReadState(states.LastOrDefault(state =>
|
||||
string.Equals(state.EntityId, _entities.Temperature, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
private IndoorTemperatureSample ReadState(HomeAssistantState? state)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_entities.Temperature) ||
|
||||
!states.TryGetValue(_entities.Temperature, out var state) || !DeviceActionRunner.IsAvailable(state))
|
||||
return new(null, null, false, "室内温度实体未配置或不可用");
|
||||
if (state is null || !DeviceActionRunner.IsAvailable(state))
|
||||
return new(null, null, false, "室内温度实体未配置或不可用") { Issue = IndoorTemperatureIssue.Unavailable };
|
||||
var observedAt = state.LastReported ?? state.LastUpdated;
|
||||
if (observedAt is null || _time.GetUtcNow() - observedAt > TimeSpan.FromMinutes(30) ||
|
||||
observedAt > _time.GetUtcNow().AddMinutes(1))
|
||||
return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期");
|
||||
var now = _time.GetUtcNow();
|
||||
if (observedAt is null || now - observedAt > TimeSpan.FromMinutes(30) || observedAt > now.AddMinutes(1))
|
||||
return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期") { Issue = IndoorTemperatureIssue.Stale };
|
||||
if (!double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var celsius) ||
|
||||
!double.IsFinite(celsius)) return new(null, observedAt, false, "室内温度数据无效");
|
||||
!double.IsFinite(celsius))
|
||||
return new(null, observedAt, false, "室内温度数据无效") { Issue = IndoorTemperatureIssue.Invalid };
|
||||
var unit = state.GetStringAttribute("unit_of_measurement");
|
||||
if (unit is "°F" or "F") celsius = (celsius - 32) * 5 / 9;
|
||||
else if (!string.IsNullOrWhiteSpace(unit) && unit is not ("°C" or "C" or "℃"))
|
||||
return new(null, observedAt, false, "室内温度单位未识别");
|
||||
if (celsius is < -20 or > 60) return new(null, observedAt, false, "室内温度超出有效范围");
|
||||
return new(null, observedAt, false, "室内温度单位未识别") { Issue = IndoorTemperatureIssue.Invalid };
|
||||
if (celsius is < -20 or > 60)
|
||||
return new(null, observedAt, false, "室内温度超出有效范围") { Issue = IndoorTemperatureIssue.Invalid };
|
||||
return new(celsius, observedAt, true, "室内温度数据有效");
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
|
||||
public sealed class BedPresenceFragranceReleaseAction(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options,
|
||||
bool wakeDevice) : IBedPresenceAction
|
||||
{
|
||||
private string ConfiguredButton => wakeDevice ? options.Value.Entities.WakeFragranceQuickRelease : options.Value.Entities.FragranceQuickRelease;
|
||||
|
||||
public string Key => wakeDevice ? "wake-fragrance-quick-release" : "fragrance-quick-release";
|
||||
public string Description => wakeDevice ? "让香氛机「唤醒」出香一次" : "让米家智能香氛机出香一次";
|
||||
|
||||
public bool SupportsTarget(string entityId) => options.Value.Enabled &&
|
||||
entityId?.StartsWith("button.", StringComparison.Ordinal) == true && entityId == ConfiguredButton;
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
var reason = await runner.SendIfAsync(FragranceModule.BuildQuickRelease(entityId), states =>
|
||||
states.Any(state => string.Equals(state.EntityId, entityId, StringComparison.OrdinalIgnoreCase) &&
|
||||
DeviceActionRunner.IsAvailable(state)) ? null : "fragrance-release-unavailable", token);
|
||||
return reason ?? "fragrance-release-sent";
|
||||
}
|
||||
}
|
||||
@@ -9,36 +9,52 @@ namespace Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
public sealed class FragranceModule : HealthDeviceModule
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
private readonly bool _wakeDevice;
|
||||
|
||||
public FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options)
|
||||
: this(runner, options, wakeDevice: false) { }
|
||||
|
||||
internal FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options, bool wakeDevice) : base(runner)
|
||||
{
|
||||
_entities = options.Value.Entities;
|
||||
Parameters =
|
||||
_wakeDevice = wakeDevice;
|
||||
var duration = wakeDevice ? _entities.WakeFragranceDuration : _entities.FragranceDuration;
|
||||
var intervalEntity = wakeDevice ? _entities.WakeFragranceInterval : _entities.FragranceInterval;
|
||||
var light = wakeDevice ? _entities.WakeFragranceLight : _entities.FragranceLight;
|
||||
var quickRelease = wakeDevice ? _entities.WakeFragranceQuickRelease : _entities.FragranceQuickRelease;
|
||||
var autoRelease = wakeDevice ? _entities.WakeFragranceAutoRelease : _entities.FragranceAutoRelease;
|
||||
var autoLight = wakeDevice ? _entities.WakeFragranceAutoLight : _entities.FragranceAutoLight;
|
||||
var lightAutoOff = wakeDevice ? _entities.WakeFragranceLightAutoOff : _entities.FragranceLightAutoOff;
|
||||
DeviceParameterDefinition[] parameters =
|
||||
[
|
||||
DeviceValues.Number("fragrance_duration", _entities.FragranceDuration, 2, 6, "出香时长"),
|
||||
new("fragrance_interval", _entities.FragranceInterval, value =>
|
||||
DeviceValues.Number("fragrance_duration", duration, 2, 6, "出香时长"),
|
||||
new("fragrance_interval", intervalEntity, value =>
|
||||
{
|
||||
var interval = DeviceValues.Integer(value, 10, 20, "出香间隔");
|
||||
if (interval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟");
|
||||
return new("设置出香间隔", _entities.FragranceInterval, "number", "set_value",
|
||||
return new("设置出香间隔", intervalEntity, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = interval });
|
||||
}, "出香间隔", 10, 20),
|
||||
DeviceValues.Switch("fragrance_light", _entities.FragranceLight, "light", "香氛灯"),
|
||||
new("fragrance_quick_release", _entities.FragranceQuickRelease, _ => new("快速出香",
|
||||
_entities.FragranceQuickRelease, "button", "press"), "快速出香"),
|
||||
DeviceValues.Switch("fragrance_auto_release", _entities.FragranceAutoRelease, "switch", "人来增香"),
|
||||
DeviceValues.Switch("fragrance_auto_light", _entities.FragranceAutoLight, "switch", "感应夜灯"),
|
||||
DeviceValues.Switch("fragrance_light_auto_off", _entities.FragranceLightAutoOff, "switch", "夜灯自动关闭")
|
||||
DeviceValues.Switch("fragrance_light", light, "light", "香氛灯"),
|
||||
new("fragrance_quick_release", quickRelease, _ => BuildQuickRelease(quickRelease), "快速出香"),
|
||||
DeviceValues.Switch("fragrance_auto_release", autoRelease, "switch", "人来增香"),
|
||||
DeviceValues.Switch("fragrance_auto_light", autoLight, "switch", "感应夜灯"),
|
||||
DeviceValues.Switch("fragrance_light_auto_off", lightAutoOff, "switch", "夜灯自动关闭")
|
||||
];
|
||||
Parameters = wakeDevice ? parameters.Select(parameter => parameter with { Key = $"wake_{parameter.Key}" }).ToArray() : parameters;
|
||||
}
|
||||
public override string Key => "fragrance";
|
||||
public override string Key => _wakeDevice ? "wake-fragrance" : "fragrance";
|
||||
public override string Category => nameof(HealthDeviceCategory.Fragrance);
|
||||
public override string DisplayName => "智能香氛机";
|
||||
public override string PrimaryEntityId => _entities.FragranceSwitch;
|
||||
public override string DisplayName => _wakeDevice ? "香氛机 唤醒" : "智能香氛机";
|
||||
public override string PrimaryEntityId => _wakeDevice ? _entities.WakeFragranceSwitch : _entities.FragranceSwitch;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
internal static DeviceCommand BuildQuickRelease(string entityId) => new("快速出香", entityId, "button", "press");
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_wakeDevice) return;
|
||||
await context.RunAsync(new(config.EnableFragrance ? "开启香氛机" : "关闭香氛机",
|
||||
PrimaryEntityId, "switch", config.EnableFragrance ? "turn_on" : "turn_off"), cancellationToken);
|
||||
if (config.EnableFragrance)
|
||||
@@ -53,7 +69,7 @@ public sealed class FragranceModule : HealthDeviceModule
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands() =>
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands() => _wakeDevice ? [] :
|
||||
[
|
||||
new("关闭香氛机", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off"),
|
||||
new("关闭香氛灯", _entities.FragranceLight, "light", "turn_off", ExpectedState: "off"),
|
||||
@@ -62,7 +78,7 @@ public sealed class FragranceModule : HealthDeviceModule
|
||||
];
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
=> string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放",
|
||||
=> _wakeDevice || string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放",
|
||||
config.EnableFragrance ? "开启香氛机;香氛灯可用时同步开启柔光" : "当前体质预设不启动香氛",
|
||||
new() { ["power"] = config.EnableFragrance ? "开启" : "关闭" });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
using Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Devices.Outlet;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
@@ -16,6 +15,7 @@ using Evergreen.Health.Application.Health.Sleep;
|
||||
using Evergreen.Health.Application.Health.Integrations.Tuya;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices;
|
||||
|
||||
@@ -24,6 +24,30 @@ public static class HealthDeviceRegistration
|
||||
public static IServiceCollection AddHealthDevices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddOptions<Ib43Options>();
|
||||
services.AddSingleton<Ib43Parser>();
|
||||
services.AddSingleton<Ib43StateStore>();
|
||||
services.AddScoped(provider => new Ib43DataRepository(
|
||||
provider.GetRequiredService<SqlSugar.ISqlSugarClient>().AsTenant().GetConnectionScope(SqlSugarConst.MainConfigId)));
|
||||
services.AddSingleton<IIb43ReportSink, Ib43ReportSink>();
|
||||
services.AddSingleton<IIb43MqttTransportFactory, Ib43MqttTransportFactory>();
|
||||
services.AddOptions<BedPresenceAutomationOptions>();
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceSleepLightAction>();
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceCurtainCloseAction>();
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceDehumidifierPreview>();
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceDehumidifierStartAction>();
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceClimatePreview>();
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceClimateHeatAction>();
|
||||
services.AddSingleton<IBedPresenceAction>(provider => new BedPresenceFragranceReleaseAction(
|
||||
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(), wakeDevice: false));
|
||||
services.AddSingleton<IBedPresenceAction>(provider => new BedPresenceFragranceReleaseAction(
|
||||
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(), wakeDevice: true));
|
||||
services.AddSingleton<IBedPresenceAction, BedPresenceSleepMusicAction>();
|
||||
services.AddSingleton<IBedPresenceExecutionLog, Ib43AutomationExecutionLog>();
|
||||
services.AddSingleton<BedPresenceAutomation>();
|
||||
services.AddSingleton<IBedPresenceSink>(provider => provider.GetRequiredService<BedPresenceAutomation>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<BedPresenceAutomation>());
|
||||
services.AddHostedService<Ib43MqttService>();
|
||||
services.AddSingleton(new DeviceExecutionOptions());
|
||||
// 仅注册扩展契约,不启动传感器轮询、上床流程或占位音频播放。
|
||||
services.AddSingleton(new SleepWorkflowOptions());
|
||||
@@ -41,7 +65,7 @@ public static class HealthDeviceRegistration
|
||||
provider.GetRequiredService<IOptions<MiAirXOptions>>()));
|
||||
services.AddSingleton<DeviceActionRunner>();
|
||||
services.AddSingleton<SceneLightSafetyState>();
|
||||
services.AddSingleton<IDeviceActionSafetyRule, LightMassageSafetyRule>();
|
||||
services.AddSingleton<IDeviceActionSafetyRule, PrimaryLightStateRule>();
|
||||
services.AddSingleton<IndoorEnvironmentReader>();
|
||||
services.AddSingleton<EnvironmentModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<EnvironmentModule>());
|
||||
@@ -50,7 +74,6 @@ public static class HealthDeviceRegistration
|
||||
services.AddSingleton<CurtainModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<CurtainModule>());
|
||||
services.AddSingleton<OutletModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<OutletModule>());
|
||||
services.AddSingleton<TcmMusicTransitionService>();
|
||||
services.AddSingleton<ITcmMusicRuntime>(provider => provider.GetRequiredService<TcmMusicTransitionService>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TcmMusicTransitionService>());
|
||||
@@ -61,23 +84,27 @@ public static class HealthDeviceRegistration
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<DehumidifierModule>());
|
||||
services.AddSingleton<FragranceModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<FragranceModule>());
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => new FragranceModule(
|
||||
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(), wakeDevice: true));
|
||||
services.AddSingleton<LightingModule>(provider => new(
|
||||
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(),
|
||||
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(
|
||||
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.Entities.AmbientLight),
|
||||
provider.GetRequiredService<LightOffDispatcher>()));
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
|
||||
services.AddSingleton<AudioModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
|
||||
services.AddSingleton<MattressModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
|
||||
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
|
||||
services.AddSingleton<LightOffDispatcher>();
|
||||
services.AddSingleton<DeviceModuleRegistry>(provider => new(
|
||||
provider.GetServices<IHealthDeviceModule>().Concat(
|
||||
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.AdditionalSleepLights.Select(light =>
|
||||
new ConfiguredSleepLightModule(provider.GetRequiredService<DeviceActionRunner>(), light,
|
||||
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(light.EntityId))))));
|
||||
services.AddSingleton<DeviceModuleRegistry>(provider =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value;
|
||||
var modules = provider.GetServices<IHealthDeviceModule>().Where(module => module.EntityIds.Any()).ToList();
|
||||
if (!string.IsNullOrWhiteSpace(options.Entities.Outlet)) modules.Add(provider.GetRequiredService<OutletModule>());
|
||||
if (!string.IsNullOrWhiteSpace(options.Entities.AmbientLight)) modules.Add(provider.GetRequiredService<LightingModule>());
|
||||
var lights = provider.GetRequiredService<HomeAssistantLightTransitionService>();
|
||||
var runner = provider.GetRequiredService<DeviceActionRunner>();
|
||||
modules.AddRange(options.AdditionalSleepLights.Select(light => new ConfiguredSleepLightModule(runner, light, lights.ForEntity(light.EntityId))));
|
||||
return new DeviceModuleRegistry(modules);
|
||||
});
|
||||
services.AddSingleton<IStandardDeviceControl, StandardLightControl>();
|
||||
services.AddSingleton<DeviceParameterController>();
|
||||
services.AddSingleton<HomeAssistantDeviceCatalog>();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
|
||||
public sealed class BedPresenceSleepLightAction(HomeAssistantLightTransitionService lights, DeviceActionRunner runner,
|
||||
DeviceExecutionOptions execution, IOptions<HomeAssistantOptions> options) : IBedPresenceAction
|
||||
{
|
||||
public string Key => "light-sleep";
|
||||
public string Description => "启动助眠渐暗";
|
||||
|
||||
public bool SupportsTarget(string entityId) => options.Value.Enabled && entityId?.StartsWith("light.", StringComparison.Ordinal) == true &&
|
||||
(entityId == options.Value.Entities.AmbientLight || options.Value.AdditionalSleepLights.Any(light => light.EntityId == entityId));
|
||||
|
||||
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
|
||||
{
|
||||
if (!SupportsTarget(entityId)) return "target-not-configured";
|
||||
var transition = await lights.StartSleepIfIdleAsync(entityId, options.Value.SleepScene.LightColorTemperatureKelvin, token);
|
||||
if (transition == null) return "already-running";
|
||||
for (var attempt = 0; attempt < execution.VerificationAttempts; attempt++)
|
||||
{
|
||||
await runner.DelayAsync(execution.VerificationDelay, token);
|
||||
try
|
||||
{
|
||||
var state = await runner.ReadAvailableStateAsync(entityId, token);
|
||||
var brightness = state.GetDoubleAttribute("brightness");
|
||||
if (state.State == "on" && brightness is double value &&
|
||||
Math.Abs(value / 255 * 100 - transition.BrightnessPercent) <= 1.5)
|
||||
return "readback-confirmed";
|
||||
}
|
||||
catch (InvalidOperationException) { /* 实体暂未回报,下一轮只重读状态。 */ }
|
||||
}
|
||||
return "sent-readback-unconfirmed";
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Evergreen.Health.Application.Health.Scenes;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
|
||||
/// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的按摩/唤醒钩子。</summary>
|
||||
/// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的关灯事件/唤醒钩子。</summary>
|
||||
public sealed class ConfiguredSleepLightModule : HealthDeviceModule
|
||||
{
|
||||
private readonly SceneLightOptions _light;
|
||||
@@ -25,8 +25,17 @@ public sealed class ConfiguredSleepLightModule : HealthDeviceModule
|
||||
}
|
||||
public override async Task SetPowerAsync(bool turnOn, CancellationToken token)
|
||||
{
|
||||
if (turnOn) { await _transition.StartSleepAsync(2700, token); return; }
|
||||
if (turnOn && _light.StartSleepOnManualPowerOn)
|
||||
{
|
||||
await _transition.StartSleepAsync(2700, token);
|
||||
return;
|
||||
}
|
||||
await _transition.CancelAsync(token);
|
||||
if (turnOn)
|
||||
{
|
||||
await base.SetPowerAsync(true, token);
|
||||
return;
|
||||
}
|
||||
var command = GetShutdownCommands().Single();
|
||||
await Runner.SendAsync(command, token);
|
||||
await Runner.VerifyParameterAsync(command, token);
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class LightingModule : HealthDeviceModule
|
||||
|
||||
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 标准手动面板复用本灯的专属安全钩子;其他灯不会触发助眠灯/床垫联动。
|
||||
// 标准手动面板复用本灯的专属安全钩子;其他灯不会触发主灯关灯事件。
|
||||
command = command with { Intent = command.Service == "turn_off"
|
||||
? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
|
||||
await _transition.CancelAsync(cancellationToken);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Evergreen.Health.Application.Health.Services;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
|
||||
public sealed class MattressModule : HealthDeviceModule, ILightOffHandler
|
||||
{
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private HomeAssistantEntityOptions Entities => _options.Entities;
|
||||
public MattressModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_options = options.Value;
|
||||
var modes = new[] { "无", "瑜伽助眠", "智能适配", "智能腰托" };
|
||||
var parts = new[] { "无", "背部", "腰部", "牵引", "腿部", "全身" };
|
||||
var parameters = new List<DeviceParameterDefinition>
|
||||
{
|
||||
DeviceValues.Number("mattress_left_heating", Entities.MattressLeftHeatingLevel, 0, 4, "床垫左侧加热档位"),
|
||||
DeviceValues.Number("mattress_right_heating", Entities.MattressRightHeatingLevel, 0, 4, "床垫右侧加热档位"),
|
||||
DeviceValues.Select("mattress_left_mode", Entities.MattressLeftMode, modes, "床垫左侧承托模式", true),
|
||||
DeviceValues.Select("mattress_right_mode", Entities.MattressRightMode, modes, "床垫右侧承托模式", true),
|
||||
DeviceValues.Select("mattress_massage_left_part", Entities.MattressLeftMassagePart, parts, "左侧按摩部位",
|
||||
intent: DeviceCommandIntent.StartMassage),
|
||||
DeviceValues.Select("mattress_massage_right_part", Entities.MattressRightMassagePart, parts, "右侧按摩部位",
|
||||
intent: DeviceCommandIntent.StartMassage),
|
||||
DeviceValues.Select("mattress_massage_level", Entities.MattressMassageLevel, ["轻柔", "强劲"], "按摩力度")
|
||||
};
|
||||
foreach (var (zone, entityId) in Entities.MattressFirmness)
|
||||
parameters.Add(DeviceValues.Select($"mattress_firmness_{zone}", entityId,
|
||||
["最软", "很软", "稍软", "稍硬", "很硬", "最硬"], $"床垫{ZoneName(zone)}软硬度"));
|
||||
Parameters = parameters;
|
||||
}
|
||||
public override string Key => "mattress";
|
||||
public override string Category => nameof(HealthDeviceCategory.Sleep);
|
||||
public override string DisplayName => "智能床垫";
|
||||
public override string PrimaryEntityId => ""; // 床垫没有单一总开关,不能把加热实体冒充开关。
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.SleepScene.EnableMattress) return;
|
||||
await context.RunAsync(BuildParameterCommand("mattress_left_heating", config.MattressHeatingLevel), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_right_heating", config.MattressHeatingLevel), cancellationToken);
|
||||
var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness);
|
||||
if (firmness is null)
|
||||
context.Results.Add(new() { Name = "按 BMI 调节床垫软硬度", Status = "Skipped",
|
||||
Message = "健康档案缺少身高或体重,未自动修改软硬度" });
|
||||
else
|
||||
foreach (var zone in Entities.MattressFirmness.Keys)
|
||||
await context.RunAsync(BuildParameterCommand($"mattress_firmness_{zone}", firmness), cancellationToken);
|
||||
var modeCommand = BuildParameterCommand("mattress_left_mode", "智能适配");
|
||||
await context.RunAsync(modeCommand, cancellationToken);
|
||||
if (context.Results.Last().Status == "Succeeded")
|
||||
await Runner.VerifyBatchAsync([modeCommand], context.Results, cancellationToken);
|
||||
// 气泵调节结束后再启动按摩,保持真实设备所需的顺序。
|
||||
await Runner.DelayAsync(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_massage_left_part", "无"), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_massage_level", "轻柔"), cancellationToken);
|
||||
var massage = BuildParameterCommand("mattress_massage_right_part", "全身") with { ExpectedState = "全身" };
|
||||
await context.RunAsync(massage, cancellationToken);
|
||||
if (context.Results.Last().Status == "Succeeded")
|
||||
{
|
||||
await Runner.DelayAsync(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
await Runner.VerifyBatchAsync([massage], context.Results, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<DeviceCommand> MassageOffCommands() =>
|
||||
[
|
||||
new("关闭床垫右侧按摩", Entities.MattressRightMassagePart, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "无" }, "无"),
|
||||
new("关闭床垫左侧按摩", Entities.MattressLeftMassagePart, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "无" }, "无")
|
||||
];
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
{
|
||||
foreach (var command in MassageOffCommands()) yield return command;
|
||||
// 未参与自动启动不等于无需关闭:手动开启的加热也要随一键关闭停止。
|
||||
yield return new("关闭床垫左侧加热", Entities.MattressLeftHeatingLevel, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = 0 }, "0");
|
||||
yield return new("关闭床垫右侧加热", Entities.MattressRightHeatingLevel, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = 0 }, "0");
|
||||
}
|
||||
|
||||
public async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var commands = MassageOffCommands().Where(command => !string.IsNullOrWhiteSpace(command.EntityId)).ToArray();
|
||||
var results = new List<HomeAssistantActionResult>();
|
||||
foreach (var command in commands)
|
||||
await Runner.RunAsync(new Dictionary<string, HomeAssistantState>(), results, command, cancellationToken, false);
|
||||
await Runner.VerifyBatchAsync(commands, results, cancellationToken);
|
||||
if (results.Any(result => result.Status == "Failed"))
|
||||
throw new InvalidOperationException("床垫按摩未确认关闭:" +
|
||||
string.Join(";", results.Where(result => result.Status == "Failed").Select(result => result.Message)));
|
||||
}
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
{
|
||||
if (!_options.SleepScene.EnableMattress || !EntityIds.Any()) return null;
|
||||
var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness) ?? "未自动调节";
|
||||
var preview = Preview("按室温和 BMI 调节床垫并开启右侧按摩",
|
||||
$"左右加热 {config.MattressHeatingLevel} 档 · 软硬度{firmness} · 右侧全身轻柔按摩",
|
||||
new() { ["heating_level"] = config.MattressHeatingLevel, ["firmness"] = firmness,
|
||||
["right_massage_part"] = "全身", ["massage_level"] = "轻柔" });
|
||||
preview.EntityId = EntityIds.First();
|
||||
return preview;
|
||||
}
|
||||
|
||||
public static string ZoneName(string zone) => zone switch
|
||||
{
|
||||
"left_shoulder" => "左肩", "right_shoulder" => "右肩", "left_back" => "左背", "right_back" => "右背",
|
||||
"left_waist" => "左腰", "right_waist" => "右腰", "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎",
|
||||
"left_hip" => "左臀", "right_hip" => "右臀", "left_leg" => "左腿", "right_leg" => "右腿", _ => zone
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Entities;
|
||||
|
||||
[SugarTable("health_ib43_automation_execution", "IB43床位家具联动执行记录")]
|
||||
[SugarIndex("ix_ib43_automation_owner", nameof(UserId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc,
|
||||
nameof(TriggeredAtMs), OrderByType.Desc)]
|
||||
public sealed class HealthIb43AutomationExecution
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, Length = 32)]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
[SugarColumn(Length = 32)]
|
||||
public string BindingId { get; set; } = "";
|
||||
public long UserId { get; set; }
|
||||
[SugarColumn(Length = 64)]
|
||||
public string ActionKey { get; set; } = "";
|
||||
[SugarColumn(Length = 128)]
|
||||
public string TargetLabel { get; set; } = "";
|
||||
public long TriggeredAtMs { get; set; }
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? CompletedAtMs { get; set; }
|
||||
[SugarColumn(Length = 48)]
|
||||
public string Result { get; set; } = "pending";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Entities;
|
||||
|
||||
[SugarTable("health_ib43_binding", "IB43床位用户绑定历史")]
|
||||
[SugarIndex("ux_ib43_active_slot", nameof(ActiveSlot), OrderByType.Asc, IsUnique = true)]
|
||||
[SugarIndex("ix_ib43_binding_user", nameof(UserId), OrderByType.Asc)]
|
||||
public sealed class HealthIb43Binding
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, Length = 32)]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
[SugarColumn(Length = 64)]
|
||||
public string DeviceKey { get; set; } = "";
|
||||
[SugarColumn(Length = 128)]
|
||||
public string ProductKey { get; set; } = "";
|
||||
[SugarColumn(Length = 128)]
|
||||
public string SerialNumber { get; set; } = "";
|
||||
[SugarColumn(Length = 5)]
|
||||
public string Side { get; set; } = "";
|
||||
// 活跃时为设备哈希+侧别,解绑时为 NULL。数据库唯一索引防止并发抢占。
|
||||
[SugarColumn(Length = 70, IsNullable = true)]
|
||||
public string ActiveSlot { get; set; }
|
||||
public long UserId { get; set; }
|
||||
[SugarColumn(Length = 80)]
|
||||
public string BedName { get; set; } = "";
|
||||
public bool Simulation { get; set; }
|
||||
public long StartedAtMs { get; set; }
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? EndedAtMs { get; set; }
|
||||
public long AssignedBy { get; set; }
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? ReleasedBy { get; set; }
|
||||
}
|
||||
|
||||
[SugarTable("health_ib43_observation", "IB43按用户侧别保存的观测增量")]
|
||||
[SugarIndex("ux_ib43_report_binding", nameof(ReportId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, IsUnique = true)]
|
||||
[SugarIndex("ix_ib43_obs_owner_time", nameof(UserId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, nameof(ReceivedAtMs), OrderByType.Asc)]
|
||||
public sealed class HealthIb43Observation
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
[SugarColumn(Length = 32)]
|
||||
public string ReportId { get; set; } = "";
|
||||
[SugarColumn(Length = 32)]
|
||||
public string BindingId { get; set; } = "";
|
||||
public long UserId { get; set; }
|
||||
public long ReceivedAtMs { get; set; }
|
||||
public bool HasHeartRate { get; set; }
|
||||
public bool HasRespiration { get; set; }
|
||||
public bool HasPresence { get; set; }
|
||||
public bool HasSnoring { get; set; }
|
||||
// 只含该侧有效解析字段及来源,不含另一侧、原始完整报文或凭据。
|
||||
[SugarColumn(ColumnDataType = "text")]
|
||||
public string ReadingsJson { get; set; } = "{}";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Entities;
|
||||
|
||||
[SugarTable("health_ib43_maintenance_audit", "IB43本地维护审计")]
|
||||
public sealed class HealthIb43MaintenanceAudit
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, Length = 32)] public string Id { get; set; }
|
||||
[SugarColumn(Length = 32)] public string BindingId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
[SugarColumn(Length = 200)] public string Actor { get; set; }
|
||||
[SugarColumn(Length = 64)] public string Operation { get; set; }
|
||||
public long OccurredAtMs { get; set; }
|
||||
}
|
||||
+15
-4
@@ -30,8 +30,13 @@ public sealed class HomeAssistantLightTransitionService : IHostedService, ILight
|
||||
}
|
||||
|
||||
public ILightTransitionRuntime ForEntity(string entityId)
|
||||
=> _lights.TryGetValue(entityId, out var light) ? light
|
||||
: entityId == _primary.GetStatus().EntityId ? _primary
|
||||
=> ResolveLight(entityId);
|
||||
|
||||
public Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(string entityId, int kelvin, CancellationToken token)
|
||||
=> ResolveLight(entityId).StartSleepIfIdleAsync(kelvin, token);
|
||||
|
||||
private LightTransitionWorker ResolveLight(string entityId)
|
||||
=> !string.IsNullOrWhiteSpace(entityId) && _lights.TryGetValue(entityId, out var light) ? light
|
||||
: throw new InvalidOperationException("该灯未配置为场景灯");
|
||||
|
||||
public HomeAssistantLightTransitionOutput GetStatus()
|
||||
@@ -44,31 +49,37 @@ public sealed class HomeAssistantLightTransitionService : IHostedService, ILight
|
||||
// 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。
|
||||
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken token)
|
||||
{
|
||||
EnsurePrimaryConfigured();
|
||||
await _primary.StartSleepAsync(kelvin, token);
|
||||
return GetStatus();
|
||||
}
|
||||
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken token)
|
||||
{
|
||||
EnsurePrimaryConfigured();
|
||||
await _primary.StartWakeAsync(token);
|
||||
return GetStatus();
|
||||
}
|
||||
public async Task<HomeAssistantLightTransitionOutput> CancelPrimaryAsync(CancellationToken token)
|
||||
{
|
||||
EnsurePrimaryConfigured();
|
||||
await _primary.CancelAsync(token);
|
||||
return GetStatus();
|
||||
}
|
||||
|
||||
private void EnsurePrimaryConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_primary.GetStatus().EntityId)) throw new InvalidOperationException("卧室灯光未配置");
|
||||
}
|
||||
|
||||
// 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。
|
||||
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken token)
|
||||
{
|
||||
foreach (var light in _lights.Values) await light.CancelAsync(token);
|
||||
if (_lights.Count == 0) await _primary.CancelAsync(token);
|
||||
return GetStatus();
|
||||
}
|
||||
public Task StartAsync(CancellationToken token) => Task.CompletedTask;
|
||||
public async Task StopAsync(CancellationToken token)
|
||||
{
|
||||
foreach (var light in _lights.Values) await light.StopAsync(token);
|
||||
if (_lights.Count == 0) await _primary.StopAsync(token);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-11
@@ -8,11 +8,24 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public int TimeoutSeconds { get; set; } = 15;
|
||||
// 匹配已退役设备的明确实体绑定,不按名称或品牌隐藏设备。
|
||||
public List<string> ExcludedDeviceEntityIds { get; set; } = [];
|
||||
public bool OnlyConfiguredDevices { get; set; }
|
||||
public List<DisplayOnlyDeviceOptions> DisplayOnlyDevices { get; set; } = [];
|
||||
// 每条独立灯具登记自己的主 light 实体,名称和可控能力读取自 HA。
|
||||
public List<string> ManualLightEntityIds { get; set; } = [];
|
||||
public HomeAssistantEntityOptions Entities { get; set; } = new();
|
||||
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
|
||||
public List<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DisplayOnlyDeviceOptions
|
||||
{
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = "Other";
|
||||
}
|
||||
|
||||
/// <summary>明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。</summary>
|
||||
public sealed class SceneLightOptions
|
||||
{
|
||||
@@ -20,6 +33,7 @@ public sealed class SceneLightOptions
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
public string? SleepEffect { get; set; }
|
||||
public bool StartSleepOnManualPowerOn { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantEntityOptions
|
||||
@@ -35,20 +49,20 @@ public sealed class HomeAssistantEntityOptions
|
||||
public string FragranceDuration { get; set; } = string.Empty;
|
||||
public string FragranceInterval { get; set; } = string.Empty;
|
||||
public string FragranceQuickRelease { get; set; } = string.Empty;
|
||||
public string WakeFragranceSwitch { get; set; } = string.Empty;
|
||||
public string WakeFragranceLight { get; set; } = string.Empty;
|
||||
public string WakeFragranceDuration { get; set; } = string.Empty;
|
||||
public string WakeFragranceInterval { get; set; } = string.Empty;
|
||||
public string WakeFragranceQuickRelease { get; set; } = string.Empty;
|
||||
public string WakeFragranceAutoRelease { get; set; } = string.Empty;
|
||||
public string WakeFragranceAutoLight { get; set; } = string.Empty;
|
||||
public string WakeFragranceLightAutoOff { get; set; } = string.Empty;
|
||||
public string FragranceAutoRelease { get; set; } = string.Empty;
|
||||
public string FragranceAutoLight { get; set; } = string.Empty;
|
||||
public string FragranceLightAutoOff { get; set; } = string.Empty;
|
||||
public string AmbientLight { get; set; } = string.Empty;
|
||||
public string Speaker { get; set; } = string.Empty;
|
||||
public string SpeakerSleepMode { get; set; } = string.Empty;
|
||||
public string MattressLeftHeatingLevel { get; set; } = string.Empty;
|
||||
public string MattressRightHeatingLevel { get; set; } = string.Empty;
|
||||
public string MattressLeftMode { get; set; } = string.Empty;
|
||||
public string MattressRightMode { get; set; } = string.Empty;
|
||||
public Dictionary<string, string> MattressFirmness { get; set; } = new();
|
||||
public string MattressLeftMassagePart { get; set; } = string.Empty;
|
||||
public string MattressRightMassagePart { get; set; } = string.Empty;
|
||||
public string MattressMassageLevel { get; set; } = string.Empty;
|
||||
public string Temperature { get; set; } = string.Empty;
|
||||
public string Humidity { get; set; } = string.Empty;
|
||||
public string Pm25 { get; set; } = string.Empty;
|
||||
@@ -66,8 +80,5 @@ public sealed class HomeAssistantSleepSceneOptions
|
||||
public int LightColorTemperatureKelvin { get; set; } = 2700;
|
||||
public bool EnableFragrance { get; set; } = true;
|
||||
public bool EnableSpeakerSleepMode { get; set; }
|
||||
public bool EnableMattress { get; set; }
|
||||
public int MattressHeatingLevel { get; set; } = 1;
|
||||
public string MattressMode { get; set; } = "智能适配";
|
||||
public bool EnableUv { get; set; }
|
||||
}
|
||||
|
||||
+24
-16
@@ -20,7 +20,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
private readonly TimeProvider _time;
|
||||
private readonly string _entityId;
|
||||
private readonly string? _sleepEffect;
|
||||
private readonly bool _controlsMassage;
|
||||
private readonly bool _isPrimaryLight;
|
||||
private readonly ILogger<HomeAssistantLightTransitionService> _logger;
|
||||
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
||||
private readonly object _statusLock = new();
|
||||
@@ -38,7 +38,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
{
|
||||
_entityId = light?.EntityId ?? options.Value.Entities.AmbientLight;
|
||||
_sleepEffect = light?.SleepEffect;
|
||||
_controlsMassage = light is null;
|
||||
_isPrimaryLight = light is null;
|
||||
_logger = logger;
|
||||
_homeAssistant = homeAssistant;
|
||||
_lightOff = lightOff;
|
||||
@@ -71,16 +71,24 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
}
|
||||
|
||||
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
||||
=> (await StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: false, cancellationToken))!;
|
||||
|
||||
internal Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
||||
=> StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: true, cancellationToken);
|
||||
|
||||
private async Task<HomeAssistantLightTransitionOutput?> StartSleepCoreAsync(int colorTemperatureKelvin, bool onlyIfIdle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (onlyIfIdle && GetStatus().Running) return null;
|
||||
await StopCurrentAsync();
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
var data = BuildSleepStartData(light, colorTemperatureKelvin);
|
||||
await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken);
|
||||
|
||||
if (_controlsMassage) _safety.MarkOn();
|
||||
if (_isPrimaryLight) _safety.MarkOn();
|
||||
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
|
||||
Launch("sleep", SleepStartPercent);
|
||||
return GetStatus();
|
||||
@@ -95,7 +103,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
{
|
||||
await StopCurrentAsync();
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
var knownOffAt = _controlsMassage ? _safety.OffAt : null;
|
||||
var knownOffAt = _isPrimaryLight ? _safety.OffAt : null;
|
||||
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) ||
|
||||
(knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt));
|
||||
var brightness = light.GetDoubleAttribute("brightness");
|
||||
@@ -105,7 +113,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
|
||||
if (current >= WakeTargetPercent && !isOff)
|
||||
{
|
||||
if (_controlsMassage) _safety.MarkOn();
|
||||
if (_isPrimaryLight) _safety.MarkOn();
|
||||
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
|
||||
return GetStatus();
|
||||
}
|
||||
@@ -119,7 +127,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
if (_controlsMassage) _safety.MarkOn();
|
||||
if (_isPrimaryLight) _safety.MarkOn();
|
||||
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
|
||||
Launch("wake", current);
|
||||
return GetStatus();
|
||||
@@ -151,7 +159,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
JsonElement modes = default;
|
||||
var hasModes = state.Attributes.ValueKind == JsonValueKind.Object &&
|
||||
state.Attributes.TryGetProperty("supported_color_modes", out modes) && modes.ValueKind == JsonValueKind.Array;
|
||||
if (_controlsMassage && !hasModes)
|
||||
if (_isPrimaryLight && !hasModes)
|
||||
{
|
||||
data["color_temp_kelvin"] = Math.Clamp(kelvin, 2200, 5000);
|
||||
return data;
|
||||
@@ -208,17 +216,17 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow());
|
||||
if (mode == "sleep" && _controlsMassage)
|
||||
if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
|
||||
if (mode == "sleep" && _isPrimaryLight)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止");
|
||||
SetStatus(true, false, mode, 0, 0, "灯已关闭,渐变已停止");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败");
|
||||
_logger.LogWarning(exception, "灯已关闭,但关灯事件处理失败");
|
||||
SetStatus(false, false, mode, 0, 0, exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -245,16 +253,16 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
{
|
||||
["entity_id"] = LightEntityId
|
||||
}, cancellationToken);
|
||||
if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow());
|
||||
SetStatus(true, false, mode, 0, 0, _controlsMassage ? "灯光已关闭,正在停止床垫按摩" : "助眠渐暗已完成,灯光已关闭");
|
||||
if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
|
||||
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
|
||||
try
|
||||
{
|
||||
if (_controlsMassage) await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, _controlsMassage ? "助眠渐暗已完成,灯光与床垫按摩已关闭" : "助眠渐暗已完成,灯光已关闭");
|
||||
if (_isPrimaryLight) await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败");
|
||||
_logger.LogWarning(exception, "助眠渐暗已完成,但关灯事件处理失败");
|
||||
SetStatus(false, false, mode, 0, 0, exception.Message);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43AutomationExecutionLog(IServiceScopeFactory scopes) : IBedPresenceExecutionLog
|
||||
{
|
||||
public async Task<string?> BeginAsync(string bindingId, long userId, string action, string target,
|
||||
DateTimeOffset triggeredAt, CancellationToken token)
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<Ib43DataRepository>()
|
||||
.BeginAutomation(bindingId, userId, action, target, triggeredAt.ToUnixTimeMilliseconds(), token);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Ib43DataRepository>()
|
||||
.CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public static class Ib43Configuration
|
||||
{
|
||||
// 在框架的 JSON 扫描和常规绑定后执行,仅覆盖明确提供的 IB43 环境变量。
|
||||
public static void ApplyEnvironmentOverrides(Ib43Options options)
|
||||
{
|
||||
new ConfigurationBuilder().AddEnvironmentVariables("Ib43__").Build().Bind(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Furion.FriendlyException;
|
||||
using SqlSugar;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43DataRepository(ISqlSugarClient db)
|
||||
{
|
||||
public static string DeviceKey(string productKey, string serialNumber) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(productKey + "/" + serialNumber)));
|
||||
|
||||
public Task<bool> UserEnabled(long userId) => db.Queryable<HealthUser>()
|
||||
.AnyAsync(u => u.Id == userId && !u.IsDelete && u.Status == HealthUserStatus.Enabled);
|
||||
|
||||
public async Task<bool> AdminEnabled(long userId) => await db.Queryable<SysUser>()
|
||||
.Where(u => u.Id == userId && u.AccountType == AccountTypeEnum.SuperAdmin && u.Status == StatusEnum.Enable).CountAsync() > 0;
|
||||
|
||||
public Task<List<HealthIb43Binding>> Bindings(long userId) => db.Queryable<HealthIb43Binding>()
|
||||
.Where(b => b.UserId == userId).OrderBy(b => b.StartedAtMs, OrderByType.Desc).ToListAsync();
|
||||
|
||||
public Task<List<HealthIb43Binding>> ActiveBindings(string productKey, string serialNumber)
|
||||
{
|
||||
var key = DeviceKey(productKey, serialNumber);
|
||||
return db.Queryable<HealthIb43Binding>().Where(b => b.DeviceKey == key && b.EndedAtMs == null).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<HealthIb43Binding> OwnedBinding(long userId, string id) =>
|
||||
await db.Queryable<HealthIb43Binding>().Where(b => b.Id == id && b.UserId == userId).FirstAsync()
|
||||
?? throw new AppFriendlyException("床位绑定不存在或无权访问", null) { StatusCode = 404 };
|
||||
|
||||
public Task<List<HealthIb43AutomationExecution>> AutomationHistory(long userId, string bindingId) =>
|
||||
db.Queryable<HealthIb43AutomationExecution>().Where(row => row.UserId == userId && row.BindingId == bindingId)
|
||||
.OrderBy(row => row.TriggeredAtMs, OrderByType.Desc).Take(20).ToListAsync();
|
||||
|
||||
public async Task<string?> BeginAutomation(string bindingId, long userId, string action, string target, long triggeredAtMs, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!await UserEnabled(userId) || !await db.Queryable<HealthIb43Binding>().AnyAsync(b => b.Id == bindingId &&
|
||||
b.UserId == userId && b.Simulation && b.EndedAtMs == null && b.StartedAtMs <= triggeredAtMs)) return null;
|
||||
var record = new HealthIb43AutomationExecution
|
||||
{ BindingId = bindingId, UserId = userId, ActionKey = action, TargetLabel = target, TriggeredAtMs = triggeredAtMs };
|
||||
token.ThrowIfCancellationRequested();
|
||||
await db.Insertable(record).ExecuteCommandAsync();
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public Task<int> CompleteAutomation(string id, string result, long completedAtMs) => db.Updateable<HealthIb43AutomationExecution>()
|
||||
.SetColumns(row => new HealthIb43AutomationExecution { Result = result, CompletedAtMs = completedAtMs })
|
||||
.Where(row => row.Id == id && row.CompletedAtMs == null).ExecuteCommandAsync();
|
||||
|
||||
public async Task Assign(HealthIb43Binding binding)
|
||||
{
|
||||
if (await db.Queryable<HealthIb43Binding>().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
|
||||
throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
|
||||
try { await db.Insertable(binding).ExecuteCommandAsync(); }
|
||||
catch
|
||||
{
|
||||
if (await db.Queryable<HealthIb43Binding>().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
|
||||
throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Release(string id, long adminId, long endedAtMs)
|
||||
{
|
||||
var count = await db.Updateable<HealthIb43Binding>()
|
||||
.SetColumns(b => new HealthIb43Binding { ActiveSlot = null, EndedAtMs = endedAtMs, ReleasedBy = adminId })
|
||||
.Where(b => b.Id == id && b.EndedAtMs == null).ExecuteCommandAsync();
|
||||
if (count == 0) throw new AppFriendlyException("绑定不存在或已解绑", null) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
public async Task<Ib43SavedReport> Save(string reportId, Ib43Binding device, Ib43Report report)
|
||||
{
|
||||
if (!report.Accepted || report.Readings.Count == 0) return new(0, []);
|
||||
var matched = 0;
|
||||
var activeBeds = new List<Ib43SavedBed>();
|
||||
var key = DeviceKey(device.ProductKey, device.SerialNumber);
|
||||
var received = report.ReceivedAt.ToUnixTimeMilliseconds();
|
||||
// 按接收时刻归属;数据库重试期间发生解绑也不转移给新用户。
|
||||
var bindings = await db.Queryable<HealthIb43Binding>().Where(b => b.DeviceKey == key &&
|
||||
b.Simulation == device.Simulation && b.StartedAtMs <= received &&
|
||||
(b.EndedAtMs == null || b.EndedAtMs > received)).ToListAsync();
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
var readings = report.Readings.Where(p => p.Key.StartsWith(binding.Side + ".", StringComparison.Ordinal))
|
||||
.ToDictionary(p => p.Key[(binding.Side.Length + 1)..], p => p.Value);
|
||||
if (readings.Count == 0) continue;
|
||||
matched++;
|
||||
if (binding.EndedAtMs == null) activeBeds.Add(new(binding.Id, binding.Side));
|
||||
if (await db.Queryable<HealthIb43Observation>().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) continue;
|
||||
var observation = new HealthIb43Observation
|
||||
{
|
||||
ReportId = reportId, BindingId = binding.Id, UserId = binding.UserId, ReceivedAtMs = received,
|
||||
HasHeartRate = readings.ContainsKey("heartRate"), HasRespiration = readings.ContainsKey("respiration"),
|
||||
HasPresence = readings.ContainsKey("presence"), HasSnoring = readings.ContainsKey("snoring"),
|
||||
ReadingsJson = JsonSerializer.Serialize(readings)
|
||||
};
|
||||
try { await db.Insertable(observation).ExecuteCommandAsync(); }
|
||||
catch
|
||||
{
|
||||
// 同一进程处理的报告重试幂等;没有设备序号,不能对 MQTT 重投宣称去重。
|
||||
if (!await db.Queryable<HealthIb43Observation>().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) throw;
|
||||
}
|
||||
}
|
||||
return new(matched, activeBeds);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, Ib43Reading>> Latest(long userId, string bindingId)
|
||||
{
|
||||
var output = new Dictionary<string, Ib43Reading>();
|
||||
foreach (var metric in new[] { "heartRate", "respiration", "presence", "snoring" })
|
||||
{
|
||||
var query = db.Queryable<HealthIb43Observation>().Where(o => o.UserId == userId && o.BindingId == bindingId);
|
||||
query = metric switch
|
||||
{
|
||||
"heartRate" => query.Where(o => o.HasHeartRate),
|
||||
"respiration" => query.Where(o => o.HasRespiration),
|
||||
"presence" => query.Where(o => o.HasPresence),
|
||||
_ => query.Where(o => o.HasSnoring)
|
||||
};
|
||||
var row = await query.OrderBy(o => o.ReceivedAtMs, OrderByType.Desc).OrderBy(o => o.Id, OrderByType.Desc).FirstAsync();
|
||||
if (row != null) output[metric] = JsonSerializer.Deserialize<Dictionary<string, Ib43Reading>>(row.ReadingsJson)[metric];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public Task<List<HealthIb43Observation>> History(long userId, string bindingId, long fromMs, long toMs, long? beforeId, int take)
|
||||
=> db.Queryable<HealthIb43Observation>().Where(o => o.UserId == userId && o.BindingId == bindingId &&
|
||||
o.ReceivedAtMs >= fromMs && o.ReceivedAtMs < toMs)
|
||||
.WhereIF(beforeId.HasValue, o => o.Id < beforeId.Value)
|
||||
.OrderBy(o => o.Id, OrderByType.Desc).Take(take).ToListAsync();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
// 仅由本地维护程序调用;不是 Controller,也不注册 HTTP 路由。
|
||||
public sealed class Ib43LocalBinding(ISqlSugarClient db, TimeProvider clock)
|
||||
{
|
||||
public async Task<HealthIb43Binding> Run(string phone, Ib43Options options, string actor, bool apply)
|
||||
{
|
||||
var device = options.Devices.SingleOrDefault(d => d.ProductKey == "evergreen-test" && d.SerialNumber == "IB43SIM001");
|
||||
if (device?.Simulation != true) throw new InvalidOperationException("模拟设备白名单不匹配,已停止。");
|
||||
var users = await db.Queryable<HealthUser>().Where(u => u.Phone == phone && !u.IsDelete &&
|
||||
u.Status == HealthUserStatus.Enabled).Select(u => new { u.Id }).ToListAsync();
|
||||
if (users.Count != 1) throw new InvalidOperationException("未找到唯一且已启用的健康账号,已停止。");
|
||||
var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
|
||||
var slot = key + ".left";
|
||||
var existing = await db.Queryable<HealthIb43Binding>().Where(b => b.ActiveSlot == slot).FirstAsync();
|
||||
if (existing != null)
|
||||
{
|
||||
if (existing.UserId != users[0].Id || !existing.Simulation || existing.EndedAtMs != null)
|
||||
throw new InvalidOperationException("左侧已被其他用户或非模拟绑定占用,未覆盖或解绑。");
|
||||
return existing;
|
||||
}
|
||||
var binding = new HealthIb43Binding
|
||||
{
|
||||
DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
|
||||
Side = "left", ActiveSlot = slot, UserId = users[0].Id, BedName = "IB43模拟床垫左侧",
|
||||
Simulation = true, StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = 0
|
||||
};
|
||||
if (!apply) return binding;
|
||||
if (string.IsNullOrWhiteSpace(actor) || actor.Length > 200) throw new InvalidOperationException("维护操作者信息不合法。");
|
||||
var stage = "binding";
|
||||
await db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
await db.Insertable(binding).ExecuteCommandAsync();
|
||||
stage = "audit";
|
||||
await db.Insertable(new HealthIb43MaintenanceAudit
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"), BindingId = binding.Id, UserId = binding.UserId,
|
||||
Actor = actor, OccurredAtMs = binding.StartedAtMs, Operation = "local-bind-simulator-left"
|
||||
}).ExecuteCommandAsync();
|
||||
stage = "commit";
|
||||
await db.Ado.CommitTranAsync();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await db.Ado.RollbackTranAsync();
|
||||
var code = error is MySqlConnector.MySqlException mysql ? $" MySQL {mysql.Number}" : "";
|
||||
throw new InvalidOperationException($"绑定事务失败 ({stage}, {error.GetType().Name}{code}),请重新预检确认数据库状态。", error);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43MqttService(IOptions<Ib43Options> options, IIb43MqttTransportFactory factory,
|
||||
Ib43StateStore store, TimeProvider clock, ILogger<Ib43MqttService> logger, IIb43ReportSink sink = null,
|
||||
IBedPresenceSink presenceSink = null) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var configuration = options.Value;
|
||||
logger.LogInformation("IB43 MQTT startup: Enabled={Enabled}, Host={Host}, Port={Port}, PasswordConfigured={PasswordConfigured}, Devices={Count}",
|
||||
configuration.Enabled, configuration.Host, configuration.Port,
|
||||
!string.IsNullOrWhiteSpace(configuration.Password), configuration.Devices?.Count ?? 0);
|
||||
if (!configuration.Enabled)
|
||||
{
|
||||
logger.LogInformation("IB43 MQTT disabled; set Ib43__Enabled=true to enable");
|
||||
return;
|
||||
}
|
||||
// 即使直接构造(测试/其他宿主)也不带错误配置进入重试。
|
||||
if (!configuration.IsValid()) throw new InvalidOperationException("Invalid Ib43 configuration");
|
||||
var failures = 0;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var connectedAt = clock.GetUtcNow();
|
||||
var connectionId = Guid.NewGuid();
|
||||
using var connection = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
var connectionToken = connection.Token;
|
||||
try
|
||||
{
|
||||
using var transport = factory.Create();
|
||||
var seenKinds = new HashSet<string>(StringComparer.Ordinal);
|
||||
int? lastMatchedBeds = null;
|
||||
transport.Message += async (topic, payload, retained) =>
|
||||
{
|
||||
var report = store.Receive(topic, payload, retained);
|
||||
if (sink != null && report.Accepted && report.Readings.Count > 0)
|
||||
{
|
||||
var binding = configuration.Devices.Single(d => d.PostTopic == topic);
|
||||
var reportId = Guid.NewGuid().ToString("N");
|
||||
Ib43SavedReport saved;
|
||||
// 等待持久化再结束回调(MQTT 自动 ACK 在此之后)。失败时阻塞重试而非静默丢弃。
|
||||
while (true)
|
||||
{
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
saved = await sink.SaveAsync(reportId, binding, report, stoppingToken);
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning("IB43 persistence failed ({Type}); retrying in 5 seconds", error.GetType().Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), clock, stoppingToken);
|
||||
}
|
||||
}
|
||||
if (saved.MatchedCount != lastMatchedBeds)
|
||||
logger.LogInformation("IB43 persistence: matched beds={Count}; zero means no user observation saved", saved.MatchedCount);
|
||||
lastMatchedBeds = saved.MatchedCount;
|
||||
// 发布失败与写库重试分开,联动问题不能导致重写已保存的报文。
|
||||
try { Ib43PresenceAdapter.Publish(presenceSink, binding, report, saved.ActiveBeds, connectionId, connectionToken); }
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning("IB43 presence publication failed ({Type})", error.GetType().Name);
|
||||
}
|
||||
}
|
||||
if (!report.Accepted || report.Issues.Count > 0)
|
||||
logger.LogDebug("IB43 report has {Count} parsing issues", report.Issues.Count);
|
||||
if (report.Accepted && (report.Readings.Count > 0 || report.Fields.Count > 0))
|
||||
foreach (var kind in report.Kinds)
|
||||
if (seenKinds.Add(kind))
|
||||
logger.LogInformation("IB43 first {Kind} report received in this connection", kind);
|
||||
};
|
||||
transport.OversizedMessage += store.RejectOversized;
|
||||
store.SetTransport("connecting");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
await transport.ConnectAndSubscribeAsync(configuration, timeout.Token);
|
||||
store.SetTransport("connected");
|
||||
connectedAt = clock.GetUtcNow();
|
||||
logger.LogInformation("IB43 MQTT connected and subscribed to {Count} configured devices", configuration.Devices.Count);
|
||||
while (transport.IsConnected)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), clock, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 不写密码、连接选项、完整异常文本或原始健康报文。
|
||||
logger.LogWarning("IB43 MQTT connection/subscription failed ({Type}); retrying", exception.GetType().Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.Cancel();
|
||||
store.SetTransport("disconnected");
|
||||
}
|
||||
if (clock.GetUtcNow() - connectedAt > TimeSpan.FromMinutes(1)) failures = 0;
|
||||
var seconds = Math.Min(30, Math.Pow(2, Math.Min(failures++, 5)));
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(seconds), clock, stoppingToken); }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using MQTTnet;
|
||||
using MQTTnet.Client;
|
||||
using MQTTnet.Formatter;
|
||||
using MQTTnet.Protocol;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public interface IIb43MqttTransport : IDisposable
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
event Func<string, string, bool, Task> Message;
|
||||
event Action<string> OversizedMessage;
|
||||
Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IIb43MqttTransportFactory
|
||||
{
|
||||
IIb43MqttTransport Create();
|
||||
}
|
||||
|
||||
public sealed class Ib43MqttTransportFactory : IIb43MqttTransportFactory
|
||||
{
|
||||
public IIb43MqttTransport Create() => new Ib43MqttTransport();
|
||||
}
|
||||
|
||||
public sealed class Ib43MqttTransport : IIb43MqttTransport
|
||||
{
|
||||
private readonly IMqttClient _client = new MqttFactory().CreateMqttClient();
|
||||
public bool IsConnected => _client.IsConnected;
|
||||
public event Func<string, string, bool, Task> Message;
|
||||
public event Action<string> OversizedMessage;
|
||||
|
||||
public Ib43MqttTransport()
|
||||
{
|
||||
_client.ApplicationMessageReceivedAsync += async e =>
|
||||
{
|
||||
var m = e.ApplicationMessage;
|
||||
// 在解码前限制长度;不要将大包全部复制进诊断缓存。
|
||||
if (m.PayloadSegment.Count > Ib43StateStore.MaxPayloadBytes)
|
||||
OversizedMessage?.Invoke(m.Topic);
|
||||
else if (Message != null) await Message.Invoke(m.Topic, m.ConvertPayloadToString(), m.Retain);
|
||||
};
|
||||
}
|
||||
|
||||
public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = new MqttClientOptionsBuilder().WithTcpServer(options.Host, options.Port)
|
||||
.WithClientId(options.ClientId).WithCredentials(options.Username, options.Password)
|
||||
.WithProtocolVersion(MqttProtocolVersion.V311).WithCleanSession()
|
||||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30)).WithTimeout(TimeSpan.FromSeconds(10));
|
||||
if (options.UseTls) builder.WithTlsOptions(t => t.UseTls());
|
||||
await _client.ConnectAsync(builder.Build(), cancellationToken);
|
||||
var subscription = new MqttClientSubscribeOptionsBuilder();
|
||||
foreach (var device in options.Devices)
|
||||
subscription.WithTopicFilter(f => f.WithTopic(device.PostTopic)
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce));
|
||||
var result = await _client.SubscribeAsync(subscription.Build(), cancellationToken);
|
||||
if (result.Items.Count != options.Devices.Count || result.Items.Any(i => (int)i.ResultCode >= 128))
|
||||
throw new InvalidOperationException("IB43 subscription rejected");
|
||||
}
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43Options
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string Host { get; set; } = "192.168.2.2";
|
||||
public int Port { get; set; } = 1883;
|
||||
public bool UseTls { get; set; }
|
||||
public string Username { get; set; } = "evergreen-backend";
|
||||
public string Password { get; set; } = "";
|
||||
public string ClientId { get; set; } = "evergreen-health-backend";
|
||||
public int StaleAfterSeconds { get; set; } = 150;
|
||||
public List<Ib43Binding> Devices { get; set; } = [];
|
||||
|
||||
public bool IsValid() => !Enabled ||
|
||||
(!string.IsNullOrWhiteSpace(Host) && Port is > 0 and <= 65535 &&
|
||||
!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password) &&
|
||||
!string.IsNullOrWhiteSpace(ClientId) && StaleAfterSeconds > 0 &&
|
||||
Devices is { Count: > 0 } && Devices.All(d => d != null &&
|
||||
ValidSegment(d.ProductKey) && ValidSegment(d.SerialNumber) &&
|
||||
d.PersonLeftIndex is null or 0 or 1) &&
|
||||
Devices.Select(d => d.PostTopic).Distinct(StringComparer.Ordinal).Count() == Devices.Count);
|
||||
|
||||
private static bool ValidSegment(string value) => !string.IsNullOrWhiteSpace(value) &&
|
||||
!value.Any(c => c is '/' or '+' or '#' || char.IsControl(c));
|
||||
}
|
||||
|
||||
public sealed class Ib43Binding
|
||||
{
|
||||
public string ProductKey { get; set; } = "";
|
||||
public string SerialNumber { get; set; } = "";
|
||||
public bool Simulation { get; set; } = true;
|
||||
// 实机验证前保持 null;不可从数组顺序猜左右。
|
||||
public int? PersonLeftIndex { get; set; }
|
||||
public string PostTopic => $"/sys/{ProductKey}/{SerialNumber}/thing/property/post";
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed record Ib43Reading(double? Value, DateTimeOffset ReceivedAt, string Source);
|
||||
|
||||
public sealed class Ib43Report
|
||||
{
|
||||
public bool Accepted { get; internal set; }
|
||||
public string Raw { get; init; }
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public List<string> Issues { get; } = [];
|
||||
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, JsonElement> Fields { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> Kinds { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>只解析协议事实;不产生控制、健康告警或睡眠分期。</summary>
|
||||
public sealed class Ib43Parser
|
||||
{
|
||||
public Ib43Report Parse(string topic, string payload, Ib43Binding binding, DateTimeOffset receivedAt)
|
||||
{
|
||||
var r = new Ib43Report { Raw = payload, ReceivedAt = receivedAt };
|
||||
if (topic != binding.PostTopic) return Reject(r, "topic-mismatch");
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
if (HasDuplicates(root)) return Reject(r, "duplicate-json-key");
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String ||
|
||||
method.GetString() != "thing.property.post" ||
|
||||
!root.TryGetProperty("params", out var p) || p.ValueKind != JsonValueKind.Object)
|
||||
return Reject(r, "invalid-envelope");
|
||||
if (!p.TryGetProperty("deviceName", out var sn) || sn.ValueKind != JsonValueKind.String ||
|
||||
sn.GetString() != binding.SerialNumber ||
|
||||
(p.TryGetProperty("productKey", out var pk) &&
|
||||
(pk.ValueKind != JsonValueKind.String || pk.GetString() != binding.ProductKey)))
|
||||
return Reject(r, "identity-mismatch");
|
||||
if (root.TryGetProperty("version", out var version) &&
|
||||
(version.ValueKind != JsonValueKind.String || version.GetString() != "1.0"))
|
||||
return Reject(r, "unsupported-version");
|
||||
r.Accepted = true;
|
||||
foreach (var key in new[] { "firmwareVer", "characteristic", "deviceMode" })
|
||||
{
|
||||
if (!p.TryGetProperty(key, out var value)) continue;
|
||||
r.Kinds.Add("startup");
|
||||
var valid = key switch
|
||||
{
|
||||
"firmwareVer" => value.ValueKind == JsonValueKind.String,
|
||||
"characteristic" => value.ValueKind == JsonValueKind.Object,
|
||||
_ => value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out _)
|
||||
};
|
||||
if (valid) r.Fields[key] = value.Clone(); else r.Issues.Add(key + ":invalid-type");
|
||||
}
|
||||
if (p.TryGetProperty("HR", out var hr))
|
||||
{
|
||||
r.Kinds.Add("periodic");
|
||||
if (Numbers(hr, 6, "HR", r) is { } values)
|
||||
{
|
||||
Vitals(r, "left", values[0], values[1], values[2], "HR");
|
||||
Vitals(r, "right", values[3], values[4], values[5], "HR");
|
||||
}
|
||||
}
|
||||
if (p.TryGetProperty("airbagsPerson", out var person))
|
||||
{
|
||||
r.Kinds.Add("periodic");
|
||||
if (Numbers(person, 2, "airbagsPerson", r) is { } values)
|
||||
{
|
||||
if (values.All(v => v is 0 or 1))
|
||||
{
|
||||
r.Fields["airbagsPerson"] = person.Clone();
|
||||
if (binding.PersonLeftIndex is int index)
|
||||
{
|
||||
Add(r, "left.presence", values[index], "airbagsPerson");
|
||||
Add(r, "right.presence", values[1 - index], "airbagsPerson");
|
||||
}
|
||||
}
|
||||
else r.Issues.Add("airbagsPerson:invalid-enum");
|
||||
}
|
||||
}
|
||||
foreach (var (key, length) in new[] { ("airbagsPressure", 8), ("motorHeight", 2),
|
||||
("motorStatus", 4), ("motorMode", 2), ("snoreStatus", 2),
|
||||
("LeftRegularTime", 10), ("RightRegularTime", 10) })
|
||||
{
|
||||
if (!p.TryGetProperty(key, out var value)) continue;
|
||||
r.Kinds.Add("periodic");
|
||||
if (Numbers(value, length, key, r) is not { } numbers) continue;
|
||||
if (key == "motorStatus" && numbers.Any(n => n is not (0 or 1 or 2)))
|
||||
r.Issues.Add("motorStatus:invalid-enum");
|
||||
else r.Fields[key] = value.Clone();
|
||||
}
|
||||
if (p.TryGetProperty("adaptive", out var adaptive))
|
||||
{
|
||||
r.Kinds.Add("periodic");
|
||||
if (adaptive.ValueKind == JsonValueKind.Array && adaptive.GetArrayLength() == 2 &&
|
||||
adaptive.EnumerateArray().All(a => a.ValueKind == JsonValueKind.Object))
|
||||
r.Fields["adaptive"] = adaptive.Clone();
|
||||
else r.Issues.Add("adaptive:invalid-shape");
|
||||
}
|
||||
var sleep = Alias(p, "SleepReportNew", r);
|
||||
if (sleep is { } s)
|
||||
{
|
||||
r.Kinds.Add("event");
|
||||
if (s.ValueKind != JsonValueKind.Object) r.Issues.Add("SleepReportNew:invalid-type");
|
||||
else if (Alias(s, "ibNew", r) is { } ib && Numbers(ib, 12, "ibNew", r) is { } values)
|
||||
{
|
||||
Presence(r, "left", values[0], "ibNew");
|
||||
Presence(r, "right", values[6], "ibNew");
|
||||
Vitals(r, "left", values[4], values[3], values[1], "ibNew");
|
||||
Vitals(r, "right", values[10], values[9], values[7], "ibNew");
|
||||
}
|
||||
else r.Issues.Add("ibNew:missing-or-invalid");
|
||||
}
|
||||
if (r.Kinds.Count == 0) r.Issues.Add("unrecognized-report");
|
||||
}
|
||||
catch (JsonException) { return Reject(r, "invalid-json"); }
|
||||
return r;
|
||||
}
|
||||
|
||||
private static Ib43Report Reject(Ib43Report r, string issue)
|
||||
{ r.Accepted = false; r.Issues.Add(issue); return r; }
|
||||
|
||||
private static bool HasDuplicates(JsonElement node) => node.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => node.EnumerateObject().GroupBy(p => p.Name, StringComparer.Ordinal).Any(g => g.Count() > 1) ||
|
||||
node.EnumerateObject().Any(p => HasDuplicates(p.Value)),
|
||||
JsonValueKind.Array => node.EnumerateArray().Any(HasDuplicates),
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static JsonElement? Alias(JsonElement p, string key, Ib43Report r)
|
||||
{
|
||||
var plain = p.TryGetProperty(key, out var a);
|
||||
var spaced = p.TryGetProperty(" " + key, out var b);
|
||||
// 双键即使内容相同也拒绝该字段,避免依赖 JSON 文本格式决定覆盖顺序。
|
||||
if (plain && spaced) { r.Issues.Add(key + ":ambiguous-alias"); return null; }
|
||||
if (spaced) { r.Fields["alias." + key] = JsonSerializer.SerializeToElement(" " + key); return b; }
|
||||
return plain ? a : null;
|
||||
}
|
||||
|
||||
private static double[] Numbers(JsonElement value, int length, string key, Ib43Report r)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() != length)
|
||||
{ r.Issues.Add(key + ":invalid-length"); return null; }
|
||||
var numbers = new double[length];
|
||||
var i = 0;
|
||||
foreach (var item in value.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Number || !item.TryGetDouble(out var number) || !double.IsFinite(number))
|
||||
{ r.Issues.Add(key + ":invalid-number"); return null; }
|
||||
numbers[i++] = number;
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
private static void Add(Ib43Report r, string key, double? value, string source)
|
||||
=> r.Readings[key] = new(value, r.ReceivedAt, source);
|
||||
|
||||
private static void Presence(Ib43Report r, string side, double value, string source)
|
||||
{
|
||||
if (value is 0 or 1) Add(r, side + ".presence", value, source);
|
||||
else r.Issues.Add(side + ".presence:invalid-enum");
|
||||
}
|
||||
|
||||
private static void Vitals(Ib43Report r, string side, double hr, double respiration, double snore, string source)
|
||||
{
|
||||
if (hr >= 0) Add(r, side + ".heartRate", hr == 0 ? null : hr, source);
|
||||
else r.Issues.Add(side + ".heartRate:negative");
|
||||
if (respiration >= 0) Add(r, side + ".respiration", respiration == 0 ? null : respiration, source);
|
||||
else r.Issues.Add(side + ".respiration:negative");
|
||||
if (snore is 0 or 1) Add(r, side + ".snoring", snore, source);
|
||||
else r.Issues.Add(side + ".snoring:invalid-enum");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed record Ib43SavedBed(string BindingId, string Side);
|
||||
public sealed record Ib43SavedReport(int MatchedCount, IReadOnlyList<Ib43SavedBed> ActiveBeds);
|
||||
|
||||
internal static class Ib43PresenceAdapter
|
||||
{
|
||||
public static void Publish(IBedPresenceSink sink, Ib43Binding device, Ib43Report report,
|
||||
IReadOnlyList<Ib43SavedBed> beds, Guid connectionId, CancellationToken connectionToken)
|
||||
{
|
||||
if (sink == null || !report.Accepted || report.Issues.Count != 0) return;
|
||||
foreach (var bed in beds)
|
||||
{
|
||||
if (bed.Side is not ("left" or "right") ||
|
||||
!report.Readings.TryGetValue(bed.Side + ".presence", out var presence) ||
|
||||
presence.Source != "ibNew" || presence.Value is not (0 or 1)) continue;
|
||||
sink.Observe(new(bed.BindingId, device.ProductKey, device.SerialNumber,
|
||||
bed.Side == "left" ? BedSide.Left : BedSide.Right, device.Simulation,
|
||||
presence.Value == 0 ? BedPresence.Empty : BedPresence.Occupied, presence.ReceivedAt, connectionId), connectionToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public interface IIb43ReportSink
|
||||
{
|
||||
Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token);
|
||||
}
|
||||
|
||||
public sealed class Ib43ReportSink(IServiceScopeFactory scopes) : IIb43ReportSink
|
||||
{
|
||||
public async Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
using var scope = scopes.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<Ib43DataRepository>().Save(reportId, binding, report);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed record Ib43Field(JsonElement Value, DateTimeOffset ReceivedAt);
|
||||
public sealed record Ib43Snapshot(string ProductKey, string SerialNumber, bool Simulation,
|
||||
string Transport, string Freshness, DateTimeOffset? LastTelemetryAt,
|
||||
IReadOnlyDictionary<string, Ib43Reading> Readings, IReadOnlyDictionary<string, Ib43Field> Fields);
|
||||
public sealed record Ib43Diagnostic(string Topic, DateTimeOffset ReceivedAt, string Raw, string[] Issues);
|
||||
|
||||
/// <summary>进程内有界缓存。没有设备遗嘱,不能将数据超时等同于已确认设备离线。</summary>
|
||||
public sealed class Ib43StateStore(IOptions<Ib43Options> options, Ib43Parser parser, TimeProvider clock)
|
||||
{
|
||||
public const int MaxPayloadBytes = 65536;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, DeviceState> _states = new(StringComparer.Ordinal);
|
||||
private readonly Queue<Ib43Diagnostic> _diagnostics = new();
|
||||
private string _transport = options.Value.Enabled ? "disconnected" : "disabled";
|
||||
|
||||
private sealed class DeviceState
|
||||
{
|
||||
public DateTimeOffset? LastTelemetryAt;
|
||||
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, Ib43Field> Fields { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public void SetTransport(string state) { lock (_gate) _transport = state; }
|
||||
|
||||
public void RejectOversized(string topic)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_diagnostics.Enqueue(new(topic, clock.GetUtcNow(), "", ["payload-too-large:body-not-retained"]));
|
||||
while (_diagnostics.Count > 32) _diagnostics.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
public Ib43Report Receive(string topic, string payload, bool retained = false)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var binding = options.Value.Devices.FirstOrDefault(d => d.PostTopic == topic);
|
||||
Ib43Report report;
|
||||
if (binding == null || retained || System.Text.Encoding.UTF8.GetByteCount(payload) > MaxPayloadBytes)
|
||||
{
|
||||
report = new() { Raw = payload.Length > 4096 ? payload[..4096] : payload, ReceivedAt = now };
|
||||
report.Issues.Add(binding == null ? "unbound-topic" : retained ? "retained-not-live" : "payload-too-large");
|
||||
}
|
||||
else report = parser.Parse(topic, payload, binding, now);
|
||||
|
||||
_diagnostics.Enqueue(new(topic, now, report.Raw, report.Issues.ToArray()));
|
||||
while (_diagnostics.Count > 32) _diagnostics.Dequeue();
|
||||
if (!report.Accepted) return report;
|
||||
if (!_states.TryGetValue(topic, out var state)) _states[topic] = state = new();
|
||||
foreach (var (key, value) in report.Readings) state.Readings[key] = value;
|
||||
foreach (var (key, value) in report.Fields) state.Fields[key] = new(value, now);
|
||||
// 只有有效实时字段刷新活跃性;能力包、未知包和坏包不能掩盖实时数据停止。
|
||||
if (report.Readings.Count > 0 || report.Fields.Keys.Any(k => k is "airbagsPerson" or
|
||||
"airbagsPressure" or "motorHeight" or "motorStatus" or "snoreStatus"))
|
||||
state.LastTelemetryAt = now;
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
public Ib43Snapshot[] Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
return options.Value.Devices.Select(binding =>
|
||||
{
|
||||
_states.TryGetValue(binding.PostTopic, out var state);
|
||||
var last = state?.LastTelemetryAt;
|
||||
var freshness = last == null ? "unknown" :
|
||||
now - last.Value > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent";
|
||||
return new Ib43Snapshot(binding.ProductKey, binding.SerialNumber, binding.Simulation,
|
||||
_transport, freshness, last,
|
||||
state == null ? new Dictionary<string, Ib43Reading>() : new(state.Readings),
|
||||
state == null ? new Dictionary<string, Ib43Field>() : new(state.Fields));
|
||||
}).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// 不通过公共 H5 API 暴露原始健康数据;后续持久化需绑定用户权限与保留期限。
|
||||
public Ib43Diagnostic[] Diagnostics() { lock (_gate) return _diagnostics.ToArray(); }
|
||||
}
|
||||
+9
-1
@@ -61,7 +61,13 @@ public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime
|
||||
tracks is not null && tracks.Count > 0 &&
|
||||
tracks.All(track => IsValidTrack(track));
|
||||
|
||||
public async Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken)
|
||||
public Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken) =>
|
||||
StartAsync(constitutionCode, onlyIfIdle: false, cancellationToken);
|
||||
|
||||
internal Task<bool> TryStartIdlePlaybackAsync(string constitutionCode, CancellationToken cancellationToken) =>
|
||||
StartAsync(constitutionCode, onlyIfIdle: true, cancellationToken);
|
||||
|
||||
private async Task<bool> StartAsync(string constitutionCode, bool onlyIfIdle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!HasPlayableTracks(constitutionCode))
|
||||
throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整");
|
||||
@@ -69,12 +75,14 @@ public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime
|
||||
await _operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (onlyIfIdle && _activeTask is { IsCompleted: false }) return false;
|
||||
await CancelCurrentLoopAsync();
|
||||
await _miAirX.SetVolumeAsync(DemoStartVolume, cancellationToken);
|
||||
await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken);
|
||||
UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱");
|
||||
_activeCancellation = new CancellationTokenSource();
|
||||
_activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token);
|
||||
return true;
|
||||
}
|
||||
finally { _operationLock.Release(); }
|
||||
}
|
||||
|
||||
@@ -99,25 +99,14 @@ public sealed class HealthDashboardOutput
|
||||
public string BmiStatus { get; set; } = string.Empty;
|
||||
public PersonalizedSceneConfig? SceneConfig { get; set; }
|
||||
public TcmAssessmentResult? TcmResult { get; set; }
|
||||
public MattressComfortOutput? MattressComfort { get; set; }
|
||||
public EnvironmentAdviceOutput? EnvironmentAdvice { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MattressComfortOutput
|
||||
public sealed class EnvironmentAdviceOutput
|
||||
{
|
||||
public decimal? Bmi { get; set; }
|
||||
public string? Firmness { get; set; }
|
||||
public int HeatingLevel { get; set; }
|
||||
public bool IsWinter { get; set; }
|
||||
public double? OutdoorTemperatureCelsius { get; set; }
|
||||
public double? FeelsLikeCelsius { get; set; }
|
||||
public bool WeatherAvailable { get; set; }
|
||||
public string? WeatherObservedAt { get; set; }
|
||||
public string WeatherReason { get; set; } = string.Empty;
|
||||
public string FirmnessReason { get; set; } = string.Empty;
|
||||
public double? IndoorTemperatureCelsius { get; set; }
|
||||
public bool IndoorTemperatureAvailable { get; set; }
|
||||
public DateTimeOffset? IndoorTemperatureObservedAt { get; set; }
|
||||
public string HeatingReason { get; set; } = string.Empty;
|
||||
public ClimateAdviceOutput ClimateAdvice { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -154,11 +143,6 @@ public sealed class PersonalizedSceneConfig
|
||||
public int LightBrightnessPercent { get; set; } = 20;
|
||||
public int LightColorTemperatureKelvin { get; set; } = 2700;
|
||||
|
||||
public decimal MattressTargetTemp { get; set; } = 36m;
|
||||
public int MattressLeftFirmness { get; set; } = 5;
|
||||
public int MattressRightFirmness { get; set; } = 6;
|
||||
public int MattressHeatingLevel { get; set; } = 1;
|
||||
public string MattressMode { get; set; } = "智能适配";
|
||||
|
||||
public bool EnableFragrance { get; set; } = true;
|
||||
public int FragranceLevel { get; set; } = 1;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace Evergreen.Health.Application.Health.Policies;
|
||||
|
||||
/// <summary>Demo 起点,可由用户手动修改,不是医学或人体工学判断。</summary>
|
||||
public static class BmiFirmnessPolicy
|
||||
{
|
||||
public static decimal? Calculate(decimal? heightCm, decimal? weightKg)
|
||||
{
|
||||
if (heightCm is null or <= 0 || weightKg is null or <= 0) return null;
|
||||
try
|
||||
{
|
||||
var height = heightCm.Value / 100m;
|
||||
return Math.Round(weightKg.Value / (height * height), 1);
|
||||
}
|
||||
catch (ArithmeticException) { return null; }
|
||||
}
|
||||
|
||||
public static string? Firmness(decimal? bmi) => bmi switch
|
||||
{
|
||||
< 18.5m => "很软", < 24m => "稍软", < 28m => "稍硬",
|
||||
< 32m => "很硬", >= 32m => "最硬", _ => null
|
||||
};
|
||||
}
|
||||
@@ -4,15 +4,21 @@ namespace Evergreen.Health.Application.Health.Policies;
|
||||
|
||||
public sealed record RecommendedRoomRange(int Minimum, int Maximum)
|
||||
{
|
||||
public static RecommendedRoomRange Default { get; } = new(22, 25);
|
||||
public string Display => $"{Minimum}–{Maximum}℃";
|
||||
public static RecommendedRoomRange ForConstitution(string code) => code switch
|
||||
{
|
||||
"yang_deficient" => new(24, 26), "yin_deficient" or "damp_heat" => new(21, 24),
|
||||
"phlegm_damp" => new(22, 24), "blood_stasis" => new(23, 25), _ => new(22, 25)
|
||||
"phlegm_damp" => new(22, 24), "blood_stasis" => new(23, 25), _ => Default
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record IndoorTemperatureSample(double? Celsius, DateTimeOffset? ObservedAt, bool Available, string Reason);
|
||||
public enum IndoorTemperatureIssue { Unavailable, Stale, Invalid }
|
||||
|
||||
public sealed record IndoorTemperatureSample(double? Celsius, DateTimeOffset? ObservedAt, bool Available, string Reason)
|
||||
{
|
||||
public IndoorTemperatureIssue? Issue { get; init; }
|
||||
}
|
||||
|
||||
public static class RoomTemperaturePolicy
|
||||
{
|
||||
@@ -25,10 +31,10 @@ public static class RoomTemperaturePolicy
|
||||
Mode = mode, RecommendedMinimumCelsius = range.Minimum, RecommendedMaximumCelsius = range.Maximum,
|
||||
Reason = mode switch
|
||||
{
|
||||
"heat" => $"室温低于建议 {range.Display}:床垫低档加热,建议空调制热",
|
||||
"cool" => $"室温高于建议 {range.Display}:床垫不加热,建议空调制冷",
|
||||
"off" => $"室温在建议 {range.Display} 内:床垫不加热,空调不开启",
|
||||
_ => $"{sample.Reason},不自动加热或开启空调"
|
||||
"heat" => $"室温低于建议 {range.Display}:建议空调制热",
|
||||
"cool" => $"室温高于建议 {range.Display}:建议空调制冷",
|
||||
"off" => $"室温在建议 {range.Display} 内:空调保持当前运行状态",
|
||||
_ => $"{sample.Reason},不自动开启空调"
|
||||
},
|
||||
PendingIntegration = mode is "heat" or "cool"
|
||||
};
|
||||
|
||||
@@ -25,13 +25,22 @@ public sealed class DeviceActionRunner
|
||||
public async Task<HomeAssistantState> ReadAvailableStateAsync(string entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entityId)) throw new InvalidOperationException("参数实体尚未配置");
|
||||
var state = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item =>
|
||||
return RequireAvailableState(await _gateway.GetStatesAsync(cancellationToken), entityId);
|
||||
}
|
||||
|
||||
private static HomeAssistantState RequireAvailableState(IReadOnlyList<HomeAssistantState> states, string entityId)
|
||||
{
|
||||
var state = states.FirstOrDefault(item =>
|
||||
string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
if (state is null || !IsAvailable(state)) throw new InvalidOperationException("目标参数实体当前离线或不可用");
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task SendAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
public Task SendAsync(DeviceCommand command, CancellationToken cancellationToken) =>
|
||||
SendIfAsync(command, null, cancellationToken);
|
||||
|
||||
public async Task<string?> SendIfAsync(DeviceCommand command,
|
||||
Func<IReadOnlyList<HomeAssistantState>, string?>? getSkipReason, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(command.EntityId))
|
||||
@@ -43,9 +52,18 @@ public sealed class DeviceActionRunner
|
||||
using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
try
|
||||
{
|
||||
IReadOnlyList<HomeAssistantState>? states = null;
|
||||
if (getSkipReason is not null)
|
||||
{
|
||||
states = await _gateway.GetStatesAsync(bounded.Token);
|
||||
var reason = getSkipReason(states);
|
||||
if (reason is not null) return reason;
|
||||
}
|
||||
if (command.ValidateCurrentState is not null)
|
||||
command.ValidateCurrentState(await ReadAvailableStateAsync(command.EntityId, bounded.Token));
|
||||
command.ValidateCurrentState(states is null ? await ReadAvailableStateAsync(command.EntityId, bounded.Token)
|
||||
: RequireAvailableState(states, command.EntityId));
|
||||
foreach (var rule in _safety) await rule.ValidateAsync(command, bounded.Token);
|
||||
bounded.Token.ThrowIfCancellationRequested();
|
||||
await _gateway.CallServiceAsync(command.Domain, command.Service, command.ToServiceData(), bounded.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeout.IsCancellationRequested)
|
||||
@@ -53,6 +71,7 @@ public sealed class DeviceActionRunner
|
||||
throw new TimeoutException($"{command.Name}发送超时,未确认完成");
|
||||
}
|
||||
foreach (var rule in _safety) rule.Accepted(command, _time.GetUtcNow());
|
||||
return null;
|
||||
}
|
||||
finally { _commandGate.Release(); }
|
||||
}
|
||||
@@ -86,22 +105,23 @@ public sealed class DeviceActionRunner
|
||||
}
|
||||
}
|
||||
|
||||
public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken, int? verificationAttempts = null)
|
||||
{
|
||||
var results = new List<HomeAssistantActionResult>
|
||||
{
|
||||
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
|
||||
};
|
||||
await VerifyBatchAsync([command], results, cancellationToken);
|
||||
await VerifyBatchAsync([command], results, cancellationToken, verificationAttempts);
|
||||
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
|
||||
}
|
||||
|
||||
public async Task VerifyBatchAsync(IReadOnlyList<DeviceCommand> commands,
|
||||
List<HomeAssistantActionResult> results, CancellationToken cancellationToken)
|
||||
List<HomeAssistantActionResult> results, CancellationToken cancellationToken, int? verificationAttempts = null)
|
||||
{
|
||||
var attempts = verificationAttempts ?? _options.VerificationAttempts;
|
||||
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
|
||||
command.ExpectedState is not null).ToList();
|
||||
for (var round = 0; round < _options.VerificationAttempts && pending.Count > 0; round++)
|
||||
for (var round = 0; round < attempts && pending.Count > 0; round++)
|
||||
{
|
||||
await DelayAsync(_options.VerificationDelay, cancellationToken);
|
||||
IReadOnlyList<HomeAssistantState> states;
|
||||
@@ -121,14 +141,17 @@ public sealed class DeviceActionRunner
|
||||
var state = states.FirstOrDefault(item => string.Equals(item.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase));
|
||||
var actual = state is null ? null : command.ExpectedAttribute is null
|
||||
? state.State : state.GetStringAttribute(command.ExpectedAttribute);
|
||||
if (state is not null && IsAvailable(state) && actual is not null && Matches(actual, command.ExpectedState!))
|
||||
if (state is not null && IsAvailable(state) && actual is not null && Matches(actual, command.ExpectedState!) &&
|
||||
(command.ExpectedEntityState is null || Matches(state.State, command.ExpectedEntityState)))
|
||||
{
|
||||
action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态";
|
||||
pending.Remove(command); continue;
|
||||
}
|
||||
if (round == _options.VerificationAttempts - 1)
|
||||
if (round == attempts - 1)
|
||||
{
|
||||
action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})";
|
||||
if (command.ExpectedEntityState is not null)
|
||||
action.Message += $",同时要求状态 {command.ExpectedEntityState}(当前:{state?.State ?? "未读取到"})";
|
||||
pending.Remove(command); continue;
|
||||
}
|
||||
if (!command.RetryOnVerificationMismatch) continue;
|
||||
|
||||
@@ -19,31 +19,13 @@ public sealed class SceneLightSafetyState
|
||||
public void MarkOn() { lock (_lock) _offAt = null; }
|
||||
}
|
||||
|
||||
/// <summary>只依赖“启动按摩”的动作意图,不知道床垫实体。防止灯灭后较晚的启动指令再开启按摩。</summary>
|
||||
public sealed class LightMassageSafetyRule : IDeviceActionSafetyRule
|
||||
/// <summary>记录主灯的已发送开关状态,供晨起渐亮处理滞后的云端回读。</summary>
|
||||
public sealed class PrimaryLightStateRule(SceneLightSafetyState state) : IDeviceActionSafetyRule
|
||||
{
|
||||
private readonly IHomeAssistantGateway _gateway;
|
||||
private readonly string _lightEntity;
|
||||
private readonly SceneLightSafetyState _state;
|
||||
public LightMassageSafetyRule(IHomeAssistantGateway gateway, IOptions<HomeAssistantOptions> options,
|
||||
SceneLightSafetyState state) => (_gateway, _lightEntity, _state) = (gateway, options.Value.Entities.AmbientLight, state);
|
||||
|
||||
public async Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.Intent != DeviceCommandIntent.StartMassage || string.IsNullOrWhiteSpace(_lightEntity)) return;
|
||||
var light = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item =>
|
||||
string.Equals(item.EntityId, _lightEntity, StringComparison.OrdinalIgnoreCase));
|
||||
var offAt = _state.OffAt;
|
||||
if (light is null || !DeviceActionRunner.IsAvailable(light) ||
|
||||
!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase) ||
|
||||
light.GetDoubleAttribute("brightness") is <= 0 ||
|
||||
(offAt is not null && (light.LastUpdated is null || light.LastUpdated <= offAt)))
|
||||
throw new InvalidOperationException("卧室灯光已关闭或状态未确认,不启动床垫按摩");
|
||||
}
|
||||
|
||||
public Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public void Accepted(DeviceCommand command, DateTimeOffset now)
|
||||
{
|
||||
if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) _state.MarkOff(now);
|
||||
else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) _state.MarkOn();
|
||||
if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) state.MarkOff(now);
|
||||
else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) state.MarkOn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Services;
|
||||
|
||||
/// <summary>依据室温与体质提供空调建议,不负责发送家具控制命令。</summary>
|
||||
public sealed class EnvironmentAdviceService : ITransient
|
||||
{
|
||||
private readonly IHomeAssistantGateway _gateway;
|
||||
private readonly IndoorEnvironmentReader _environment;
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public EnvironmentAdviceService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
|
||||
IOptions<HomeAssistantOptions> options)
|
||||
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
|
||||
|
||||
public async Task<EnvironmentAdviceOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyDictionary<string, HomeAssistantState> states;
|
||||
try
|
||||
{
|
||||
states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
states = new Dictionary<string, HomeAssistantState>();
|
||||
}
|
||||
return Get(profile, states);
|
||||
}
|
||||
|
||||
public EnvironmentAdviceOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> states)
|
||||
{
|
||||
var tcm = TcmConstitutionRules.Read(profile);
|
||||
var sample = _environment.Read(states);
|
||||
var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode));
|
||||
advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate);
|
||||
return new()
|
||||
{
|
||||
IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available,
|
||||
IndoorTemperatureObservedAt = sample.ObservedAt,
|
||||
ClimateAdvice = advice
|
||||
};
|
||||
}
|
||||
|
||||
public static void ApplyTo(PersonalizedSceneConfig config, EnvironmentAdviceOutput recommendation)
|
||||
{
|
||||
config.ClimateAdvice = recommendation.ClimateAdvice;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
|
||||
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
|
||||
private readonly SqlSugarRepository<HealthModuleSelection> _moduleRepository;
|
||||
private readonly SqlSugarRepository<HealthPlan> _planRepository;
|
||||
private readonly MattressComfortService _mattressComfort;
|
||||
private readonly EnvironmentAdviceService _environmentAdvice;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public HealthAppService(
|
||||
@@ -32,14 +32,14 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
|
||||
SqlSugarRepository<HealthProfile> profileRepository,
|
||||
SqlSugarRepository<HealthModuleSelection> moduleRepository,
|
||||
SqlSugarRepository<HealthPlan> planRepository,
|
||||
MattressComfortService mattressComfort,
|
||||
EnvironmentAdviceService environmentAdvice,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_profileRepository = profileRepository;
|
||||
_moduleRepository = moduleRepository;
|
||||
_planRepository = planRepository;
|
||||
_mattressComfort = mattressComfort;
|
||||
_environmentAdvice = environmentAdvice;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
@@ -118,11 +118,11 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
|
||||
}
|
||||
|
||||
var tcmResult = TcmConstitutionRules.Read(profile);
|
||||
MattressComfortOutput? mattressComfort = null;
|
||||
EnvironmentAdviceOutput? environmentAdvice = null;
|
||||
if (tcmResult.HasCompleted)
|
||||
{
|
||||
mattressComfort = await _mattressComfort.GetAsync(profile);
|
||||
MattressComfortService.ApplyTo(tcmResult.SceneConfig, mattressComfort);
|
||||
environmentAdvice = await _environmentAdvice.GetAsync(profile);
|
||||
EnvironmentAdviceService.ApplyTo(tcmResult.SceneConfig, environmentAdvice);
|
||||
}
|
||||
|
||||
return new HealthDashboardOutput
|
||||
@@ -136,7 +136,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
|
||||
BmiStatus = bmiStatus,
|
||||
SceneConfig = tcmResult.HasCompleted ? tcmResult.SceneConfig : null,
|
||||
TcmResult = tcmResult,
|
||||
MattressComfort = mattressComfort
|
||||
EnvironmentAdvice = environmentAdvice
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
|
||||
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
|
||||
private readonly MattressComfortService _mattressComfort;
|
||||
private readonly EnvironmentAdviceService _environmentAdvice;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly DeviceModuleRegistry _modules;
|
||||
private readonly DeviceParameterController _parameters;
|
||||
@@ -46,7 +46,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
||||
IOptions<HomeAssistantOptions> options,
|
||||
SqlSugarRepository<HealthSceneExecution> executionRepository,
|
||||
SqlSugarRepository<HealthProfile> profileRepository,
|
||||
MattressComfortService mattressComfort,
|
||||
EnvironmentAdviceService environmentAdvice,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
DeviceModuleRegistry modules,
|
||||
DeviceParameterController parameters,
|
||||
@@ -61,7 +61,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
||||
_options = options.Value;
|
||||
_executionRepository = executionRepository;
|
||||
_profileRepository = profileRepository;
|
||||
_mattressComfort = mattressComfort;
|
||||
_environmentAdvice = environmentAdvice;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_modules = modules;
|
||||
_parameters = parameters;
|
||||
@@ -128,8 +128,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
||||
{
|
||||
constitutionCode = tcmResult.ConstitutionCode;
|
||||
userConfig = tcmResult.SceneConfig;
|
||||
var comfort = _mattressComfort.Get(profile, stateMap);
|
||||
MattressComfortService.ApplyTo(userConfig, comfort);
|
||||
var comfort = _environmentAdvice.Get(profile, stateMap);
|
||||
EnvironmentAdviceService.ApplyTo(userConfig, comfort);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
@@ -340,8 +340,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
||||
if (!tcmResult.HasCompleted)
|
||||
throw new ScenePreparationException("请先完成中医四诊,生成体质倾向后再一键启动");
|
||||
var config = tcmResult.SceneConfig;
|
||||
var mattressComfort = await _mattressComfort.GetAsync(profile, token);
|
||||
MattressComfortService.ApplyTo(config, mattressComfort);
|
||||
var environmentAdvice = await _environmentAdvice.GetAsync(profile, token);
|
||||
EnvironmentAdviceService.ApplyTo(config, environmentAdvice);
|
||||
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
|
||||
return new PreparedSleepScene(config, tcmResult.ConstitutionCode);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Furion.FriendlyException;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Services;
|
||||
|
||||
public sealed class AssignIb43BedInput
|
||||
{
|
||||
[Required, MaxLength(128)] public string ProductKey { get; set; }
|
||||
[Required, MaxLength(128)] public string SerialNumber { get; set; }
|
||||
[Required, RegularExpression("^(left|right)$")] public string Side { get; set; }
|
||||
[Range(1, long.MaxValue)] public long UserId { get; set; }
|
||||
[Required, MaxLength(80)] public string BedName { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Ib43BedOutput(string Id, string ProductKey, string SerialNumber, string Side,
|
||||
string BedName, bool Simulation, long StartedAtMs, long? EndedAtMs);
|
||||
public sealed record Ib43AdminBedOutput(Ib43BedOutput Bed, long UserId);
|
||||
public sealed record Ib43MetricOutput(double? Value, DateTimeOffset? ReceivedAt, string Source, string Freshness);
|
||||
public sealed record Ib43CurrentOutput(Ib43BedOutput Bed, string Transport, bool Active,
|
||||
IReadOnlyDictionary<string, Ib43MetricOutput> Readings);
|
||||
public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary<string, Ib43Reading> Readings);
|
||||
public sealed record Ib43HistoryOutput(IReadOnlyList<Ib43HistoryItem> Items, long? NextBeforeId);
|
||||
public sealed record Ib43AutomationRecord(string Id, string ActionKey, string TargetLabel, long TriggeredAtMs, long? CompletedAtMs, string Result);
|
||||
public sealed record Ib43AutomationOutput(bool Available, bool Enabled, string ActionLabel, string TargetLabel,
|
||||
IReadOnlyList<Ib43AutomationRecord> Records, IReadOnlyList<BedPresenceRuleStatus> Rules);
|
||||
|
||||
[Authorize]
|
||||
[ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)]
|
||||
public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context,
|
||||
IOptions<Ib43Options> options, Ib43StateStore states, TimeProvider clock, BedPresenceAutomation automation) : IDynamicApiController, ITransient
|
||||
{
|
||||
[HttpGet("/api/health/ib43/admin/bindings")]
|
||||
public async Task<Ib43AdminBedOutput[]> ActiveBindings(string productKey, string serialNumber)
|
||||
{
|
||||
await RequireAdmin();
|
||||
if (string.IsNullOrWhiteSpace(productKey) || string.IsNullOrWhiteSpace(serialNumber) || productKey.Length > 128 || serialNumber.Length > 128)
|
||||
throw new AppFriendlyException("请指定产品和设备编号", null) { StatusCode = 400 };
|
||||
return (await repository.ActiveBindings(productKey, serialNumber)).Select(b => new Ib43AdminBedOutput(Map(b), b.UserId)).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("/api/health/ib43/admin/bindings")]
|
||||
public async Task<Ib43BedOutput> Assign(AssignIb43BedInput input)
|
||||
{
|
||||
var admin = await RequireAdmin();
|
||||
if (input == null || input.Side is not ("left" or "right") || string.IsNullOrWhiteSpace(input.BedName) ||
|
||||
input.BedName.Length > 80 || input.UserId <= 0)
|
||||
throw new AppFriendlyException("床位参数不合法", null) { StatusCode = 400 };
|
||||
var device = options.Value.Devices.SingleOrDefault(d => d.ProductKey == input.ProductKey && d.SerialNumber == input.SerialNumber);
|
||||
if (device == null || input.ProductKey.Length > 128 || input.SerialNumber.Length > 128)
|
||||
throw new AppFriendlyException("设备不在后台接入白名单中", null) { StatusCode = 400 };
|
||||
if (!await repository.UserEnabled(input.UserId)) throw new AppFriendlyException("目标健康用户不存在或已停用", null) { StatusCode = 400 };
|
||||
var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
|
||||
var binding = new HealthIb43Binding
|
||||
{
|
||||
DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
|
||||
Side = input.Side, ActiveSlot = key + "." + input.Side, UserId = input.UserId,
|
||||
BedName = input.BedName.Trim(), Simulation = device.Simulation,
|
||||
StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = admin
|
||||
};
|
||||
await repository.Assign(binding);
|
||||
return Map(binding);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/health/ib43/admin/bindings/{bindingId}")]
|
||||
public async Task Release(string bindingId)
|
||||
{
|
||||
var admin = await RequireAdmin();
|
||||
await repository.Release(bindingId, admin, clock.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
automation.RevokeBinding(bindingId);
|
||||
}
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings/{bindingId}/automation")]
|
||||
public async Task<Ib43AutomationOutput> AutomationStatus(string bindingId)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
var binding = await repository.OwnedBinding(user, bindingId);
|
||||
var records = await repository.AutomationHistory(user, binding.Id);
|
||||
var bed = AutomationBinding(binding);
|
||||
var canManage = CanManageAutomation(binding);
|
||||
var primary = canManage ? automation.PrimaryRuleStatus(bed) : null;
|
||||
return new(primary is not null, primary?.Enabled == true, primary?.ActionLabel ?? "",
|
||||
primary?.TargetLabel ?? "", records.Select(row => new Ib43AutomationRecord(row.Id, row.ActionKey,
|
||||
row.TargetLabel, row.TriggeredAtMs, row.CompletedAtMs, row.Result)).ToArray(),
|
||||
canManage ? automation.RuleStatuses(bed) : []);
|
||||
}
|
||||
|
||||
[HttpPost("/api/health/ib43/bindings/{bindingId}/automation/start")]
|
||||
public async Task StartAutomation(string bindingId, [FromQuery] string? actionKey = null)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
var binding = await repository.OwnedBinding(user, bindingId);
|
||||
if (!CanManageAutomation(binding) || !automation.TryEnable(AutomationBinding(binding), actionKey))
|
||||
throw new AppFriendlyException("该床位当前无法开启家具联动", null) { StatusCode = 409 };
|
||||
}
|
||||
|
||||
[HttpPost("/api/health/ib43/bindings/{bindingId}/automation/stop")]
|
||||
public async Task StopAutomation(string bindingId, [FromQuery] string? actionKey = null)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
var binding = await repository.OwnedBinding(user, bindingId);
|
||||
if (!automation.Disable(binding.Id, user, actionKey))
|
||||
throw new AppFriendlyException("家具联动规则不存在", null) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
private bool CanManageAutomation(HealthIb43Binding binding) => binding.EndedAtMs is null &&
|
||||
binding.Side is "left" or "right" && options.Value.Enabled && options.Value.Devices.Any(device =>
|
||||
device.Simulation && device.ProductKey == binding.ProductKey && device.SerialNumber == binding.SerialNumber);
|
||||
|
||||
private static BedAutomationBinding AutomationBinding(HealthIb43Binding binding) => new(binding.Id, binding.UserId,
|
||||
binding.ProductKey, binding.SerialNumber, binding.Side == "left" ? BedSide.Left : BedSide.Right, binding.Simulation);
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings")]
|
||||
public async Task<Ib43BedOutput[]> Bindings()
|
||||
{
|
||||
var user = await RequireUser();
|
||||
return (await repository.Bindings(user)).Select(Map).ToArray();
|
||||
}
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings/{bindingId}/current")]
|
||||
public async Task<Ib43CurrentOutput> Current(string bindingId)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
var binding = await repository.OwnedBinding(user, bindingId);
|
||||
var readings = await repository.Latest(user, bindingId);
|
||||
var now = clock.GetUtcNow();
|
||||
var projected = new Dictionary<string, Ib43MetricOutput>();
|
||||
foreach (var key in new[] { "heartRate", "respiration", "presence", "snoring" })
|
||||
{
|
||||
readings.TryGetValue(key, out var reading);
|
||||
projected[key] = reading == null ? new(null, null, null, "unknown") :
|
||||
new(reading.Value, reading.ReceivedAt, reading.Source,
|
||||
now - reading.ReceivedAt > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent");
|
||||
}
|
||||
var transport = states.Snapshot().FirstOrDefault(s => s.ProductKey == binding.ProductKey &&
|
||||
s.SerialNumber == binding.SerialNumber && s.Simulation == binding.Simulation)?.Transport ?? "unconfigured";
|
||||
return new(Map(binding), transport, binding.EndedAtMs == null, projected);
|
||||
}
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings/{bindingId}/history")]
|
||||
public async Task<Ib43HistoryOutput> History(string bindingId, long fromMs, long toMs, long? beforeId = null, int take = 100)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
await repository.OwnedBinding(user, bindingId);
|
||||
// 固定半开 UTC 毫秒区间;游标按数据库接收记录Id倒序,避免同毫秒漏页。
|
||||
if (fromMs < 0 || toMs <= fromMs || toMs - fromMs > 7L * 86400000 || take is < 1 or > 200 || beforeId is <= 0)
|
||||
throw new AppFriendlyException("查询范围最多7天,每页1至200条", null) { StatusCode = 400 };
|
||||
var rows = await repository.History(user, bindingId, fromMs, toMs, beforeId, take + 1);
|
||||
var more = rows.Count > take;
|
||||
var items = rows.Take(take).Select(r => new Ib43HistoryItem(r.Id, r.ReceivedAtMs,
|
||||
JsonSerializer.Deserialize<Dictionary<string, Ib43Reading>>(r.ReadingsJson))).ToArray();
|
||||
return new(items, more ? items[^1].Id : null);
|
||||
}
|
||||
|
||||
private async Task<long> RequireUser()
|
||||
{
|
||||
var principal = context.HttpContext?.User;
|
||||
if (principal?.Identity?.IsAuthenticated != true ||
|
||||
!long.TryParse(principal.FindFirstValue("health_user_id"), out var user) || user <= 0)
|
||||
throw new AppFriendlyException("请先登录健康端账号", null) { StatusCode = 401 };
|
||||
if (!await repository.UserEnabled(user)) throw new AppFriendlyException("健康用户已停用或不存在", null) { StatusCode = 403 };
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<long> RequireAdmin()
|
||||
{
|
||||
var principal = context.HttpContext?.User;
|
||||
if (principal?.Identity?.IsAuthenticated != true || principal.HasClaim(c => c.Type == "health_user_id") ||
|
||||
principal.FindFirstValue(ClaimConst.AccountType) != ((int)AccountTypeEnum.SuperAdmin).ToString() ||
|
||||
!long.TryParse(principal.FindFirstValue(ClaimConst.UserId), out var admin) || admin <= 0)
|
||||
throw new AppFriendlyException("只有后台超级管理员可以分配床位", null) { StatusCode = 403 };
|
||||
if (!await repository.AdminEnabled(admin)) throw new AppFriendlyException("后台管理员已失效", null) { StatusCode = 403 };
|
||||
return admin;
|
||||
}
|
||||
|
||||
private static Ib43BedOutput Map(HealthIb43Binding b) => new(b.Id, b.ProductKey, b.SerialNumber,
|
||||
b.Side, b.BedName, b.Simulation, b.StartedAtMs, b.EndedAtMs);
|
||||
}
|
||||
@@ -213,8 +213,6 @@ public static class TcmConstitutionRules
|
||||
{
|
||||
TargetHumidity = humidity, DehumidifierMode = "睡眠", FanLevel = "1档",
|
||||
LightBrightnessPercent = 20, LightColorTemperatureKelvin = kelvin,
|
||||
MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0,
|
||||
MattressMode = "智能适配",
|
||||
EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1,
|
||||
EnableSpeakerSleepMode = true, SpeakerVolumePercent = 50
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Services;
|
||||
|
||||
/// <summary>组合独立的 BMI 和室温策略,不负责发送家具控制命令。</summary>
|
||||
public sealed class MattressComfortService : ITransient
|
||||
{
|
||||
private readonly IHomeAssistantGateway _gateway;
|
||||
private readonly IndoorEnvironmentReader _environment;
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public MattressComfortService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
|
||||
IOptions<HomeAssistantOptions> options)
|
||||
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
|
||||
|
||||
public async Task<MattressComfortOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyDictionary<string, HomeAssistantState> states;
|
||||
try
|
||||
{
|
||||
states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
states = new Dictionary<string, HomeAssistantState>();
|
||||
}
|
||||
return Get(profile, states);
|
||||
}
|
||||
|
||||
public MattressComfortOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> states)
|
||||
{
|
||||
var bmi = BmiFirmnessPolicy.Calculate(profile?.HeightCm, profile?.WeightKg);
|
||||
var firmness = BmiFirmnessPolicy.Firmness(bmi);
|
||||
var tcm = TcmConstitutionRules.Read(profile);
|
||||
var sample = _environment.Read(states);
|
||||
var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode));
|
||||
advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate);
|
||||
return new()
|
||||
{
|
||||
Bmi = bmi, Firmness = firmness, HeatingLevel = advice.Mode == "heat" ? 1 : 0,
|
||||
IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available,
|
||||
IndoorTemperatureObservedAt = sample.ObservedAt, HeatingReason = advice.Reason,
|
||||
WeatherReason = advice.Reason, // 旧客户端兼容字段,不再读取或依赖室外天气。
|
||||
ClimateAdvice = advice,
|
||||
FirmnessReason = firmness is null
|
||||
? "健康档案尚无有效身高和体重,暂不自动调节软硬度"
|
||||
: $"按 BMI {bmi:0.0} 给出“{firmness}”演示起点,可在设备详情手动调整"
|
||||
};
|
||||
}
|
||||
|
||||
public static void ApplyTo(PersonalizedSceneConfig config, MattressComfortOutput recommendation)
|
||||
{
|
||||
config.MattressHeatingLevel = recommendation.HeatingLevel;
|
||||
config.MattressLeftFirmness = recommendation.Firmness is null ? 0 : FirmnessLevel(recommendation.Firmness);
|
||||
config.MattressRightFirmness = config.MattressLeftFirmness;
|
||||
config.ClimateAdvice = recommendation.ClimateAdvice;
|
||||
}
|
||||
|
||||
public static int FirmnessLevel(string firmness) => firmness switch
|
||||
{
|
||||
"最软" => 1, "很软" => 2, "稍软" => 3, "稍硬" => 4, "很硬" => 5, "最硬" => 6, _ => 0
|
||||
};
|
||||
|
||||
public static string? FirmnessName(int level) => level switch
|
||||
{
|
||||
1 => "最软", 2 => "很软", 3 => "稍软", 4 => "稍硬", 5 => "很硬", 6 => "最硬", _ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum BedSide { Left, Right }
|
||||
public enum BedAutomationMode { Disabled, Preview, Execute, Managed }
|
||||
|
||||
public sealed record BedPresenceSample(string BindingId, string ProductKey, string SerialNumber, BedSide Side,
|
||||
bool Simulation, BedPresence Presence, DateTimeOffset ReceivedAt, Guid ConnectionId);
|
||||
|
||||
public interface IBedPresenceSink
|
||||
{
|
||||
void Observe(BedPresenceSample sample, CancellationToken connectionToken);
|
||||
}
|
||||
|
||||
public interface IBedPresenceAction
|
||||
{
|
||||
string Key { get; }
|
||||
string Description { get; }
|
||||
bool PreviewOnly => false;
|
||||
string? ConditionLabel => null;
|
||||
TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(25);
|
||||
bool SupportsTarget(string entityId);
|
||||
Task<string> ExecuteAsync(string entityId, CancellationToken token);
|
||||
}
|
||||
|
||||
public sealed class BedPresenceAutomationOptions : BedPresenceRuleOptions
|
||||
{
|
||||
public List<BedPresenceRuleOptions> AdditionalRules { get; set; } = [];
|
||||
}
|
||||
|
||||
public class BedPresenceRuleOptions
|
||||
{
|
||||
public BedAutomationMode Mode { get; set; }
|
||||
public string BindingId { get; set; } = "";
|
||||
public string ProductKey { get; set; } = "evergreen-test";
|
||||
public string SerialNumber { get; set; } = "IB43SIM001";
|
||||
public BedSide Side { get; set; } = BedSide.Left;
|
||||
public string ActionKey { get; set; } = "";
|
||||
public string TargetEntityId { get; set; } = "";
|
||||
public string TargetLabel { get; set; } = "";
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
if (Mode == BedAutomationMode.Disabled) return true;
|
||||
var bindingValid = Mode switch
|
||||
{
|
||||
BedAutomationMode.Managed => string.IsNullOrEmpty(BindingId) || Guid.TryParseExact(BindingId, "N", out _),
|
||||
BedAutomationMode.Preview => Guid.TryParseExact(BindingId, "N", out _),
|
||||
_ => false
|
||||
};
|
||||
return bindingValid && !string.IsNullOrWhiteSpace(ProductKey) && !string.IsNullOrWhiteSpace(SerialNumber) &&
|
||||
Enum.IsDefined(Side) && !string.IsNullOrWhiteSpace(ActionKey) && ActionKey.Length <= 64 &&
|
||||
!string.IsNullOrWhiteSpace(TargetEntityId) && TargetEntityId.Length <= 128 && (TargetLabel?.Length ?? 0) <= 128;
|
||||
}
|
||||
|
||||
public bool Matches(BedPresenceSample sample) => MatchesSource(sample.BindingId, sample.ProductKey, sample.SerialNumber, sample.Side, sample.Simulation);
|
||||
|
||||
public bool Matches(BedAutomationBinding binding) => MatchesSource(binding.Id, binding.ProductKey, binding.SerialNumber, binding.Side, binding.Simulation);
|
||||
|
||||
private bool MatchesSource(string id, string productKey, string serialNumber, BedSide side, bool simulation) =>
|
||||
simulation && (id == BindingId || Mode == BedAutomationMode.Managed && string.IsNullOrEmpty(BindingId)) &&
|
||||
productKey == ProductKey && serialNumber == SerialNumber && side == Side;
|
||||
}
|
||||
|
||||
/// <summary>按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。</summary>
|
||||
public sealed class BedPresenceEdge
|
||||
{
|
||||
public static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(60);
|
||||
private BedPresenceSample? _previous;
|
||||
|
||||
public bool Observe(BedPresenceSample sample, DateTimeOffset now)
|
||||
{
|
||||
if (sample.Presence is not (BedPresence.Empty or BedPresence.Occupied) ||
|
||||
sample.ReceivedAt > now || now - sample.ReceivedAt > MaxAge) return false;
|
||||
var previous = _previous;
|
||||
var sameBedConnection = previous != null && previous.ConnectionId == sample.ConnectionId &&
|
||||
previous.BindingId == sample.BindingId && previous.Side == sample.Side;
|
||||
if (sameBedConnection && sample.ReceivedAt < previous!.ReceivedAt) return false;
|
||||
_previous = sample;
|
||||
return sameBedConnection && previous!.Presence == BedPresence.Empty &&
|
||||
sample.Presence == BedPresence.Occupied && sample.ReceivedAt - previous.ReceivedAt <= MaxAge;
|
||||
}
|
||||
|
||||
public void Reset() => _previous = null;
|
||||
}
|
||||
|
||||
public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions> options, IEnumerable<IBedPresenceAction> actions,
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger, IBedPresenceExecutionLog? executionLog = null) : BackgroundService, IBedPresenceSink
|
||||
{
|
||||
private readonly BedPresenceRule[] _rules = CreateRules(options.Value, actions, clock, logger, executionLog);
|
||||
|
||||
public BedPresenceRuleStatus? PrimaryRuleStatus(BedAutomationBinding binding) => _rules[0].Status(binding);
|
||||
|
||||
public BedPresenceRuleStatus[] RuleStatuses(BedAutomationBinding binding) =>
|
||||
_rules.Select(rule => rule.Status(binding)).OfType<BedPresenceRuleStatus>().ToArray();
|
||||
|
||||
public bool IsEnabled(string bindingId, long userId, string? actionKey = null) =>
|
||||
FindRule(actionKey)?.IsEnabled(bindingId, userId) == true;
|
||||
|
||||
public bool TryEnable(BedAutomationBinding binding, string? actionKey = null) =>
|
||||
FindRule(actionKey)?.TryEnable(binding) == true;
|
||||
|
||||
public bool Disable(string bindingId, long userId, string? actionKey = null)
|
||||
{
|
||||
var rule = FindRule(actionKey);
|
||||
if (rule is null) return false;
|
||||
rule.Disable(bindingId, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RevokeBinding(string bindingId)
|
||||
{
|
||||
foreach (var rule in _rules) rule.RevokeBinding(bindingId);
|
||||
}
|
||||
|
||||
public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
|
||||
{
|
||||
foreach (var rule in _rules) rule.Observe(sample, connectionToken);
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
|
||||
Task.WhenAll(_rules.Select(rule => rule.RunAsync(stoppingToken)));
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var rule in _rules) rule.Stop();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private BedPresenceRule? FindRule(string? actionKey) => actionKey is null
|
||||
? _rules[0] : _rules.FirstOrDefault(rule => rule.ActionKey == actionKey);
|
||||
|
||||
private static BedPresenceRule[] CreateRules(BedPresenceAutomationOptions configuration,
|
||||
IEnumerable<IBedPresenceAction> actions, TimeProvider clock, ILogger<BedPresenceAutomation> logger,
|
||||
IBedPresenceExecutionLog? executionLog)
|
||||
{
|
||||
var registeredActions = actions.ToArray();
|
||||
var rules = new List<BedPresenceRule> { new(configuration, registeredActions, clock, logger, executionLog) };
|
||||
if (configuration.Mode != BedAutomationMode.Managed) return rules.ToArray();
|
||||
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal) { configuration.ActionKey };
|
||||
foreach (var rule in configuration.AdditionalRules)
|
||||
{
|
||||
if (rule.Mode != BedAutomationMode.Managed) continue;
|
||||
if (!keys.Add(rule.ActionKey))
|
||||
{
|
||||
logger.LogWarning("Bed presence automation ignored duplicate action key {Action}", rule.ActionKey);
|
||||
continue;
|
||||
}
|
||||
rules.Add(new(rule, registeredActions, clock, logger, executionLog));
|
||||
}
|
||||
return rules.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BedPresenceRuleStatus(string ActionKey, string ActionLabel, string TargetLabel, bool Enabled,
|
||||
bool PreviewOnly, string? ConditionLabel);
|
||||
|
||||
/// <summary>每条规则独立处理上床事件,慢速设备回读不会阻塞其他规则。</summary>
|
||||
internal sealed class BedPresenceRule(BedPresenceRuleOptions configuration, IReadOnlyList<IBedPresenceAction> actions,
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger, IBedPresenceExecutionLog? executionLog)
|
||||
{
|
||||
private sealed class Session(string bindingId, long userId, DateTimeOffset startedAt)
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
public string BindingId { get; } = bindingId;
|
||||
public long UserId { get; } = userId;
|
||||
public DateTimeOffset StartedAt { get; } = startedAt;
|
||||
public CancellationToken Token => _stop.Token;
|
||||
public void Cancel() => _stop.Cancel();
|
||||
}
|
||||
|
||||
private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken, Session? Session);
|
||||
private readonly Channel<Item> _queue = Channel.CreateBounded<Item>(new BoundedChannelOptions(32)
|
||||
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private long _sequence;
|
||||
private volatile IBedPresenceAction? _activeAction;
|
||||
private readonly object _sessionGate = new();
|
||||
private volatile Session? _session;
|
||||
|
||||
public string ActionKey => configuration.ActionKey;
|
||||
private string TargetLabel => string.IsNullOrWhiteSpace(configuration.TargetLabel) ? configuration.TargetEntityId : configuration.TargetLabel;
|
||||
|
||||
public BedPresenceRuleStatus? Status(BedAutomationBinding binding) =>
|
||||
_activeAction is { } action && configuration.Mode == BedAutomationMode.Managed && configuration.Matches(binding)
|
||||
? new(action.Key, action.Description, TargetLabel, IsEnabled(binding.Id, binding.UserId), action.PreviewOnly, action.ConditionLabel)
|
||||
: null;
|
||||
|
||||
public bool IsEnabled(string bindingId, long userId) => _session is { } session && session.BindingId == bindingId && session.UserId == userId;
|
||||
|
||||
public bool TryEnable(BedAutomationBinding binding)
|
||||
{
|
||||
Session? previous;
|
||||
lock (_sessionGate)
|
||||
{
|
||||
if (_activeAction is null || configuration.Mode != BedAutomationMode.Managed || !configuration.Matches(binding)) return false;
|
||||
if (IsEnabled(binding.Id, binding.UserId)) return true;
|
||||
previous = _session;
|
||||
_session = new(binding.Id, binding.UserId, clock.GetUtcNow());
|
||||
}
|
||||
previous?.Cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Disable(string bindingId, long userId)
|
||||
{
|
||||
Session? previous;
|
||||
lock (_sessionGate)
|
||||
{
|
||||
if (!IsEnabled(bindingId, userId)) return;
|
||||
previous = _session;
|
||||
_session = null;
|
||||
}
|
||||
previous?.Cancel();
|
||||
}
|
||||
|
||||
public void RevokeBinding(string bindingId)
|
||||
{
|
||||
if (_session is { } session && session.BindingId == bindingId) Disable(bindingId, session.UserId);
|
||||
}
|
||||
|
||||
public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
|
||||
{
|
||||
if (_activeAction is null || !configuration.Matches(sample) || connectionToken.IsCancellationRequested) return;
|
||||
var session = _session;
|
||||
if (configuration.Mode == BedAutomationMode.Managed &&
|
||||
(session is null || session.BindingId != sample.BindingId || sample.ReceivedAt < session.StartedAt)) return;
|
||||
if (!_queue.Writer.TryWrite(new(Interlocked.Increment(ref _sequence), sample, connectionToken, session)))
|
||||
logger.LogWarning("Bed presence automation queue full; observation dropped for {Action}", ActionKey);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (configuration.Mode == BedAutomationMode.Disabled) return;
|
||||
if (configuration.Mode == BedAutomationMode.Execute)
|
||||
{
|
||||
logger.LogWarning("Legacy Execute mode is disabled; use Managed and enable the rule in H5");
|
||||
return;
|
||||
}
|
||||
var candidates = actions.Where(action => string.Equals(action.Key, configuration.ActionKey, StringComparison.Ordinal)).Take(2).ToArray();
|
||||
if (!configuration.IsValid() || candidates.Length != 1 || !candidates[0].SupportsTarget(configuration.TargetEntityId) ||
|
||||
configuration.Mode == BedAutomationMode.Managed && executionLog is null)
|
||||
{
|
||||
logger.LogWarning("Bed presence automation inactive: invalid binding, action or target for {Action}", ActionKey);
|
||||
return;
|
||||
}
|
||||
var action = candidates[0];
|
||||
var target = configuration.TargetEntityId;
|
||||
_activeAction = action;
|
||||
logger.LogInformation("Bed presence automation ready: Mode={Mode}, Side={Side}, Action={Action}, Target={EntityId}",
|
||||
configuration.Mode, configuration.Side, action.Key, target);
|
||||
var edge = new BedPresenceEdge();
|
||||
Session? lastSession = null;
|
||||
long lastSequence = 0;
|
||||
await foreach (var item in _queue.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
if (item.Sequence != lastSequence + 1 || item.ConnectionToken.IsCancellationRequested || item.Session != lastSession) edge.Reset();
|
||||
lastSequence = item.Sequence;
|
||||
lastSession = item.Session;
|
||||
if (configuration.Mode == BedAutomationMode.Managed && (item.Session is null || item.Session != _session)) continue;
|
||||
if (item.ConnectionToken.IsCancellationRequested || !edge.Observe(item.Sample, clock.GetUtcNow())) continue;
|
||||
if (configuration.Mode == BedAutomationMode.Preview)
|
||||
{
|
||||
logger.LogInformation("Bed presence preview: empty -> occupied, Action={Action}, Plan={Plan}, Target={EntityId}",
|
||||
action.Key, action.Description, target);
|
||||
continue;
|
||||
}
|
||||
if (clock.GetUtcNow() - item.Sample.ReceivedAt > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
logger.LogInformation("Bed presence action skipped: observation aged in queue");
|
||||
continue;
|
||||
}
|
||||
await ExecuteManagedAction(item, action, target, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteManagedAction(Item item, IBedPresenceAction action, string target, CancellationToken stoppingToken)
|
||||
{
|
||||
var session = item.Session!;
|
||||
using var timeout = new CancellationTokenSource(action.ExecutionTimeout, clock);
|
||||
using var operation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, item.ConnectionToken, session.Token, timeout.Token);
|
||||
string? id = null;
|
||||
var result = "failed-unconfirmed";
|
||||
try
|
||||
{
|
||||
operation.Token.ThrowIfCancellationRequested();
|
||||
id = await executionLog!.BeginAsync(session.BindingId, session.UserId, action.Key, TargetLabel, item.Sample.ReceivedAt, operation.Token);
|
||||
if (id is null) return;
|
||||
operation.Token.ThrowIfCancellationRequested();
|
||||
if (session != _session) throw new OperationCanceledException();
|
||||
if (clock.GetUtcNow() - item.Sample.ReceivedAt > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
result = "expired";
|
||||
return;
|
||||
}
|
||||
result = await action.ExecuteAsync(target, operation.Token);
|
||||
logger.LogInformation("Bed presence action: Action={Action}, Target={EntityId}, Result={Result}", action.Key, target, result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
result = "cancelled-unconfirmed";
|
||||
logger.LogWarning("Bed presence action not confirmed: cancelled or timed out for {EntityId}", target);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning("Bed presence action not confirmed: Target={EntityId}, Error={Type}", target, error.GetType().Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (id is not null)
|
||||
{
|
||||
try { await executionLog!.CompleteAsync(id, result, clock.GetUtcNow()); }
|
||||
catch (Exception error) { logger.LogWarning("Bed presence execution result could not be saved ({Type})", error.GetType().Name); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Session? previous;
|
||||
lock (_sessionGate)
|
||||
{
|
||||
_activeAction = null;
|
||||
previous = _session;
|
||||
_session = null;
|
||||
}
|
||||
previous?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public sealed record BedAutomationBinding(string Id, long UserId, string ProductKey, string SerialNumber,
|
||||
BedSide Side, bool Simulation);
|
||||
|
||||
public interface IBedPresenceExecutionLog
|
||||
{
|
||||
Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt,
|
||||
CancellationToken token);
|
||||
Task CompleteAsync(string id, string result, DateTimeOffset completedAt);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum ReservedSleepPhase { Disabled, WaitingForBed, Settling, Guiding, Music, Stopped }
|
||||
public enum SleepWorkflowActionKind { PrepareEnvironment, StartGentleRightMassage, RequestGuidance, RestartMusicAtFifty, StartLightFade }
|
||||
public enum SleepWorkflowActionKind { PrepareEnvironment = 0, RequestGuidance = 2, RestartMusicAtFifty = 3, StartLightFade = 4 }
|
||||
public sealed record SleepWorkflowAction(SleepWorkflowActionKind Kind, GuidanceClip? Clip = null, int? VolumePercent = null);
|
||||
|
||||
/// <summary>
|
||||
@@ -44,8 +44,7 @@ public sealed class ReservedSleepWorkflow
|
||||
_pendingClip = _guidance.InitialRelaxation;
|
||||
_initialGuide = true;
|
||||
Phase = ReservedSleepPhase.Guiding;
|
||||
return [new(SleepWorkflowActionKind.StartGentleRightMassage),
|
||||
new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)];
|
||||
return [new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Evergreen.Health.Application/Evergreen.Health.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,71 @@
|
||||
using Admin.NET.Core;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SqlSugar;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
if (args.Length is < 2 or > 3 || !Regex.IsMatch(args[1], "^1[3-9][0-9]{9}$") ||
|
||||
(args.Length == 3 && args[2] is not ("--apply" or "--verify")))
|
||||
{
|
||||
Console.Error.WriteLine("用法: <项目根目录> <健康端手机号> [--apply|--verify];默认只预检。");
|
||||
return 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
var directory = Path.Combine(Path.GetFullPath(args[0]), "Admin.NET", "Evergreen.Health.Application", "Configuration");
|
||||
var config = new ConfigurationBuilder().AddJsonFile(Path.Combine(directory, "Database.json"))
|
||||
.AddJsonFile(Path.Combine(directory, "Ib43.json")).AddEnvironmentVariables().Build();
|
||||
var dbOptions = config.GetSection("DbConnection").Get<DbConnectionOptions>();
|
||||
dbOptions.PostConfigure(dbOptions, config);
|
||||
var connection = dbOptions.ConnectionConfigs.Single(c => c.ConfigId?.ToString() == SqlSugarConst.MainConfigId);
|
||||
if (connection.DbSettings.EnableConnStringEncrypt)
|
||||
throw new InvalidOperationException("该工具不支持加密连接配置,已停止。");
|
||||
if (connection.DbType is not (DbType.MySql or DbType.MySqlConnector))
|
||||
throw new InvalidOperationException("当前维护工具只允许项目 MySQL 主库。");
|
||||
var options = new Ib43Options();
|
||||
config.GetSection("Ib43").Bind(options);
|
||||
Ib43Configuration.ApplyEnvironmentOverrides(options);
|
||||
SqlSugarSetup.SetDbConfig(connection);
|
||||
// 控制台没有 Web 容器。沿用表和列映射,但不调用依赖 Web DI 的二级缓存。
|
||||
connection.ConfigureExternalServices.DataInfoCacheService = null;
|
||||
connection.MoreSettings.IsAutoRemoveDataCache = false;
|
||||
using var db = new SqlSugarClient(connection);
|
||||
db.Ado.CommandTimeOut = 10;
|
||||
foreach (var table in new[] { "health_user", "health_ib43_binding" })
|
||||
if (!db.DbMaintenance.IsAnyTable(table, false)) throw new InvalidOperationException("缺少业务表,请先启动新版后端。");
|
||||
var runner = new Ib43LocalBinding(db, TimeProvider.System);
|
||||
var actor = Environment.MachineName + "\\" + Environment.UserName;
|
||||
var preview = await runner.Run(args[1], options, actor, false);
|
||||
Console.WriteLine($"预检通过: userId={preview.UserId}, evergreen-test/IB43SIM001, left, simulation=true");
|
||||
if (args.Length == 2) { Console.WriteLine("未修改数据库。加 --apply 执行绑定。"); return 0; }
|
||||
if (args[2] == "--verify")
|
||||
{
|
||||
var repository = new Ib43DataRepository(db);
|
||||
var binding = await repository.OwnedBinding(preview.UserId, preview.Id);
|
||||
var hasAudit = await db.Queryable<HealthIb43MaintenanceAudit>().Where(a => a.BindingId == binding.Id && a.UserId == binding.UserId).CountAsync() > 0;
|
||||
var observations = await db.Queryable<HealthIb43Observation>().Where(o => o.BindingId == binding.Id && o.UserId == binding.UserId).CountAsync();
|
||||
var readings = await repository.Latest(binding.UserId, binding.Id);
|
||||
var sampleMatches = readings.TryGetValue("heartRate", out var heart) && heart.Value == 72 &&
|
||||
readings.TryGetValue("respiration", out var breath) && breath.Value == 16 &&
|
||||
readings.TryGetValue("snoring", out var snoring) && snoring.Value == 0;
|
||||
Console.WriteLine($"bindingId={binding.Id}, audit={hasAudit}, observations={observations}, expectedSample={sampleMatches}");
|
||||
return hasAudit && observations > 0 && sampleMatches ? 0 : 2;
|
||||
}
|
||||
// 只维护新增审计表,不初始化/重建其他业务表,不启动 Web、MQTT 或种子数据。
|
||||
if (!db.DbMaintenance.IsAnyTable("health_ib43_maintenance_audit", false))
|
||||
db.CodeFirst.InitTables<HealthIb43MaintenanceAudit>();
|
||||
var result = await runner.Run(args[1], options, actor, true);
|
||||
var verified = await db.Queryable<HealthIb43Binding>().Where(b => b.Id == result.Id && b.UserId == result.UserId &&
|
||||
b.Side == "left" && b.Simulation && b.EndedAtMs == null).CountAsync();
|
||||
if (verified != 1) throw new InvalidOperationException("绑定读回验证失败。");
|
||||
Console.WriteLine($"PASS: bindingId={result.Id}, userId={result.UserId}; 右侧未修改。重复运行复用已有绑定。");
|
||||
return 0;
|
||||
}
|
||||
catch (InvalidOperationException error) { Console.Error.WriteLine(error.Message); return 1; }
|
||||
catch (Exception error)
|
||||
{
|
||||
// 不打印第三方数据库异常正文,避免连接凭据进入控制台。
|
||||
Console.Error.WriteLine($"维护失败 ({error.GetType().Name})。请检查数据库连接和表结构;未输出凭据。");
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user