111
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user