1
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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 Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Audio;
|
||||
|
||||
public sealed class AudioModule : HealthDeviceModule
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
private readonly ITcmMusicRuntime _music;
|
||||
public AudioModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options, ITcmMusicRuntime music) : base(runner)
|
||||
{
|
||||
_entities = options.Value.Entities; _music = music;
|
||||
Parameters =
|
||||
[
|
||||
new("volume", PrimaryEntityId, value => new("设置音箱音量", PrimaryEntityId, "audio", "volume",
|
||||
new Dictionary<string, object?> { ["volume"] = DeviceValues.Integer(value, 0, 100, "音箱音量") }),
|
||||
"音箱音量", 0, 100),
|
||||
DeviceValues.Switch("speaker_sleep_mode", _entities.SpeakerSleepMode, "switch", "音箱睡眠模式")
|
||||
];
|
||||
}
|
||||
public override string Key => "audio";
|
||||
public override string Category => nameof(HealthDeviceCategory.Media);
|
||||
public override string DisplayName => "助眠音箱";
|
||||
public override string PrimaryEntityId => _entities.Speaker;
|
||||
public override bool PowerUsesIndependentGateway => true;
|
||||
public override bool ParameterUsesIndependentGateway(DeviceCommand command) => command.Domain == "audio";
|
||||
public override Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
||||
=> turnOn ? _music.PlayTestMusicAsync(cancellationToken) : _music.StopMusicAsync(cancellationToken);
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
=> command.Domain == "audio"
|
||||
? _music.SetManualVolumeAsync((int)command.Data!["volume"]!, cancellationToken)
|
||||
: base.ExecuteParameterAsync(command, cancellationToken);
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RunAsync(BuildParameterCommand("speaker_sleep_mode", config.EnableSpeakerSleepMode), cancellationToken);
|
||||
await context.ExternalAsync("播放体质助眠音乐并渐弱", PrimaryEntityId,
|
||||
token => _music.StartForConstitutionAsync(constitutionCode, token), cancellationToken);
|
||||
}
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
=> [new("关闭音箱睡眠模式", _entities.SpeakerSleepMode, "switch", "turn_off", ExpectedState: "off")];
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PrimaryEntityId)) return null;
|
||||
var preview = Preview(config.EnableSpeakerSleepMode ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
|
||||
"体质曲目循环播放 · 音量50%每5秒降1%",
|
||||
new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = config.EnableSpeakerSleepMode ? "开启" : "关闭", ["volume"] = "50%→0%" });
|
||||
preview.Available = _music.HasPlayableTracks(constitutionCode);
|
||||
preview.AvailabilityMessage = preview.Available ? "体质音乐已配置,执行时验证音箱" : "该体质的 OSS 音乐 URL 尚未配置";
|
||||
return preview;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
|
||||
public enum DeviceCommandIntent { Normal, StartMassage, TurnRelaxationLightOn, TurnRelaxationLightOff }
|
||||
|
||||
/// <summary>实体由后端模块提供,不接受客户端任意实体或任意服务名。</summary>
|
||||
public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service,
|
||||
IReadOnlyDictionary<string, object?>? Data = null, string? ExpectedState = null)
|
||||
{
|
||||
public DeviceCommandIntent Intent { get; init; } = DeviceCommandIntent.Normal;
|
||||
// 新设备的能力校验由模块提供,所有发送路径(含场景关闭)都不可绕过。
|
||||
public Action<HomeAssistantState>? ValidateCurrentState { get; init; }
|
||||
// 不为空时 ExpectedState 表示此属性的期望值,而不是实体的顶层状态。
|
||||
public string? ExpectedAttribute { get; init; }
|
||||
public bool RetryOnVerificationMismatch { get; init; } = true;
|
||||
public Dictionary<string, object?> ToServiceData()
|
||||
{
|
||||
var result = Data is null ? new Dictionary<string, object?>() : new Dictionary<string, object?>(Data);
|
||||
result["entity_id"] = EntityId;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DeviceParameterDefinition(string Key, string EntityId,
|
||||
Func<object?, DeviceCommand> Build, string Label,
|
||||
int? Minimum = null, int? Maximum = null, IReadOnlyList<string>? Options = null);
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
|
||||
public static class DeviceValues
|
||||
{
|
||||
public static string Text(object? value)
|
||||
=> value is JsonElement element
|
||||
? (element.ValueKind == JsonValueKind.String ? element.GetString() ?? "" : element.ToString()).Trim()
|
||||
: Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim() ?? "";
|
||||
|
||||
public static int Integer(object? value, int minimum, int maximum, string label)
|
||||
{
|
||||
if (!int.TryParse(Text(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) ||
|
||||
number < minimum || number > maximum)
|
||||
throw new InvalidOperationException($"{label}应在 {minimum} 到 {maximum} 之间");
|
||||
return number;
|
||||
}
|
||||
|
||||
public static bool Boolean(object? value)
|
||||
{
|
||||
if (bool.TryParse(Text(value), out var result)) return result;
|
||||
throw new InvalidOperationException("开关参数无效");
|
||||
}
|
||||
|
||||
public static string Choice(object? value, IReadOnlyList<string> options, string label)
|
||||
{
|
||||
var text = Text(value);
|
||||
if (!options.Contains(text, StringComparer.Ordinal))
|
||||
throw new InvalidOperationException($"{label}选项无效");
|
||||
return text;
|
||||
}
|
||||
|
||||
public static DeviceParameterDefinition Switch(string key, string entity, string domain, string label)
|
||||
=> new(key, entity, value => new(label, entity, domain,
|
||||
Boolean(value) ? "turn_on" : "turn_off"), label);
|
||||
|
||||
public static DeviceParameterDefinition Number(string key, string entity, int min, int max, string label)
|
||||
=> new(key, entity, value => new(label, entity, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = Integer(value, min, max, label) }), label, min, max);
|
||||
|
||||
public static DeviceParameterDefinition Select(string key, string entity, IReadOnlyList<string> options, string label,
|
||||
bool verify = false, DeviceCommandIntent intent = DeviceCommandIntent.Normal)
|
||||
=> new(key, entity, value =>
|
||||
{
|
||||
var option = Choice(value, options, label);
|
||||
return new(label, entity, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = option }, verify ? option : null)
|
||||
{ Intent = option == "无" ? DeviceCommandIntent.Normal : intent };
|
||||
}, label, Options: options);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
|
||||
/// <summary>新增家具实现此契约并注册即可;场景和 API 不枚举具体家具类型。</summary>
|
||||
public interface IHealthDeviceModule
|
||||
{
|
||||
string Key { get; }
|
||||
string Category { get; }
|
||||
string DisplayName { get; }
|
||||
string PrimaryEntityId { get; }
|
||||
bool PowerUsesIndependentGateway { get; }
|
||||
IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
IEnumerable<string> EntityIds { get; }
|
||||
DeviceCommand BuildParameterCommand(string parameterType, object? value);
|
||||
Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken);
|
||||
bool ParameterUsesIndependentGateway(DeviceCommand command);
|
||||
Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken);
|
||||
Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken);
|
||||
IEnumerable<DeviceCommand> GetShutdownCommands();
|
||||
HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
|
||||
/// <summary>窗帘开合不是电源开关;本版只提供单独手动控制,不擅自加入舒睡/全关。</summary>
|
||||
public sealed class CurtainModule : HealthDeviceModule
|
||||
{
|
||||
private readonly string _entityId;
|
||||
public CurtainModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_entityId = options.Value.Entities.Curtain;
|
||||
Parameters =
|
||||
[
|
||||
new("curtain_action", _entityId, BuildAction, "窗帘动作", Options: ["open", "stop", "close"]),
|
||||
new("curtain_position", _entityId, BuildPosition, "窗帘开度", 0, 100)
|
||||
];
|
||||
}
|
||||
public override string Key => "curtain";
|
||||
public override string Category => nameof(HealthDeviceCategory.Curtain);
|
||||
public override string DisplayName => "窗帘";
|
||||
public override string PrimaryEntityId => _entityId;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
private DeviceCommand BuildAction(object? value)
|
||||
{
|
||||
var action = DeviceValues.Choice(value, ["open", "stop", "close"], "窗帘动作");
|
||||
var (service, feature, label) = action switch
|
||||
{
|
||||
"open" => ("open_cover", 1, "打开窗帘"), "close" => ("close_cover", 2, "关闭窗帘"),
|
||||
_ => ("stop_cover", 8, "停止窗帘")
|
||||
};
|
||||
return new(label, _entityId, "cover", service)
|
||||
{
|
||||
ValidateCurrentState = state => ValidateFeature(state, feature, label),
|
||||
RetryOnVerificationMismatch = false
|
||||
};
|
||||
}
|
||||
|
||||
private DeviceCommand BuildPosition(object? value)
|
||||
{
|
||||
var position = DeviceValues.Integer(value, 0, 100, "窗帘开度");
|
||||
return new("设置窗帘开度", _entityId, "cover", "set_cover_position",
|
||||
new Dictionary<string, object?> { ["position"] = position })
|
||||
{
|
||||
ValidateCurrentState = state => ValidateFeature(state, 4, "设置窗帘开度"),
|
||||
RetryOnVerificationMismatch = false
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateFeature(HomeAssistantState state, int feature, string label)
|
||||
{
|
||||
if (!_entityId.StartsWith("cover.", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("窗帘主控实体绑定无效");
|
||||
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}");
|
||||
}
|
||||
|
||||
public override Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
||||
=> throw new InvalidOperationException("窗帘没有电源开关,请使用打开、停止或关闭控制");
|
||||
|
||||
public override Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
context.Results.Add(new() { Name = "窗帘场景联动", EntityId = _entityId, Status = "Skipped",
|
||||
Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "本版窗帘只支持手动控制,舒睡不自动改变开合位置" });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
|
||||
public sealed class DehumidifierModule : HealthDeviceModule
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public DehumidifierModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_entities = options.Value.Entities;
|
||||
Parameters =
|
||||
[
|
||||
new("humidity", PrimaryEntityId, value => new("设置目标湿度", PrimaryEntityId, "humidifier", "set_humidity",
|
||||
new Dictionary<string, object?> { ["humidity"] = DeviceValues.Integer(value, 40, 70, "目标湿度") }), "目标湿度", 40, 70),
|
||||
new("fan_level", _entities.DehumidifierFanLevel, value => new("设置风速", _entities.DehumidifierFanLevel, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = DeviceValues.Choice(DeviceValues.Text(value).Replace('档', '挡'),
|
||||
["1挡", "2挡", "3挡"], "风速档位") }), "风速档位", Options: ["1挡", "2挡", "3挡"]),
|
||||
new("dehumidifier_mode", PrimaryEntityId, value => new("设置除湿模式", PrimaryEntityId, "humidifier", "set_mode",
|
||||
new Dictionary<string, object?> { ["mode"] = DeviceValues.Choice(value, ["睡眠", "智能"], "除湿模式") }),
|
||||
"除湿模式", Options: ["睡眠", "智能"]),
|
||||
DeviceValues.Switch("uv", _entities.DehumidifierUv, "switch", "UV 杀菌")
|
||||
];
|
||||
}
|
||||
public override string Key => "dehumidifier";
|
||||
public override string Category => nameof(HealthDeviceCategory.EnvironmentControl);
|
||||
public override string DisplayName => "除湿机";
|
||||
public override string PrimaryEntityId => _entities.Dehumidifier;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RunAsync(new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on"), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("humidity", config.TargetHumidity), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("dehumidifier_mode", config.DehumidifierMode), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("fan_level", config.FanLevel), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("uv", config.EnableUv), cancellationToken);
|
||||
}
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
=> [new("关闭除湿机", PrimaryEntityId, "humidifier", "turn_off", ExpectedState: "off")];
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
=> string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview("开启恒湿舒睡",
|
||||
$"目标湿度 {config.TargetHumidity}% · {config.DehumidifierMode}模式 · 风速{config.FanLevel} · UV{(config.EnableUv ? "开" : "关")}",
|
||||
new() { ["target_humidity"] = $"{config.TargetHumidity}%", ["mode"] = config.DehumidifierMode,
|
||||
["fan_level"] = config.FanLevel, ["uv"] = config.EnableUv ? "开启" : "关闭" });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices;
|
||||
|
||||
public sealed class DeviceModuleRegistry
|
||||
{
|
||||
public IReadOnlyList<IHealthDeviceModule> Modules { get; }
|
||||
private readonly IReadOnlyDictionary<string, IHealthDeviceModule> _parameterOwners;
|
||||
|
||||
public DeviceModuleRegistry(IEnumerable<IHealthDeviceModule> modules)
|
||||
{
|
||||
Modules = modules.ToArray();
|
||||
if (Modules.Select(module => module.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != Modules.Count)
|
||||
throw new InvalidOperationException("设备模块 Key 重复");
|
||||
var owners = new Dictionary<string, IHealthDeviceModule>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var module in Modules)
|
||||
foreach (var parameter in module.Parameters)
|
||||
if (!owners.TryAdd(parameter.Key, module))
|
||||
throw new InvalidOperationException($"设备参数重复注册:{parameter.Key}");
|
||||
_parameterOwners = owners;
|
||||
}
|
||||
|
||||
public IHealthDeviceModule ForParameter(string parameterType)
|
||||
=> _parameterOwners.TryGetValue(parameterType, out var module)
|
||||
? module : throw new InvalidOperationException($"不支持的参数类型:{parameterType}");
|
||||
|
||||
public IHealthDeviceModule? ForEntities(IEnumerable<string> entityIds)
|
||||
{
|
||||
var ids = entityIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return Modules.Select(module => new { Module = module,
|
||||
Score = module.EntityIds.Count(id => !string.IsNullOrWhiteSpace(id) && ids.Contains(id)) })
|
||||
.Where(item => item.Score > 0).OrderByDescending(item => item.Score)
|
||||
.Select(item => item.Module).FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using Evergreen.Health.Application.Health.Devices;
|
||||
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.Discovery;
|
||||
|
||||
public sealed class HomeAssistantDeviceCatalog
|
||||
{
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private readonly DeviceModuleRegistry _modules;
|
||||
public HomeAssistantDeviceCatalog(IOptions<HomeAssistantOptions> options, DeviceModuleRegistry modules)
|
||||
=> (_options, _modules) = (options.Value, modules);
|
||||
|
||||
public List<HomeAssistantDiscoveredDeviceOutput> BuildDiscoveredDevices(
|
||||
HomeAssistantRegistries? registries,
|
||||
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||
{
|
||||
var discovered = new List<HomeAssistantDiscoveredDeviceOutput>();
|
||||
|
||||
if (registries is not null && registries.Devices.Count > 0)
|
||||
{
|
||||
var areaMap = registries.Areas.ToDictionary(a => a.AreaId, a => a.Name, StringComparer.OrdinalIgnoreCase);
|
||||
var entitiesByDevice = registries.Entities
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.DeviceId) && string.IsNullOrWhiteSpace(e.DisabledBy))
|
||||
.GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。
|
||||
foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) &&
|
||||
!string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
|
||||
if (regEntities.Count == 0) continue;
|
||||
|
||||
var entityOutputs = new List<HomeAssistantEntityOutput>();
|
||||
foreach (var regEntity in regEntities)
|
||||
{
|
||||
stateMap.TryGetValue(regEntity.EntityId, out var state);
|
||||
var available = state is not null && IsAvailable(state);
|
||||
var domain = regEntity.EntityId.Contains('.') ? regEntity.EntityId.Split('.')[0] : "";
|
||||
var name = !string.IsNullOrWhiteSpace(regEntity.Name)
|
||||
? regEntity.Name
|
||||
: (!string.IsNullOrWhiteSpace(regEntity.OriginalName) ? regEntity.OriginalName : regEntity.EntityId);
|
||||
|
||||
entityOutputs.Add(new HomeAssistantEntityOutput
|
||||
{
|
||||
EntityId = regEntity.EntityId,
|
||||
Domain = domain,
|
||||
Name = name,
|
||||
State = state?.State ?? "unavailable",
|
||||
Available = available,
|
||||
Unit = state?.GetStringAttribute("unit_of_measurement"),
|
||||
Attributes = ExtractSafeAttributes(state)
|
||||
});
|
||||
}
|
||||
|
||||
var isOnline = regEntities.Any(e =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(e.DisabledBy)) return false;
|
||||
return stateMap.TryGetValue(e.EntityId, out var s) && IsAvailable(s);
|
||||
});
|
||||
|
||||
var displayName = !string.IsNullOrWhiteSpace(device.NameByUser)
|
||||
? device.NameByUser
|
||||
: (!string.IsNullOrWhiteSpace(device.Name) ? device.Name : "智能设备");
|
||||
|
||||
var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an)
|
||||
? an
|
||||
: null;
|
||||
|
||||
var module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId));
|
||||
var category = module?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
|
||||
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
||||
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
|
||||
|
||||
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
|
||||
{
|
||||
DeviceId = device.Id,
|
||||
ModuleKey = module?.Key ?? "",
|
||||
Name = displayName,
|
||||
Manufacturer = device.Manufacturer ?? "",
|
||||
Model = device.Model ?? "",
|
||||
AreaName = areaName,
|
||||
Category = category,
|
||||
Online = isOnline,
|
||||
PrimaryControlEntityId = primaryControlEntityId,
|
||||
Entities = entityOutputs,
|
||||
Metrics = metrics
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 有效注册表被过滤后为空,不代表注册表读取失败,不能重新生成占位设备。
|
||||
if (registries is null || registries.Devices.Count == 0)
|
||||
{
|
||||
discovered = BuildFallbackDevices(stateMap);
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
private List<HomeAssistantDiscoveredDeviceOutput> BuildFallbackDevices(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||
{
|
||||
var list = new List<HomeAssistantDiscoveredDeviceOutput>();
|
||||
// 注册信息暂不可读时只展示真实配置过的模块,不凭空创建床垫/体脂秤。
|
||||
foreach (var module in _modules.Modules)
|
||||
{
|
||||
var entityOutputs = module.EntityIds.Select(id =>
|
||||
{
|
||||
stateMap.TryGetValue(id, out var state);
|
||||
return new HomeAssistantEntityOutput
|
||||
{
|
||||
EntityId = id, Domain = id.Split('.')[0], Name = state?.GetStringAttribute("friendly_name") ?? module.DisplayName,
|
||||
State = state?.State ?? "unavailable", Available = state is not null && IsAvailable(state),
|
||||
Unit = state?.GetStringAttribute("unit_of_measurement"), Attributes = ExtractSafeAttributes(state)
|
||||
};
|
||||
}).ToList();
|
||||
if (entityOutputs.Count == 0) continue;
|
||||
list.Add(new()
|
||||
{
|
||||
DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName,
|
||||
Category = module.Category, Online = entityOutputs.Any(entity => entity.Available),
|
||||
PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs),
|
||||
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private string? FindPrimaryControlEntityId(
|
||||
string category,
|
||||
IReadOnlyList<HomeAssistantEntityOutput> entities)
|
||||
{
|
||||
var module = _modules.ForEntities(entities.Select(entity => entity.EntityId));
|
||||
if (module is not null)
|
||||
return !string.IsNullOrWhiteSpace(module.PrimaryEntityId) &&
|
||||
entities.Any(entity => string.Equals(entity.EntityId, module.PrimaryEntityId, StringComparison.OrdinalIgnoreCase))
|
||||
? module.PrimaryEntityId : null;
|
||||
|
||||
return category switch
|
||||
{
|
||||
nameof(HealthDeviceCategory.EnvironmentControl) => entities.FirstOrDefault(e => e.Domain == "humidifier")?.EntityId,
|
||||
nameof(HealthDeviceCategory.Lighting) => entities.FirstOrDefault(e => e.Domain == "light")?.EntityId,
|
||||
nameof(HealthDeviceCategory.Media) => entities.FirstOrDefault(e => e.Domain == "media_player")?.EntityId,
|
||||
nameof(HealthDeviceCategory.Climate) => entities.FirstOrDefault(e => e.Domain == "climate")?.EntityId,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static HomeAssistantSummaryOutput BuildSummary(
|
||||
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices)
|
||||
{
|
||||
var totalDevices = devices.Count;
|
||||
var onlineDevices = devices.Count(d => d.Online);
|
||||
var offlineDevices = totalDevices - onlineDevices;
|
||||
var totalEntities = devices.Sum(d => d.Entities.Count);
|
||||
var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available));
|
||||
|
||||
return new HomeAssistantSummaryOutput
|
||||
{
|
||||
TotalDevices = totalDevices,
|
||||
OnlineDevices = onlineDevices,
|
||||
OfflineDevices = offlineDevices,
|
||||
TotalEntities = totalEntities,
|
||||
AvailableEntities = availableEntities
|
||||
};
|
||||
}
|
||||
|
||||
public HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||
{
|
||||
var configured = _modules.Modules.Where(module => module.EntityIds.Any()).ToArray();
|
||||
var sceneAvailableCount = configured.Count(module => module.EntityIds.Any(entityId =>
|
||||
stateMap.TryGetValue(entityId, out var state) && IsAvailable(state)));
|
||||
|
||||
return new HomeAssistantSceneDevicesSummaryOutput
|
||||
{
|
||||
Total = configured.Length,
|
||||
Available = sceneAvailableCount
|
||||
};
|
||||
}
|
||||
|
||||
public HomeAssistantEnvironmentOutput BuildEnvironmentOutput(
|
||||
IReadOnlyDictionary<string, HomeAssistantState> stateMap,
|
||||
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices)
|
||||
{
|
||||
var entities = _options.Entities;
|
||||
var envDevice = devices.FirstOrDefault(d => d.Category == nameof(HealthDeviceCategory.EnvironmentSensor));
|
||||
|
||||
double? GetVal(string entityId, string metricKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entityId))
|
||||
{
|
||||
return stateMap.TryGetValue(entityId, out var state) && IsAvailable(state) &&
|
||||
double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) &&
|
||||
double.IsFinite(value) ? value : null;
|
||||
}
|
||||
if (envDevice?.Metrics.TryGetValue(metricKey, out var metric) == true && metric is not null &&
|
||||
double.TryParse(Convert.ToString(metric, CultureInfo.InvariantCulture), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var fallback) && double.IsFinite(fallback)) return fallback;
|
||||
return null;
|
||||
}
|
||||
|
||||
var humidity = GetVal(entities.Humidity, "humidity");
|
||||
if (humidity == null && !string.IsNullOrWhiteSpace(entities.Dehumidifier) &&
|
||||
stateMap.TryGetValue(entities.Dehumidifier, out var dehumState) && IsAvailable(dehumState))
|
||||
{
|
||||
humidity = dehumState.GetDoubleAttribute("current_humidity");
|
||||
}
|
||||
|
||||
return new HomeAssistantEnvironmentOutput
|
||||
{
|
||||
Temperature = GetVal(entities.Temperature, "temperature"),
|
||||
Humidity = humidity,
|
||||
Pm25 = GetVal(entities.Pm25, "pm25"),
|
||||
Pm10 = GetVal(string.Empty, "pm10"),
|
||||
Co2 = GetVal(entities.Co2, "co2"),
|
||||
Hcho = GetVal(entities.Formaldehyde, "hcho"),
|
||||
Tvoc = GetVal(entities.Tvoc, "tvoc")
|
||||
};
|
||||
}
|
||||
|
||||
private static string CategorizeDevice(
|
||||
string name,
|
||||
string model,
|
||||
string manufacturer,
|
||||
IReadOnlyList<HomeAssistantEntityOutput> entities)
|
||||
{
|
||||
var text = $"{name} {model} {manufacturer}".ToLowerInvariant();
|
||||
|
||||
if (text.Contains("je01") || text.Contains("睡眠监测") || text.Contains("睡眠检测") || text.Contains("雷达"))
|
||||
return nameof(HealthDeviceCategory.SleepMonitoring);
|
||||
if (entities.Any(entity => entity.Domain == "climate")) return nameof(HealthDeviceCategory.Climate);
|
||||
if (entities.Any(entity => entity.Domain == "cover")) return nameof(HealthDeviceCategory.Curtain);
|
||||
|
||||
if (text.Contains("除湿") || text.Contains("derh") || entities.Any(e => e.Domain == "humidifier"))
|
||||
return nameof(HealthDeviceCategory.EnvironmentControl);
|
||||
|
||||
if (text.Contains("空气检测") || text.Contains("airm") || text.Contains("et71"))
|
||||
return nameof(HealthDeviceCategory.EnvironmentSensor);
|
||||
|
||||
if (text.Contains("床垫") || text.Contains("mattress"))
|
||||
return nameof(HealthDeviceCategory.Sleep);
|
||||
|
||||
if (text.Contains("体脂") || text.Contains("scale"))
|
||||
return nameof(HealthDeviceCategory.BodyComposition);
|
||||
|
||||
if (text.Contains("香氛") || text.Contains("diffuser") || text.Contains("xw002"))
|
||||
return nameof(HealthDeviceCategory.Fragrance);
|
||||
|
||||
if (text.Contains("灯") || text.Contains("light") || text.Contains("lemesh"))
|
||||
return nameof(HealthDeviceCategory.Lighting);
|
||||
|
||||
if (text.Contains("sound") || text.Contains("音箱") || text.Contains("wifispeaker") || text.Contains("l17a") || entities.Any(e => e.Domain == "media_player"))
|
||||
return nameof(HealthDeviceCategory.Media);
|
||||
|
||||
return nameof(HealthDeviceCategory.Other);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ExtractDeviceMetrics(string category, IReadOnlyList<HomeAssistantEntityOutput> entities)
|
||||
{
|
||||
var metrics = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (!entity.Available) continue;
|
||||
var id = entity.EntityId.ToLowerInvariant();
|
||||
|
||||
if (id.Contains("relative_humidity") || (entity.Domain == "humidifier" && entity.Attributes?.ContainsKey("current_humidity") == true))
|
||||
{
|
||||
metrics["humidity"] = entity.Attributes?.GetValueOrDefault("current_humidity") ?? entity.State;
|
||||
}
|
||||
if (entity.Domain == "humidifier")
|
||||
{
|
||||
if (entity.Attributes?.TryGetValue("humidity", out var th) == true) metrics["targetHumidity"] = th;
|
||||
if (entity.Attributes?.TryGetValue("mode", out var mode) == true) metrics["mode"] = mode;
|
||||
}
|
||||
if (id.Contains("temperature")) metrics["temperature"] = entity.State;
|
||||
if (id.Contains("pm2_5") || id.Contains("pm25")) metrics["pm25"] = entity.State;
|
||||
if (id.Contains("pm10")) metrics["pm10"] = entity.State;
|
||||
if (id.Contains("co2")) metrics["co2"] = entity.State;
|
||||
if (id.Contains("hcho") || id.Contains("formaldehyde")) metrics["hcho"] = entity.State;
|
||||
if (id.Contains("tvoc")) metrics["tvoc"] = entity.State;
|
||||
if (id.Contains("weight")) metrics["weight"] = entity.State;
|
||||
if (id.Contains("body_fat") || id.Contains("fat_rate")) metrics["bodyFatRate"] = entity.State;
|
||||
if (id.Contains("firmness") || id.Contains("soft")) metrics["firmness"] = entity.State;
|
||||
if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?>? ExtractSafeAttributes(HomeAssistantState? state)
|
||||
{
|
||||
if (state == null || state.Attributes.ValueKind != JsonValueKind.Object) return null;
|
||||
var safe = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
var safeKeys = new[] {
|
||||
"friendly_name", "unit_of_measurement", "device_class",
|
||||
"current_humidity", "humidity", "mode", "fan_mode", "percentage",
|
||||
"brightness", "color_temp", "color_temp_kelvin",
|
||||
"volume_level", "is_volume_muted", "media_title",
|
||||
"battery_level", "temperature", "options", "min", "max", "step", "hvac_modes", "supported_features",
|
||||
"current_temperature", "temperature_unit", "min_temp", "max_temp", "target_temp_step",
|
||||
"fan_modes", "swing_modes", "swing_mode", "hvac_action", "current_position"
|
||||
};
|
||||
|
||||
foreach (var key in safeKeys)
|
||||
{
|
||||
if (state.Attributes.TryGetProperty(key, out var prop))
|
||||
{
|
||||
safe[key] = ToApiAttributeValue(prop);
|
||||
}
|
||||
}
|
||||
return safe.Count > 0 ? safe : null;
|
||||
}
|
||||
|
||||
// HA 用 System.Text.Json 读取,API 用 Newtonsoft.Json 输出。不能把 JsonElement
|
||||
// 留在 object[] 中,否则选项被输出为 ValueKind 对象,而不是原始字符串。
|
||||
private static object? ToApiAttributeValue(JsonElement value) => value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.TryGetInt64(out var integer) ? (object)integer : value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Array => value.EnumerateArray().Select(ToApiAttributeValue).ToArray(),
|
||||
JsonValueKind.Object => value.EnumerateObject().ToDictionary(property => property.Name,
|
||||
property => ToApiAttributeValue(property.Value), StringComparer.Ordinal),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static bool IsAvailable(HomeAssistantState state) => DeviceActionRunner.IsAvailable(state);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Environment;
|
||||
|
||||
public sealed class EnvironmentModule : HealthDeviceModule
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public EnvironmentModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
=> _entities = options.Value.Entities;
|
||||
public override string Key => "environment";
|
||||
public override string Category => nameof(HealthDeviceCategory.EnvironmentSensor);
|
||||
public override string DisplayName => "室内环境检测仪";
|
||||
public override string PrimaryEntityId => "";
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters => [];
|
||||
public override IEnumerable<string> EntityIds => new[]
|
||||
{
|
||||
_entities.Temperature, _entities.Humidity, _entities.Pm25, _entities.Co2, _entities.Formaldehyde, _entities.Tvoc
|
||||
}.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Policies;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Environment;
|
||||
|
||||
public sealed class IndoorEnvironmentReader
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
private readonly TimeProvider _time;
|
||||
public IndoorEnvironmentReader(IOptions<HomeAssistantOptions> options, TimeProvider time)
|
||||
=> (_entities, _time) = (options.Value.Entities, time);
|
||||
|
||||
public IndoorTemperatureSample Read(IReadOnlyDictionary<string, HomeAssistantState> states)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_entities.Temperature) ||
|
||||
!states.TryGetValue(_entities.Temperature, out var state) || !DeviceActionRunner.IsAvailable(state))
|
||||
return new(null, null, false, "室内温度实体未配置或不可用");
|
||||
var observedAt = state.LastReported ?? state.LastUpdated;
|
||||
if (observedAt is null || _time.GetUtcNow() - observedAt > TimeSpan.FromMinutes(30) ||
|
||||
observedAt > _time.GetUtcNow().AddMinutes(1))
|
||||
return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期");
|
||||
if (!double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var celsius) ||
|
||||
!double.IsFinite(celsius)) return new(null, observedAt, false, "室内温度数据无效");
|
||||
var unit = state.GetStringAttribute("unit_of_measurement");
|
||||
if (unit is "°F" or "F") celsius = (celsius - 32) * 5 / 9;
|
||||
else if (!string.IsNullOrWhiteSpace(unit) && unit is not ("°C" or "C" or "℃"))
|
||||
return new(null, observedAt, false, "室内温度单位未识别");
|
||||
if (celsius is < -20 or > 60) return new(null, observedAt, false, "室内温度超出有效范围");
|
||||
return new(celsius, observedAt, true, "室内温度数据有效");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
|
||||
public sealed class FragranceModule : HealthDeviceModule
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
public FragranceModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_entities = options.Value.Entities;
|
||||
Parameters =
|
||||
[
|
||||
DeviceValues.Number("fragrance_duration", _entities.FragranceDuration, 2, 6, "出香时长"),
|
||||
new("fragrance_interval", _entities.FragranceInterval, value =>
|
||||
{
|
||||
var interval = DeviceValues.Integer(value, 10, 20, "出香间隔");
|
||||
if (interval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟");
|
||||
return new("设置出香间隔", _entities.FragranceInterval, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = interval });
|
||||
}, "出香间隔", 10, 20),
|
||||
DeviceValues.Switch("fragrance_light", _entities.FragranceLight, "light", "香氛灯"),
|
||||
new("fragrance_quick_release", _entities.FragranceQuickRelease, _ => new("快速出香",
|
||||
_entities.FragranceQuickRelease, "button", "press"), "快速出香"),
|
||||
DeviceValues.Switch("fragrance_auto_release", _entities.FragranceAutoRelease, "switch", "人来增香"),
|
||||
DeviceValues.Switch("fragrance_auto_light", _entities.FragranceAutoLight, "switch", "感应夜灯"),
|
||||
DeviceValues.Switch("fragrance_light_auto_off", _entities.FragranceLightAutoOff, "switch", "夜灯自动关闭")
|
||||
];
|
||||
}
|
||||
public override string Key => "fragrance";
|
||||
public override string Category => nameof(HealthDeviceCategory.Fragrance);
|
||||
public override string DisplayName => "智能香氛机";
|
||||
public override string PrimaryEntityId => _entities.FragranceSwitch;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RunAsync(new(config.EnableFragrance ? "开启香氛机" : "关闭香氛机",
|
||||
PrimaryEntityId, "switch", config.EnableFragrance ? "turn_on" : "turn_off"), cancellationToken);
|
||||
if (config.EnableFragrance)
|
||||
{
|
||||
var level = Math.Clamp(config.FragranceLevel, 1, 3);
|
||||
await context.RunAsync(BuildParameterCommand("fragrance_duration", level * 2), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("fragrance_interval", 25 - level * 5), cancellationToken);
|
||||
}
|
||||
await context.RunAsync(new(config.EnableFragrance ? "开启香氛灯" : "关闭香氛灯",
|
||||
_entities.FragranceLight, "light", config.EnableFragrance ? "turn_on" : "turn_off",
|
||||
config.EnableFragrance ? new Dictionary<string, object?> { ["brightness_pct"] = config.LightBrightnessPercent } : null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands() =>
|
||||
[
|
||||
new("关闭香氛机", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off"),
|
||||
new("关闭香氛灯", _entities.FragranceLight, "light", "turn_off", ExpectedState: "off"),
|
||||
new("关闭感应夜灯", _entities.FragranceAutoLight, "switch", "turn_off", ExpectedState: "off"),
|
||||
new("关闭人来增香", _entities.FragranceAutoRelease, "switch", "turn_off", ExpectedState: "off")
|
||||
];
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
=> string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放",
|
||||
config.EnableFragrance ? "开启香氛机;香氛灯可用时同步开启柔光" : "当前体质预设不启动香氛",
|
||||
new() { ["power"] = config.EnableFragrance ? "开启" : "关闭" });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices;
|
||||
|
||||
public abstract class HealthDeviceModule : IHealthDeviceModule
|
||||
{
|
||||
protected DeviceActionRunner Runner { get; }
|
||||
protected HealthDeviceModule(DeviceActionRunner runner) => Runner = runner;
|
||||
public abstract string Key { get; }
|
||||
public abstract string Category { get; }
|
||||
public abstract string DisplayName { get; }
|
||||
public abstract string PrimaryEntityId { get; }
|
||||
public virtual bool PowerUsesIndependentGateway => false;
|
||||
public virtual bool ParameterUsesIndependentGateway(DeviceCommand command) => false;
|
||||
public abstract IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
public virtual IEnumerable<string> EntityIds => Parameters.Select(parameter => parameter.EntityId)
|
||||
.Append(PrimaryEntityId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DeviceCommand BuildParameterCommand(string parameterType, object? value)
|
||||
=> (Parameters.FirstOrDefault(parameter => string.Equals(parameter.Key, parameterType, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"不支持的参数类型:{parameterType}")).Build(value);
|
||||
|
||||
public virtual async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await Runner.SendAsync(command, cancellationToken);
|
||||
if (command.ExpectedState is not null)
|
||||
await Runner.VerifyParameterAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public virtual Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
||||
{
|
||||
var domain = PrimaryEntityId.Split('.')[0];
|
||||
if (domain is not ("switch" or "humidifier" or "light" or "media_player" or "climate"))
|
||||
throw new InvalidOperationException("该模块没有安全的主控开关");
|
||||
return Runner.SendAsync(new(turnOn ? "开启设备" : "关闭设备", PrimaryEntityId, domain,
|
||||
turnOn ? "turn_on" : "turn_off"), cancellationToken);
|
||||
}
|
||||
|
||||
public virtual Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public virtual IEnumerable<DeviceCommand> GetShutdownCommands() => [];
|
||||
public virtual HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) => null;
|
||||
|
||||
protected HomeAssistantScenePresetItem Preview(string action, string detail,
|
||||
Dictionary<string, object?>? parameters = null) => new()
|
||||
{
|
||||
DeviceName = DisplayName, Category = Category, EntityId = PrimaryEntityId,
|
||||
Action = action, Detail = detail, Parameters = parameters ?? new()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Evergreen.Health.Application.Health.Devices.Audio;
|
||||
using Evergreen.Health.Application.Health.Devices.Contracts;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
using Evergreen.Health.Application.Health.Devices.Discovery;
|
||||
using Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
using Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
using Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
using Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
using Evergreen.Health.Application.Health.Devices.Outlet;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Evergreen.Health.Application.Health.Integrations.Tuya;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices;
|
||||
|
||||
public static class HealthDeviceRegistration
|
||||
{
|
||||
public static IServiceCollection AddHealthDevices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(new DeviceExecutionOptions());
|
||||
// 仅注册扩展契约,不启动传感器轮询、上床流程或占位音频播放。
|
||||
services.AddSingleton(new SleepWorkflowOptions());
|
||||
services.AddSingleton<GuidanceCatalog>();
|
||||
services.AddSingleton<ISleepObservationSource, ReservedJe01Source>();
|
||||
services.AddSingleton<IGuidancePlayer, ReservedGuidancePlayer>();
|
||||
services.AddSingleton<ITuyaGateway, ReservedTuyaGateway>();
|
||||
services.AddTransient<ReservedSleepWorkflow>();
|
||||
services.AddTransient<AnomalyReminderGate>();
|
||||
services.AddSingleton<IHomeAssistantGateway>(provider => new HomeAssistantClient(
|
||||
provider.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(HomeAssistantClient)),
|
||||
provider.GetRequiredService<IOptions<HomeAssistantOptions>>()));
|
||||
services.AddSingleton<IAudioPlaybackGateway>(provider => new MiAirXClient(
|
||||
provider.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(MiAirXClient)),
|
||||
provider.GetRequiredService<IOptions<MiAirXOptions>>()));
|
||||
services.AddSingleton<DeviceActionRunner>();
|
||||
services.AddSingleton<SceneLightSafetyState>();
|
||||
services.AddSingleton<IDeviceActionSafetyRule, LightMassageSafetyRule>();
|
||||
services.AddSingleton<IndoorEnvironmentReader>();
|
||||
services.AddSingleton<EnvironmentModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<EnvironmentModule>());
|
||||
services.AddSingleton<ClimateModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<ClimateModule>());
|
||||
services.AddSingleton<CurtainModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<CurtainModule>());
|
||||
services.AddSingleton<OutletModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<OutletModule>());
|
||||
services.AddSingleton<TcmMusicTransitionService>();
|
||||
services.AddSingleton<ITcmMusicRuntime>(provider => provider.GetRequiredService<TcmMusicTransitionService>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TcmMusicTransitionService>());
|
||||
services.AddSingleton<HomeAssistantLightTransitionService>();
|
||||
services.AddSingleton<ILightTransitionRuntime>(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>());
|
||||
services.AddSingleton<DehumidifierModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<DehumidifierModule>());
|
||||
services.AddSingleton<FragranceModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<FragranceModule>());
|
||||
services.AddSingleton<LightingModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
|
||||
services.AddSingleton<AudioModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
|
||||
services.AddSingleton<MattressModule>();
|
||||
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
|
||||
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
|
||||
services.AddSingleton<LightOffDispatcher>();
|
||||
services.AddSingleton<DeviceModuleRegistry>();
|
||||
services.AddSingleton<HomeAssistantDeviceCatalog>();
|
||||
services.AddSingleton<HealthSceneCoordinator>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Lighting;
|
||||
|
||||
public sealed class LightingModule : HealthDeviceModule
|
||||
{
|
||||
private readonly HomeAssistantEntityOptions _entities;
|
||||
private readonly ILightTransitionRuntime _transition;
|
||||
private readonly LightOffDispatcher _lightOff;
|
||||
public LightingModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options,
|
||||
ILightTransitionRuntime transition, LightOffDispatcher lightOff) : base(runner)
|
||||
{
|
||||
_entities = options.Value.Entities; _transition = transition; _lightOff = lightOff;
|
||||
Parameters =
|
||||
[
|
||||
new("brightness", PrimaryEntityId, value =>
|
||||
{
|
||||
var brightness = DeviceValues.Integer(value, 0, 100, "灯光亮度");
|
||||
return new("设置灯光亮度", PrimaryEntityId, "light", brightness == 0 ? "turn_off" : "turn_on",
|
||||
brightness == 0 ? null : new Dictionary<string, object?> { ["brightness_pct"] = brightness })
|
||||
{ Intent = brightness == 0 ? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
|
||||
}, "灯光亮度", 0, 100),
|
||||
new("color_temp", PrimaryEntityId, value => new("设置灯光色温", PrimaryEntityId, "light", "turn_on",
|
||||
new Dictionary<string, object?> { ["color_temp_kelvin"] = DeviceValues.Integer(value, 2200, 5000, "灯光色温") })
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOn },
|
||||
"灯光色温", 2200, 5000)
|
||||
];
|
||||
}
|
||||
public override string Key => "lighting";
|
||||
public override string Category => nameof(HealthDeviceCategory.Lighting);
|
||||
public override string DisplayName => "卧室灯光";
|
||||
public override string PrimaryEntityId => _entities.AmbientLight;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await _transition.CancelAsync(cancellationToken);
|
||||
await base.ExecuteParameterAsync(command, cancellationToken);
|
||||
if (command.Service == "turn_off") await _lightOff.NotifyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
||||
{
|
||||
if (turnOn) { await _transition.StartSleepAsync(2700, cancellationToken); return; }
|
||||
await _transition.CancelAsync(cancellationToken);
|
||||
await Runner.SendAsync(new("关闭助眠灯光", PrimaryEntityId, "light", "turn_off")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }, cancellationToken);
|
||||
await _lightOff.NotifyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PrimaryEntityId) || !context.States.TryGetValue(PrimaryEntityId, out var state) ||
|
||||
!DeviceActionRunner.IsAvailable(state))
|
||||
{
|
||||
context.Results.Add(new() { Name = "开启助眠渐暗灯光", EntityId = PrimaryEntityId,
|
||||
Status = "Skipped", Message = "灯光未配置或离线" }); return;
|
||||
}
|
||||
await context.ExternalAsync("开启助眠渐暗灯光", PrimaryEntityId,
|
||||
async token => { await _transition.StartSleepAsync(config.LightColorTemperatureKelvin, token); }, cancellationToken);
|
||||
}
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
=> [new("关闭助眠灯光", PrimaryEntityId, "light", "turn_off", ExpectedState: "off")
|
||||
{ Intent = DeviceCommandIntent.TurnRelaxationLightOff }];
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
=> string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview("开启助眠渐暗灯光",
|
||||
$"从50%开始,每5秒降低1%,最终关灯 · 色温 {config.LightColorTemperatureKelvin}K",
|
||||
new() { ["brightness"] = "50%→关闭", ["color_temp"] = $"{config.LightColorTemperatureKelvin}K" });
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 Evergreen.Health.Application.Health.Services;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Mattress;
|
||||
|
||||
public sealed class MattressModule : HealthDeviceModule, ILightOffHandler
|
||||
{
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private HomeAssistantEntityOptions Entities => _options.Entities;
|
||||
public MattressModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
{
|
||||
_options = options.Value;
|
||||
var modes = new[] { "无", "瑜伽助眠", "智能适配", "智能腰托" };
|
||||
var parts = new[] { "无", "背部", "腰部", "牵引", "腿部", "全身" };
|
||||
var parameters = new List<DeviceParameterDefinition>
|
||||
{
|
||||
DeviceValues.Number("mattress_left_heating", Entities.MattressLeftHeatingLevel, 0, 4, "床垫左侧加热档位"),
|
||||
DeviceValues.Number("mattress_right_heating", Entities.MattressRightHeatingLevel, 0, 4, "床垫右侧加热档位"),
|
||||
DeviceValues.Select("mattress_left_mode", Entities.MattressLeftMode, modes, "床垫左侧承托模式", true),
|
||||
DeviceValues.Select("mattress_right_mode", Entities.MattressRightMode, modes, "床垫右侧承托模式", true),
|
||||
DeviceValues.Select("mattress_massage_left_part", Entities.MattressLeftMassagePart, parts, "左侧按摩部位",
|
||||
intent: DeviceCommandIntent.StartMassage),
|
||||
DeviceValues.Select("mattress_massage_right_part", Entities.MattressRightMassagePart, parts, "右侧按摩部位",
|
||||
intent: DeviceCommandIntent.StartMassage),
|
||||
DeviceValues.Select("mattress_massage_level", Entities.MattressMassageLevel, ["轻柔", "强劲"], "按摩力度")
|
||||
};
|
||||
foreach (var (zone, entityId) in Entities.MattressFirmness)
|
||||
parameters.Add(DeviceValues.Select($"mattress_firmness_{zone}", entityId,
|
||||
["最软", "很软", "稍软", "稍硬", "很硬", "最硬"], $"床垫{ZoneName(zone)}软硬度"));
|
||||
Parameters = parameters;
|
||||
}
|
||||
public override string Key => "mattress";
|
||||
public override string Category => nameof(HealthDeviceCategory.Sleep);
|
||||
public override string DisplayName => "智能床垫";
|
||||
public override string PrimaryEntityId => ""; // 床垫没有单一总开关,不能把加热实体冒充开关。
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters { get; }
|
||||
|
||||
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
||||
string constitutionCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.SleepScene.EnableMattress) return;
|
||||
await context.RunAsync(BuildParameterCommand("mattress_left_heating", config.MattressHeatingLevel), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_right_heating", config.MattressHeatingLevel), cancellationToken);
|
||||
var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness);
|
||||
if (firmness is null)
|
||||
context.Results.Add(new() { Name = "按 BMI 调节床垫软硬度", Status = "Skipped",
|
||||
Message = "健康档案缺少身高或体重,未自动修改软硬度" });
|
||||
else
|
||||
foreach (var zone in Entities.MattressFirmness.Keys)
|
||||
await context.RunAsync(BuildParameterCommand($"mattress_firmness_{zone}", firmness), cancellationToken);
|
||||
var modeCommand = BuildParameterCommand("mattress_left_mode", "智能适配");
|
||||
await context.RunAsync(modeCommand, cancellationToken);
|
||||
if (context.Results.Last().Status == "Succeeded")
|
||||
await Runner.VerifyBatchAsync([modeCommand], context.Results, cancellationToken);
|
||||
// 气泵调节结束后再启动按摩,保持真实设备所需的顺序。
|
||||
await Runner.DelayAsync(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_massage_left_part", "无"), cancellationToken);
|
||||
await context.RunAsync(BuildParameterCommand("mattress_massage_level", "轻柔"), cancellationToken);
|
||||
var massage = BuildParameterCommand("mattress_massage_right_part", "全身") with { ExpectedState = "全身" };
|
||||
await context.RunAsync(massage, cancellationToken);
|
||||
if (context.Results.Last().Status == "Succeeded")
|
||||
{
|
||||
await Runner.DelayAsync(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
await Runner.VerifyBatchAsync([massage], context.Results, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<DeviceCommand> MassageOffCommands() =>
|
||||
[
|
||||
new("关闭床垫右侧按摩", Entities.MattressRightMassagePart, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "无" }, "无"),
|
||||
new("关闭床垫左侧按摩", Entities.MattressLeftMassagePart, "select", "select_option",
|
||||
new Dictionary<string, object?> { ["option"] = "无" }, "无")
|
||||
];
|
||||
|
||||
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
||||
{
|
||||
foreach (var command in MassageOffCommands()) yield return command;
|
||||
// 未参与自动启动不等于无需关闭:手动开启的加热也要随一键关闭停止。
|
||||
yield return new("关闭床垫左侧加热", Entities.MattressLeftHeatingLevel, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = 0 }, "0");
|
||||
yield return new("关闭床垫右侧加热", Entities.MattressRightHeatingLevel, "number", "set_value",
|
||||
new Dictionary<string, object?> { ["value"] = 0 }, "0");
|
||||
}
|
||||
|
||||
public async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var commands = MassageOffCommands().Where(command => !string.IsNullOrWhiteSpace(command.EntityId)).ToArray();
|
||||
var results = new List<HomeAssistantActionResult>();
|
||||
foreach (var command in commands)
|
||||
await Runner.RunAsync(new Dictionary<string, HomeAssistantState>(), results, command, cancellationToken, false);
|
||||
await Runner.VerifyBatchAsync(commands, results, cancellationToken);
|
||||
if (results.Any(result => result.Status == "Failed"))
|
||||
throw new InvalidOperationException("床垫按摩未确认关闭:" +
|
||||
string.Join(";", results.Where(result => result.Status == "Failed").Select(result => result.Message)));
|
||||
}
|
||||
|
||||
public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
|
||||
{
|
||||
if (!_options.SleepScene.EnableMattress || !EntityIds.Any()) return null;
|
||||
var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness) ?? "未自动调节";
|
||||
var preview = Preview("按室温和 BMI 调节床垫并开启右侧按摩",
|
||||
$"左右加热 {config.MattressHeatingLevel} 档 · 软硬度{firmness} · 右侧全身轻柔按摩",
|
||||
new() { ["heating_level"] = config.MattressHeatingLevel, ["firmness"] = firmness,
|
||||
["right_massage_part"] = "全身", ["massage_level"] = "轻柔" });
|
||||
preview.EntityId = EntityIds.First();
|
||||
return preview;
|
||||
}
|
||||
|
||||
public static string ZoneName(string zone) => zone switch
|
||||
{
|
||||
"left_shoulder" => "左肩", "right_shoulder" => "右肩", "left_back" => "左背", "right_back" => "右背",
|
||||
"left_waist" => "左腰", "right_waist" => "右腰", "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎",
|
||||
"left_hip" => "左臀", "right_hip" => "右臀", "left_leg" => "左腿", "right_leg" => "右腿", _ => zone
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Devices.Outlet;
|
||||
|
||||
/// <summary>仅单独控制明确绑定的插座主开关;不使用服务开关、切换按钮或内部保护参数。</summary>
|
||||
public sealed class OutletModule : HealthDeviceModule
|
||||
{
|
||||
private readonly string _entityId;
|
||||
public OutletModule(DeviceActionRunner runner, IOptions<HomeAssistantOptions> options) : base(runner)
|
||||
=> _entityId = options.Value.Entities.Outlet;
|
||||
|
||||
public override string Key => "outlet";
|
||||
public override string Category => nameof(HealthDeviceCategory.Outlet);
|
||||
public override string DisplayName => "智能插座";
|
||||
public override string PrimaryEntityId => _entityId;
|
||||
public override IReadOnlyList<DeviceParameterDefinition> Parameters => [];
|
||||
|
||||
public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!_entityId.StartsWith("switch.", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("插座主开关尚未正确绑定");
|
||||
var command = new DeviceCommand(turnOn ? "开启插座电源" : "关闭插座电源", _entityId, "switch",
|
||||
turnOn ? "turn_on" : "turn_off", ExpectedState: turnOn ? "on" : "off")
|
||||
{
|
||||
ValidateCurrentState = state =>
|
||||
{
|
||||
if (!string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase) ||
|
||||
state.State is not ("on" or "off"))
|
||||
throw new InvalidOperationException("插座主开关状态未确认,请刷新后再试");
|
||||
},
|
||||
// 等待有限次数回读,不因读数延迟再次通电,也不使用 toggle 反转电源。
|
||||
RetryOnVerificationMismatch = false
|
||||
};
|
||||
await Runner.SendAsync(command, cancellationToken);
|
||||
await Runner.VerifyParameterAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
// 本版不接入舒睡/唤醒/一键关闭:沿用基类的无场景动作、无关闭命令、无场景预览。
|
||||
}
|
||||
Reference in New Issue
Block a user