Files
Evergreen-Health/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs
T
2026-10-09 16:46:30 +08:00

432 lines
20 KiB
C#

using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Policies;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models;
using Furion.DatabaseAccessor;
using Furion.FriendlyException;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using SqlSugar;
using System.Globalization;
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 TcmMusicTransitionService _musicTransition;
private readonly HomeAssistantLightTransitionService _lightTransition;
private readonly HomeAssistantRegistryService _registryService;
private readonly HomeAssistantOptions _options;
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly MattressComfortService _mattressComfort;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DeviceModuleRegistry _modules;
private readonly HealthSceneCoordinator _scenes;
private readonly HomeAssistantDeviceCatalog _catalog;
private readonly ILogger<HealthHomeAssistantService> _logger;
public HealthHomeAssistantService(
HomeAssistantClient homeAssistant,
TcmMusicTransitionService musicTransition,
HomeAssistantLightTransitionService lightTransition,
HomeAssistantRegistryService registryService,
IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository,
SqlSugarRepository<HealthProfile> profileRepository,
MattressComfortService mattressComfort,
IHttpContextAccessor httpContextAccessor,
DeviceModuleRegistry modules,
HealthSceneCoordinator scenes,
HomeAssistantDeviceCatalog catalog,
ILogger<HealthHomeAssistantService> logger)
{
_homeAssistant = homeAssistant;
_musicTransition = musicTransition;
_lightTransition = lightTransition;
_registryService = registryService;
_options = options.Value;
_executionRepository = executionRepository;
_profileRepository = profileRepository;
_mattressComfort = mattressComfort;
_httpContextAccessor = httpContextAccessor;
_modules = modules;
_scenes = scenes;
_catalog = catalog;
_logger = logger;
}
[Authorize]
[HttpGet("/api/health/home-assistant/lights/transition")]
public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.GetStatus();
[Authorize]
[HttpGet("/api/health/home-assistant/music/transition")]
public TcmMusicTransitionOutput GetMusicTransitionStatus() => _musicTransition.GetStatus();
[Authorize]
[HttpPost("/api/health/home-assistant/lights/sleep")]
public async Task<HomeAssistantLightTransitionOutput> StartSleepLight(CancellationToken cancellationToken)
{
try { return await _lightTransition.StartSleepAsync(_options.SleepScene.LightColorTemperatureKelvin, cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpPost("/api/health/home-assistant/lights/wake")]
public async Task<HomeAssistantLightTransitionOutput> StartWakeLight(CancellationToken cancellationToken)
{
try { return await _lightTransition.StartWakeAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpPost("/api/health/home-assistant/lights/stop")]
public async Task<HomeAssistantLightTransitionOutput> StopLightTransition(CancellationToken cancellationToken)
{
try { return await _lightTransition.CancelAsync(cancellationToken); }
catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpGet("/api/health/home-assistant/status")]
public async Task<HomeAssistantStatusOutput> GetStatus(CancellationToken cancellationToken)
{
var userId = GetCurrentUserId();
try
{
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discoveredDevices = _catalog.BuildDiscoveredDevices(registries, stateMap);
var summary = HomeAssistantDeviceCatalog.BuildSummary(discoveredDevices);
var sceneDevices = _catalog.BuildSceneDevicesSummary(stateMap);
var environment = _catalog.BuildEnvironmentOutput(stateMap, discoveredDevices);
PersonalizedSceneConfig? userConfig = null;
var constitutionCode = string.Empty;
try
{
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
var tcmResult = TcmConstitutionRules.Read(profile);
if (tcmResult.HasCompleted)
{
constitutionCode = tcmResult.ConstitutionCode;
userConfig = tcmResult.SceneConfig;
var comfort = _mattressComfort.Get(profile, stateMap);
MattressComfortService.ApplyTo(userConfig, comfort);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "读取用户 {UserId} 的设备参数预览失败", userId);
}
var scenePresets = userConfig is null
? new List<HomeAssistantScenePresetItem>()
: BuildScenePresets(stateMap, userConfig, constitutionCode);
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) when (exception is not OperationCanceledException)
{
return new HomeAssistantStatusOutput
{
Connected = false,
Message = $"Home Assistant 连接失败:{exception.Message}",
CheckedAt = DateTimeOffset.Now,
Summary = new HomeAssistantSummaryOutput(),
Devices = [],
SceneDevices = _catalog.BuildSceneDevicesSummary(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
Environment = new HomeAssistantEnvironmentOutput(),
ScenePresets = [],
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 = _catalog.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 module = _modules.ForEntities(targetDevice.Entities.Select(entity => entity.EntityId));
if (module is not null && !string.Equals(module.PrimaryEntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))
module = null;
if (module?.PowerUsesIndependentGateway != true &&
(!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState)))
throw new InvalidOperationException("目标设备当前处于离线状态");
if (module is not null)
await module.SetPowerAsync(input.TurnOn, cancellationToken);
else
await _homeAssistant.CallServiceAsync(candidate.Domain, input.TurnOn ? "turn_on" : "turn_off",
new Dictionary<string, object?> { ["entity_id"] = targetEntityId }, cancellationToken);
var reportedState = "";
try
{
reportedState = (await _homeAssistant.GetStatesAsync(cancellationToken)).FirstOrDefault(state =>
string.Equals(state.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))?.State ?? "";
}
catch (Exception exception) when (exception is not OperationCanceledException) { }
return new HomeAssistantDeviceSwitchOutput
{
Success = true, DeviceId = input.DeviceId, EntityId = targetEntityId, State = reportedState,
Message = input.TurnOn ? "开启指令已发送" : "关闭指令已发送"
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
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 = _catalog.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();
var module = _modules.ForParameter(parameterType);
var command = module.BuildParameterCommand(parameterType, input.Value);
if (string.IsNullOrWhiteSpace(command.EntityId))
throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体");
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
!string.Equals(input.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
if (!targetDevice.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("该参数不属于指定设备");
// 外部音频网关不依赖 HA 媒体实体是否在线,但仍校验实体归属。
if (!module.ParameterUsesIndependentGateway(command) &&
(!stateMap.TryGetValue(command.EntityId, out var state) || !IsAvailable(state)))
throw new InvalidOperationException("目标参数实体当前离线或不可用");
await module.ExecuteParameterAsync(command, cancellationToken);
return new HomeAssistantSetParameterOutput
{
Success = true, DeviceId = input.DeviceId, ParameterType = parameterType,
Message = command.ExpectedState is null ? "参数调节指令已发送" : "已回读确认参数"
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
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)
{
var userId = GetCurrentUserId();
return await ExecuteScene("sleep", null, "", cancellationToken, async token =>
{
token.ThrowIfCancellationRequested();
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
token.ThrowIfCancellationRequested();
var tcmResult = TcmConstitutionRules.Read(profile);
if (!tcmResult.HasCompleted)
throw new ScenePreparationException("请先完成中医四诊,生成体质倾向后再一键启动");
var config = tcmResult.SceneConfig;
var mattressComfort = await _mattressComfort.GetAsync(profile, token);
MattressComfortService.ApplyTo(config, mattressComfort);
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
return new PreparedSleepScene(config, tcmResult.ConstitutionCode);
});
}
[Authorize]
[HttpPost("/api/health/home-assistant/scenes/all-off")]
public Task<HomeAssistantSceneOutput> ExecuteAllOffScene(CancellationToken cancellationToken)
=> ExecuteScene("all-off", null, "", cancellationToken);
private async Task<HomeAssistantSceneOutput> ExecuteScene(string sceneCode,
PersonalizedSceneConfig? config, string constitutionCode, CancellationToken cancellationToken,
Func<CancellationToken, Task<PreparedSleepScene>>? prepare = null)
{
var userId = GetCurrentUserId();
var output = prepare is null
? await _scenes.ExecuteAsync(sceneCode, config, constitutionCode, cancellationToken)
: await _scenes.ExecutePreparedSleepAsync(prepare, cancellationToken);
try
{
await _executionRepository.InsertAsync(new HealthSceneExecution
{
UserId = userId, SceneCode = output.SceneCode, SceneName = output.SceneName,
Status = output.Status, Detail = JsonSerializer.Serialize(new { output.Message, output.Actions, output.ClimateAdvice })
});
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogError(exception, "场景 {SceneCode} 已执行,但用户 {UserId} 的执行记录未保存", sceneCode, userId);
output.Message += "(执行记录暂未保存)";
}
return output;
}
private long GetCurrentUserId()
{
var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(HealthUserIdClaim);
if (!long.TryParse(value, out var userId) || userId <= 0)
throw Oops.Oh("登录状态已失效,请重新登录").StatusCode(401);
return userId;
}
private Dictionary<string, HomeAssistantParameterStateOutput> BuildControlParameterStates(
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var result = new Dictionary<string, HomeAssistantParameterStateOutput>(StringComparer.OrdinalIgnoreCase);
foreach (var parameter in _modules.Modules.SelectMany(module => module.Parameters))
{
if (string.IsNullOrWhiteSpace(parameter.EntityId) || !stateMap.TryGetValue(parameter.EntityId, out var state)) continue;
result[parameter.Key] = new()
{
EntityId = parameter.EntityId, State = state.State, Available = IsAvailable(state),
Attributes = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)
};
}
return result;
}
private List<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> states,
PersonalizedSceneConfig? config, string constitutionCode)
{
if (config is null) return [];
var previews = _modules.Modules.Select(module => module.BuildPreview(config, constitutionCode))
.Where(preview => preview is not null).ToList();
foreach (var preview in previews)
{
if (!string.IsNullOrWhiteSpace(preview!.AvailabilityMessage)) continue;
preview.Available = !string.IsNullOrWhiteSpace(preview.EntityId) &&
states.TryGetValue(preview.EntityId, out var state) && IsAvailable(state);
preview.AvailabilityMessage = preview.Available ? "当前可执行" : "当前不可用,执行时跳过";
}
return previews!;
}
private static bool IsAvailable(HomeAssistantState state)
=> DeviceActionRunner.IsAvailable(state);
}