using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using System.Globalization; namespace Evergreen.Health.Application.Health.Scenes; /// 共用发送、取消、回读和有限重试,任何家具都可复用。 public sealed class DeviceActionRunner { private readonly IHomeAssistantGateway _gateway; private readonly TimeProvider _time; private readonly DeviceExecutionOptions _options; private readonly IReadOnlyList _safety; private readonly SemaphoreSlim _commandGate = new(1, 1); public DeviceActionRunner(IHomeAssistantGateway gateway, TimeProvider time, DeviceExecutionOptions options, IEnumerable? safety = null) => (_gateway, _time, _options, _safety) = (gateway, time, options, safety?.ToArray() ?? []); public static bool IsAvailable(HomeAssistantState state) => !string.IsNullOrWhiteSpace(state.State) && !string.Equals(state.State, "unavailable", StringComparison.OrdinalIgnoreCase) && (!string.Equals(state.State, "unknown", StringComparison.OrdinalIgnoreCase) || state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase)); public async Task ReadAvailableStateAsync(string entityId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(entityId)) throw new InvalidOperationException("参数实体尚未配置"); return RequireAvailableState(await _gateway.GetStatesAsync(cancellationToken), entityId); } private static HomeAssistantState RequireAvailableState(IReadOnlyList 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 Task SendAsync(DeviceCommand command, CancellationToken cancellationToken) => SendIfAsync(command, null, cancellationToken); public async Task SendIfAsync(DeviceCommand command, Func, string?>? getSkipReason, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(command.EntityId)) throw new InvalidOperationException("参数实体尚未配置"); await _commandGate.WaitAsync(cancellationToken); try { using var timeout = new CancellationTokenSource(_options.CommandTimeout, _time); using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); try { IReadOnlyList? 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(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) { throw new TimeoutException($"{command.Name}发送超时,未确认完成"); } foreach (var rule in _safety) rule.Accepted(command, _time.GetUtcNow()); return null; } finally { _commandGate.Release(); } } public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => Task.Delay(delay, _time, cancellationToken); public async Task RunAsync(IReadOnlyDictionary states, List results, DeviceCommand command, CancellationToken cancellationToken, bool requireAvailable = true) { cancellationToken.ThrowIfCancellationRequested(); var action = new HomeAssistantActionResult { Name = command.Name, EntityId = command.EntityId }; results.Add(action); if (string.IsNullOrWhiteSpace(command.EntityId)) { action.Status = "Skipped"; action.Message = "实体未配置"; return; } if (requireAvailable && (!states.TryGetValue(command.EntityId, out var state) || !IsAvailable(state))) { action.Status = "Skipped"; action.Message = "实体不存在或当前离线"; return; } try { await SendAsync(command, cancellationToken); action.Status = "Succeeded"; action.Message = "控制指令已发送"; } catch (Exception exception) when (exception is not OperationCanceledException) { action.Status = "Failed"; action.Message = exception.Message; } } public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken, int? verificationAttempts = null) { var results = new List { new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" } }; await VerifyBatchAsync([command], results, cancellationToken, verificationAttempts); if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message); } public async Task VerifyBatchAsync(IReadOnlyList commands, List 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 < attempts && pending.Count > 0; round++) { await DelayAsync(_options.VerificationDelay, cancellationToken); IReadOnlyList states; try { states = await _gateway.GetStatesAsync(cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { foreach (var command in pending) { var action = FindResult(results, command); action.Status = "Failed"; action.Message = $"无法回读确认:{exception.Message}"; } return; } foreach (var command in pending.ToArray()) { var action = FindResult(results, command); 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!) && (command.ExpectedEntityState is null || Matches(state.State, command.ExpectedEntityState))) { action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态"; pending.Remove(command); continue; } 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; // 关闭属于尽力而为:缓存离线不能阻止发出重试,发送失败也不影响其他家具。 try { await SendAsync(command, cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { action.Status = "Failed"; action.Message = $"重试失败:{exception.Message}"; } } } } private static HomeAssistantActionResult FindResult(List results, DeviceCommand command) => results.Last(item => item.Name == command.Name && item.EntityId == command.EntityId); private static bool Matches(string actual, string expected) => string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) || (decimal.TryParse(actual, NumberStyles.Number, CultureInfo.InvariantCulture, out var actualNumber) && decimal.TryParse(expected, NumberStyles.Number, CultureInfo.InvariantCulture, out var expectedNumber) && actualNumber == expectedNumber); } public sealed class DeviceExecutionOptions { public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10); public int VerificationAttempts { get; init; } = 3; public TimeSpan VerificationDelay { get; init; } = TimeSpan.FromSeconds(2); }