using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Scenes; using Microsoft.Extensions.Options; using System.Globalization; using System.Text.Json; namespace Evergreen.Health.Application.Health.Devices.Climate; /// 空调只使用已绑定的 climate 实体;每条命令按设备当前公开能力校验。 public sealed class ClimateModule : HealthDeviceModule { private readonly string _entityId; private const int TargetTemperatureFeature = 1, FanModeFeature = 8, TurnOffFeature = 128, TurnOnFeature = 256; private static readonly string[] KnownModes = ["off", "heat", "cool", "fan_only", "auto", "dry", "heat_cool"]; public ClimateModule(DeviceActionRunner runner, IOptions options) : base(runner) { _entityId = options.Value.Entities.Climate; Parameters = [ new("climate_mode", _entityId, BuildModeCommand, "空调模式"), new("climate_temperature", _entityId, BuildTemperatureCommand, "目标温度"), new("climate_fan_mode", _entityId, BuildFanCommand, "风速") ]; } public override string Key => "climate"; public override string Category => nameof(HealthDeviceCategory.Climate); public override string DisplayName => "空调"; public override string PrimaryEntityId => _entityId; public override IReadOnlyList Parameters { get; } public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken) { var command = PowerCommand(turnOn); await Runner.SendAsync(command, cancellationToken); if (!turnOn) await Runner.VerifyParameterAsync(command, cancellationToken); } private DeviceCommand PowerCommand(bool turnOn) => new(turnOn ? "开启空调" : "关闭空调", _entityId, "climate", turnOn ? "turn_on" : "turn_off", ExpectedState: turnOn ? null : "off") { ValidateCurrentState = state => { ValidateIdentity(state); RequireFeature(state, turnOn ? TurnOnFeature : TurnOffFeature, turnOn ? "开启" : "关闭"); }, RetryOnVerificationMismatch = !turnOn }; private DeviceCommand BuildModeCommand(object? value) { var mode = DeviceValues.Choice(value, KnownModes, "空调模式"); return new("设置空调模式", _entityId, "climate", "set_hvac_mode", new Dictionary { ["hvac_mode"] = mode }, mode) { ValidateCurrentState = state => { ValidateIdentity(state); RequireOption(state, "hvac_modes", mode, "空调模式"); }, RetryOnVerificationMismatch = false }; } private DeviceCommand BuildTemperatureCommand(object? value) { if (!decimal.TryParse(DeviceValues.Text(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var temperature)) throw new InvalidOperationException("目标温度必须是有效数值"); return new("设置空调目标温度", _entityId, "climate", "set_temperature", new Dictionary { ["temperature"] = temperature }, temperature.ToString(CultureInfo.InvariantCulture)) { ExpectedAttribute = "temperature", ValidateCurrentState = state => ValidateTemperature(state, temperature), RetryOnVerificationMismatch = false }; } private DeviceCommand BuildFanCommand(object? value) { var fan = DeviceValues.Text(value); if (string.IsNullOrWhiteSpace(fan)) throw new InvalidOperationException("风速选项不能为空"); return new("设置空调风速", _entityId, "climate", "set_fan_mode", new Dictionary { ["fan_mode"] = fan }, fan) { ExpectedAttribute = "fan_mode", ValidateCurrentState = state => { ValidateIdentity(state); RequireFeature(state, FanModeFeature, "风速设置"); RequireOption(state, "fan_modes", fan, "风速"); }, RetryOnVerificationMismatch = false }; } public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, string constitutionCode, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var advice = config.ClimateAdvice; if (string.IsNullOrWhiteSpace(_entityId)) { Skip(context, "空调实体尚未配置,仅保留室温建议"); return; } if (advice.Mode is not ("heat" or "cool")) { Skip(context, advice.Mode == "off" ? "室温在建议范围内,本次不额外开启空调,保持现有状态" : advice.Reason); return; } if (!context.States.TryGetValue(_entityId, out var state) || !DeviceActionRunner.IsAvailable(state)) { Skip(context, "空调当前离线或不可用,本次不发送启动指令"); return; } // 先校验所有参数,禁止在温度无效时先开启空调。随机值由场景准备阶段只抽一次。 var modeCommand = BuildModeCommand(advice.Mode); modeCommand.ValidateCurrentState!(state); var target = ResolveSceneTarget(state, advice); advice.TargetTemperatureCelsius = target; var temperatureCommand = BuildTemperatureCommand(target); temperatureCommand.ValidateCurrentState!(state); await context.ExternalAsync(modeCommand.Name, _entityId, token => ExecuteParameterAsync(modeCommand, token), cancellationToken); if (context.Results.Last().Status != "Succeeded") return; await context.ExternalAsync(temperatureCommand.Name, _entityId, token => ExecuteParameterAsync(temperatureCommand, token), cancellationToken); } private int ResolveSceneTarget(HomeAssistantState state, ClimateAdviceOutput advice) { var (minimum, maximum, step) = TemperatureBounds(state); if (advice.TargetTemperatureCelsius is not int preparedTarget) throw new InvalidOperationException("本次空调目标温度尚未生成,不自动使用默认温度"); var low = Math.Max((decimal)advice.RecommendedMinimumCelsius, minimum); var high = Math.Min((decimal)advice.RecommendedMaximumCelsius, maximum); if (low > high || high - low > 1000) throw new InvalidOperationException("设备温度范围与本次室温建议没有有效交集"); var candidates = new List(); for (var candidate = decimal.Ceiling(low); candidate <= decimal.Floor(high); candidate++) if (IsOnStep(candidate, minimum, step) && candidate >= int.MinValue && candidate <= int.MaxValue) candidates.Add((int)candidate); if (candidates.Count == 0) throw new InvalidOperationException("室温建议范围内没有符合设备步进的整数目标温度"); // 只做确定性范围/步进校正,不因重试或状态刷新重新随机。 return candidates.OrderBy(candidate => Math.Abs((long)candidate - preparedTarget)).ThenBy(candidate => candidate).First(); } private void ValidateIdentity(HomeAssistantState state) { if (!_entityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) || !string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("空调主控实体绑定无效"); } private void ValidateTemperature(HomeAssistantState state, decimal temperature) { ValidateIdentity(state); var (minimum, maximum, step) = TemperatureBounds(state); if (temperature < minimum || temperature > maximum) throw new InvalidOperationException($"该设备目标温度应在 {minimum} 到 {maximum} 之间"); if (!IsOnStep(temperature, minimum, step)) throw new InvalidOperationException($"目标温度必须符合设备 {step}℃ 的调节步进"); } private static (decimal Minimum, decimal Maximum, decimal Step) TemperatureBounds(HomeAssistantState state) { RequireFeature(state, TargetTemperatureFeature, "目标温度设置"); if (state.GetStringAttribute("temperature_unit") != "°C") throw new InvalidOperationException("尚未确认该空调使用摄氏温度,暂不发送目标温度"); var min = state.GetDoubleAttribute("min_temp"); var max = state.GetDoubleAttribute("max_temp"); var step = state.GetDoubleAttribute("target_temp_step"); if (min is null || max is null || step is null || step <= 0 || min > max || min < (double)decimal.MinValue || max > (double)decimal.MaxValue || step > (double)decimal.MaxValue) throw new InvalidOperationException("空调未提供有效温度范围或调节步进"); var decimalStep = (decimal)step.Value; if (decimalStep <= 0) throw new InvalidOperationException("空调调节步进无效"); return ((decimal)min.Value, (decimal)max.Value, decimalStep); } private static bool IsOnStep(decimal value, decimal minimum, decimal step) { var steps = (value - minimum) / step; return Math.Abs(steps - decimal.Round(steps)) <= 0.00000001m; } private static void RequireFeature(HomeAssistantState state, int feature, string label) { var features = state.GetDoubleAttribute("supported_features"); if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) || (((int)features.Value & feature) != feature)) throw new InvalidOperationException($"该空调未声明支持{label}"); } private static void RequireOption(HomeAssistantState state, string attribute, string option, string label) { if (state.Attributes.ValueKind != JsonValueKind.Object || !state.Attributes.TryGetProperty(attribute, out var list) || list.ValueKind != JsonValueKind.Array || !list.EnumerateArray().Any(item => item.ValueKind == JsonValueKind.String && string.Equals(item.GetString(), option, StringComparison.Ordinal))) throw new InvalidOperationException($"该空调不支持{label}:{option}"); } private void Skip(DeviceSceneContext context, string message) => context.Results.Add(new() { Name = "空调舒睡设置", EntityId = _entityId, Status = "Skipped", Message = message }); public override IEnumerable GetShutdownCommands() => string.IsNullOrWhiteSpace(_entityId) ? [] : [PowerCommand(false)]; public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) { var advice = config.ClimateAdvice; var preview = Preview(advice.Mode switch { "heat" => "按室温开启制热", "cool" => "按室温开启制冷", "off" => "不额外开启空调", _ => "等待有效室温" }, advice.Reason, new() { ["mode"] = advice.Mode, ["temperature_range"] = $"{advice.RecommendedMinimumCelsius}–{advice.RecommendedMaximumCelsius}℃", ["target_temperature"] = advice.TargetTemperatureCelsius }); if (string.IsNullOrWhiteSpace(_entityId)) { preview.Available = false; preview.AvailabilityMessage = "空调实体尚未配置,仅显示建议"; } return preview; } }