153 lines
8.5 KiB
C#
153 lines
8.5 KiB
C#
using Evergreen.Health.Application.Health.Devices;
|
||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||
using Evergreen.Health.Application.Health.Models;
|
||
|
||
namespace Evergreen.Health.Application.Health.Scenes;
|
||
|
||
/// <summary>同一房间串行执行,后来的开启/关闭取消旧启动,避免延迟指令重新打开家具。</summary>
|
||
public sealed class HealthSceneCoordinator
|
||
{
|
||
private readonly DeviceModuleRegistry _modules;
|
||
private readonly DeviceActionRunner _runner;
|
||
private readonly IHomeAssistantGateway _gateway;
|
||
private readonly ILightTransitionRuntime _light;
|
||
private readonly ITcmMusicRuntime _music;
|
||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||
private readonly object _stateLock = new();
|
||
private CancellationTokenSource? _active;
|
||
|
||
public HealthSceneCoordinator(DeviceModuleRegistry modules, DeviceActionRunner runner,
|
||
IHomeAssistantGateway gateway, ILightTransitionRuntime light, ITcmMusicRuntime music)
|
||
=> (_modules, _runner, _gateway, _light, _music) = (modules, runner, gateway, light, music);
|
||
|
||
public Task<HomeAssistantSceneOutput> ExecuteAsync(string sceneCode, PersonalizedSceneConfig? config,
|
||
string constitutionCode, CancellationToken cancellationToken)
|
||
=> ExecuteCoreAsync(sceneCode, config, constitutionCode, cancellationToken, null);
|
||
|
||
// 先登记本次操作,再读取档案/室温。否则较慢的旧准备请求可能在全关之后才开始开家具。
|
||
public Task<HomeAssistantSceneOutput> ExecutePreparedSleepAsync(
|
||
Func<CancellationToken, Task<PreparedSleepScene>> prepare, CancellationToken cancellationToken)
|
||
=> ExecuteCoreAsync("sleep", null, "", cancellationToken, prepare);
|
||
|
||
private async Task<HomeAssistantSceneOutput> ExecuteCoreAsync(string sceneCode, PersonalizedSceneConfig? config,
|
||
string constitutionCode, CancellationToken cancellationToken,
|
||
Func<CancellationToken, Task<PreparedSleepScene>>? prepare)
|
||
{
|
||
if (sceneCode is not ("sleep" or "all-off")) throw new InvalidOperationException("不支持的场景");
|
||
if (sceneCode == "sleep" && config is null && prepare is null) throw new InvalidOperationException("舒睡场景缺少体质参数");
|
||
CancellationTokenSource operation;
|
||
lock (_stateLock)
|
||
{
|
||
_active?.Cancel();
|
||
operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
_active = operation;
|
||
}
|
||
var results = new List<HomeAssistantActionResult>();
|
||
var entered = false;
|
||
var cancelled = false;
|
||
string? preparationFailureStatus = null;
|
||
try
|
||
{
|
||
await _gate.WaitAsync(operation.Token); entered = true;
|
||
if (prepare is not null)
|
||
{
|
||
var prepared = await prepare(operation.Token);
|
||
operation.Token.ThrowIfCancellationRequested();
|
||
config = prepared.Config; constitutionCode = prepared.ConstitutionCode;
|
||
}
|
||
var context = new DeviceSceneContext(_runner, new Dictionary<string, HomeAssistantState>(), results);
|
||
await context.ExternalAsync("停止旧灯光渐变任务", "",
|
||
async token => { await _light.CancelAsync(token); }, operation.Token);
|
||
await context.ExternalAsync("停止旧助眠音乐", _modules.Modules.FirstOrDefault(module => module.Key == "audio")?.PrimaryEntityId ?? "",
|
||
_music.StopMusicAsync, operation.Token);
|
||
IReadOnlyDictionary<string, HomeAssistantState> states;
|
||
try
|
||
{
|
||
states = (await _gateway.GetStatesAsync(operation.Token)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||
{
|
||
// HA 不可达不能阻止 MiAirX 停止或尝试发送其他关闭指令。
|
||
results.Add(new() { Name = "读取设备状态", Status = "Failed", Message = exception.Message });
|
||
states = new Dictionary<string, HomeAssistantState>();
|
||
}
|
||
context = new DeviceSceneContext(_runner, states, results);
|
||
if (sceneCode == "sleep")
|
||
{
|
||
foreach (var module in _modules.Modules)
|
||
{
|
||
operation.Token.ThrowIfCancellationRequested();
|
||
try { await module.ExecuteSleepAsync(context, config!, constitutionCode, operation.Token); }
|
||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||
{
|
||
results.Add(new() { Name = $"{module.DisplayName}舒睡设置", Status = "Failed", Message = exception.Message });
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var shutdown = _modules.Modules.SelectMany(module => module.GetShutdownCommands()).ToArray();
|
||
foreach (var command in shutdown)
|
||
await _runner.RunAsync(states, results, command, operation.Token, requireAvailable: false);
|
||
await _runner.VerifyBatchAsync(shutdown, results, operation.Token);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (operation.IsCancellationRequested)
|
||
{
|
||
cancelled = true;
|
||
if (entered)
|
||
{
|
||
var context = new DeviceSceneContext(_runner, new Dictionary<string, HomeAssistantState>(), results);
|
||
await context.ExternalAsync("取消灯光渐变", "", async token => { await _light.CancelAsync(token); }, CancellationToken.None);
|
||
await context.ExternalAsync("取消音乐渐弱", "", _music.StopMusicAsync, CancellationToken.None);
|
||
}
|
||
}
|
||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||
{
|
||
preparationFailureStatus = exception is ScenePreparationException ? "AssessmentRequired" : "Failed";
|
||
results.Add(new() { Name = "准备或协调场景", Status = "Failed", Message = exception.Message });
|
||
}
|
||
finally
|
||
{
|
||
if (entered) _gate.Release();
|
||
lock (_stateLock)
|
||
{
|
||
if (ReferenceEquals(_active, operation)) _active = null;
|
||
operation.Dispose();
|
||
}
|
||
}
|
||
|
||
// 取消内部计时任务不能冒充家具执行成功。
|
||
var successCount = results.Count(result => result.Status == "Succeeded" && !string.IsNullOrWhiteSpace(result.EntityId));
|
||
var failed = results.Where(result => result.Status == "Failed").ToArray();
|
||
var skippedCount = results.Count(result => result.Status == "Skipped");
|
||
var status = preparationFailureStatus ?? (cancelled ? "Cancelled" : failed.Length == 0 && successCount > 0
|
||
? "Succeeded" : successCount > 0 ? "Partial" : "Failed");
|
||
var message = status switch
|
||
{
|
||
"Cancelled" => "当前场景已被更新的操作取消",
|
||
"AssessmentRequired" => failed.First().Message,
|
||
"Succeeded" => $"场景已执行:{successCount} 项成功,{skippedCount} 项未配置或离线而跳过",
|
||
"Partial" => $"场景部分完成:{failed.Length} 项失败,{skippedCount} 项跳过;未完成:{string.Join("、", failed.Select(result => result.Name).Distinct().Take(3))}",
|
||
_ => failed.Length == 0 ? "没有已执行的家具控制;设备未配置或当前不可用"
|
||
: $"场景未完成:{string.Join(";", failed.Select(result => result.Message).Take(3))}"
|
||
};
|
||
return new()
|
||
{
|
||
SceneCode = sceneCode, SceneName = sceneCode == "sleep" ? "舒睡模式" : "一键关闭",
|
||
Success = status is "Succeeded" or "Partial", Status = status, Message = message,
|
||
ClimateAdvice = sceneCode == "sleep" ? config?.ClimateAdvice : null,
|
||
Actions = results.Select(result => new HomeAssistantSceneActionOutput
|
||
{ Name = result.Name, EntityId = result.EntityId, Status = result.Status, Message = result.Message }).ToList()
|
||
};
|
||
}
|
||
}
|
||
|
||
public sealed record PreparedSleepScene(PersonalizedSceneConfig Config, string ConstitutionCode);
|
||
public sealed class ScenePreparationException : InvalidOperationException
|
||
{
|
||
public ScenePreparationException(string message) : base(message) { }
|
||
}
|