230 lines
12 KiB
C#
230 lines
12 KiB
C#
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;
|
||
}
|
||
}
|