1111
This commit is contained in:
@@ -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<HomeAssistantDeviceRegistryItem> Devices { get; set; } = [];
|
||||
public List<HomeAssistantEntityRegistryItem> Entities { get; set; } = [];
|
||||
public List<HomeAssistantAreaRegistryItem> Areas { get; set; } = [];
|
||||
public DateTimeOffset CachedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantActionResult
|
||||
|
||||
+208
@@ -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<HomeAssistantRegistryService> _logger;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private HomeAssistantRegistries? _cachedRegistries;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public HomeAssistantRegistryService(
|
||||
IOptions<HomeAssistantOptions> options,
|
||||
ILogger<HomeAssistantRegistryService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HomeAssistantRegistries?> 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<HomeAssistantRegistries?> 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<List<HomeAssistantDeviceRegistryItem>>(devicesJson) ?? [];
|
||||
|
||||
// 5. 获取实体注册表 (id: 2)
|
||||
var entitiesJson = await SendCommandAsync(ws, 2, "config/entity_registry/list", cts.Token);
|
||||
var entities = ParseRegistryResult<List<HomeAssistantEntityRegistryItem>>(entitiesJson) ?? [];
|
||||
|
||||
// 6. 获取区域注册表 (id: 3)
|
||||
var areasJson = await SendCommandAsync(ws, 3, "config/area_registry/list", cts.Token);
|
||||
var areas = ParseRegistryResult<List<HomeAssistantAreaRegistryItem>>(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<string> 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<T>(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<T>(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<byte>(bytes), WebSocketMessageType.Text, true, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextMessageAsync(ClientWebSocket ws, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
using var ms = new MemoryStream();
|
||||
while (true)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, object?>? 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<HomeAssistantEntityOutput> Entities { get; set; } = [];
|
||||
public Dictionary<string, object?> 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<HomeAssistantDeviceOutput> Devices { get; set; } = [];
|
||||
public List<HomeAssistantDeviceOutput> Environment { get; set; } = [];
|
||||
public HomeAssistantSummaryOutput Summary { get; set; } = new();
|
||||
public List<HomeAssistantDiscoveredDeviceOutput> Devices { get; set; } = [];
|
||||
public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new();
|
||||
public HomeAssistantEnvironmentOutput Environment { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantSceneOutput
|
||||
|
||||
@@ -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<HealthSceneExecution> _executionRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public HealthHomeAssistantService(
|
||||
HomeAssistantClient homeAssistant,
|
||||
HomeAssistantRegistryService registryService,
|
||||
IOptions<HomeAssistantOptions> options,
|
||||
SqlSugarRepository<HealthSceneExecution> 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<string, HomeAssistantState> states,
|
||||
string code,
|
||||
string name,
|
||||
string entityId,
|
||||
string? attributeName = null,
|
||||
string? attributeUnit = null)
|
||||
private List<HomeAssistantDiscoveredDeviceOutput> BuildDiscoveredDevices(
|
||||
HomeAssistantRegistries? registries,
|
||||
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entityId) || !states.TryGetValue(entityId, out var state))
|
||||
var discovered = new List<HomeAssistantDiscoveredDeviceOutput>();
|
||||
|
||||
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<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
|
||||
: "睡眠空间";
|
||||
|
||||
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<HomeAssistantDiscoveredDeviceOutput> BuildFallbackDevices(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||
{
|
||||
var entities = _options.Entities;
|
||||
var list = new List<HomeAssistantDiscoveredDeviceOutput>();
|
||||
|
||||
void AddFallback(string id, string name, string model, string category, string area, params string[] entityIds)
|
||||
{
|
||||
var entityOutputs = new List<HomeAssistantEntityOutput>();
|
||||
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<HomeAssistantDiscoveredDeviceOutput> devices,
|
||||
IReadOnlyList<HomeAssistantState> 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<string, HomeAssistantState> 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<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 (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<HomeAssistantEntityOutput> 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<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("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<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",
|
||||
"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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user