362 lines
19 KiB
C#
362 lines
19 KiB
C#
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<HomeAssistantOptions> options, DeviceModuleRegistry modules)
|
|
=> (_options, _modules) = (options.Value, modules);
|
|
|
|
public List<HomeAssistantDiscoveredDeviceOutput> BuildDiscoveredDevices(
|
|
HomeAssistantRegistries? registries,
|
|
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
|
{
|
|
var discovered = new List<HomeAssistantDiscoveredDeviceOutput>();
|
|
|
|
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);
|
|
|
|
// 使用完整注册表识别退役设备;即使用于识别的实体被禁用,也不能重新发现同一设备。
|
|
var excludedDevices = registries.Entities.Where(entity =>
|
|
_options.ExcludedDeviceEntityIds.Contains(entity.EntityId, StringComparer.OrdinalIgnoreCase))
|
|
.Select(entity => entity.DeviceId).ToHashSet(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) && !excludedDevices.Contains(device.Id)))
|
|
{
|
|
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
|
|
if (regEntities.Count == 0) continue;
|
|
var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var module = _modules.ForEntities(entityIds);
|
|
var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId));
|
|
if (_options.OnlyConfiguredDevices && module is null && displayOnly is null) continue;
|
|
|
|
var entityOutputs = new List<HomeAssistantEntityOutput>();
|
|
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 = module?.Category ?? displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
|
|
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
|
var primaryControlEntityId = module is null && displayOnly is not null ? null : 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,
|
|
DefaultLightEffect = DefaultLightEffect(primaryControlEntityId),
|
|
Entities = entityOutputs,
|
|
Metrics = metrics
|
|
});
|
|
}
|
|
}
|
|
|
|
// 有效注册表被过滤后为空,不代表注册表读取失败,不能重新生成占位设备。
|
|
if (registries is null || registries.Devices.Count == 0)
|
|
{
|
|
discovered = BuildFallbackDevices(stateMap);
|
|
}
|
|
|
|
return discovered;
|
|
}
|
|
|
|
private List<HomeAssistantDiscoveredDeviceOutput> BuildFallbackDevices(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
|
{
|
|
var list = new List<HomeAssistantDiscoveredDeviceOutput>();
|
|
// 注册信息暂不可读时只展示真实配置过的模块,不凭空创建床垫/体脂秤。
|
|
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),
|
|
DefaultLightEffect = DefaultLightEffect(module.PrimaryEntityId),
|
|
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
|
|
});
|
|
}
|
|
foreach (var device in _options.DisplayOnlyDevices)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(device.EntityId) || _modules.ForEntities([device.EntityId]) is not null ||
|
|
_options.ExcludedDeviceEntityIds.Contains(device.EntityId, StringComparer.OrdinalIgnoreCase)) continue;
|
|
stateMap.TryGetValue(device.EntityId, out var state);
|
|
var available = state is not null && IsAvailable(state);
|
|
list.Add(new()
|
|
{
|
|
DeviceId = $"fallback_{device.EntityId}", Name = device.Name, Category = device.Category,
|
|
Online = available, Entities = [new() { EntityId = device.EntityId, Domain = device.EntityId.Split('.')[0],
|
|
Name = state?.GetStringAttribute("friendly_name") ?? device.Name, State = state?.State ?? "unavailable",
|
|
Available = available, Attributes = ExtractSafeAttributes(state) }]
|
|
});
|
|
}
|
|
return list;
|
|
}
|
|
|
|
private string? DefaultLightEffect(string? entityId) => _options.AdditionalSleepLights.FirstOrDefault(light =>
|
|
string.Equals(light.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.SleepEffect;
|
|
|
|
private string? FindPrimaryControlEntityId(
|
|
string category,
|
|
IReadOnlyList<HomeAssistantEntityOutput> 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<HomeAssistantDiscoveredDeviceOutput> 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<string, HomeAssistantState> 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<string, HomeAssistantState> stateMap,
|
|
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> 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<HomeAssistantEntityOutput> 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<string, object?> ExtractDeviceMetrics(string category, IReadOnlyList<HomeAssistantEntityOutput> entities)
|
|
{
|
|
var metrics = new Dictionary<string, object?>(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("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State;
|
|
}
|
|
|
|
return metrics;
|
|
}
|
|
|
|
public static Dictionary<string, object?>? ExtractSafeAttributes(HomeAssistantState? state)
|
|
{
|
|
if (state == null || state.Attributes.ValueKind != JsonValueKind.Object) return null;
|
|
var safe = new Dictionary<string, object?>(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",
|
|
"supported_color_modes", "color_mode", "min_color_temp_kelvin", "max_color_temp_kelvin", "effect_list", "effect",
|
|
"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);
|
|
}
|