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; /// 同一房间串行执行,后来的开启/关闭取消旧启动,避免延迟指令重新打开家具。 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 ExecuteAsync(string sceneCode, PersonalizedSceneConfig? config, string constitutionCode, CancellationToken cancellationToken) => ExecuteCoreAsync(sceneCode, config, constitutionCode, cancellationToken, null); // 先登记本次操作,再读取档案/室温。否则较慢的旧准备请求可能在全关之后才开始开家具。 public Task ExecutePreparedSleepAsync( Func> prepare, CancellationToken cancellationToken) => ExecuteCoreAsync("sleep", null, "", cancellationToken, prepare); private async Task ExecuteCoreAsync(string sceneCode, PersonalizedSceneConfig? config, string constitutionCode, CancellationToken cancellationToken, Func>? 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(); 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(), 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 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(); } 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(), 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) { } }