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 _executionRepository; private readonly SqlSugarRepository _profileRepository; private readonly MattressComfortService _mattressComfort; private readonly IHttpContextAccessor _httpContextAccessor; private readonly DeviceModuleRegistry _modules; private readonly HealthSceneCoordinator _scenes; private readonly HomeAssistantDeviceCatalog _catalog; private readonly ILogger _logger; public HealthHomeAssistantService( HomeAssistantClient homeAssistant, TcmMusicTransitionService musicTransition, HomeAssistantLightTransitionService lightTransition, HomeAssistantRegistryService registryService, IOptions options, SqlSugarRepository executionRepository, SqlSugarRepository profileRepository, MattressComfortService mattressComfort, IHttpContextAccessor httpContextAccessor, DeviceModuleRegistry modules, HealthSceneCoordinator scenes, HomeAssistantDeviceCatalog catalog, ILogger 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 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 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 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 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() : 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(StringComparer.OrdinalIgnoreCase)), Environment = new HomeAssistantEnvironmentOutput(), ScenePresets = [], ControlParameters = [] }; } } [Authorize] [HttpPost("/api/health/home-assistant/devices/switch")] public async Task 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 { ["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 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 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 ExecuteAllOffScene(CancellationToken cancellationToken) => ExecuteScene("all-off", null, "", cancellationToken); private async Task ExecuteScene(string sceneCode, PersonalizedSceneConfig? config, string constitutionCode, CancellationToken cancellationToken, Func>? 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 BuildControlParameterStates( IReadOnlyDictionary stateMap) { var result = new Dictionary(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 BuildScenePresets( IReadOnlyDictionary 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); }