diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json index 723ec9c..c18e7c9 100644 --- a/Evergreen.Health.Application/Configuration/HomeAssistant.json +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -12,6 +12,10 @@ "AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light", "Speaker": "media_player.xiaomi_cn_1221084723_l17a", "SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3", + "MattressLeftHeatingLevel": "number.qushui_cn_2108064504_zerox_left_level_p_6_1", + "MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2", + "MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1", + "MattressRightMode": "select.qushui_cn_2108064504_zerox_right_mode_p_5_2", "Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1", "Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2", "Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3", @@ -26,7 +30,10 @@ "LightBrightnessPercent": 20, "LightColorTemperatureKelvin": 2700, "EnableFragrance": true, - "EnableSpeakerSleepMode": false, + "EnableSpeakerSleepMode": true, + "EnableMattress": true, + "MattressHeatingLevel": 1, + "MattressMode": "智能适配", "EnableUv": false } } diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index 32c758d..0ea9e0c 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -22,6 +22,10 @@ public sealed class HomeAssistantEntityOptions public string AmbientLight { get; set; } = string.Empty; public string Speaker { get; set; } = string.Empty; public string SpeakerSleepMode { get; set; } = string.Empty; + public string MattressLeftHeatingLevel { get; set; } = string.Empty; + public string MattressRightHeatingLevel { get; set; } = string.Empty; + public string MattressLeftMode { get; set; } = string.Empty; + public string MattressRightMode { get; set; } = string.Empty; public string Temperature { get; set; } = string.Empty; public string Humidity { get; set; } = string.Empty; public string Pm25 { get; set; } = string.Empty; @@ -39,5 +43,8 @@ public sealed class HomeAssistantSleepSceneOptions public int LightColorTemperatureKelvin { get; set; } = 2700; public bool EnableFragrance { get; set; } = true; public bool EnableSpeakerSleepMode { get; set; } + public bool EnableMattress { get; set; } + public int MattressHeatingLevel { get; set; } = 1; + public string MattressMode { get; set; } = "智能适配"; public bool EnableUv { get; set; } } diff --git a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs index 876f181..5d826cb 100644 --- a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs @@ -32,6 +32,7 @@ public sealed class HomeAssistantDiscoveredDeviceOutput public string? AreaName { get; set; } public string Category { get; set; } = nameof(HealthDeviceCategory.Other); public bool Online { get; set; } + public string? PrimaryControlEntityId { get; set; } public List Entities { get; set; } = []; public Dictionary Metrics { get; set; } = []; } @@ -71,6 +72,35 @@ public sealed class HomeAssistantStatusOutput public List Devices { get; set; } = []; public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new(); public HomeAssistantEnvironmentOutput Environment { get; set; } = new(); + public List ScenePresets { get; set; } = []; +} + +public sealed class HomeAssistantScenePresetItem +{ + public string DeviceName { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + public bool Available { get; set; } + public string AvailabilityMessage { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string Detail { get; set; } = string.Empty; + public Dictionary Parameters { get; set; } = []; +} + +public sealed class HomeAssistantDeviceSwitchInput +{ + public string DeviceId { get; set; } = string.Empty; + public string? EntityId { get; set; } + public bool TurnOn { get; set; } +} + +public sealed class HomeAssistantDeviceSwitchOutput +{ + public bool Success { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; } public sealed class HomeAssistantSceneOutput diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index f31e0be..35d4cd8 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -45,9 +45,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var registries = await _registryService.GetRegistriesAsync(cancellationToken); var discoveredDevices = BuildDiscoveredDevices(registries, stateMap); - var summary = BuildSummary(discoveredDevices, states); + var summary = BuildSummary(discoveredDevices); var sceneDevices = BuildSceneDevicesSummary(stateMap); var environment = BuildEnvironmentOutput(stateMap, discoveredDevices); + var scenePresets = BuildScenePresets(stateMap); return new HomeAssistantStatusOutput { @@ -57,7 +58,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie Summary = summary, Devices = discoveredDevices, SceneDevices = sceneDevices, - Environment = environment + Environment = environment, + ScenePresets = scenePresets }; } catch (Exception exception) @@ -69,8 +71,121 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie CheckedAt = DateTimeOffset.Now, Summary = new HomeAssistantSummaryOutput(), Devices = [], - SceneDevices = new HomeAssistantSceneDevicesSummaryOutput { Total = 4, Available = 0 }, - Environment = new HomeAssistantEnvironmentOutput() + SceneDevices = BuildSceneDevicesSummary(new Dictionary(StringComparer.OrdinalIgnoreCase)), + Environment = new HomeAssistantEnvironmentOutput(), + ScenePresets = BuildScenePresets(new Dictionary(StringComparer.OrdinalIgnoreCase)) + }; + } + } + + [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 = "目标设备当前处于离线状态" + }; + } + + var service = input.TurnOn ? "turn_on" : "turn_off"; + if (domain == "media_player") + { + service = input.TurnOn ? "media_play" : "media_pause"; + } + + var serviceData = new Dictionary + { + ["entity_id"] = targetEntityId + }; + + await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); + + return new HomeAssistantDeviceSwitchOutput + { + Success = true, + DeviceId = input.DeviceId, + EntityId = targetEntityId, + State = domain == "media_player" + ? (input.TurnOn ? "playing" : "paused") + : (input.TurnOn ? "on" : "off"), + Message = domain == "media_player" + ? $"已成功{(input.TurnOn ? "开始播放" : "暂停播放")}" + : $"已成功{(input.TurnOn ? "开启" : "关闭")}设备" + }; + } + catch (Exception ex) + { + return new HomeAssistantDeviceSwitchOutput + { + Success = false, + DeviceId = input.DeviceId, + EntityId = input.EntityId ?? string.Empty, + Message = $"控制失败:{ex.Message}" }; } } @@ -180,6 +295,18 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie if (scene.EnableSpeakerSleepMode) await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken); + + if (scene.EnableMattress) + { + await RunAction(states, results, "设置床垫左侧低档加热", entities.MattressLeftHeatingLevel, "number", "set_value", + new Dictionary { ["value"] = scene.MattressHeatingLevel }, cancellationToken); + await RunAction(states, results, "设置床垫右侧低档加热", entities.MattressRightHeatingLevel, "number", "set_value", + new Dictionary { ["value"] = scene.MattressHeatingLevel }, cancellationToken); + await RunAction(states, results, "设置床垫左侧智能适配", entities.MattressLeftMode, "select", "select_option", + new Dictionary { ["option"] = scene.MattressMode }, cancellationToken); + await RunAction(states, results, "设置床垫右侧智能适配", entities.MattressRightMode, "select", "select_option", + new Dictionary { ["option"] = scene.MattressMode }, cancellationToken); + } } private async Task ExecuteAllOffActions( @@ -193,6 +320,13 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie 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); + 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( @@ -263,7 +397,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie || (device.Model?.Contains("xiaomi", StringComparison.OrdinalIgnoreCase) == true) || regEntities.Any(e => string.Equals(e.Platform, "xiaomi_home", StringComparison.OrdinalIgnoreCase)); - if (!isXiaomi && regEntities.Count == 0) + if (!isXiaomi) { continue; } @@ -302,10 +436,11 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie 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 { @@ -316,6 +451,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie AreaName = areaName, Category = category, Online = isOnline, + PrimaryControlEntityId = primaryControlEntityId, Entities = entityOutputs, Metrics = metrics }); @@ -357,6 +493,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var online = entityOutputs.Any(e => e.Available); var metrics = ExtractDeviceMetrics(category, entityOutputs); + var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs); list.Add(new HomeAssistantDiscoveredDeviceOutput { @@ -367,6 +504,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie AreaName = area, Category = category, Online = online, + PrimaryControlEntityId = primaryControlEntityId, Entities = entityOutputs, Metrics = metrics }); @@ -388,15 +526,42 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie 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, - IReadOnlyList states) + IReadOnlyList devices) { var totalDevices = devices.Count; var onlineDevices = devices.Count(d => d.Online); var offlineDevices = totalDevices - onlineDevices; - var totalEntities = states.Count; - var availableEntities = states.Count(IsAvailable); + var totalEntities = devices.Sum(d => d.Entities.Count); + var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available)); return new HomeAssistantSummaryOutput { @@ -411,30 +576,27 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary stateMap) { var entities = _options.Entities; - var sceneAvailableCount = 0; + var sceneEntityIds = new List(); - if (!string.IsNullOrWhiteSpace(entities.Dehumidifier) && - stateMap.TryGetValue(entities.Dehumidifier, out var sDehum) && IsAvailable(sDehum)) - sceneAvailableCount++; + 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 (_options.SleepScene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(entities.SpeakerSleepMode)) + sceneEntityIds.Add(entities.SpeakerSleepMode); + if (_options.SleepScene.EnableMattress && !string.IsNullOrWhiteSpace(entities.MattressLeftHeatingLevel)) + sceneEntityIds.Add(entities.MattressLeftHeatingLevel); - if (!string.IsNullOrWhiteSpace(entities.FragranceSwitch) && - stateMap.TryGetValue(entities.FragranceSwitch, out var sFrag) && IsAvailable(sFrag)) - sceneAvailableCount++; - - if (!string.IsNullOrWhiteSpace(entities.AmbientLight) && - stateMap.TryGetValue(entities.AmbientLight, out var sLight) && IsAvailable(sLight)) - sceneAvailableCount++; - - var speakerOnline = (!string.IsNullOrWhiteSpace(entities.Speaker) && - stateMap.TryGetValue(entities.Speaker, out var sSpk) && IsAvailable(sSpk)) - || (!string.IsNullOrWhiteSpace(entities.SpeakerSleepMode) && - stateMap.TryGetValue(entities.SpeakerSleepMode, out var sSpkSleep) && IsAvailable(sSpkSleep)); - if (speakerOnline) sceneAvailableCount++; + sceneEntityIds = sceneEntityIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var sceneAvailableCount = sceneEntityIds.Count(entityId => + stateMap.TryGetValue(entityId, out var state) && IsAvailable(state)); return new HomeAssistantSceneDevicesSummaryOutput { - Total = 4, - Available = Math.Min(4, sceneAvailableCount) + Total = sceneEntityIds.Count, + Available = sceneAvailableCount }; } @@ -470,6 +632,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie 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") @@ -570,6 +733,104 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie return safe.Count > 0 ? safe : null; } + private List BuildScenePresets( + IReadOnlyDictionary stateMap) + { + var scene = _options.SleepScene; + 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 = $"目标湿度 {scene.TargetHumidity}% · {scene.DehumidifierMode}模式 · 风速{scene.FanLevel} · UV{(scene.EnableUv ? "开" : "关")}", + Parameters = new() + { + ["target_humidity"] = $"{scene.TargetHumidity}%", + ["mode"] = scene.DehumidifierMode, + ["fan_level"] = scene.FanLevel, + ["uv"] = scene.EnableUv ? "开启" : "关闭" + } + }); + } + + if (scene.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 = $"亮度 {scene.LightBrightnessPercent}% · 色温 {scene.LightColorTemperatureKelvin}K", + Parameters = new() + { + ["brightness"] = $"{scene.LightBrightnessPercent}%", + ["color_temp"] = $"{scene.LightColorTemperatureKelvin}K" + } + }); + } + + if (scene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(_options.Entities.SpeakerSleepMode)) + { + presets.Add(new HomeAssistantScenePresetItem + { + DeviceName = "Xiaomi Sound Pro", + Category = nameof(HealthDeviceCategory.Media), + EntityId = _options.Entities.SpeakerSleepMode, + Action = "开启音箱睡眠模式", + Detail = "仅切换已确认的睡眠模式开关,不自动播放未知媒体内容", + Parameters = new() { ["sleep_mode"] = "开启" } + }); + } + + if (scene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel)) + { + presets.Add(new HomeAssistantScenePresetItem + { + DeviceName = "8H AI智能好睡眠床垫X", + Category = nameof(HealthDeviceCategory.Sleep), + EntityId = _options.Entities.MattressLeftHeatingLevel, + Action = "开启低档加热与智能适配", + Detail = $"左右加热 {scene.MattressHeatingLevel} 档 · 左右气袋模式 {scene.MattressMode}", + Parameters = new() + { + ["left_heating_level"] = scene.MattressHeatingLevel, + ["right_heating_level"] = scene.MattressHeatingLevel, + ["left_mode"] = scene.MattressMode, + ["right_mode"] = scene.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"); }