576 lines
26 KiB
C#
576 lines
26 KiB
C#
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<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;
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpGet("/api/health/home-assistant/status")]
|
|
public async Task<HomeAssistantStatusOutput> 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<HomeAssistantSceneOutput> ExecuteSleepScene(CancellationToken cancellationToken)
|
|
=> ExecuteScene("sleep", "舒睡模式", ExecuteSleepActions, cancellationToken);
|
|
|
|
[Authorize]
|
|
[HttpPost("/api/health/home-assistant/scenes/all-off")]
|
|
public Task<HomeAssistantSceneOutput> ExecuteAllOffScene(CancellationToken cancellationToken)
|
|
=> ExecuteScene("all-off", "一键关闭", ExecuteAllOffActions, cancellationToken);
|
|
|
|
private async Task<HomeAssistantSceneOutput> ExecuteScene(
|
|
string sceneCode,
|
|
string sceneName,
|
|
Func<IReadOnlyDictionary<string, HomeAssistantState>, List<HomeAssistantActionResult>, CancellationToken, Task> executor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var userId = GetCurrentUserId();
|
|
var actions = new List<HomeAssistantActionResult>();
|
|
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<string, HomeAssistantState> states,
|
|
List<HomeAssistantActionResult> 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<string, object?> { ["humidity"] = scene.TargetHumidity }, cancellationToken);
|
|
await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode",
|
|
new Dictionary<string, object?> { ["mode"] = scene.DehumidifierMode }, cancellationToken);
|
|
await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option",
|
|
new Dictionary<string, object?> { ["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<string, object?> { ["brightness_pct"] = scene.LightBrightnessPercent }, cancellationToken);
|
|
}
|
|
|
|
await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["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<string, HomeAssistantState> states,
|
|
List<HomeAssistantActionResult> 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<string, HomeAssistantState> states,
|
|
List<HomeAssistantActionResult> results,
|
|
string name,
|
|
string entityId,
|
|
string domain,
|
|
string service,
|
|
IReadOnlyDictionary<string, object?>? 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<string, object?> { ["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<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))
|
|
.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<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
|
|
});
|
|
}
|
|
}
|
|
|
|
if (discovered.Count == 0)
|
|
{
|
|
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");
|
|
}
|