Files
Evergreen-Health/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs
T
2026-09-22 15:01:29 +08:00

1164 lines
54 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 SqlSugarRepository<HealthProfile> _profileRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public HealthHomeAssistantService(
HomeAssistantClient homeAssistant,
HomeAssistantRegistryService registryService,
IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository,
SqlSugarRepository<HealthProfile> profileRepository,
IHttpContextAccessor httpContextAccessor)
{
_homeAssistant = homeAssistant;
_registryService = registryService;
_options = options.Value;
_executionRepository = executionRepository;
_profileRepository = profileRepository;
_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);
var sceneDevices = BuildSceneDevicesSummary(stateMap);
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
PersonalizedSceneConfig? userConfig = null;
try
{
var userId = GetCurrentUserId();
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson))
{
userConfig = JsonSerializer.Deserialize<PersonalizedSceneConfig>(profile.CustomSceneConfigJson);
}
}
catch
{
}
var scenePresets = BuildScenePresets(stateMap, userConfig);
return new HomeAssistantStatusOutput
{
Connected = true,
Message = "Home Assistant 已连接",
CheckedAt = DateTimeOffset.Now,
Summary = summary,
Devices = discoveredDevices,
SceneDevices = sceneDevices,
Environment = environment,
ScenePresets = scenePresets,
ControlParameters = BuildControlParameterStates(stateMap)
};
}
catch (Exception exception)
{
return new HomeAssistantStatusOutput
{
Connected = false,
Message = $"Home Assistant 连接失败:{exception.Message}",
CheckedAt = DateTimeOffset.Now,
Summary = new HomeAssistantSummaryOutput(),
Devices = [],
SceneDevices = BuildSceneDevicesSummary(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
Environment = new HomeAssistantEnvironmentOutput(),
ScenePresets = BuildScenePresets(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
ControlParameters = []
};
}
}
[Authorize]
[HttpPost("/api/health/home-assistant/devices/switch")]
public async Task<HomeAssistantDeviceSwitchOutput> SwitchDevice(
[FromBody] HomeAssistantDeviceSwitchInput input,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(input.DeviceId))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
Message = "未指定控制设备"
};
}
try
{
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(s => s.EntityId, StringComparer.OrdinalIgnoreCase);
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discovered = BuildDiscoveredDevices(registries, stateMap);
var targetDevice = discovered.FirstOrDefault(d =>
string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase));
if (targetDevice is null || string.IsNullOrWhiteSpace(targetDevice.PrimaryControlEntityId))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
Message = "该设备没有经过确认的主控开关"
};
}
var targetEntityId = targetDevice.PrimaryControlEntityId;
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
!string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = input.EntityId,
Message = "请求的实体不属于该设备的主控能力"
};
}
var candidate = targetDevice.Entities.FirstOrDefault(e =>
string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase));
if (candidate is null || candidate.Domain is not ("switch" or "humidifier" or "light" or "media_player" or "climate"))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
Message = "该实体不支持安全的开关控制"
};
}
var domain = candidate.Domain;
if (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState))
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
Message = "目标设备当前处于离线状态"
};
}
var service = input.TurnOn ? "turn_on" : "turn_off";
if (domain == "media_player")
{
service = input.TurnOn ? "media_play" : "media_pause";
}
var serviceData = new Dictionary<string, object?>
{
["entity_id"] = targetEntityId
};
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
return new HomeAssistantDeviceSwitchOutput
{
Success = true,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = domain == "media_player"
? (input.TurnOn ? "playing" : "paused")
: (input.TurnOn ? "on" : "off"),
Message = domain == "media_player"
? $"已成功{(input.TurnOn ? "开始播放" : "暂停播放")}"
: $"已成功{(input.TurnOn ? "开启" : "关闭")}设备"
};
}
catch (Exception ex)
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = input.EntityId ?? string.Empty,
Message = $"控制失败:{ex.Message}"
};
}
}
[Authorize]
[HttpPost("/api/health/home-assistant/devices/set-parameter")]
public async Task<HomeAssistantSetParameterOutput> SetParameter(
[FromBody] HomeAssistantSetParameterInput input,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(input.DeviceId) || string.IsNullOrWhiteSpace(input.ParameterType))
{
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = "未指定控制设备或参数"
};
}
try
{
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discovered = BuildDiscoveredDevices(registries, stateMap);
var targetDevice = discovered.FirstOrDefault(d =>
string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase));
if (targetDevice is null)
{
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = "未找到指定设备"
};
}
var parameterType = input.ParameterType.Trim().ToLowerInvariant();
string targetEntityId;
string domain;
string service;
var serviceData = new Dictionary<string, object?>();
switch (parameterType)
{
case "humidity":
targetEntityId = _options.Entities.Dehumidifier;
domain = "humidifier";
service = "set_humidity";
serviceData["humidity"] = ReadInt(input.Value, 40, 70, "目标湿度");
break;
case "fan_level":
targetEntityId = _options.Entities.DehumidifierFanLevel;
domain = "select";
service = "select_option";
var fanLevel = ReadString(input.Value).Replace('档', '挡');
if (fanLevel is not ("1挡" or "2挡" or "3挡")) throw new InvalidOperationException("风速档位无效");
serviceData["option"] = fanLevel;
break;
case "dehumidifier_mode":
targetEntityId = _options.Entities.Dehumidifier;
domain = "humidifier";
service = "set_mode";
var dehumidifierMode = ReadString(input.Value);
if (dehumidifierMode is not ("睡眠" or "智能")) throw new InvalidOperationException("除湿模式无效");
serviceData["mode"] = dehumidifierMode;
break;
case "brightness":
targetEntityId = _options.Entities.AmbientLight;
domain = "light";
service = "turn_on";
serviceData["brightness_pct"] = ReadInt(input.Value, 1, 100, "灯光亮度");
break;
case "color_temp":
targetEntityId = _options.Entities.AmbientLight;
domain = "light";
service = "turn_on";
serviceData["color_temp_kelvin"] = ReadInt(input.Value, 2200, 5000, "灯光色温");
break;
case "volume":
targetEntityId = _options.Entities.Speaker;
domain = "media_player";
service = "volume_set";
serviceData["volume_level"] = ReadInt(input.Value, 0, 100, "音箱音量") / 100d;
break;
case "speaker_sleep_mode":
targetEntityId = _options.Entities.SpeakerSleepMode;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "mattress_left_heating":
targetEntityId = _options.Entities.MattressLeftHeatingLevel;
domain = "number";
service = "set_value";
serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫左侧加热档位");
break;
case "mattress_right_heating":
targetEntityId = _options.Entities.MattressRightHeatingLevel;
domain = "number";
service = "set_value";
serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫右侧加热档位");
break;
case "mattress_left_mode":
targetEntityId = _options.Entities.MattressLeftMode;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMattressMode(input.Value);
break;
case "mattress_right_mode":
targetEntityId = _options.Entities.MattressRightMode;
domain = "select";
service = "select_option";
serviceData["option"] = ReadMattressMode(input.Value);
break;
case "uv":
targetEntityId = _options.Entities.DehumidifierUv;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
default:
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType,
Message = $"不支持的参数类型: {input.ParameterType}"
};
}
if (string.IsNullOrWhiteSpace(targetEntityId))
throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体");
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
!string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
if (!targetDevice.Entities.Any(e => string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("该参数不属于指定设备");
if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState))
throw new InvalidOperationException("目标参数实体当前离线或不可用");
serviceData["entity_id"] = targetEntityId;
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
try
{
var userId = GetCurrentUserId();
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
if (profile != null)
{
var customConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson)
? new PersonalizedSceneConfig()
: JsonSerializer.Deserialize<PersonalizedSceneConfig>(profile.CustomSceneConfigJson) ?? new PersonalizedSceneConfig();
switch (parameterType)
{
case "humidity": customConfig.TargetHumidity = (int)serviceData["humidity"]!; break;
case "fan_level": customConfig.FanLevel = (string)serviceData["option"]!; break;
case "dehumidifier_mode": customConfig.DehumidifierMode = (string)serviceData["mode"]!; break;
case "brightness": customConfig.LightBrightnessPercent = (int)serviceData["brightness_pct"]!; break;
case "color_temp": customConfig.LightColorTemperatureKelvin = (int)serviceData["color_temp_kelvin"]!; break;
case "volume": customConfig.SpeakerVolumePercent = ReadInt(input.Value, 0, 100, "音箱音量"); break;
case "speaker_sleep_mode": customConfig.EnableSpeakerSleepMode = ReadBool(input.Value); break;
case "uv": customConfig.EnableUv = ReadBool(input.Value); break;
case "mattress_left_heating":
case "mattress_right_heating": customConfig.MattressHeatingLevel = ReadInt(input.Value, 0, 4, "床垫加热档位"); break;
case "mattress_left_mode":
case "mattress_right_mode": customConfig.MattressMode = ReadMattressMode(input.Value); break;
}
profile.CustomSceneConfigJson = JsonSerializer.Serialize(customConfig);
await _profileRepository.UpdateAsync(profile);
}
}
catch
{
}
return new HomeAssistantSetParameterOutput
{
Success = true,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType ?? "",
Message = "参数调节成功"
};
}
catch (Exception ex)
{
return new HomeAssistantSetParameterOutput
{
Success = false,
DeviceId = input.DeviceId,
ParameterType = input.ParameterType ?? "",
Message = $"调节失败:{ex.Message}"
};
}
}
[Authorize]
[HttpPost("/api/health/home-assistant/scenes/sleep")]
public async Task<HomeAssistantSceneOutput> ExecuteSleepScene(CancellationToken cancellationToken)
{
PersonalizedSceneConfig? customConfig = null;
try
{
var userId = GetCurrentUserId();
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson))
{
customConfig = JsonSerializer.Deserialize<PersonalizedSceneConfig>(profile.CustomSceneConfigJson);
}
}
catch
{
}
return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => ExecuteSleepActions(states, results, customConfig, ct), 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,
PersonalizedSceneConfig? customConfig,
CancellationToken cancellationToken)
{
var entities = _options.Entities;
var scene = _options.SleepScene;
var targetHumidity = customConfig?.TargetHumidity ?? scene.TargetHumidity;
var dehumMode = customConfig?.DehumidifierMode ?? scene.DehumidifierMode;
var fanLevel = customConfig?.FanLevel ?? scene.FanLevel;
var enableUv = customConfig?.EnableUv ?? scene.EnableUv;
var lightBrightness = customConfig?.LightBrightnessPercent ?? scene.LightBrightnessPercent;
var lightColorTemp = customConfig?.LightColorTemperatureKelvin ?? scene.LightColorTemperatureKelvin;
var enableFragrance = customConfig?.EnableFragrance ?? scene.EnableFragrance;
var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode;
var speakerVolume = customConfig?.SpeakerVolumePercent ?? 15;
var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel;
var mattressMode = customConfig?.MattressMode ?? scene.MattressMode;
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"] = targetHumidity }, cancellationToken);
await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode",
new Dictionary<string, object?> { ["mode"] = dehumMode }, cancellationToken);
await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option",
new Dictionary<string, object?> { ["option"] = fanLevel }, cancellationToken);
await RunAction(states, results, enableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv,
"switch", enableUv ? "turn_on" : "turn_off", null, cancellationToken);
if (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"] = lightBrightness }, cancellationToken);
}
await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on",
new Dictionary<string, object?>
{
["brightness_pct"] = lightBrightness,
["color_temp_kelvin"] = lightColorTemp
}, cancellationToken);
if (enableSpeakerSleepMode)
{
await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken);
await RunAction(states, results, "设置助眠音量", entities.Speaker, "media_player", "volume_set",
new Dictionary<string, object?> { ["volume_level"] = speakerVolume / 100d }, cancellationToken);
}
else
{
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
}
if (scene.EnableMattress)
{
await RunAction(states, results, "设置床垫左侧加热档位", entities.MattressLeftHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫左侧模式", entities.MattressLeftMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = mattressMode }, cancellationToken);
await RunAction(states, results, "设置床垫右侧模式", entities.MattressRightMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = mattressMode }, 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);
if (_options.SleepScene.EnableMattress)
{
await RunAction(states, results, "关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = 0 }, cancellationToken);
await RunAction(states, results, "关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = 0 }, 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)
{
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 = CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
var metrics = ExtractDeviceMetrics(category, entityOutputs);
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
{
DeviceId = device.Id,
Name = displayName,
Manufacturer = device.Manufacturer ?? "Xiaomi",
Model = device.Model ?? "",
AreaName = areaName,
Category = category,
Online = isOnline,
PrimaryControlEntityId = primaryControlEntityId,
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);
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
list.Add(new HomeAssistantDiscoveredDeviceOutput
{
DeviceId = id,
Name = name,
Manufacturer = "Xiaomi",
Model = model,
AreaName = area,
Category = category,
Online = online,
PrimaryControlEntityId = primaryControlEntityId,
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 string? FindPrimaryControlEntityId(
string category,
IReadOnlyList<HomeAssistantEntityOutput> entities)
{
var configuredEntityIds = category switch
{
nameof(HealthDeviceCategory.EnvironmentControl) => new[] { _options.Entities.Dehumidifier },
nameof(HealthDeviceCategory.Fragrance) => new[] { _options.Entities.FragranceSwitch },
nameof(HealthDeviceCategory.Lighting) => new[] { _options.Entities.AmbientLight, _options.Entities.FragranceLight },
nameof(HealthDeviceCategory.Media) => new[] { _options.Entities.Speaker },
_ => Array.Empty<string>()
};
foreach (var configuredEntityId in configuredEntityIds.Where(id => !string.IsNullOrWhiteSpace(id)))
{
if (entities.Any(e => string.Equals(e.EntityId, configuredEntityId, StringComparison.OrdinalIgnoreCase)))
return configuredEntityId;
}
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,
_ => null
};
}
private 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
};
}
private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var entities = _options.Entities;
var sceneEntityIds = new List<string>();
if (!string.IsNullOrWhiteSpace(entities.Dehumidifier))
sceneEntityIds.Add(entities.Dehumidifier);
if (_options.SleepScene.EnableFragrance && !string.IsNullOrWhiteSpace(entities.FragranceSwitch))
sceneEntityIds.Add(entities.FragranceSwitch);
if (!string.IsNullOrWhiteSpace(entities.AmbientLight))
sceneEntityIds.Add(entities.AmbientLight);
if (_options.SleepScene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(entities.SpeakerSleepMode))
sceneEntityIds.Add(entities.SpeakerSleepMode);
if (_options.SleepScene.EnableMattress && !string.IsNullOrWhiteSpace(entities.MattressLeftHeatingLevel))
sceneEntityIds.Add(entities.MattressLeftHeatingLevel);
sceneEntityIds = sceneEntityIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var sceneAvailableCount = sceneEntityIds.Count(entityId =>
stateMap.TryGetValue(entityId, out var state) && IsAvailable(state));
return new HomeAssistantSceneDevicesSummaryOutput
{
Total = sceneEntityIds.Count,
Available = 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"),
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("除湿") || 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 Dictionary<string, HomeAssistantParameterStateOutput> BuildControlParameterStates(
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var configured = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["humidity"] = _options.Entities.Dehumidifier,
["fan_level"] = _options.Entities.DehumidifierFanLevel,
["uv"] = _options.Entities.DehumidifierUv,
["brightness"] = _options.Entities.AmbientLight,
["color_temp"] = _options.Entities.AmbientLight,
["volume"] = _options.Entities.Speaker,
["speaker_sleep_mode"] = _options.Entities.SpeakerSleepMode,
["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel,
["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel,
["mattress_left_mode"] = _options.Entities.MattressLeftMode,
["mattress_right_mode"] = _options.Entities.MattressRightMode
};
var result = new Dictionary<string, HomeAssistantParameterStateOutput>(StringComparer.OrdinalIgnoreCase);
foreach (var (parameterType, entityId) in configured)
{
if (string.IsNullOrWhiteSpace(entityId) || !stateMap.TryGetValue(entityId, out var state)) continue;
result[parameterType] = new HomeAssistantParameterStateOutput
{
EntityId = entityId,
State = state.State,
Available = IsAvailable(state),
Attributes = ExtractSafeAttributes(state)
};
}
return result;
}
private static string ReadString(object? value)
{
if (value is JsonElement element)
{
return element.ValueKind == JsonValueKind.String
? element.GetString()?.Trim() ?? string.Empty
: element.ToString().Trim();
}
return value?.ToString()?.Trim() ?? string.Empty;
}
private static int ReadInt(object? value, int min, int max, string label)
{
if (!int.TryParse(ReadString(value), out var parsed) || parsed < min || parsed > max)
throw new InvalidOperationException($"{label}应在 {min} 到 {max} 之间");
return parsed;
}
private static bool ReadBool(object? value)
{
if (value is JsonElement element && element.ValueKind is JsonValueKind.True or JsonValueKind.False)
return element.GetBoolean();
if (bool.TryParse(ReadString(value), out var parsed)) return parsed;
throw new InvalidOperationException("开关参数无效");
}
private static string ReadMattressMode(object? value)
{
var mode = ReadString(value);
if (mode is not ("无" or "瑜伽助眠" or "智能适配" or "智能腰托"))
throw new InvalidOperationException("床垫模式无效");
return mode;
}
private List<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> stateMap,
PersonalizedSceneConfig? userConfig = null)
{
var defaultScene = _options.SleepScene;
var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity;
var dehumMode = userConfig?.DehumidifierMode ?? defaultScene.DehumidifierMode;
var fanLevel = userConfig?.FanLevel ?? defaultScene.FanLevel;
var enableUv = userConfig?.EnableUv ?? defaultScene.EnableUv;
var lightBrightness = userConfig?.LightBrightnessPercent ?? defaultScene.LightBrightnessPercent;
var lightColorTemp = userConfig?.LightColorTemperatureKelvin ?? defaultScene.LightColorTemperatureKelvin;
var enableFragrance = userConfig?.EnableFragrance ?? defaultScene.EnableFragrance;
var enableSpeaker = userConfig?.EnableSpeakerSleepMode ?? defaultScene.EnableSpeakerSleepMode;
var presets = new List<HomeAssistantScenePresetItem>();
if (!string.IsNullOrWhiteSpace(_options.Entities.Dehumidifier))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "米家变频净化除湿机 Max",
Category = nameof(HealthDeviceCategory.EnvironmentControl),
EntityId = _options.Entities.Dehumidifier,
Action = "开启恒湿舒睡",
Detail = $"目标湿度 {targetHumidity}% · {dehumMode}模式 · 风速{fanLevel} · UV{(enableUv ? "开" : "关")}",
Parameters = new()
{
["target_humidity"] = $"{targetHumidity}%",
["mode"] = dehumMode,
["fan_level"] = fanLevel,
["uv"] = enableUv ? "开启" : "关闭"
}
});
}
if (enableFragrance && !string.IsNullOrWhiteSpace(_options.Entities.FragranceSwitch))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "米家智能香氛机",
Category = nameof(HealthDeviceCategory.Fragrance),
EntityId = _options.Entities.FragranceSwitch,
Action = "开启香氛释放",
Detail = "开启香氛机;香氛灯可用时同步开启柔光",
Parameters = new() { ["power"] = "开启" }
});
}
if (!string.IsNullOrWhiteSpace(_options.Entities.AmbientLight))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "Lemesh 色温灯",
Category = nameof(HealthDeviceCategory.Lighting),
EntityId = _options.Entities.AmbientLight,
Action = "开启暖光助眠",
Detail = $"亮度 {lightBrightness}% · 色温 {lightColorTemp}K",
Parameters = new()
{
["brightness"] = $"{lightBrightness}%",
["color_temp"] = $"{lightColorTemp}K"
}
});
}
if (enableSpeaker && !string.IsNullOrWhiteSpace(_options.Entities.SpeakerSleepMode))
{
var vol = userConfig?.SpeakerVolumePercent ?? 15;
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "Xiaomi Sound Pro",
Category = nameof(HealthDeviceCategory.Media),
EntityId = _options.Entities.SpeakerSleepMode,
Action = "开启音箱睡眠模式",
Detail = $"切换睡眠模式开关 · 音量 {vol}%",
Parameters = new() { ["sleep_mode"] = "开启", ["volume"] = $"{vol}%" }
});
}
if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel))
{
var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel;
var mattressMode = userConfig?.MattressMode ?? defaultScene.MattressMode;
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "8H AI智能好睡眠床垫X",
Category = nameof(HealthDeviceCategory.Sleep),
EntityId = _options.Entities.MattressLeftHeatingLevel,
Action = "开启分区加热与承托模式",
Detail = $"左右加热 {heatingLevel} 档 · {mattressMode}",
Parameters = new()
{
["heating_level"] = heatingLevel,
["mode"] = mattressMode
}
});
}
foreach (var preset in presets)
{
preset.Available = !string.IsNullOrWhiteSpace(preset.EntityId) &&
stateMap.TryGetValue(preset.EntityId, out var state) &&
IsAvailable(state);
preset.AvailabilityMessage = preset.Available ? "当前可执行" : "当前不可用,执行时跳过";
}
return presets;
}
private static bool IsAvailable(HomeAssistantState state)
=> state.State is not ("unavailable" or "unknown");
}