Files
Evergreen-Health/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs
T
2026-10-08 10:16:43 +08:00

1602 lines
77 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
using Furion.DatabaseAccessor;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using SqlSugar;
using System.Globalization;
using System.Security.Claims;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Services;
[ApiDescriptionSettings("健康管理", Name = "HealthHomeAssistant", Order = 51)]
public sealed class HealthHomeAssistantService : IDynamicApiController, ITransient
{
private const string HealthUserIdClaim = "health_user_id";
private readonly HomeAssistantClient _homeAssistant;
private readonly TcmMusicTransitionService _musicTransition;
private readonly HomeAssistantLightTransitionService _lightTransition;
private readonly HomeAssistantRegistryService _registryService;
private readonly HomeAssistantOptions _options;
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly MattressComfortService _mattressComfort;
private readonly IHttpContextAccessor _httpContextAccessor;
public HealthHomeAssistantService(
HomeAssistantClient homeAssistant,
TcmMusicTransitionService musicTransition,
HomeAssistantLightTransitionService lightTransition,
HomeAssistantRegistryService registryService,
IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository,
SqlSugarRepository<HealthProfile> profileRepository,
MattressComfortService mattressComfort,
IHttpContextAccessor httpContextAccessor)
{
_homeAssistant = homeAssistant;
_musicTransition = musicTransition;
_lightTransition = lightTransition;
_registryService = registryService;
_options = options.Value;
_executionRepository = executionRepository;
_profileRepository = profileRepository;
_mattressComfort = mattressComfort;
_httpContextAccessor = httpContextAccessor;
}
[Authorize]
[HttpGet("/api/health/home-assistant/lights/transition")]
public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.GetStatus();
[Authorize]
[HttpGet("/api/health/home-assistant/music/transition")]
public TcmMusicTransitionOutput GetMusicTransitionStatus() => _musicTransition.GetStatus();
[Authorize]
[HttpPost("/api/health/home-assistant/lights/sleep")]
public async Task<HomeAssistantLightTransitionOutput> StartSleepLight(CancellationToken cancellationToken)
{
try { return await _lightTransition.StartSleepAsync(_options.SleepScene.LightColorTemperatureKelvin, cancellationToken); }
catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpPost("/api/health/home-assistant/lights/wake")]
public async Task<HomeAssistantLightTransitionOutput> StartWakeLight(CancellationToken cancellationToken)
{
try { return await _lightTransition.StartWakeAsync(cancellationToken); }
catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpPost("/api/health/home-assistant/lights/stop")]
public async Task<HomeAssistantLightTransitionOutput> StopLightTransition(CancellationToken cancellationToken)
{
try { return await _lightTransition.CancelAsync(cancellationToken); }
catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpGet("/api/health/home-assistant/status")]
public async Task<HomeAssistantStatusOutput> GetStatus(CancellationToken cancellationToken)
{
try
{
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discoveredDevices = BuildDiscoveredDevices(registries, stateMap);
var summary = BuildSummary(discoveredDevices);
var sceneDevices = BuildSceneDevicesSummary(stateMap);
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
PersonalizedSceneConfig? userConfig = null;
var constitutionCode = string.Empty;
try
{
var userId = GetCurrentUserId();
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
var tcmResult = TcmConstitutionRules.Read(profile);
if (tcmResult.HasCompleted)
{
constitutionCode = tcmResult.ConstitutionCode;
userConfig = tcmResult.SceneConfig;
var comfort = await _mattressComfort.GetAsync(profile, cancellationToken);
MattressComfortService.ApplyTo(userConfig, comfort);
}
}
catch
{
}
var scenePresets = userConfig is null
? new List<HomeAssistantScenePresetItem>()
: BuildScenePresets(stateMap, userConfig, constitutionCode);
return new HomeAssistantStatusOutput
{
Connected = true,
Message = "Home Assistant 已连接",
CheckedAt = DateTimeOffset.Now,
Summary = summary,
Devices = discoveredDevices,
SceneDevices = sceneDevices,
Environment = environment,
ScenePresets = scenePresets,
ControlParameters = BuildControlParameterStates(stateMap)
};
}
catch (Exception exception)
{
return new HomeAssistantStatusOutput
{
Connected = false,
Message = $"Home Assistant 连接失败:{exception.Message}",
CheckedAt = DateTimeOffset.Now,
Summary = new HomeAssistantSummaryOutput(),
Devices = [],
SceneDevices = BuildSceneDevicesSummary(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
Environment = new HomeAssistantEnvironmentOutput(),
ScenePresets = [],
ControlParameters = []
};
}
}
[Authorize]
[HttpPost("/api/health/home-assistant/devices/switch")]
public async Task<HomeAssistantDeviceSwitchOutput> SwitchDevice(
[FromBody] HomeAssistantDeviceSwitchInput input,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(input.DeviceId))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
Message = "未指定控制设备"
};
}
try
{
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(s => s.EntityId, StringComparer.OrdinalIgnoreCase);
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discovered = BuildDiscoveredDevices(registries, stateMap);
var targetDevice = discovered.FirstOrDefault(d =>
string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase));
if (targetDevice is null || string.IsNullOrWhiteSpace(targetDevice.PrimaryControlEntityId))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
Message = "该设备没有经过确认的主控开关"
};
}
var targetEntityId = targetDevice.PrimaryControlEntityId;
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
!string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = input.EntityId,
Message = "请求的实体不属于该设备的主控能力"
};
}
var candidate = targetDevice.Entities.FirstOrDefault(e =>
string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase));
if (candidate is null || candidate.Domain is not ("switch" or "humidifier" or "light" or "media_player" or "climate"))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
Message = "该实体不支持安全的开关控制"
};
}
var domain = candidate.Domain;
if (domain == "media_player")
{
if (!string.Equals(targetEntityId, _options.Entities.Speaker, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("该音箱不是已配置的助眠音箱");
if (input.TurnOn)
await _musicTransition.PlayTestMusicAsync(cancellationToken);
else
await _musicTransition.StopMusicAsync(cancellationToken);
return new HomeAssistantDeviceSwitchOutput
{
Success = true,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
Message = input.TurnOn ? "播放指令已发送" : "停止指令已发送"
};
}
if (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
Message = "目标设备当前处于离线状态"
};
}
if (domain == "light" && string.Equals(targetEntityId, _options.Entities.AmbientLight, StringComparison.OrdinalIgnoreCase))
{
if (input.TurnOn)
{
var transition = await _lightTransition.StartSleepAsync(2700, cancellationToken);
return new HomeAssistantDeviceSwitchOutput
{
Success = transition.Success,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = "on",
Message = transition.Message
};
}
await _lightTransition.CancelAsync(cancellationToken);
}
var targetOn = input.TurnOn;
var service = targetOn ? "turn_on" : "turn_off";
var serviceData = new Dictionary<string, object?>
{
["entity_id"] = targetEntityId
};
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
return new HomeAssistantDeviceSwitchOutput
{
Success = true,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = targetOn ? "on" : "off",
Message = $"已成功{(targetOn ? "开启" : "关闭")}设备"
};
}
catch (Exception ex)
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = input.EntityId ?? string.Empty,
Message = $"控制失败:{ex.Message}"
};
}
}
[Authorize]
[HttpPost("/api/health/home-assistant/devices/set-parameter")]
public async Task<HomeAssistantSetParameterOutput> SetParameter(
[FromBody] HomeAssistantSetParameterInput input,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(input.DeviceId) || string.IsNullOrWhiteSpace(input.ParameterType))
{
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = "未指定控制设备或参数"
};
}
try
{
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discovered = BuildDiscoveredDevices(registries, stateMap);
var targetDevice = discovered.FirstOrDefault(d =>
string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase));
if (targetDevice is null)
{
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = "未找到指定设备"
};
}
var parameterType = input.ParameterType.Trim().ToLowerInvariant();
string targetEntityId;
string domain;
string service;
var serviceData = new Dictionary<string, object?>();
switch (parameterType)
{
case "humidity":
targetEntityId = _options.Entities.Dehumidifier;
domain = "humidifier";
service = "set_humidity";
serviceData["humidity"] = ReadInt(input.Value, 40, 70, "目标湿度");
break;
case "fan_level":
targetEntityId = _options.Entities.DehumidifierFanLevel;
domain = "select";
service = "select_option";
var fanLevel = ReadString(input.Value).Replace('档', '挡');
if (fanLevel is not ("1挡" or "2挡" or "3挡")) throw new InvalidOperationException("风速档位无效");
serviceData["option"] = fanLevel;
break;
case "dehumidifier_mode":
targetEntityId = _options.Entities.Dehumidifier;
domain = "humidifier";
service = "set_mode";
var dehumidifierMode = ReadString(input.Value);
if (dehumidifierMode is not ("睡眠" or "智能")) throw new InvalidOperationException("除湿模式无效");
serviceData["mode"] = dehumidifierMode;
break;
case "brightness":
targetEntityId = _options.Entities.AmbientLight;
domain = "light";
service = "turn_on";
serviceData["brightness_pct"] = ReadInt(input.Value, 1, 100, "灯光亮度");
break;
case "color_temp":
targetEntityId = _options.Entities.AmbientLight;
domain = "light";
service = "turn_on";
serviceData["color_temp_kelvin"] = ReadInt(input.Value, 2200, 5000, "灯光色温");
break;
case "volume":
targetEntityId = _options.Entities.Speaker;
domain = "media_player";
service = "";
serviceData["volume"] = ReadInt(input.Value, 0, 100, "音箱音量");
break;
case "speaker_sleep_mode":
targetEntityId = _options.Entities.SpeakerSleepMode;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_duration":
targetEntityId = _options.Entities.FragranceDuration;
domain = "number";
service = "set_value";
serviceData["value"] = ReadInt(input.Value, 2, 6, "出香时长");
break;
case "fragrance_interval":
targetEntityId = _options.Entities.FragranceInterval;
domain = "number";
service = "set_value";
var fragranceInterval = ReadInt(input.Value, 10, 20, "出香间隔");
if (fragranceInterval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟");
serviceData["value"] = fragranceInterval;
break;
case "fragrance_light":
targetEntityId = _options.Entities.FragranceLight;
domain = "light";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_quick_release":
targetEntityId = _options.Entities.FragranceQuickRelease;
domain = "button";
service = "press";
break;
case "fragrance_auto_release":
targetEntityId = _options.Entities.FragranceAutoRelease;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_auto_light":
targetEntityId = _options.Entities.FragranceAutoLight;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_light_auto_off":
targetEntityId = _options.Entities.FragranceLightAutoOff;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "mattress_left_heating":
targetEntityId = _options.Entities.MattressLeftHeatingLevel;
domain = "number";
service = "set_value";
serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫左侧加热档位");
break;
case "mattress_right_heating":
targetEntityId = _options.Entities.MattressRightHeatingLevel;
domain = "number";
service = "set_value";
serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫右侧加热档位");
break;
case "mattress_left_mode":
targetEntityId = _options.Entities.MattressLeftMode;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMattressMode(input.Value);
break;
case "mattress_right_mode":
targetEntityId = _options.Entities.MattressRightMode;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMattressMode(input.Value);
break;
case "mattress_massage_left_part":
targetEntityId = _options.Entities.MattressLeftMassagePart;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMassagePart(input.Value);
break;
case "mattress_massage_right_part":
targetEntityId = _options.Entities.MattressRightMassagePart;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMassagePart(input.Value);
break;
case "mattress_massage_level":
targetEntityId = _options.Entities.MattressMassageLevel;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMassageLevel(input.Value);
break;
case string firmnessType when firmnessType.StartsWith("mattress_firmness_", StringComparison.Ordinal):
var zone = firmnessType["mattress_firmness_".Length..];
if (!_options.Entities.MattressFirmness.TryGetValue(zone, out targetEntityId))
throw new InvalidOperationException("不支持该床垫分区");
domain = "select";
service = "select_option";
serviceData["option"] = ReadFirmness(input.Value);
break;
case "uv":
targetEntityId = _options.Entities.DehumidifierUv;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
default:
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = $"不支持的参数类型: {input.ParameterType}"
};
}
if (string.IsNullOrWhiteSpace(targetEntityId))
throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体");
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
!string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
if (!targetDevice.Entities.Any(e => string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("该参数不属于指定设备");
if (parameterType == "volume")
{
await _musicTransition.SetManualVolumeAsync((int)serviceData["volume"]!, cancellationToken);
return new HomeAssistantSetParameterOutput
{
Success = true,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = "音箱音量指令已发送"
};
}
if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState))
throw new InvalidOperationException("目标参数实体当前离线或不可用");
if (parameterType is "brightness" or "color_temp")
await _lightTransition.CancelAsync(cancellationToken);
serviceData["entity_id"] = targetEntityId;
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
if (parameterType is "mattress_left_mode" or "mattress_right_mode")
await VerifyMattressModeAsync(targetEntityId, serviceData["option"]?.ToString() ?? "", serviceData,
cancellationToken);
var successMessage = parameterType switch
{
"fragrance_quick_release" => "快速出香指令已发送",
"mattress_left_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
"左侧承托模式已启动;床垫共用气泵,右侧会自动停止",
"mattress_right_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
"右侧承托模式已启动;床垫共用气泵,左侧会自动停止",
_ => "参数调节成功"
};
return new HomeAssistantSetParameterOutput
{
Success = true,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType ?? "",
Message = successMessage
};
}
catch (Exception ex)
{
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType ?? "",
Message = $"调节失败:{ex.Message}"
};
}
}
[Authorize]
[HttpPost("/api/health/home-assistant/scenes/sleep")]
public async Task<HomeAssistantSceneOutput> ExecuteSleepScene(CancellationToken cancellationToken)
{
PersonalizedSceneConfig? customConfig = null;
var userId = GetCurrentUserId();
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
var tcmResult = TcmConstitutionRules.Read(profile);
if (!tcmResult.HasCompleted)
{
return new HomeAssistantSceneOutput
{
SceneCode = "sleep", SceneName = "舒睡模式", Success = false,
Status = "AssessmentRequired", Message = "请先完成中医四诊,生成体质倾向后再一键启动"
};
}
customConfig = tcmResult.SceneConfig;
var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken);
MattressComfortService.ApplyTo(customConfig, mattressComfort);
return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) =>
ExecuteSleepActions(states, results, customConfig, tcmResult.ConstitutionCode, ct), cancellationToken);
}
[Authorize]
[HttpPost("/api/health/home-assistant/scenes/all-off")]
public Task<HomeAssistantSceneOutput> ExecuteAllOffScene(CancellationToken cancellationToken)
=> ExecuteScene("all-off", "一键关闭", ExecuteAllOffActions, cancellationToken);
private async Task<HomeAssistantSceneOutput> ExecuteScene(
string sceneCode,
string sceneName,
Func<IReadOnlyDictionary<string, HomeAssistantState>, List<HomeAssistantActionResult>, CancellationToken, Task> executor,
CancellationToken cancellationToken)
{
var userId = GetCurrentUserId();
var actions = new List<HomeAssistantActionResult>();
string status;
string message;
try
{
// 关闭场景先停止音乐;即使 HA 暂时不可用,也要尽量避免音箱继续播放。
if (sceneCode == "all-off")
await RunMiAirXAction(actions, "停止助眠音乐", _options.Entities.Speaker,
_musicTransition.StopMusicAsync, cancellationToken);
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
await executor(stateMap, actions, cancellationToken);
var successCount = actions.Count(item => item.Status == "Succeeded");
var failedCount = actions.Count(item => item.Status == "Failed");
var skippedCount = actions.Count(item => item.Status == "Skipped");
var failedNames = string.Join("、", actions.Where(item => item.Status == "Failed")
.Select(item => item.Name).Distinct().Take(3));
status = failedCount == 0 && successCount > 0 ? "Succeeded" : successCount > 0 ? "Partial" : "Failed";
message = status switch
{
"Succeeded" when skippedCount > 0 => $"场景已执行,{successCount} 项成功,{skippedCount} 项因设备离线或未配置而跳过",
"Succeeded" => $"场景已执行,{successCount} 项成功",
"Partial" => $"场景部分执行成功:{successCount} 项成功,{failedCount} 项失败,{skippedCount} 项跳过;未完成:{failedNames}",
_ => "当前没有可执行的在线设备"
};
}
catch (Exception exception)
{
status = "Failed";
message = $"场景执行失败:{exception.Message}";
}
await _executionRepository.InsertAsync(new HealthSceneExecution
{
UserId = userId,
SceneCode = sceneCode,
SceneName = sceneName,
Status = status,
Detail = JsonSerializer.Serialize(new { message, actions })
});
return new HomeAssistantSceneOutput
{
SceneCode = sceneCode,
SceneName = sceneName,
Success = status is "Succeeded" or "Partial",
Status = status,
Message = message,
Actions = actions.Select(item => new HomeAssistantSceneActionOutput
{
Name = item.Name,
EntityId = item.EntityId,
Status = item.Status,
Message = item.Message
}).ToList()
};
}
private async Task ExecuteSleepActions(
IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results,
PersonalizedSceneConfig? customConfig,
string constitutionCode,
CancellationToken cancellationToken)
{
var entities = _options.Entities;
var scene = _options.SleepScene;
var targetHumidity = customConfig?.TargetHumidity ?? scene.TargetHumidity;
var dehumMode = customConfig?.DehumidifierMode ?? scene.DehumidifierMode;
var fanLevel = customConfig?.FanLevel ?? scene.FanLevel;
var enableUv = customConfig?.EnableUv ?? scene.EnableUv;
var lightBrightness = customConfig?.LightBrightnessPercent ?? scene.LightBrightnessPercent;
var lightColorTemp = customConfig?.LightColorTemperatureKelvin ?? scene.LightColorTemperatureKelvin;
var enableFragrance = customConfig?.EnableFragrance ?? scene.EnableFragrance;
var fragranceLevel = Math.Clamp(customConfig?.FragranceLevel ?? 1, 1, 3);
var fragranceDuration = fragranceLevel switch { 1 => 2, 2 => 4, _ => 6 };
var fragranceInterval = fragranceLevel switch { 1 => 20, 2 => 15, _ => 10 };
var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode;
var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel;
var mattressFirmness = MattressComfortService.FirmnessName(customConfig?.MattressLeftFirmness ?? 0);
await RunAction(states, results, "开启除湿机", entities.Dehumidifier, "humidifier", "turn_on", null, cancellationToken);
await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity",
new Dictionary<string, object?> { ["humidity"] = targetHumidity }, cancellationToken);
await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode",
new Dictionary<string, object?> { ["mode"] = dehumMode }, cancellationToken);
await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option",
new Dictionary<string, object?> { ["option"] = fanLevel }, cancellationToken);
await RunAction(states, results, enableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv,
"switch", enableUv ? "turn_on" : "turn_off", null, cancellationToken);
if (enableFragrance)
{
await RunAction(states, results, "开启香氛机", entities.FragranceSwitch, "switch", "turn_on", null, cancellationToken);
await RunAction(states, results, "设置出香时长", entities.FragranceDuration, "number", "set_value",
new Dictionary<string, object?> { ["value"] = fragranceDuration }, cancellationToken);
await RunAction(states, results, "设置出香间隔", entities.FragranceInterval, "number", "set_value",
new Dictionary<string, object?> { ["value"] = fragranceInterval }, cancellationToken);
await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on",
new Dictionary<string, object?> { ["brightness_pct"] = lightBrightness }, cancellationToken);
}
else
{
await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken);
await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken);
}
if (string.IsNullOrWhiteSpace(entities.AmbientLight) ||
!states.TryGetValue(entities.AmbientLight, out var lightState) || !IsAvailable(lightState))
{
results.Add(new HomeAssistantActionResult
{
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
Status = "Skipped", Message = "设备未配置或离线"
});
}
else
{
try
{
await _lightTransition.StartSleepAsync(lightColorTemp, cancellationToken);
results.Add(new HomeAssistantActionResult
{
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
Status = "Succeeded", Message = "已从50%开始,每5秒降低1%,最终关灯"
});
}
catch (Exception exception)
{
results.Add(new HomeAssistantActionResult
{
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
Status = "Failed", Message = exception.Message
});
}
}
if (enableSpeakerSleepMode)
{
await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken);
}
else
{
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
}
// 睡眠模式仍由 Home Assistant 控制;体质选曲和渐弱在 MiAirX 后台任务中进行。
await RunMiAirXAction(results, "播放体质助眠音乐并渐弱", entities.Speaker,
token => _musicTransition.StartForConstitutionAsync(constitutionCode, token), cancellationToken);
if (scene.EnableMattress)
{
await RunAction(states, results, "设置床垫左侧加热档位", entities.MattressLeftHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
// 左右承托模式共用气泵;一键场景只实际启动左侧,右侧留作页面预选。
if (mattressFirmness == null)
{
results.Add(new HomeAssistantActionResult
{
Name = "按 BMI 调节床垫软硬度", Status = "Skipped",
Message = "健康档案缺少身高或体重,未自动修改软硬度"
});
}
else
{
foreach (var (zone, entityId) in entities.MattressFirmness)
{
await RunAction(states, results, $"设置床垫{MattressZoneName(zone)}软硬度",
entityId, "select", "select_option",
new Dictionary<string, object?> { ["option"] = mattressFirmness }, cancellationToken);
}
}
const string leftModeActionName = "开启床垫左侧智能适配";
await RunAction(states, results, leftModeActionName, entities.MattressLeftMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "智能适配" }, cancellationToken);
var leftModeAction = results.LastOrDefault(item => item.Name == leftModeActionName);
if (leftModeAction?.Status == "Succeeded")
{
try
{
await VerifyMattressModeAsync(entities.MattressLeftMode, "智能适配",
new Dictionary<string, object?> { ["entity_id"] = entities.MattressLeftMode, ["option"] = "智能适配" },
cancellationToken);
leftModeAction.Message = "已确认左侧智能适配运行,右侧保持待机预选";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
leftModeAction.Status = "Failed";
leftModeAction.Message = exception.Message;
}
}
// 软硬度调节可能占用床垫气泵;等这些指令完成后,最后启动按摩。
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
await RunAction(states, results, "停止床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
await RunAction(states, results, "设置床垫轻柔按摩", entities.MattressMassageLevel, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "轻柔" }, cancellationToken);
const string rightMassageActionName = "开启床垫右侧全身按摩";
await RunAction(states, results, rightMassageActionName, entities.MattressRightMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "全身" }, cancellationToken);
// HA 接受指令不等于设备保持该状态;此前曾在两秒后自动复位为“无”。
var rightMassageAction = results.LastOrDefault(item => item.Name == rightMassageActionName);
if (rightMassageAction?.Status == "Succeeded")
{
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
try
{
var currentStates = await _homeAssistant.GetStatesAsync(cancellationToken);
var currentRightMassage = currentStates.FirstOrDefault(item =>
string.Equals(item.EntityId, entities.MattressRightMassagePart, StringComparison.OrdinalIgnoreCase));
if (currentRightMassage?.State != "全身")
{
rightMassageAction.Status = "Failed";
rightMassageAction.Message = $"指令已发送,但右侧按摩未保持全身模式(当前:{currentRightMassage?.State ?? "未读取到状态"})";
}
else
{
rightMassageAction.Message = "已确认右侧全身按摩保持开启";
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
rightMassageAction.Status = "Failed";
rightMassageAction.Message = $"指令已发送,但无法确认右侧按摩状态:{exception.Message}";
}
}
}
}
private async Task ExecuteAllOffActions(
IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results,
CancellationToken cancellationToken)
{
var entities = _options.Entities;
await _lightTransition.CancelAsync(cancellationToken);
var commands = new List<OffCommand>
{
new("关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", "off", null),
new("关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", "off", null),
new("关闭香氛灯", entities.FragranceLight, "light", "turn_off", "off", null),
new("关闭感应夜灯", entities.FragranceAutoLight, "switch", "turn_off", "off", null),
new("关闭人来增香", entities.FragranceAutoRelease, "switch", "turn_off", "off", null),
new("关闭助眠灯光", entities.AmbientLight, "light", "turn_off", "off", null),
new("关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", "off", null),
new("关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option", "无",
new Dictionary<string, object?> { ["option"] = "无" }),
new("关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", "无",
new Dictionary<string, object?> { ["option"] = "无" })
};
if (_options.SleepScene.EnableMattress)
{
commands.Add(new("关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", "0",
new Dictionary<string, object?> { ["value"] = 0 }));
commands.Add(new("关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", "0",
new Dictionary<string, object?> { ["value"] = 0 }));
}
foreach (var command in commands)
await RunAction(states, results, command.Name, command.EntityId, command.Domain, command.Service,
command.ExtraData, cancellationToken);
await VerifyOffActionsAsync(commands, results, cancellationToken);
}
private sealed record OffCommand(string Name, string EntityId, string Domain, string Service,
string ExpectedState, IReadOnlyDictionary<string, object?>? ExtraData);
private async Task VerifyOffActionsAsync(
IReadOnlyList<OffCommand> commands,
List<HomeAssistantActionResult> results,
CancellationToken cancellationToken)
{
var pending = commands.Where(item => !string.IsNullOrWhiteSpace(item.EntityId)).ToList();
for (var round = 0; round < 3 && pending.Count > 0; round++)
{
// HA 接受指令并不代表设备已变更,尤其是云端控制的香氛机。
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
IReadOnlyDictionary<string, HomeAssistantState> freshStates;
try
{
freshStates = (await _homeAssistant.GetStatesAsync(cancellationToken))
.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
foreach (var command in pending)
{
var action = results.Last(item => item.Name == command.Name);
action.Status = "Failed";
action.Message = $"关闭指令已发送,但无法回读确认:{exception.Message}";
}
return;
}
foreach (var command in pending.ToArray())
{
var action = results.Last(item => item.Name == command.Name);
freshStates.TryGetValue(command.EntityId, out var state);
if (state is not null && IsOffState(state.State, command.ExpectedState))
{
action.Status = "Succeeded";
action.Message = round == 0 ? "已回读确认关闭" : "重试后已回读确认关闭";
pending.Remove(command);
continue;
}
if (round == 2)
{
action.Status = "Failed";
action.Message = $"关闭后仍未确认目标状态(当前:{state?.State ?? "未读取到"})";
pending.Remove(command);
continue;
}
if (state is null || !IsAvailable(state)) continue;
try
{
var data = new Dictionary<string, object?> { ["entity_id"] = command.EntityId };
if (command.ExtraData is not null)
foreach (var item in command.ExtraData) data[item.Key] = item.Value;
await _homeAssistant.CallServiceAsync(command.Domain, command.Service, data, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
action.Status = "Failed";
action.Message = $"重试关闭失败:{exception.Message}";
}
}
}
}
private static bool IsOffState(string actual, string expected)
=> expected == "0"
? decimal.TryParse(actual, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) && value == 0
: string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
private async Task VerifyMattressModeAsync(string entityId, string expected,
IReadOnlyDictionary<string, object?> serviceData, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 3; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
var actual = (await _homeAssistant.GetStatesAsync(cancellationToken))
.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.State;
if (string.Equals(actual, expected, StringComparison.Ordinal)) return;
if (attempt < 2)
await _homeAssistant.CallServiceAsync("select", "select_option", serviceData, cancellationToken);
else
throw new InvalidOperationException($"床垫承托模式未确认切换到{expected}(当前:{actual ?? "未读取到"})");
}
}
private static async Task<bool> RunMiAirXAction(
List<HomeAssistantActionResult> results,
string name,
string entityId,
Func<CancellationToken, Task> command,
CancellationToken cancellationToken)
{
try
{
await command(cancellationToken);
results.Add(new HomeAssistantActionResult
{
Name = name, EntityId = entityId, Status = "Succeeded", Message = "MiAirX 指令已发送"
});
return true;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
results.Add(new HomeAssistantActionResult
{
Name = name, EntityId = entityId, Status = "Failed", Message = exception.Message
});
return false;
}
}
private async Task RunAction(
IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results,
string name,
string entityId,
string domain,
string service,
IReadOnlyDictionary<string, object?>? extraData,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entityId) || !states.TryGetValue(entityId, out var state))
{
results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Skipped", Message = "实体未配置或不存在" });
return;
}
if (!IsAvailable(state))
{
results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Skipped", Message = "设备当前离线" });
return;
}
var data = new Dictionary<string, object?> { ["entity_id"] = entityId };
if (extraData is not null)
{
foreach (var item in extraData) data[item.Key] = item.Value;
}
try
{
await _homeAssistant.CallServiceAsync(domain, service, data, cancellationToken);
results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Succeeded", Message = "执行成功" });
}
catch (Exception exception)
{
results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Failed", Message = exception.Message });
}
}
private long GetCurrentUserId()
{
var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(HealthUserIdClaim);
if (!long.TryParse(value, out var userId))
throw new UnauthorizedAccessException("登录状态已失效");
return userId;
}
private 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))
.GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var device in registries.Devices)
{
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
var isXiaomi = (device.Manufacturer?.Contains("Xiaomi", StringComparison.OrdinalIgnoreCase) == true)
|| (device.Model?.Contains("xiaomi", StringComparison.OrdinalIgnoreCase) == true)
|| regEntities.Any(e => string.Equals(e.Platform, "xiaomi_home", StringComparison.OrdinalIgnoreCase));
if (!isXiaomi)
{
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 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,
Name = displayName,
Manufacturer = device.Manufacturer ?? "Xiaomi",
Model = device.Model ?? "",
AreaName = areaName,
Category = category,
Online = isOnline,
PrimaryControlEntityId = primaryControlEntityId,
Entities = entityOutputs,
Metrics = metrics
});
}
}
if (discovered.Count == 0)
{
discovered = BuildFallbackDevices(stateMap);
}
return discovered;
}
private List<HomeAssistantDiscoveredDeviceOutput> BuildFallbackDevices(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var entities = _options.Entities;
var list = new List<HomeAssistantDiscoveredDeviceOutput>();
void AddFallback(string id, string name, string model, string category, string area, params string[] entityIds)
{
var entityOutputs = new List<HomeAssistantEntityOutput>();
foreach (var eid in entityIds.Where(s => !string.IsNullOrWhiteSpace(s)))
{
stateMap.TryGetValue(eid, out var state);
var available = state is not null && IsAvailable(state);
var domain = eid.Contains('.') ? eid.Split('.')[0] : "";
entityOutputs.Add(new HomeAssistantEntityOutput
{
EntityId = eid,
Domain = domain,
Name = name,
State = state?.State ?? "unavailable",
Available = available,
Unit = state?.GetStringAttribute("unit_of_measurement"),
Attributes = ExtractSafeAttributes(state)
});
}
var online = entityOutputs.Any(e => e.Available);
var metrics = ExtractDeviceMetrics(category, entityOutputs);
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
list.Add(new HomeAssistantDiscoveredDeviceOutput
{
DeviceId = id,
Name = name,
Manufacturer = "Xiaomi",
Model = model,
AreaName = area,
Category = category,
Online = online,
PrimaryControlEntityId = primaryControlEntityId,
Entities = entityOutputs,
Metrics = metrics
});
}
AddFallback("fallback_dehumidifier", "米家变频净化除湿机 Max", "xiaomi.derh.60l", nameof(HealthDeviceCategory.EnvironmentControl), "睡眠空间",
entities.Dehumidifier, entities.DehumidifierFanLevel, entities.DehumidifierUv);
AddFallback("fallback_fragrance", "米家智能香氛机", "xiaomi.diffuser.xw002", nameof(HealthDeviceCategory.Fragrance), "睡眠空间",
entities.FragranceSwitch, entities.FragranceLight, entities.FragranceDuration, entities.FragranceInterval,
entities.FragranceQuickRelease, entities.FragranceAutoRelease, entities.FragranceAutoLight, entities.FragranceLightAutoOff);
AddFallback("fallback_ambient_light", "米家夜灯/氛围灯", "lemesh.light.wy0d02", nameof(HealthDeviceCategory.Lighting), "睡眠空间",
entities.AmbientLight);
AddFallback("fallback_speaker", "Xiaomi Sound Pro 智能音箱", "xiaomi.wifispeaker.l17a", nameof(HealthDeviceCategory.Media), "睡眠空间",
entities.Speaker, entities.SpeakerSleepMode);
AddFallback("fallback_air_monitor", "米家空气检测仪", "mirsz.airm.et71", nameof(HealthDeviceCategory.EnvironmentSensor), "环境监测",
entities.Temperature, entities.Humidity, entities.Pm25, entities.Co2, entities.Formaldehyde, entities.Tvoc);
AddFallback("fallback_mattress", "8H 智能调温睡眠床垫", "8h.mattress.v1", nameof(HealthDeviceCategory.Sleep), "睡眠空间");
AddFallback("fallback_scale", "小米体脂秤 S400", "xiaomi.scale.v2", nameof(HealthDeviceCategory.BodyComposition), "卫浴空间");
return list;
}
private string? FindPrimaryControlEntityId(
string category,
IReadOnlyList<HomeAssistantEntityOutput> entities)
{
var configuredEntityIds = category switch
{
nameof(HealthDeviceCategory.EnvironmentControl) => new[] { _options.Entities.Dehumidifier },
nameof(HealthDeviceCategory.Fragrance) => new[] { _options.Entities.FragranceSwitch },
nameof(HealthDeviceCategory.Lighting) => new[] { _options.Entities.AmbientLight, _options.Entities.FragranceLight },
nameof(HealthDeviceCategory.Media) => new[] { _options.Entities.Speaker },
_ => Array.Empty<string>()
};
foreach (var configuredEntityId in configuredEntityIds.Where(id => !string.IsNullOrWhiteSpace(id)))
{
if (entities.Any(e => string.Equals(e.EntityId, configuredEntityId, StringComparison.OrdinalIgnoreCase)))
return configuredEntityId;
}
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,
_ => null
};
}
private 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
};
}
private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var entities = _options.Entities;
var sceneEntityIds = new List<string>();
if (!string.IsNullOrWhiteSpace(entities.Dehumidifier))
sceneEntityIds.Add(entities.Dehumidifier);
if (_options.SleepScene.EnableFragrance && !string.IsNullOrWhiteSpace(entities.FragranceSwitch))
sceneEntityIds.Add(entities.FragranceSwitch);
if (!string.IsNullOrWhiteSpace(entities.AmbientLight))
sceneEntityIds.Add(entities.AmbientLight);
if (!string.IsNullOrWhiteSpace(entities.Speaker))
sceneEntityIds.Add(entities.Speaker);
else if (_options.SleepScene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(entities.SpeakerSleepMode))
sceneEntityIds.Add(entities.SpeakerSleepMode);
if (_options.SleepScene.EnableMattress && !string.IsNullOrWhiteSpace(entities.MattressLeftHeatingLevel))
sceneEntityIds.Add(entities.MattressLeftHeatingLevel);
sceneEntityIds = sceneEntityIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var sceneAvailableCount = sceneEntityIds.Count(entityId =>
stateMap.TryGetValue(entityId, out var state) && IsAvailable(state));
return new HomeAssistantSceneDevicesSummaryOutput
{
Total = sceneEntityIds.Count,
Available = sceneAvailableCount
};
}
private 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 (envDevice?.Metrics.TryGetValue(metricKey, out var mv) == true && mv != null)
{
if (double.TryParse(mv.ToString(), out var dVal)) return dVal;
}
if (!string.IsNullOrWhiteSpace(entityId) && stateMap.TryGetValue(entityId, out var state) && IsAvailable(state))
{
if (double.TryParse(state.State, out var dVal)) return dVal;
}
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("除湿") || 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;
}
private 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"
};
foreach (var key in safeKeys)
{
if (state.Attributes.TryGetProperty(key, out var prop))
{
safe[key] = prop.ValueKind switch
{
JsonValueKind.String => prop.GetString(),
JsonValueKind.Number => prop.TryGetInt64(out var l) ? l : prop.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => prop.ToString()
};
}
}
return safe.Count > 0 ? safe : null;
}
private Dictionary<string, HomeAssistantParameterStateOutput> BuildControlParameterStates(
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var configured = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["humidity"] = _options.Entities.Dehumidifier,
["fan_level"] = _options.Entities.DehumidifierFanLevel,
["uv"] = _options.Entities.DehumidifierUv,
["brightness"] = _options.Entities.AmbientLight,
["color_temp"] = _options.Entities.AmbientLight,
["volume"] = _options.Entities.Speaker,
["speaker_sleep_mode"] = _options.Entities.SpeakerSleepMode,
["fragrance_duration"] = _options.Entities.FragranceDuration,
["fragrance_interval"] = _options.Entities.FragranceInterval,
["fragrance_light"] = _options.Entities.FragranceLight,
["fragrance_quick_release"] = _options.Entities.FragranceQuickRelease,
["fragrance_auto_release"] = _options.Entities.FragranceAutoRelease,
["fragrance_auto_light"] = _options.Entities.FragranceAutoLight,
["fragrance_light_auto_off"] = _options.Entities.FragranceLightAutoOff,
["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel,
["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel,
["mattress_left_mode"] = _options.Entities.MattressLeftMode,
["mattress_right_mode"] = _options.Entities.MattressRightMode,
["mattress_massage_left_part"] = _options.Entities.MattressLeftMassagePart,
["mattress_massage_right_part"] = _options.Entities.MattressRightMassagePart,
["mattress_massage_level"] = _options.Entities.MattressMassageLevel
};
foreach (var (zone, entityId) in _options.Entities.MattressFirmness)
configured[$"mattress_firmness_{zone}"] = entityId;
var result = new Dictionary<string, HomeAssistantParameterStateOutput>(StringComparer.OrdinalIgnoreCase);
foreach (var (parameterType, entityId) in configured)
{
if (string.IsNullOrWhiteSpace(entityId) || !stateMap.TryGetValue(entityId, out var state)) continue;
result[parameterType] = new HomeAssistantParameterStateOutput
{
EntityId = entityId,
State = state.State,
Available = IsAvailable(state),
Attributes = ExtractSafeAttributes(state)
};
}
return result;
}
private static string ReadString(object? value)
{
if (value is JsonElement element)
{
return element.ValueKind == JsonValueKind.String
? element.GetString()?.Trim() ?? string.Empty
: element.ToString().Trim();
}
return value?.ToString()?.Trim() ?? string.Empty;
}
private static int ReadInt(object? value, int min, int max, string label)
{
if (!int.TryParse(ReadString(value), out var parsed) || parsed < min || parsed > max)
throw new InvalidOperationException($"{label}应在 {min} 到 {max} 之间");
return parsed;
}
private static bool ReadBool(object? value)
{
if (value is JsonElement element && element.ValueKind is JsonValueKind.True or JsonValueKind.False)
return element.GetBoolean();
if (bool.TryParse(ReadString(value), out var parsed)) return parsed;
throw new InvalidOperationException("开关参数无效");
}
private static string ReadMattressMode(object? value)
{
var mode = ReadString(value);
if (mode is not ("无" or "瑜伽助眠" or "智能适配" or "智能腰托"))
throw new InvalidOperationException("床垫模式无效");
return mode;
}
private static string ReadFirmness(object? value)
{
var firmness = ReadString(value);
if (firmness is not ("最软" or "很软" or "稍软" or "稍硬" or "很硬" or "最硬"))
throw new InvalidOperationException("床垫软硬度选项无效");
return firmness;
}
private static string MattressZoneName(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
};
private static string ReadMassagePart(object? value)
{
var part = ReadString(value);
if (part is not ("无" or "背部" or "腰部" or "牵引" or "腿部" or "全身"))
throw new InvalidOperationException("按摩部位选项无效");
return part;
}
private static string ReadMassageLevel(object? value)
{
var level = ReadString(value);
if (level is not ("轻柔" or "强劲"))
throw new InvalidOperationException("按摩力度选项无效");
return level;
}
private List<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> stateMap,
PersonalizedSceneConfig? userConfig,
string constitutionCode)
{
var defaultScene = _options.SleepScene;
var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity;
var dehumMode = userConfig?.DehumidifierMode ?? defaultScene.DehumidifierMode;
var fanLevel = userConfig?.FanLevel ?? defaultScene.FanLevel;
var enableUv = userConfig?.EnableUv ?? defaultScene.EnableUv;
var lightColorTemp = userConfig?.LightColorTemperatureKelvin ?? defaultScene.LightColorTemperatureKelvin;
var enableFragrance = userConfig?.EnableFragrance ?? defaultScene.EnableFragrance;
var enableSpeaker = userConfig?.EnableSpeakerSleepMode ?? defaultScene.EnableSpeakerSleepMode;
var presets = new List<HomeAssistantScenePresetItem>();
if (!string.IsNullOrWhiteSpace(_options.Entities.Dehumidifier))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "米家变频净化除湿机 Max",
Category = nameof(HealthDeviceCategory.EnvironmentControl),
EntityId = _options.Entities.Dehumidifier,
Action = "开启恒湿舒睡",
Detail = $"目标湿度 {targetHumidity}% · {dehumMode}模式 · 风速{fanLevel} · UV{(enableUv ? "开" : "关")}",
Parameters = new()
{
["target_humidity"] = $"{targetHumidity}%",
["mode"] = dehumMode,
["fan_level"] = fanLevel,
["uv"] = enableUv ? "开启" : "关闭"
}
});
}
if (enableFragrance && !string.IsNullOrWhiteSpace(_options.Entities.FragranceSwitch))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "米家智能香氛机",
Category = nameof(HealthDeviceCategory.Fragrance),
EntityId = _options.Entities.FragranceSwitch,
Action = "开启香氛释放",
Detail = "开启香氛机;香氛灯可用时同步开启柔光",
Parameters = new() { ["power"] = "开启" }
});
}
if (!string.IsNullOrWhiteSpace(_options.Entities.AmbientLight))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "Lemesh 色温灯",
Category = nameof(HealthDeviceCategory.Lighting),
EntityId = _options.Entities.AmbientLight,
Action = "开启助眠渐暗灯光",
Detail = $"从50%开始,每5秒降低1%,最终关灯 · 色温 {lightColorTemp}K",
Parameters = new()
{
["brightness"] = "50%→关闭",
["color_temp"] = $"{lightColorTemp}K"
}
});
}
if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "Xiaomi Sound Pro",
Category = nameof(HealthDeviceCategory.Media),
EntityId = _options.Entities.Speaker,
Action = enableSpeaker ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
Detail = enableSpeaker ? "体质曲目循环播放 · 音量50%每5秒降1% · 开启睡眠模式" : "体质曲目循环播放 · 音量50%每5秒降1%",
Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "50%→0%" }
});
}
if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel))
{
var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel;
var firmness = MattressComfortService.FirmnessName(userConfig?.MattressLeftFirmness ?? 0);
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "8H AI智能好睡眠床垫X",
Category = nameof(HealthDeviceCategory.Sleep),
EntityId = _options.Entities.MattressLeftHeatingLevel,
Action = "按天气和 BMI 调节床垫并开启右侧按摩",
Detail = $"左右加热 {heatingLevel} 档 · 软硬度{firmness ?? "待填写 BMI"} · 右侧全身轻柔按摩",
Parameters = new()
{
["heating_level"] = heatingLevel,
["firmness"] = firmness ?? "未自动调节",
["right_massage_part"] = "全身",
["massage_level"] = "轻柔"
}
});
}
foreach (var preset in presets)
{
if (preset.Category == nameof(HealthDeviceCategory.Media))
{
preset.Available = _musicTransition.HasPlayableTracks(constitutionCode);
preset.AvailabilityMessage = preset.Available
? "体质音乐已配置,执行时验证音箱"
: "该体质的 OSS 音乐 URL 尚未配置";
continue;
}
preset.Available = !string.IsNullOrWhiteSpace(preset.EntityId) &&
stateMap.TryGetValue(preset.EntityId, out var state) &&
IsAvailable(state);
preset.AvailabilityMessage = preset.Available ? "当前可执行" : "当前不可用,执行时跳过";
}
return presets;
}
private static bool IsAvailable(HomeAssistantState state)
=> state.State != "unavailable" &&
(state.State != "unknown" || state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase));
}