diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs index da5c613..785717d 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs @@ -22,6 +22,76 @@ public sealed class HomeAssistantState if (!Attributes.TryGetProperty(name, out var value)) return null; return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); } + + public double? GetDoubleAttribute(string name) + { + if (!Attributes.TryGetProperty(name, out var value)) return null; + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d)) return d; + if (double.TryParse(value.ToString(), out var parsed)) return parsed; + return null; + } +} + +public sealed class HomeAssistantDeviceRegistryItem +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("name_by_user")] + public string? NameByUser { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("manufacturer")] + public string? Manufacturer { get; set; } + + [JsonPropertyName("area_id")] + public string? AreaId { get; set; } + + [JsonPropertyName("disabled_by")] + public string? DisabledBy { get; set; } +} + +public sealed class HomeAssistantEntityRegistryItem +{ + [JsonPropertyName("entity_id")] + public string EntityId { get; set; } = string.Empty; + + [JsonPropertyName("device_id")] + public string? DeviceId { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("original_name")] + public string? OriginalName { get; set; } + + [JsonPropertyName("platform")] + public string? Platform { get; set; } + + [JsonPropertyName("disabled_by")] + public string? DisabledBy { get; set; } +} + +public sealed class HomeAssistantAreaRegistryItem +{ + [JsonPropertyName("area_id")] + public string AreaId { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public sealed class HomeAssistantRegistries +{ + public List Devices { get; set; } = []; + public List Entities { get; set; } = []; + public List Areas { get; set; } = []; + public DateTimeOffset CachedAt { get; set; } = DateTimeOffset.UtcNow; } public sealed class HomeAssistantActionResult diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs new file mode 100644 index 0000000..5bb2187 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs @@ -0,0 +1,208 @@ +using Furion.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +public sealed class HomeAssistantRegistryService : ISingleton +{ + private readonly HomeAssistantOptions _options; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + private HomeAssistantRegistries? _cachedRegistries; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + public HomeAssistantRegistryService( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task GetRegistriesAsync(CancellationToken cancellationToken = default) + { + if (_cachedRegistries is not null && DateTimeOffset.UtcNow - _cachedRegistries.CachedAt < CacheDuration) + { + return _cachedRegistries; + } + + await _lock.WaitAsync(cancellationToken); + try + { + if (_cachedRegistries is not null && DateTimeOffset.UtcNow - _cachedRegistries.CachedAt < CacheDuration) + { + return _cachedRegistries; + } + + var registries = await FetchRegistriesFromWebSocketAsync(cancellationToken); + if (registries is not null) + { + _cachedRegistries = registries; + } + return _cachedRegistries; + } + catch (Exception ex) + { + _logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message); + return _cachedRegistries; + } + finally + { + _lock.Release(); + } + } + + private async Task FetchRegistriesFromWebSocketAsync(CancellationToken cancellationToken) + { + if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.AccessToken)) + { + return null; + } + + var wsUri = ConvertToWebSocketUri(_options.BaseUrl); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 5, 20))); + + using var ws = new ClientWebSocket(); + ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); + + try + { + await ws.ConnectAsync(wsUri, cts.Token); + + // 1. 等待 auth_required + var authRequiredJson = await ReceiveTextMessageAsync(ws, cts.Token); + using (var doc = JsonDocument.Parse(authRequiredJson)) + { + var type = doc.RootElement.GetProperty("type").GetString(); + if (type != "auth_required") + { + throw new InvalidOperationException($"Home Assistant WebSocket 未按预期发送 auth_required,实际接收:{type}"); + } + } + + // 2. 发送认证消息 + var authMessage = JsonSerializer.Serialize(new + { + type = "auth", + access_token = _options.AccessToken + }); + await SendTextMessageAsync(ws, authMessage, cts.Token); + + // 3. 等待 auth_ok + var authResultJson = await ReceiveTextMessageAsync(ws, cts.Token); + using (var doc = JsonDocument.Parse(authResultJson)) + { + var type = doc.RootElement.GetProperty("type").GetString(); + if (type != "auth_ok") + { + var msg = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "认证失败"; + throw new InvalidOperationException($"Home Assistant WebSocket 认证失败:{msg}"); + } + } + + // 4. 获取设备注册表 (id: 1) + var devicesJson = await SendCommandAsync(ws, 1, "config/device_registry/list", cts.Token); + var devices = ParseRegistryResult>(devicesJson) ?? []; + + // 5. 获取实体注册表 (id: 2) + var entitiesJson = await SendCommandAsync(ws, 2, "config/entity_registry/list", cts.Token); + var entities = ParseRegistryResult>(entitiesJson) ?? []; + + // 6. 获取区域注册表 (id: 3) + var areasJson = await SendCommandAsync(ws, 3, "config/area_registry/list", cts.Token); + var areas = ParseRegistryResult>(areasJson) ?? []; + + try + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", CancellationToken.None); + } + catch + { + // 忽略正常关闭异常 + } + + return new HomeAssistantRegistries + { + Devices = devices, + Entities = entities, + Areas = areas, + CachedAt = DateTimeOffset.UtcNow + }; + } + catch (Exception ex) + { + _logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message); + return null; + } + } + + private static async Task SendCommandAsync(ClientWebSocket ws, int id, string type, CancellationToken cancellationToken) + { + var command = JsonSerializer.Serialize(new { id, type }); + await SendTextMessageAsync(ws, command, cancellationToken); + return await ReceiveTextMessageAsync(ws, cancellationToken); + } + + private static T? ParseRegistryResult(string responseJson) + { + using var doc = JsonDocument.Parse(responseJson); + var root = doc.RootElement; + if (root.TryGetProperty("success", out var success) && success.GetBoolean()) + { + if (root.TryGetProperty("result", out var result)) + { + return JsonSerializer.Deserialize(result.GetRawText()); + } + } + return default; + } + + private static async Task SendTextMessageAsync(ClientWebSocket ws, string text, CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(text); + await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, cancellationToken); + } + + private static async Task ReceiveTextMessageAsync(ClientWebSocket ws, CancellationToken cancellationToken) + { + var buffer = new byte[8192]; + using var ms = new MemoryStream(); + while (true) + { + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + throw new WebSocketException("WebSocket 服务端已关闭连接"); + } + ms.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + break; + } + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private static Uri ConvertToWebSocketUri(string baseUrl) + { + var url = baseUrl.TrimEnd('/'); + if (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + url = "wss://" + url[8..] + "/api/websocket"; + } + else if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + url = "ws://" + url[7..] + "/api/websocket"; + } + else + { + url = "ws://" + url + "/api/websocket"; + } + return new Uri(url); + } +} diff --git a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs index 0f36755..876f181 100644 --- a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs @@ -1,15 +1,65 @@ namespace Evergreen.Health.Application.Health.Models; -public sealed class HomeAssistantDeviceOutput +public enum HealthDeviceCategory +{ + EnvironmentControl, + EnvironmentSensor, + Sleep, + BodyComposition, + Fragrance, + Lighting, + Media, + Other +} + +public sealed class HomeAssistantEntityOutput { - public string Code { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; public string EntityId { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; public string State { get; set; } = string.Empty; - public string? Value { get; set; } + public bool Available { get; set; } public string? Unit { get; set; } - public bool IsAvailable { get; set; } - public DateTimeOffset? LastUpdated { get; set; } + public Dictionary? Attributes { get; set; } +} + +public sealed class HomeAssistantDiscoveredDeviceOutput +{ + public string DeviceId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Manufacturer { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public string? AreaName { get; set; } + public string Category { get; set; } = nameof(HealthDeviceCategory.Other); + public bool Online { get; set; } + public List Entities { get; set; } = []; + public Dictionary Metrics { get; set; } = []; +} + +public sealed class HomeAssistantSummaryOutput +{ + public int TotalDevices { get; set; } + public int OnlineDevices { get; set; } + public int OfflineDevices { get; set; } + public int TotalEntities { get; set; } + public int AvailableEntities { get; set; } +} + +public sealed class HomeAssistantSceneDevicesSummaryOutput +{ + public int Total { get; set; } = 4; + public int Available { get; set; } +} + +public sealed class HomeAssistantEnvironmentOutput +{ + public double? Temperature { get; set; } + public double? Humidity { get; set; } + public double? Pm25 { get; set; } + public double? Pm10 { get; set; } + public double? Co2 { get; set; } + public double? Hcho { get; set; } + public double? Tvoc { get; set; } } public sealed class HomeAssistantStatusOutput @@ -17,8 +67,10 @@ public sealed class HomeAssistantStatusOutput public bool Connected { get; set; } public string Message { get; set; } = string.Empty; public DateTimeOffset CheckedAt { get; set; } = DateTimeOffset.Now; - public List Devices { get; set; } = []; - public List Environment { get; set; } = []; + public HomeAssistantSummaryOutput Summary { get; set; } = new(); + public List Devices { get; set; } = []; + public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new(); + public HomeAssistantEnvironmentOutput Environment { get; set; } = new(); } public sealed class HomeAssistantSceneOutput diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 9b44cae..f31e0be 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -15,17 +15,20 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie { 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; @@ -39,28 +42,22 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie { var states = await _homeAssistant.GetStatesAsync(cancellationToken); var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); - var entities = _options.Entities; + 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 已连接", - Devices = - [ - BuildDevice(stateMap, "dehumidifier", "除湿机", entities.Dehumidifier, "current_humidity", "%"), - BuildDevice(stateMap, "fragrance", "香氛机", entities.FragranceSwitch), - BuildDevice(stateMap, "ambient-light", "氛围灯", entities.AmbientLight), - BuildDevice(stateMap, "speaker", "Xiaomi Sound Pro", entities.Speaker), - ], - Environment = - [ - BuildDevice(stateMap, "temperature", "温度", entities.Temperature), - BuildDevice(stateMap, "humidity", "湿度", entities.Humidity), - BuildDevice(stateMap, "pm25", "PM2.5", entities.Pm25), - BuildDevice(stateMap, "co2", "CO₂", entities.Co2), - BuildDevice(stateMap, "formaldehyde", "甲醛", entities.Formaldehyde), - BuildDevice(stateMap, "tvoc", "TVOC", entities.Tvoc), - ] + CheckedAt = DateTimeOffset.Now, + Summary = summary, + Devices = discoveredDevices, + SceneDevices = sceneDevices, + Environment = environment }; } catch (Exception exception) @@ -68,7 +65,12 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie return new HomeAssistantStatusOutput { Connected = false, - Message = $"Home Assistant 连接失败:{exception.Message}" + Message = $"Home Assistant 连接失败:{exception.Message}", + CheckedAt = DateTimeOffset.Now, + Summary = new HomeAssistantSummaryOutput(), + Devices = [], + SceneDevices = new HomeAssistantSceneDevicesSummaryOutput { Total = 4, Available = 0 }, + Environment = new HomeAssistantEnvironmentOutput() }; } } @@ -240,40 +242,334 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie return userId; } - private static HomeAssistantDeviceOutput BuildDevice( - IReadOnlyDictionary states, - string code, - string name, - string entityId, - string? attributeName = null, - string? attributeUnit = null) + private List BuildDiscoveredDevices( + HomeAssistantRegistries? registries, + IReadOnlyDictionary stateMap) { - if (string.IsNullOrWhiteSpace(entityId) || !states.TryGetValue(entityId, out var state)) + var discovered = new List(); + + if (registries is not null && registries.Devices.Count > 0) { - return new HomeAssistantDeviceOutput + 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) { - Code = code, - Name = name, - EntityId = entityId, - State = "not_found", - IsAvailable = false - }; + 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 + }); + } } - var value = attributeName is null ? state.State : state.GetStringAttribute(attributeName); - return new HomeAssistantDeviceOutput + if (discovered.Count == 0) { - Code = code, - Name = name, - EntityId = entityId, - State = state.State, - Value = value, - Unit = attributeUnit ?? state.GetStringAttribute("unit_of_measurement"), - IsAvailable = IsAvailable(state), - LastUpdated = state.LastUpdated + 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"); }