1
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user