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 readonly HomeAssistantClient _homeAssistant; private readonly HomeAssistantRegistryService _registryService; private readonly HomeAssistantOptions _options; private readonly SqlSugarRepository _executionRepository; private readonly IHttpContextAccessor _httpContextAccessor; public HealthHomeAssistantService( HomeAssistantClient homeAssistant, HomeAssistantRegistryService registryService, IOptions options, SqlSugarRepository executionRepository, IHttpContextAccessor httpContextAccessor) { _homeAssistant = homeAssistant; _registryService = registryService; _options = options.Value; _executionRepository = executionRepository; _httpContextAccessor = httpContextAccessor; } [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, states); var sceneDevices = BuildSceneDevicesSummary(stateMap); var environment = BuildEnvironmentOutput(stateMap, discoveredDevices); return new HomeAssistantStatusOutput { Connected = true, Message = "Home Assistant 已连接", CheckedAt = DateTimeOffset.Now, Summary = summary, Devices = discoveredDevices, SceneDevices = sceneDevices, Environment = environment }; } catch (Exception exception) { return new HomeAssistantStatusOutput { Connected = false, Message = $"Home Assistant 连接失败:{exception.Message}", CheckedAt = DateTimeOffset.Now, Summary = new HomeAssistantSummaryOutput(), Devices = [], SceneDevices = new HomeAssistantSceneDevicesSummaryOutput { Total = 4, Available = 0 }, Environment = new HomeAssistantEnvironmentOutput() }; } } [Authorize] [HttpPost("/api/health/home-assistant/scenes/sleep")] public Task ExecuteSleepScene(CancellationToken cancellationToken) => ExecuteScene("sleep", "舒睡模式", ExecuteSleepActions, 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, CancellationToken cancellationToken) { var entities = _options.Entities; var scene = _options.SleepScene; await RunAction(states, results, "开启除湿机", entities.Dehumidifier, "humidifier", "turn_on", null, cancellationToken); await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity", new Dictionary { ["humidity"] = scene.TargetHumidity }, cancellationToken); await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode", new Dictionary { ["mode"] = scene.DehumidifierMode }, cancellationToken); await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option", new Dictionary { ["option"] = scene.FanLevel }, cancellationToken); await RunAction(states, results, scene.EnableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv, "switch", scene.EnableUv ? "turn_on" : "turn_off", null, cancellationToken); if (scene.EnableFragrance) { await RunAction(states, results, "开启香氛机", entities.FragranceSwitch, "switch", "turn_on", null, cancellationToken); await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on", new Dictionary { ["brightness_pct"] = scene.LightBrightnessPercent }, cancellationToken); } await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on", new Dictionary { ["brightness_pct"] = scene.LightBrightnessPercent, ["color_temp_kelvin"] = scene.LightColorTemperatureKelvin }, cancellationToken); if (scene.EnableSpeakerSleepMode) await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken); } private async Task ExecuteAllOffActions( IReadOnlyDictionary states, List results, CancellationToken cancellationToken) { var entities = _options.Entities; 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); } 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 && regEntities.Count == 0) { 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 : "睡眠空间"; var category = CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs); var metrics = ExtractDeviceMetrics(category, entityOutputs); discovered.Add(new HomeAssistantDiscoveredDeviceOutput { DeviceId = device.Id, Name = displayName, Manufacturer = device.Manufacturer ?? "Xiaomi", Model = device.Model ?? "", AreaName = areaName, Category = category, Online = isOnline, 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); list.Add(new HomeAssistantDiscoveredDeviceOutput { DeviceId = id, Name = name, Manufacturer = "Xiaomi", Model = model, AreaName = area, Category = category, Online = online, 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); 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 static HomeAssistantSummaryOutput BuildSummary( IReadOnlyList devices, IReadOnlyList states) { var totalDevices = devices.Count; var onlineDevices = devices.Count(d => d.Online); var offlineDevices = totalDevices - onlineDevices; var totalEntities = states.Count; var availableEntities = states.Count(IsAvailable); return new HomeAssistantSummaryOutput { TotalDevices = totalDevices, OnlineDevices = onlineDevices, OfflineDevices = offlineDevices, TotalEntities = totalEntities, AvailableEntities = availableEntities }; } private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary stateMap) { var entities = _options.Entities; var sceneAvailableCount = 0; if (!string.IsNullOrWhiteSpace(entities.Dehumidifier) && stateMap.TryGetValue(entities.Dehumidifier, out var sDehum) && IsAvailable(sDehum)) sceneAvailableCount++; 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++; return new HomeAssistantSceneDevicesSummaryOutput { Total = 4, Available = Math.Min(4, 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"), 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 static bool IsAvailable(HomeAssistantState state) => state.State is not ("unavailable" or "unknown"); }