111111
This commit is contained in:
@@ -1,11 +1,77 @@
|
||||
{
|
||||
"BedPresenceAutomation": {
|
||||
"Mode": "Disabled",
|
||||
"Mode": "Managed",
|
||||
"BindingId": "",
|
||||
"ProductKey": "evergreen-test",
|
||||
"SerialNumber": "IB43SIM001",
|
||||
"Side": "Left",
|
||||
"ActionKey": "curtain-close",
|
||||
"TargetEntityId": "cover.czmydz_cn_2251706154_mym1_s_2_curtain"
|
||||
"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,6 +15,14 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -27,13 +35,6 @@
|
||||
"Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8"
|
||||
},
|
||||
"OnlyConfiguredDevices": true,
|
||||
"DisplayOnlyDevices": [
|
||||
{
|
||||
"Name": "香氛机 唤醒",
|
||||
"EntityId": "switch.xiaomi_cn_1238447032_xw002_on_p_2_2",
|
||||
"Category": "Fragrance"
|
||||
}
|
||||
],
|
||||
"SleepScene": {
|
||||
"TargetHumidity": 55,
|
||||
"DehumidifierMode": "睡眠",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+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);
|
||||
|
||||
+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 ? "开启" : "关闭" });
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ public static class HealthDeviceRegistration
|
||||
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>());
|
||||
@@ -74,6 +84,8 @@ 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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -46,6 +46,14 @@ 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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,26 @@ public sealed class Ib43DataRepository(ISqlSugarClient db)
|
||||
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))
|
||||
|
||||
+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(); }
|
||||
}
|
||||
|
||||
@@ -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,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(); }
|
||||
}
|
||||
@@ -122,7 +141,8 @@ 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;
|
||||
@@ -130,6 +150,8 @@ public sealed class DeviceActionRunner
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -26,11 +27,14 @@ public sealed record Ib43CurrentOutput(Ib43BedOutput Bed, string Transport, bool
|
||||
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) : IDynamicApiController, ITransient
|
||||
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)
|
||||
@@ -69,8 +73,49 @@ public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContex
|
||||
{
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Threading.Channels;
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum BedSide { Left, Right }
|
||||
public enum BedAutomationMode { Disabled, Preview, Execute }
|
||||
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);
|
||||
@@ -20,12 +20,19 @@ 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
|
||||
public sealed class BedPresenceAutomationOptions : BedPresenceRuleOptions
|
||||
{
|
||||
public List<BedPresenceRuleOptions> AdditionalRules { get; set; } = [];
|
||||
}
|
||||
|
||||
public class BedPresenceRuleOptions
|
||||
{
|
||||
public BedAutomationMode Mode { get; set; }
|
||||
public string BindingId { get; set; } = "";
|
||||
@@ -34,14 +41,29 @@ public sealed class BedPresenceAutomationOptions
|
||||
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() => Mode == BedAutomationMode.Disabled ||
|
||||
(Mode is BedAutomationMode.Preview or BedAutomationMode.Execute && Guid.TryParseExact(BindingId, "N", out _) &&
|
||||
!string.IsNullOrWhiteSpace(ProductKey) && !string.IsNullOrWhiteSpace(SerialNumber) &&
|
||||
Enum.IsDefined(Side) && !string.IsNullOrWhiteSpace(ActionKey) && !string.IsNullOrWhiteSpace(TargetEntityId));
|
||||
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) => sample.Simulation && sample.BindingId == BindingId &&
|
||||
sample.ProductKey == ProductKey && sample.SerialNumber == SerialNumber && sample.Side == Side;
|
||||
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>
|
||||
@@ -66,45 +88,180 @@ public sealed class BedPresenceEdge
|
||||
public void Reset() => _previous = null;
|
||||
}
|
||||
|
||||
/// <summary>异步执行单条联调规则。采集线程只提交归一化事件。</summary>
|
||||
public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions> options, IEnumerable<IBedPresenceAction> actions,
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger) : BackgroundService, IBedPresenceSink
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger, IBedPresenceExecutionLog? executionLog = null) : BackgroundService, IBedPresenceSink
|
||||
{
|
||||
private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken);
|
||||
private readonly Channel<Item> _queue = Channel.CreateBounded<Item>(new BoundedChannelOptions(32)
|
||||
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private long _sequence;
|
||||
private volatile bool _accepting;
|
||||
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)
|
||||
{
|
||||
var configuration = options.Value;
|
||||
if (!_accepting || !configuration.Matches(sample) || connectionToken.IsCancellationRequested) return;
|
||||
if (!_queue.Writer.TryWrite(new(Interlocked.Increment(ref _sequence), sample, connectionToken)))
|
||||
logger.LogWarning("Bed presence automation queue full; observation dropped");
|
||||
foreach (var rule in _rules) rule.Observe(sample, connectionToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
|
||||
Task.WhenAll(_rules.Select(rule => rule.RunAsync(stoppingToken)));
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = options.Value;
|
||||
if (configuration.Mode == BedAutomationMode.Disabled) 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))
|
||||
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)
|
||||
{
|
||||
logger.LogWarning("Bed presence automation inactive: invalid binding, action or target");
|
||||
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;
|
||||
_accepting = true;
|
||||
_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) edge.Reset();
|
||||
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)
|
||||
{
|
||||
@@ -117,28 +274,60 @@ public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions>
|
||||
logger.LogInformation("Bed presence action skipped: observation aged in queue");
|
||||
continue;
|
||||
}
|
||||
using var timeout = new CancellationTokenSource(action.ExecutionTimeout, clock);
|
||||
using var operation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, item.ConnectionToken, timeout.Token);
|
||||
try
|
||||
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))
|
||||
{
|
||||
operation.Token.ThrowIfCancellationRequested();
|
||||
var result = await action.ExecuteAsync(target, operation.Token);
|
||||
logger.LogInformation("Bed presence action: Action={Action}, Target={EntityId}, Result={Result}", action.Key, target, result);
|
||||
result = "expired";
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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 override Task StopAsync(CancellationToken cancellationToken)
|
||||
public void Stop()
|
||||
{
|
||||
_accepting = false;
|
||||
return base.StopAsync(cancellationToken);
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user