using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; using Furion.DatabaseAccessor; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using SqlSugar; 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 const int PlayMediaFeature = 512; private const string DemoAudioUrl = "https://oss.myzxjgt.com/App.Live/uploads/%E9%9F%B3%E9%A2%91/2026-05-29/57a6a64d8dfa4a1bb876a15bb8fdfd24.mp3"; private readonly HomeAssistantClient _homeAssistant; private readonly HomeAssistantLightTransitionService _lightTransition; private readonly HomeAssistantRegistryService _registryService; private readonly HomeAssistantOptions _options; private readonly SqlSugarRepository _executionRepository; private readonly SqlSugarRepository _profileRepository; private readonly IHttpContextAccessor _httpContextAccessor; public HealthHomeAssistantService( HomeAssistantClient homeAssistant, HomeAssistantLightTransitionService lightTransition, HomeAssistantRegistryService registryService, IOptions options, SqlSugarRepository executionRepository, SqlSugarRepository profileRepository, IHttpContextAccessor httpContextAccessor) { _homeAssistant = homeAssistant; _lightTransition = lightTransition; _registryService = registryService; _options = options.Value; _executionRepository = executionRepository; _profileRepository = profileRepository; _httpContextAccessor = httpContextAccessor; } [Authorize] [HttpGet("/api/health/home-assistant/lights/transition")] public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.GetStatus(); [Authorize] [HttpPost("/api/health/home-assistant/lights/sleep")] public async Task 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 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 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/speaker/demo")] public async Task GetSpeakerDemo(CancellationToken cancellationToken) { var result = new HomeAssistantSpeakerDemoOutput { AudioUrl = DemoAudioUrl }; try { var entityId = _options.Entities.Speaker; if (string.IsNullOrWhiteSpace(entityId)) { result.Message = "尚未配置音箱实体"; return result; } var state = (await _homeAssistant.GetStatesAsync(cancellationToken)) .FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); result.SpeakerOnline = state is not null && IsAvailable(state); if (!result.SpeakerOnline) { result.Message = "音箱当前离线或不可用"; return result; } var features = (long)(state!.GetDoubleAttribute("supported_features") ?? 0); result.DirectPlaybackSupported = (features & PlayMediaFeature) != 0; result.Message = result.DirectPlaybackSupported ? "音箱声明支持指定音频播放,可以尝试 OSS 示例音频" : "当前 Xiaomi Home 音箱实体不支持播放指定音频;OSS 链接可在浏览器试听,但暂不能投放到音箱"; } catch (Exception exception) { result.Message = $"音箱能力检测失败:{exception.Message}"; } return result; } [Authorize] [HttpPost("/api/health/home-assistant/speaker/play-demo")] public async Task PlaySpeakerDemo(CancellationToken cancellationToken) { var result = await GetSpeakerDemo(cancellationToken); if (!result.SpeakerOnline || !result.DirectPlaybackSupported) return result; try { await _homeAssistant.CallServiceAsync("media_player", "play_media", new Dictionary { ["entity_id"] = _options.Entities.Speaker, ["media_content_id"] = DemoAudioUrl, ["media_content_type"] = "music" }, cancellationToken); result.CommandAccepted = true; result.Message = "播放指令已发送,请确认音箱是否实际出声;接口接受不等于播放成功"; } catch (Exception exception) { result.Message = $"音箱未接受播放指令:{exception.Message}"; } return result; } [Authorize] [HttpGet("/api/health/home-assistant/status")] public async Task 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; try { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) { userConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); } } catch { } var scenePresets = BuildScenePresets(stateMap, userConfig); 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(StringComparer.OrdinalIgnoreCase)), Environment = new HomeAssistantEnvironmentOutput(), ScenePresets = BuildScenePresets(new Dictionary(StringComparer.OrdinalIgnoreCase)), ControlParameters = [] }; } } [Authorize] [HttpPost("/api/health/home-assistant/devices/switch")] public async Task 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 (!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; bool? targetMuted = null; var service = targetOn ? "turn_on" : "turn_off"; if (domain == "media_player") { // Xiaomi Sound Pro 的 media_player state 会长期保持 playing,不能用它 // 判断声音是否开启;is_volume_muted 才是该实体可稳定回读的控制状态。 var currentMutedText = currentState.GetStringAttribute("is_volume_muted"); var currentMuted = bool.TryParse(currentMutedText, out var parsedMuted) && parsedMuted; targetMuted = !currentMuted; targetOn = !targetMuted.Value; service = "volume_mute"; } var serviceData = new Dictionary { ["entity_id"] = targetEntityId }; if (targetMuted.HasValue) { serviceData["is_volume_muted"] = targetMuted.Value; } await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); var resultState = domain == "media_player" ? currentState.State : (targetOn ? "on" : "off"); bool? resultMuted = null; if (domain == "media_player") { var stateConfirmed = false; for (var attempt = 0; attempt < 5; attempt++) { await Task.Delay(300, cancellationToken); var refreshedStates = await _homeAssistant.GetStatesAsync(cancellationToken); var refreshedState = refreshedStates.FirstOrDefault(s => string.Equals(s.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)); if (refreshedState is null) { continue; } resultState = refreshedState.State; var refreshedMutedText = refreshedState.GetStringAttribute("is_volume_muted"); resultMuted = bool.TryParse(refreshedMutedText, out var refreshedMuted) ? refreshedMuted : null; stateConfirmed = resultMuted == targetMuted; if (stateConfirmed) { break; } } if (!stateConfirmed) { return new HomeAssistantDeviceSwitchOutput { Success = false, DeviceId = input.DeviceId, EntityId = targetEntityId, State = resultState, IsMuted = resultMuted, Message = $"音箱指令已发送,但静音状态未确认更新(当前:{(resultMuted.HasValue ? (resultMuted.Value ? "已静音" : "声音开启") : "未知")})" }; } } return new HomeAssistantDeviceSwitchOutput { Success = true, DeviceId = input.DeviceId, EntityId = targetEntityId, State = resultState, IsMuted = resultMuted, Message = domain == "media_player" ? (targetMuted == true ? "音箱已静音" : "音箱声音已开启") : $"已成功{(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 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(); 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 = "volume_set"; serviceData["volume_level"] = ReadInt(input.Value, 0, 100, "音箱音量") / 100d; 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 "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 (!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); try { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); if (profile != null) { var customConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson) ? new PersonalizedSceneConfig() : JsonSerializer.Deserialize(profile.CustomSceneConfigJson) ?? new PersonalizedSceneConfig(); switch (parameterType) { case "humidity": customConfig.TargetHumidity = (int)serviceData["humidity"]!; break; case "fan_level": customConfig.FanLevel = (string)serviceData["option"]!; break; case "dehumidifier_mode": customConfig.DehumidifierMode = (string)serviceData["mode"]!; break; case "brightness": customConfig.LightBrightnessPercent = (int)serviceData["brightness_pct"]!; break; case "color_temp": customConfig.LightColorTemperatureKelvin = (int)serviceData["color_temp_kelvin"]!; break; case "volume": customConfig.SpeakerVolumePercent = ReadInt(input.Value, 0, 100, "音箱音量"); break; case "speaker_sleep_mode": customConfig.EnableSpeakerSleepMode = ReadBool(input.Value); break; case "uv": customConfig.EnableUv = ReadBool(input.Value); break; case "mattress_left_heating": case "mattress_right_heating": customConfig.MattressHeatingLevel = ReadInt(input.Value, 0, 4, "床垫加热档位"); break; case "mattress_left_mode": case "mattress_right_mode": customConfig.MattressMode = ReadMattressMode(input.Value); break; } profile.CustomSceneConfigJson = JsonSerializer.Serialize(customConfig); await _profileRepository.UpdateAsync(profile); } } catch { } 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 ExecuteSleepScene(CancellationToken cancellationToken) { PersonalizedSceneConfig? customConfig = null; try { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) { customConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); } } catch { } return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => ExecuteSleepActions(states, results, customConfig, ct), cancellationToken); } [Authorize] [HttpPost("/api/health/home-assistant/scenes/all-off")] public Task ExecuteAllOffScene(CancellationToken cancellationToken) => ExecuteScene("all-off", "一键关闭", ExecuteAllOffActions, cancellationToken); private async Task ExecuteScene( string sceneCode, string sceneName, Func, List, CancellationToken, Task> executor, CancellationToken cancellationToken) { var userId = GetCurrentUserId(); var actions = new List(); string status; string message; try { 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"); 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} 项跳过", _ => "当前没有可执行的在线设备" }; } 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 states, List results, PersonalizedSceneConfig? customConfig, 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 speakerVolume = customConfig?.SpeakerVolumePercent ?? 15; var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel; var mattressMode = customConfig?.MattressMode ?? scene.MattressMode; await RunAction(states, results, "开启除湿机", entities.Dehumidifier, "humidifier", "turn_on", null, cancellationToken); await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity", new Dictionary { ["humidity"] = targetHumidity }, cancellationToken); await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode", new Dictionary { ["mode"] = dehumMode }, cancellationToken); await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option", new Dictionary { ["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 { ["value"] = fragranceDuration }, cancellationToken); await RunAction(states, results, "设置出香间隔", entities.FragranceInterval, "number", "set_value", new Dictionary { ["value"] = fragranceInterval }, cancellationToken); await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on", new Dictionary { ["brightness_pct"] = lightBrightness }, 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); } // 音箱声音与睡眠模式是两种不同能力。即使用户未启用睡眠模式, // 一键舒睡也应取消静音并应用个性化音量。 await RunAction(states, results, "开启音箱声音", entities.Speaker, "media_player", "volume_mute", new Dictionary { ["is_volume_muted"] = false }, cancellationToken); await RunAction(states, results, "设置助眠音量", entities.Speaker, "media_player", "volume_set", new Dictionary { ["volume_level"] = speakerVolume / 100d }, cancellationToken); if (scene.EnableMattress) { await RunAction(states, results, "设置床垫左侧加热档位", entities.MattressLeftHeatingLevel, "number", "set_value", new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value", new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); await RunAction(states, results, "设置床垫左侧模式", entities.MattressLeftMode, "select", "select_option", new Dictionary { ["option"] = mattressMode }, cancellationToken); await RunAction(states, results, "设置床垫右侧模式", entities.MattressRightMode, "select", "select_option", new Dictionary { ["option"] = mattressMode }, cancellationToken); } } private async Task ExecuteAllOffActions( IReadOnlyDictionary states, List results, CancellationToken cancellationToken) { var entities = _options.Entities; await _lightTransition.CancelAsync(cancellationToken); await RunAction(states, results, "关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", null, cancellationToken); await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken); await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken); await RunAction(states, results, "关闭助眠灯光", entities.AmbientLight, "light", "turn_off", null, cancellationToken); await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken); await RunAction(states, results, "静音音箱", entities.Speaker, "media_player", "volume_mute", new Dictionary { ["is_volume_muted"] = true }, cancellationToken); if (_options.SleepScene.EnableMattress) { await RunAction(states, results, "关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", new Dictionary { ["value"] = 0 }, cancellationToken); await RunAction(states, results, "关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", new Dictionary { ["value"] = 0 }, cancellationToken); } } private async Task RunAction( IReadOnlyDictionary states, List results, string name, string entityId, string domain, string service, IReadOnlyDictionary? 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 { ["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 BuildDiscoveredDevices( HomeAssistantRegistries? registries, IReadOnlyDictionary stateMap) { var discovered = new List(); 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(); 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 BuildFallbackDevices(IReadOnlyDictionary stateMap) { var entities = _options.Entities; var list = new List(); void AddFallback(string id, string name, string model, string category, string area, params string[] entityIds) { var entityOutputs = new List(); 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 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() }; 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 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 stateMap) { var entities = _options.Entities; var sceneEntityIds = new List(); 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 stateMap, IReadOnlyList 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 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 ExtractDeviceMetrics(string category, IReadOnlyList entities) { var metrics = new Dictionary(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? ExtractSafeAttributes(HomeAssistantState? state) { if (state == null || state.Attributes.ValueKind != JsonValueKind.Object) return null; var safe = new Dictionary(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 BuildControlParameterStates( IReadOnlyDictionary stateMap) { var configured = new Dictionary(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 }; var result = new Dictionary(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 List BuildScenePresets( IReadOnlyDictionary stateMap, PersonalizedSceneConfig? userConfig = null) { 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(); 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)) { var vol = userConfig?.SpeakerVolumePercent ?? 15; presets.Add(new HomeAssistantScenePresetItem { DeviceName = "Xiaomi Sound Pro", Category = nameof(HealthDeviceCategory.Media), EntityId = _options.Entities.Speaker, Action = enableSpeaker ? "开启音箱声音和睡眠模式" : "开启音箱声音", Detail = enableSpeaker ? $"取消静音 · 开启睡眠模式 · 音量 {vol}%" : $"取消静音 · 音量 {vol}%", Parameters = new() { ["muted"] = false, ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = $"{vol}%" } }); } if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel)) { var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel; var mattressMode = userConfig?.MattressMode ?? defaultScene.MattressMode; presets.Add(new HomeAssistantScenePresetItem { DeviceName = "8H AI智能好睡眠床垫X", Category = nameof(HealthDeviceCategory.Sleep), EntityId = _options.Entities.MattressLeftHeatingLevel, Action = "开启分区加热与承托模式", Detail = $"左右加热 {heatingLevel} 档 · {mattressMode}", Parameters = new() { ["heating_level"] = heatingLevel, ["mode"] = mattressMode } }); } foreach (var preset in presets) { 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 is not ("unavailable" or "unknown"); }