using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Scenes; using Microsoft.Extensions.Options; using System.Globalization; using System.Text.Json; namespace Evergreen.Health.Application.Health.Devices.Discovery; public sealed class HomeAssistantDeviceCatalog { private readonly HomeAssistantOptions _options; private readonly DeviceModuleRegistry _modules; public HomeAssistantDeviceCatalog(IOptions options, DeviceModuleRegistry modules) => (_options, _modules) = (options.Value, modules); public 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) && string.IsNullOrWhiteSpace(e.DisabledBy)) .GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); // HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。 foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) && !string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase))) { var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? []; if (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 : null; var module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId)); var category = module?.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, ModuleKey = module?.Key ?? "", Name = displayName, Manufacturer = device.Manufacturer ?? "", Model = device.Model ?? "", AreaName = areaName, Category = category, Online = isOnline, PrimaryControlEntityId = primaryControlEntityId, Entities = entityOutputs, Metrics = metrics }); } } // 有效注册表被过滤后为空,不代表注册表读取失败,不能重新生成占位设备。 if (registries is null || registries.Devices.Count == 0) { discovered = BuildFallbackDevices(stateMap); } return discovered; } private List BuildFallbackDevices(IReadOnlyDictionary stateMap) { var list = new List(); // 注册信息暂不可读时只展示真实配置过的模块,不凭空创建床垫/体脂秤。 foreach (var module in _modules.Modules) { var entityOutputs = module.EntityIds.Select(id => { stateMap.TryGetValue(id, out var state); return new HomeAssistantEntityOutput { EntityId = id, Domain = id.Split('.')[0], Name = state?.GetStringAttribute("friendly_name") ?? module.DisplayName, State = state?.State ?? "unavailable", Available = state is not null && IsAvailable(state), Unit = state?.GetStringAttribute("unit_of_measurement"), Attributes = ExtractSafeAttributes(state) }; }).ToList(); if (entityOutputs.Count == 0) continue; list.Add(new() { DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName, Category = module.Category, Online = entityOutputs.Any(entity => entity.Available), PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs), Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs) }); } return list; } private string? FindPrimaryControlEntityId( string category, IReadOnlyList entities) { var module = _modules.ForEntities(entities.Select(entity => entity.EntityId)); if (module is not null) return !string.IsNullOrWhiteSpace(module.PrimaryEntityId) && entities.Any(entity => string.Equals(entity.EntityId, module.PrimaryEntityId, StringComparison.OrdinalIgnoreCase)) ? module.PrimaryEntityId : null; 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, nameof(HealthDeviceCategory.Climate) => entities.FirstOrDefault(e => e.Domain == "climate")?.EntityId, _ => null }; } public 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 }; } public HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary stateMap) { var configured = _modules.Modules.Where(module => module.EntityIds.Any()).ToArray(); var sceneAvailableCount = configured.Count(module => module.EntityIds.Any(entityId => stateMap.TryGetValue(entityId, out var state) && IsAvailable(state))); return new HomeAssistantSceneDevicesSummaryOutput { Total = configured.Length, Available = sceneAvailableCount }; } public 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 (!string.IsNullOrWhiteSpace(entityId)) { return stateMap.TryGetValue(entityId, out var state) && IsAvailable(state) && double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && double.IsFinite(value) ? value : null; } if (envDevice?.Metrics.TryGetValue(metricKey, out var metric) == true && metric is not null && double.TryParse(Convert.ToString(metric, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var fallback) && double.IsFinite(fallback)) return fallback; 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("je01") || text.Contains("睡眠监测") || text.Contains("睡眠检测") || text.Contains("雷达")) return nameof(HealthDeviceCategory.SleepMonitoring); if (entities.Any(entity => entity.Domain == "climate")) return nameof(HealthDeviceCategory.Climate); if (entities.Any(entity => entity.Domain == "cover")) return nameof(HealthDeviceCategory.Curtain); 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; } public 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", "options", "min", "max", "step", "hvac_modes", "supported_features", "current_temperature", "temperature_unit", "min_temp", "max_temp", "target_temp_step", "fan_modes", "swing_modes", "swing_mode", "hvac_action", "current_position" }; foreach (var key in safeKeys) { if (state.Attributes.TryGetProperty(key, out var prop)) { safe[key] = ToApiAttributeValue(prop); } } return safe.Count > 0 ? safe : null; } // HA 用 System.Text.Json 读取,API 用 Newtonsoft.Json 输出。不能把 JsonElement // 留在 object[] 中,否则选项被输出为 ValueKind 对象,而不是原始字符串。 private static object? ToApiAttributeValue(JsonElement value) => value.ValueKind switch { JsonValueKind.String => value.GetString(), JsonValueKind.Number => value.TryGetInt64(out var integer) ? (object)integer : value.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Array => value.EnumerateArray().Select(ToApiAttributeValue).ToArray(), JsonValueKind.Object => value.EnumerateObject().ToDictionary(property => property.Name, property => ToApiAttributeValue(property.Value), StringComparer.Ordinal), _ => null }; private static bool IsAvailable(HomeAssistantState state) => DeviceActionRunner.IsAvailable(state); }