Files
Evergreen-Health/Evergreen.Health.Application/Health/Scenes/HealthSceneCoordinator.cs
T
2026-10-09 16:46:30 +08:00

153 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) { }
}