1475 lines
71 KiB
C#
1475 lines
71 KiB
C#
using Evergreen.Health.Application.Health.Entities;
|
||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||
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 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;
|
||
|
||
public HealthHomeAssistantService(
|
||
HomeAssistantClient homeAssistant,
|
||
TcmMusicTransitionService musicTransition,
|
||
HomeAssistantLightTransitionService lightTransition,
|
||
HomeAssistantRegistryService registryService,
|
||
IOptions<HomeAssistantOptions> options,
|
||
SqlSugarRepository<HealthSceneExecution> executionRepository,
|
||
SqlSugarRepository<HealthProfile> profileRepository,
|
||
MattressComfortService mattressComfort,
|
||
IHttpContextAccessor httpContextAccessor)
|
||
{
|
||
_homeAssistant = homeAssistant;
|
||
_musicTransition = musicTransition;
|
||
_lightTransition = lightTransition;
|
||
_registryService = registryService;
|
||
_options = options.Value;
|
||
_executionRepository = executionRepository;
|
||
_profileRepository = profileRepository;
|
||
_mattressComfort = mattressComfort;
|
||
_httpContextAccessor = httpContextAccessor;
|
||
}
|
||
|
||
[Authorize]
|
||
[HttpGet("/api/health/home-assistant/lights/transition")]
|
||
public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.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) { 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) { 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) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
|
||
}
|
||
|
||
[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;
|
||
var constitutionCode = string.Empty;
|
||
try
|
||
{
|
||
var userId = GetCurrentUserId();
|
||
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 = await _mattressComfort.GetAsync(profile, cancellationToken);
|
||
MattressComfortService.ApplyTo(userConfig, comfort);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
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)
|
||
{
|
||
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 = [],
|
||
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 (domain == "media_player")
|
||
{
|
||
if (!string.Equals(targetEntityId, _options.Entities.Speaker, StringComparison.OrdinalIgnoreCase))
|
||
throw new InvalidOperationException("该音箱不是已配置的助眠音箱");
|
||
if (input.TurnOn)
|
||
await _musicTransition.PlayTestMusicAsync(cancellationToken);
|
||
else
|
||
await _musicTransition.StopMusicAsync(cancellationToken);
|
||
return new HomeAssistantDeviceSwitchOutput
|
||
{
|
||
Success = true,
|
||
DeviceId = input.DeviceId,
|
||
EntityId = targetEntityId,
|
||
Message = input.TurnOn ? "播放指令已发送" : "停止指令已发送"
|
||
};
|
||
}
|
||
|
||
if (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState))
|
||
{
|
||
return new HomeAssistantDeviceSwitchOutput
|
||
{
|
||
Success = false,
|
||
DeviceId = input.DeviceId,
|
||
EntityId = targetEntityId,
|
||
Message = "目标设备当前处于离线状态"
|
||
};
|
||
}
|
||
|
||
if (domain == "light" && string.Equals(targetEntityId, _options.Entities.AmbientLight, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (input.TurnOn)
|
||
{
|
||
var transition = await _lightTransition.StartSleepAsync(2700, cancellationToken);
|
||
return new HomeAssistantDeviceSwitchOutput
|
||
{
|
||
Success = transition.Success,
|
||
DeviceId = input.DeviceId,
|
||
EntityId = targetEntityId,
|
||
State = "on",
|
||
Message = transition.Message
|
||
};
|
||
}
|
||
|
||
await _lightTransition.CancelAsync(cancellationToken);
|
||
}
|
||
|
||
var targetOn = input.TurnOn;
|
||
var service = targetOn ? "turn_on" : "turn_off";
|
||
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 = targetOn ? "on" : "off",
|
||
Message = $"已成功{(targetOn ? "开启" : "关闭")}设备"
|
||
};
|
||
}
|
||
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 = "";
|
||
serviceData["volume"] = ReadInt(input.Value, 0, 100, "音箱音量");
|
||
break;
|
||
case "speaker_sleep_mode":
|
||
targetEntityId = _options.Entities.SpeakerSleepMode;
|
||
domain = "switch";
|
||
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
|
||
break;
|
||
case "fragrance_duration":
|
||
targetEntityId = _options.Entities.FragranceDuration;
|
||
domain = "number";
|
||
service = "set_value";
|
||
serviceData["value"] = ReadInt(input.Value, 2, 6, "出香时长");
|
||
break;
|
||
case "fragrance_interval":
|
||
targetEntityId = _options.Entities.FragranceInterval;
|
||
domain = "number";
|
||
service = "set_value";
|
||
var fragranceInterval = ReadInt(input.Value, 10, 20, "出香间隔");
|
||
if (fragranceInterval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟");
|
||
serviceData["value"] = fragranceInterval;
|
||
break;
|
||
case "fragrance_light":
|
||
targetEntityId = _options.Entities.FragranceLight;
|
||
domain = "light";
|
||
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
|
||
break;
|
||
case "fragrance_quick_release":
|
||
targetEntityId = _options.Entities.FragranceQuickRelease;
|
||
domain = "button";
|
||
service = "press";
|
||
break;
|
||
case "fragrance_auto_release":
|
||
targetEntityId = _options.Entities.FragranceAutoRelease;
|
||
domain = "switch";
|
||
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
|
||
break;
|
||
case "fragrance_auto_light":
|
||
targetEntityId = _options.Entities.FragranceAutoLight;
|
||
domain = "switch";
|
||
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
|
||
break;
|
||
case "fragrance_light_auto_off":
|
||
targetEntityId = _options.Entities.FragranceLightAutoOff;
|
||
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 "mattress_massage_left_part":
|
||
targetEntityId = _options.Entities.MattressLeftMassagePart;
|
||
domain = "select";
|
||
service = "select_option";
|
||
serviceData["option"] = ReadMassagePart(input.Value);
|
||
break;
|
||
case "mattress_massage_right_part":
|
||
targetEntityId = _options.Entities.MattressRightMassagePart;
|
||
domain = "select";
|
||
service = "select_option";
|
||
serviceData["option"] = ReadMassagePart(input.Value);
|
||
break;
|
||
case "mattress_massage_level":
|
||
targetEntityId = _options.Entities.MattressMassageLevel;
|
||
domain = "select";
|
||
service = "select_option";
|
||
serviceData["option"] = ReadMassageLevel(input.Value);
|
||
break;
|
||
case string firmnessType when firmnessType.StartsWith("mattress_firmness_", StringComparison.Ordinal):
|
||
var zone = firmnessType["mattress_firmness_".Length..];
|
||
if (!_options.Entities.MattressFirmness.TryGetValue(zone, out targetEntityId))
|
||
throw new InvalidOperationException("不支持该床垫分区");
|
||
domain = "select";
|
||
service = "select_option";
|
||
serviceData["option"] = ReadFirmness(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 (parameterType == "volume")
|
||
{
|
||
await _musicTransition.SetManualVolumeAsync((int)serviceData["volume"]!, cancellationToken);
|
||
return new HomeAssistantSetParameterOutput
|
||
{
|
||
Success = true,
|
||
DeviceId = input.DeviceId,
|
||
ParameterType = input.ParameterType,
|
||
Message = "音箱音量指令已发送"
|
||
};
|
||
}
|
||
if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState))
|
||
throw new InvalidOperationException("目标参数实体当前离线或不可用");
|
||
|
||
if (parameterType is "brightness" or "color_temp")
|
||
await _lightTransition.CancelAsync(cancellationToken);
|
||
|
||
serviceData["entity_id"] = targetEntityId;
|
||
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
|
||
|
||
var successMessage = parameterType switch
|
||
{
|
||
"fragrance_quick_release" => "快速出香指令已发送",
|
||
"mattress_left_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
|
||
"左侧承托模式已启动;床垫共用气泵,右侧会自动停止",
|
||
"mattress_right_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
|
||
"右侧承托模式已启动;床垫共用气泵,左侧会自动停止",
|
||
_ => "参数调节成功"
|
||
};
|
||
|
||
return new HomeAssistantSetParameterOutput
|
||
{
|
||
Success = true,
|
||
DeviceId = input.DeviceId,
|
||
ParameterType = input.ParameterType ?? "",
|
||
Message = successMessage
|
||
};
|
||
}
|
||
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;
|
||
var userId = GetCurrentUserId();
|
||
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
|
||
var tcmResult = TcmConstitutionRules.Read(profile);
|
||
if (!tcmResult.HasCompleted)
|
||
{
|
||
return new HomeAssistantSceneOutput
|
||
{
|
||
SceneCode = "sleep", SceneName = "舒睡模式", Success = false,
|
||
Status = "AssessmentRequired", Message = "请先完成中医四诊,生成体质倾向后再一键启动"
|
||
};
|
||
}
|
||
customConfig = tcmResult.SceneConfig;
|
||
var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken);
|
||
MattressComfortService.ApplyTo(customConfig, mattressComfort);
|
||
|
||
return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) =>
|
||
ExecuteSleepActions(states, results, customConfig, tcmResult.ConstitutionCode, 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
|
||
{
|
||
// 关闭场景先停止音乐;即使 HA 暂时不可用,也要尽量避免音箱继续播放。
|
||
if (sceneCode == "all-off")
|
||
await RunMiAirXAction(actions, "停止助眠音乐", _options.Entities.Speaker,
|
||
_musicTransition.StopMusicAsync, cancellationToken);
|
||
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,
|
||
string constitutionCode,
|
||
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 fragranceLevel = Math.Clamp(customConfig?.FragranceLevel ?? 1, 1, 3);
|
||
var fragranceDuration = fragranceLevel switch { 1 => 2, 2 => 4, _ => 6 };
|
||
var fragranceInterval = fragranceLevel switch { 1 => 20, 2 => 15, _ => 10 };
|
||
var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode;
|
||
var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel;
|
||
var mattressFirmness = MattressComfortService.FirmnessName(customConfig?.MattressLeftFirmness ?? 0);
|
||
|
||
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.FragranceDuration, "number", "set_value",
|
||
new Dictionary<string, object?> { ["value"] = fragranceDuration }, cancellationToken);
|
||
await RunAction(states, results, "设置出香间隔", entities.FragranceInterval, "number", "set_value",
|
||
new Dictionary<string, object?> { ["value"] = fragranceInterval }, cancellationToken);
|
||
await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on",
|
||
new Dictionary<string, object?> { ["brightness_pct"] = lightBrightness }, cancellationToken);
|
||
}
|
||
else
|
||
{
|
||
await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken);
|
||
await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken);
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(entities.AmbientLight) ||
|
||
!states.TryGetValue(entities.AmbientLight, out var lightState) || !IsAvailable(lightState))
|
||
{
|
||
results.Add(new HomeAssistantActionResult
|
||
{
|
||
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
|
||
Status = "Skipped", Message = "设备未配置或离线"
|
||
});
|
||
}
|
||
else
|
||
{
|
||
try
|
||
{
|
||
await _lightTransition.StartSleepAsync(lightColorTemp, cancellationToken);
|
||
results.Add(new HomeAssistantActionResult
|
||
{
|
||
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
|
||
Status = "Succeeded", Message = "已从50%开始,每5秒降低1%,最终关灯"
|
||
});
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
results.Add(new HomeAssistantActionResult
|
||
{
|
||
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
|
||
Status = "Failed", Message = exception.Message
|
||
});
|
||
}
|
||
}
|
||
|
||
if (enableSpeakerSleepMode)
|
||
{
|
||
await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken);
|
||
}
|
||
else
|
||
{
|
||
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
|
||
}
|
||
// 睡眠模式仍由 Home Assistant 控制;体质选曲和渐弱在 MiAirX 后台任务中进行。
|
||
await RunMiAirXAction(results, "播放体质助眠音乐并渐弱", entities.Speaker,
|
||
token => _musicTransition.StartForConstitutionAsync(constitutionCode, token), 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);
|
||
// 左右承托模式共用气泵,一键场景不同时启动两侧模式。
|
||
if (mattressFirmness == null)
|
||
{
|
||
results.Add(new HomeAssistantActionResult
|
||
{
|
||
Name = "按 BMI 调节床垫软硬度", Status = "Skipped",
|
||
Message = "健康档案缺少身高或体重,未自动修改软硬度"
|
||
});
|
||
}
|
||
else
|
||
{
|
||
foreach (var (zone, entityId) in entities.MattressFirmness)
|
||
{
|
||
await RunAction(states, results, $"设置床垫{MattressZoneName(zone)}软硬度",
|
||
entityId, "select", "select_option",
|
||
new Dictionary<string, object?> { ["option"] = mattressFirmness }, cancellationToken);
|
||
}
|
||
}
|
||
// 软硬度调节可能占用床垫气泵;等这些指令完成后,最后启动按摩。
|
||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||
await RunAction(states, results, "停止床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option",
|
||
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
|
||
await RunAction(states, results, "设置床垫轻柔按摩", entities.MattressMassageLevel, "select", "select_option",
|
||
new Dictionary<string, object?> { ["option"] = "轻柔" }, cancellationToken);
|
||
const string rightMassageActionName = "开启床垫右侧全身按摩";
|
||
await RunAction(states, results, rightMassageActionName, entities.MattressRightMassagePart, "select", "select_option",
|
||
new Dictionary<string, object?> { ["option"] = "全身" }, cancellationToken);
|
||
|
||
// HA 接受指令不等于设备保持该状态;此前曾在两秒后自动复位为“无”。
|
||
var rightMassageAction = results.LastOrDefault(item => item.Name == rightMassageActionName);
|
||
if (rightMassageAction?.Status == "Succeeded")
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||
try
|
||
{
|
||
var currentStates = await _homeAssistant.GetStatesAsync(cancellationToken);
|
||
var currentRightMassage = currentStates.FirstOrDefault(item =>
|
||
string.Equals(item.EntityId, entities.MattressRightMassagePart, StringComparison.OrdinalIgnoreCase));
|
||
if (currentRightMassage?.State != "全身")
|
||
{
|
||
rightMassageAction.Status = "Failed";
|
||
rightMassageAction.Message = $"指令已发送,但右侧按摩未保持全身模式(当前:{currentRightMassage?.State ?? "未读取到状态"})";
|
||
}
|
||
else
|
||
{
|
||
rightMassageAction.Message = "已确认右侧全身按摩保持开启";
|
||
}
|
||
}
|
||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||
{
|
||
rightMassageAction.Status = "Failed";
|
||
rightMassageAction.Message = $"指令已发送,但无法确认右侧按摩状态:{exception.Message}";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task ExecuteAllOffActions(
|
||
IReadOnlyDictionary<string, HomeAssistantState> states,
|
||
List<HomeAssistantActionResult> results,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var entities = _options.Entities;
|
||
await _lightTransition.CancelAsync(cancellationToken);
|
||
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);
|
||
await RunAction(states, results, "关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option",
|
||
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
|
||
await RunAction(states, results, "关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option",
|
||
new Dictionary<string, object?> { ["option"] = "无" }, 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 static async Task<bool> RunMiAirXAction(
|
||
List<HomeAssistantActionResult> results,
|
||
string name,
|
||
string entityId,
|
||
Func<CancellationToken, Task> command,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
await command(cancellationToken);
|
||
results.Add(new HomeAssistantActionResult
|
||
{
|
||
Name = name, EntityId = entityId, Status = "Succeeded", Message = "MiAirX 指令已发送"
|
||
});
|
||
return true;
|
||
}
|
||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||
{
|
||
results.Add(new HomeAssistantActionResult
|
||
{
|
||
Name = name, EntityId = entityId, Status = "Failed", Message = exception.Message
|
||
});
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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, entities.FragranceDuration, entities.FragranceInterval,
|
||
entities.FragranceQuickRelease, entities.FragranceAutoRelease, entities.FragranceAutoLight, entities.FragranceLightAutoOff);
|
||
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 (!string.IsNullOrWhiteSpace(entities.Speaker))
|
||
sceneEntityIds.Add(entities.Speaker);
|
||
else 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,
|
||
["fragrance_duration"] = _options.Entities.FragranceDuration,
|
||
["fragrance_interval"] = _options.Entities.FragranceInterval,
|
||
["fragrance_light"] = _options.Entities.FragranceLight,
|
||
["fragrance_quick_release"] = _options.Entities.FragranceQuickRelease,
|
||
["fragrance_auto_release"] = _options.Entities.FragranceAutoRelease,
|
||
["fragrance_auto_light"] = _options.Entities.FragranceAutoLight,
|
||
["fragrance_light_auto_off"] = _options.Entities.FragranceLightAutoOff,
|
||
["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel,
|
||
["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel,
|
||
["mattress_left_mode"] = _options.Entities.MattressLeftMode,
|
||
["mattress_right_mode"] = _options.Entities.MattressRightMode,
|
||
["mattress_massage_left_part"] = _options.Entities.MattressLeftMassagePart,
|
||
["mattress_massage_right_part"] = _options.Entities.MattressRightMassagePart,
|
||
["mattress_massage_level"] = _options.Entities.MattressMassageLevel
|
||
};
|
||
foreach (var (zone, entityId) in _options.Entities.MattressFirmness)
|
||
configured[$"mattress_firmness_{zone}"] = entityId;
|
||
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 static string ReadFirmness(object? value)
|
||
{
|
||
var firmness = ReadString(value);
|
||
if (firmness is not ("最软" or "很软" or "稍软" or "稍硬" or "很硬" or "最硬"))
|
||
throw new InvalidOperationException("床垫软硬度选项无效");
|
||
return firmness;
|
||
}
|
||
|
||
private static string MattressZoneName(string zone) => zone switch
|
||
{
|
||
"left_shoulder" => "左肩", "right_shoulder" => "右肩",
|
||
"left_back" => "左背", "right_back" => "右背",
|
||
"left_waist" => "左腰", "right_waist" => "右腰",
|
||
"left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎",
|
||
"left_hip" => "左臀", "right_hip" => "右臀",
|
||
"left_leg" => "左腿", "right_leg" => "右腿",
|
||
_ => zone
|
||
};
|
||
|
||
private static string ReadMassagePart(object? value)
|
||
{
|
||
var part = ReadString(value);
|
||
if (part is not ("无" or "背部" or "腰部" or "牵引" or "腿部" or "全身"))
|
||
throw new InvalidOperationException("按摩部位选项无效");
|
||
return part;
|
||
}
|
||
|
||
private static string ReadMassageLevel(object? value)
|
||
{
|
||
var level = ReadString(value);
|
||
if (level is not ("轻柔" or "强劲"))
|
||
throw new InvalidOperationException("按摩力度选项无效");
|
||
return level;
|
||
}
|
||
|
||
private List<HomeAssistantScenePresetItem> BuildScenePresets(
|
||
IReadOnlyDictionary<string, HomeAssistantState> stateMap,
|
||
PersonalizedSceneConfig? userConfig,
|
||
string constitutionCode)
|
||
{
|
||
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 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 = $"从50%开始,每5秒降低1%,最终关灯 · 色温 {lightColorTemp}K",
|
||
Parameters = new()
|
||
{
|
||
["brightness"] = "50%→关闭",
|
||
["color_temp"] = $"{lightColorTemp}K"
|
||
}
|
||
});
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker))
|
||
{
|
||
presets.Add(new HomeAssistantScenePresetItem
|
||
{
|
||
DeviceName = "Xiaomi Sound Pro",
|
||
Category = nameof(HealthDeviceCategory.Media),
|
||
EntityId = _options.Entities.Speaker,
|
||
Action = enableSpeaker ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
|
||
Detail = enableSpeaker ? "体质曲目顺序播放 · 音量60%每5秒降1% · 开启睡眠模式" : "体质曲目顺序播放 · 音量60%每5秒降1%",
|
||
Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "60%→0%" }
|
||
});
|
||
}
|
||
|
||
if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel))
|
||
{
|
||
var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel;
|
||
var firmness = MattressComfortService.FirmnessName(userConfig?.MattressLeftFirmness ?? 0);
|
||
presets.Add(new HomeAssistantScenePresetItem
|
||
{
|
||
DeviceName = "8H AI智能好睡眠床垫X",
|
||
Category = nameof(HealthDeviceCategory.Sleep),
|
||
EntityId = _options.Entities.MattressLeftHeatingLevel,
|
||
Action = "按天气和 BMI 调节床垫并开启右侧按摩",
|
||
Detail = $"左右加热 {heatingLevel} 档 · 软硬度{firmness ?? "待填写 BMI"} · 右侧全身轻柔按摩",
|
||
Parameters = new()
|
||
{
|
||
["heating_level"] = heatingLevel,
|
||
["firmness"] = firmness ?? "未自动调节",
|
||
["right_massage_part"] = "全身",
|
||
["massage_level"] = "轻柔"
|
||
}
|
||
});
|
||
}
|
||
|
||
foreach (var preset in presets)
|
||
{
|
||
if (preset.Category == nameof(HealthDeviceCategory.Media))
|
||
{
|
||
preset.Available = _musicTransition.HasPlayableTracks(constitutionCode);
|
||
preset.AvailabilityMessage = preset.Available
|
||
? "体质音乐已配置,执行时验证音箱"
|
||
: "该体质的 OSS 音乐 URL 尚未配置";
|
||
continue;
|
||
}
|
||
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 != "unavailable" &&
|
||
(state.State != "unknown" || state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase));
|
||
}
|