423 lines
20 KiB
C#
423 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 EnvironmentAdviceService _environmentAdvice;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly DeviceModuleRegistry _modules;
|
|
private readonly DeviceParameterController _parameters;
|
|
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,
|
|
EnvironmentAdviceService environmentAdvice,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
DeviceModuleRegistry modules,
|
|
DeviceParameterController parameters,
|
|
HealthSceneCoordinator scenes,
|
|
HomeAssistantDeviceCatalog catalog,
|
|
ILogger<HealthHomeAssistantService> logger)
|
|
{
|
|
_homeAssistant = homeAssistant;
|
|
_musicTransition = musicTransition;
|
|
_lightTransition = lightTransition;
|
|
_registryService = registryService;
|
|
_options = options.Value;
|
|
_executionRepository = executionRepository;
|
|
_profileRepository = profileRepository;
|
|
_environmentAdvice = environmentAdvice;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_modules = modules;
|
|
_parameters = parameters;
|
|
_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.CancelPrimaryAsync(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 = _environmentAdvice.Get(profile, stateMap);
|
|
EnvironmentAdviceService.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 command = await _parameters.ExecuteAsync(targetDevice, stateMap, parameterType, input.Value,
|
|
input.EntityId, 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 environmentAdvice = await _environmentAdvice.GetAsync(profile, token);
|
|
EnvironmentAdviceService.ApplyTo(config, environmentAdvice);
|
|
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);
|
|
}
|