Files
Evergreen-Health/Evergreen.Health.Application/Health/Devices/Climate/ClimateModule.cs
T
2026-10-09 16:46:30 +08:00

230 lines
12 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.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;
/// <summary>空调只使用已绑定的 climate 实体;每条命令按设备当前公开能力校验。</summary>
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<HomeAssistantOptions> 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<DeviceParameterDefinition> 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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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<int>();
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<DeviceCommand> 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;
}
}