This commit is contained in:
fuenmao
2026-09-28 16:36:41 +08:00
parent 973efa34cc
commit af596104e2
12 changed files with 513 additions and 96 deletions
@@ -1,5 +1,6 @@
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;
@@ -15,6 +16,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
{
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;
@@ -25,6 +27,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
public HealthHomeAssistantService(
HomeAssistantClient homeAssistant,
TcmMusicTransitionService musicTransition,
HomeAssistantLightTransitionService lightTransition,
HomeAssistantRegistryService registryService,
IOptions<HomeAssistantOptions> options,
@@ -34,6 +37,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IHttpContextAccessor httpContextAccessor)
{
_homeAssistant = homeAssistant;
_musicTransition = musicTransition;
_lightTransition = lightTransition;
_registryService = registryService;
_options = options.Value;
@@ -87,6 +91,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
PersonalizedSceneConfig? userConfig = null;
var constitutionCode = string.Empty;
try
{
var userId = GetCurrentUserId();
@@ -94,6 +99,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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);
@@ -105,7 +111,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var scenePresets = userConfig is null
? new List<HomeAssistantScenePresetItem>()
: BuildScenePresets(stateMap, userConfig);
: BuildScenePresets(stateMap, userConfig, constitutionCode);
return new HomeAssistantStatusOutput
{
@@ -200,6 +206,23 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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
@@ -230,85 +253,21 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
}
var targetOn = input.TurnOn;
bool? targetMuted = null;
var service = targetOn ? "turn_on" : "turn_off";
if (domain == "media_player")
{
// Xiaomi Sound Pro 的 media_player state 会长期保持 playing,不能用它
// 判断声音是否开启;is_volume_muted 才是该实体可稳定回读的控制状态。
var currentMutedText = currentState.GetStringAttribute("is_volume_muted");
var currentMuted = bool.TryParse(currentMutedText, out var parsedMuted) && parsedMuted;
targetMuted = !currentMuted;
targetOn = !targetMuted.Value;
service = "volume_mute";
}
var serviceData = new Dictionary<string, object?>
{
["entity_id"] = targetEntityId
};
if (targetMuted.HasValue)
{
serviceData["is_volume_muted"] = targetMuted.Value;
}
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
var resultState = domain == "media_player"
? currentState.State
: (targetOn ? "on" : "off");
bool? resultMuted = null;
if (domain == "media_player")
{
var stateConfirmed = false;
for (var attempt = 0; attempt < 5; attempt++)
{
await Task.Delay(300, cancellationToken);
var refreshedStates = await _homeAssistant.GetStatesAsync(cancellationToken);
var refreshedState = refreshedStates.FirstOrDefault(s =>
string.Equals(s.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase));
if (refreshedState is null)
{
continue;
}
resultState = refreshedState.State;
var refreshedMutedText = refreshedState.GetStringAttribute("is_volume_muted");
resultMuted = bool.TryParse(refreshedMutedText, out var refreshedMuted)
? refreshedMuted
: null;
stateConfirmed = resultMuted == targetMuted;
if (stateConfirmed)
{
break;
}
}
if (!stateConfirmed)
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = resultState,
IsMuted = resultMuted,
Message = $"音箱指令已发送,但静音状态未确认更新(当前:{(resultMuted.HasValue ? (resultMuted.Value ? "已静音" : "声音开启") : "未知")})"
};
}
}
return new HomeAssistantDeviceSwitchOutput
{
Success = true,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = resultState,
IsMuted = resultMuted,
Message = domain == "media_player"
? (targetMuted == true ? "音箱已静音" : "音箱声音已开启")
: $"已成功{(targetOn ? "开启" : "关闭")}设备"
State = targetOn ? "on" : "off",
Message = $"已成功{(targetOn ? "开启" : "关闭")}设备"
};
}
catch (Exception ex)
@@ -403,8 +362,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
case "volume":
targetEntityId = _options.Entities.Speaker;
domain = "media_player";
service = "volume_set";
serviceData["volume_level"] = ReadInt(input.Value, 0, 100, "音箱音量") / 100d;
service = "";
serviceData["volume"] = ReadInt(input.Value, 0, 100, "音箱音量");
break;
case "speaker_sleep_mode":
targetEntityId = _options.Entities.SpeakerSleepMode;
@@ -522,6 +481,17 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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("目标参数实体当前离线或不可用");
@@ -581,7 +551,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken);
MattressComfortService.ApplyTo(customConfig, mattressComfort);
return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => ExecuteSleepActions(states, results, customConfig, ct), cancellationToken);
return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) =>
ExecuteSleepActions(states, results, customConfig, tcmResult.ConstitutionCode, ct), cancellationToken);
}
[Authorize]
@@ -602,6 +573,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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);
@@ -654,6 +629,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results,
PersonalizedSceneConfig? customConfig,
string constitutionCode,
CancellationToken cancellationToken)
{
var entities = _options.Entities;
@@ -670,7 +646,6 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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 speakerVolume = customConfig?.SpeakerVolumePercent ?? 15;
var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel;
var mattressFirmness = MattressComfortService.FirmnessName(customConfig?.MattressLeftFirmness ?? 0);
@@ -738,12 +713,9 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
{
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
}
// 音箱声音与睡眠模式是两种不同能力。即使用户未启用睡眠模式,
// 一键舒睡也应取消静音并应用个性化音量。
await RunAction(states, results, "开启音箱声音", entities.Speaker, "media_player", "volume_mute",
new Dictionary<string, object?> { ["is_volume_muted"] = false }, cancellationToken);
await RunAction(states, results, "设置助眠音量", entities.Speaker, "media_player", "volume_set",
new Dictionary<string, object?> { ["volume_level"] = speakerVolume / 100d }, cancellationToken);
// 睡眠模式仍由 Home Assistant 控制;体质选曲和渐弱在 MiAirX 后台任务中进行。
await RunMiAirXAction(results, "播放体质助眠音乐并渐弱", entities.Speaker,
token => _musicTransition.StartForConstitutionAsync(constitutionCode, token), cancellationToken);
if (scene.EnableMattress)
{
@@ -820,8 +792,6 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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.Speaker, "media_player", "volume_mute",
new Dictionary<string, object?> { ["is_volume_muted"] = true }, 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",
@@ -835,6 +805,32 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
}
}
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,
@@ -1357,7 +1353,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
private List<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> stateMap,
PersonalizedSceneConfig? userConfig = null)
PersonalizedSceneConfig? userConfig,
string constitutionCode)
{
var defaultScene = _options.SleepScene;
var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity;
@@ -1420,15 +1417,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker))
{
var vol = userConfig?.SpeakerVolumePercent ?? 15;
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "Xiaomi Sound Pro",
Category = nameof(HealthDeviceCategory.Media),
EntityId = _options.Entities.Speaker,
Action = enableSpeaker ? "开启音箱声音和睡眠模式" : "开启音箱声音",
Detail = enableSpeaker ? $"取消静音 · 开启睡眠模式 · 音量 {vol}%" : $"取消静音 · 音量 {vol}%",
Parameters = new() { ["muted"] = false, ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = $"{vol}%" }
Action = enableSpeaker ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
Detail = enableSpeaker ? "体质曲目顺序播放 · 音量60%每5秒降1% · 开启睡眠模式" : "体质曲目顺序播放 · 音量60%每5秒降1%",
Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "60%→0%" }
});
}
@@ -1455,6 +1451,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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);