diff --git a/Evergreen.Health.Application/Configuration/TcmMusic.json b/Evergreen.Health.Application/Configuration/TcmMusic.json index 4874e31..893cca7 100644 --- a/Evergreen.Health.Application/Configuration/TcmMusic.json +++ b/Evergreen.Health.Application/Configuration/TcmMusic.json @@ -1,6 +1,6 @@ { "TcmMusic": { - "StartVolume": 60, + "StartVolume": 50, "FadeStepSeconds": 5, "Tracks": { "balanced": [ diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs index 156531b..da882ca 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs @@ -155,7 +155,20 @@ public sealed class HomeAssistantLightTransitionService : IHostedService var light = await ReadAvailableLightAsync(cancellationToken); if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase)) { - SetStatus(false, false, mode, 0, mode == "wake" ? WakeTargetPercent : 0, "灯已从其他位置关闭,渐变已停止"); + if (mode == "sleep") + { + try + { + await StopMattressMassageAsync(cancellationToken); + SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败"); + SetStatus(false, false, mode, 0, 0, exception.Message); + } + } + else SetStatus(false, false, mode, 0, WakeTargetPercent, "灯已从其他位置关闭,渐变已停止"); return; } @@ -178,7 +191,17 @@ public sealed class HomeAssistantLightTransitionService : IHostedService { ["entity_id"] = LightEntityId }, cancellationToken); - SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭"); + SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩"); + try + { + await StopMattressMassageAsync(cancellationToken); + SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败"); + SetStatus(false, false, mode, 0, 0, exception.Message); + } return; } @@ -204,6 +227,43 @@ public sealed class HomeAssistantLightTransitionService : IHostedService } } + private async Task StopMattressMassageAsync(CancellationToken cancellationToken) + { + var massageEntities = new[] + { + _options.Entities.MattressLeftMassagePart, + _options.Entities.MattressRightMassagePart + }.Where(entityId => !string.IsNullOrWhiteSpace(entityId)).Distinct().ToArray(); + var failures = new List(); + foreach (var entityId in massageEntities) + { + try + { + var confirmed = false; + for (var attempt = 0; attempt < 2 && !confirmed; attempt++) + { + await _homeAssistant.CallServiceAsync("select", "select_option", new Dictionary + { + ["entity_id"] = entityId, + ["option"] = "无" + }, cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + var current = (await _homeAssistant.GetStatesAsync(cancellationToken)) + .FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); + confirmed = current?.State == "无"; + } + if (!confirmed) failures.Add(entityId); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "灯光熄灭后未能关闭床垫按摩 {EntityId}", entityId); + failures.Add(entityId); + } + } + if (failures.Count > 0) + throw new InvalidOperationException($"灯光已关闭,但床垫按摩未确认关闭:{string.Join("、", failures)}"); + } + private async Task StopCurrentAsync() { if (_activeCancellation is null) return; diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs index 8156908..41a57bd 100644 --- a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs @@ -4,7 +4,7 @@ namespace Evergreen.Health.Application.Health.Integrations.MiAirX; public sealed class TcmMusicOptions : IConfigurableOptions { - public int StartVolume { get; set; } = 60; + public int StartVolume { get; set; } = 50; public int FadeStepSeconds { get; set; } = 5; public Dictionary> Tracks { get; set; } = new(); } diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs index 4c23ce6..86b513a 100644 --- a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs @@ -12,6 +12,8 @@ public sealed class TcmMusicTransitionService : IHostedService private readonly TcmMusicOptions _options; private readonly ILogger _logger; private readonly SemaphoreSlim _operationLock = new(1, 1); + private readonly object _statusLock = new(); + private TcmMusicTransitionOutput _status = new(); private CancellationTokenSource? _activeCancellation; private Task? _activeTask; @@ -28,6 +30,18 @@ public sealed class TcmMusicTransitionService : IHostedService public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public TcmMusicTransitionOutput GetStatus() + { + lock (_statusLock) + return new TcmMusicTransitionOutput + { + Running = _status.Running, + HasVolume = _status.HasVolume, + VolumePercent = _status.VolumePercent, + Message = _status.Message + }; + } + public async Task StopAsync(CancellationToken cancellationToken) { try { await StopMusicAsync(cancellationToken); } @@ -56,6 +70,7 @@ public sealed class TcmMusicTransitionService : IHostedService await CancelCurrentLoopAsync(); await _miAirX.SetVolumeAsync(_options.StartVolume, cancellationToken); await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken); + UpdateStatus(true, _options.StartVolume, "体质音乐正在自动渐弱"); _activeCancellation = new CancellationTokenSource(); _activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token); } @@ -69,6 +84,7 @@ public sealed class TcmMusicTransitionService : IHostedService { await CancelCurrentLoopAsync(); await _miAirX.PlayAsync(cancellationToken); + UpdateStatus(false, null, "正在播放测试音乐"); } finally { _operationLock.Release(); } } @@ -81,6 +97,7 @@ public sealed class TcmMusicTransitionService : IHostedService // 手动调音量接管当前播放,避免下一次自动渐弱立刻覆盖用户选择。 await CancelCurrentLoopAsync(); await _miAirX.SetVolumeAsync(volume, cancellationToken); + UpdateStatus(false, volume, "音量已手动设置"); } finally { _operationLock.Release(); } } @@ -92,6 +109,7 @@ public sealed class TcmMusicTransitionService : IHostedService { await CancelCurrentLoopAsync(); await _miAirX.StopAsync(cancellationToken); + UpdateStatus(false, null, "音乐已停止"); } finally { _operationLock.Release(); } } @@ -116,12 +134,14 @@ public sealed class TcmMusicTransitionService : IHostedService { currentVolume--; await _miAirX.SetVolumeAsync(currentVolume, cancellationToken); + UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱"); nextFadeSecond += _options.FadeStepSeconds; } if (currentVolume == 0) { await _miAirX.StopAsync(cancellationToken); + UpdateStatus(false, 0, "音量已降至 0,音乐已停止"); _logger.LogInformation("体质 {ConstitutionCode} 的助眠音乐已渐弱至 0 并停止", constitutionCode); return; } @@ -140,6 +160,7 @@ public sealed class TcmMusicTransitionService : IHostedService _logger.LogWarning(exception, "体质音乐渐弱或续播中断"); try { await _miAirX.StopAsync(CancellationToken.None); } catch (Exception stopException) { _logger.LogWarning(stopException, "体质音乐中断后停止音箱失败"); } + UpdateStatus(false, null, "自动渐弱中断,请查看后端日志"); } } @@ -153,6 +174,21 @@ public sealed class TcmMusicTransitionService : IHostedService cancellation.Cancel(); try { if (task is not null) await task; } finally { cancellation.Dispose(); } + UpdateStatus(false, null, "自动渐弱已停止"); + } + + private void UpdateStatus(bool running, int? volume, string message) + { + lock (_statusLock) + { + _status.Running = running; + if (volume.HasValue) + { + _status.HasVolume = true; + _status.VolumePercent = volume.Value; + } + _status.Message = message; + } } private static bool IsValidTrack(TcmMusicTrackOptions track) @@ -160,3 +196,11 @@ public sealed class TcmMusicTransitionService : IHostedService Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; } + +public sealed class TcmMusicTransitionOutput +{ + public bool Running { get; set; } + public bool HasVolume { get; set; } + public int VolumePercent { get; set; } + public string Message { get; set; } = string.Empty; +} diff --git a/Evergreen.Health.Application/Health/Models/HealthModels.cs b/Evergreen.Health.Application/Health/Models/HealthModels.cs index 19e3dc0..576a1b0 100644 --- a/Evergreen.Health.Application/Health/Models/HealthModels.cs +++ b/Evergreen.Health.Application/Health/Models/HealthModels.cs @@ -160,7 +160,7 @@ public sealed class PersonalizedSceneConfig public bool EnableSpeakerSleepMode { get; set; } = true; public string SpeakerAudioMode { get; set; } = "助眠白噪音"; - public int SpeakerVolumePercent { get; set; } = 15; + public int SpeakerVolumePercent { get; set; } = 50; } public sealed class HealthAssessmentAnswers diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index ffb61c8..76f84d0 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -6,6 +6,7 @@ using Furion.DatabaseAccessor; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using SqlSugar; +using System.Globalization; using System.Security.Claims; using System.Text.Json; @@ -51,6 +52,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie [HttpGet("/api/health/home-assistant/lights/transition")] public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.GetStatus(); + [Authorize] + [HttpGet("/api/health/home-assistant/music/transition")] + public TcmMusicTransitionOutput GetMusicTransitionStatus() => _musicTransition.GetStatus(); + [Authorize] [HttpPost("/api/health/home-assistant/lights/sleep")] public async Task StartSleepLight(CancellationToken cancellationToken) @@ -500,6 +505,9 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie serviceData["entity_id"] = targetEntityId; await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); + if (parameterType is "mattress_left_mode" or "mattress_right_mode") + await VerifyMattressModeAsync(targetEntityId, serviceData["option"]?.ToString() ?? "", serviceData, + cancellationToken); var successMessage = parameterType switch { @@ -584,12 +592,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var successCount = actions.Count(item => item.Status == "Succeeded"); var failedCount = actions.Count(item => item.Status == "Failed"); var skippedCount = actions.Count(item => item.Status == "Skipped"); + var failedNames = string.Join("、", actions.Where(item => item.Status == "Failed") + .Select(item => item.Name).Distinct().Take(3)); 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} 项跳过", + "Partial" => $"场景部分执行成功:{successCount} 项成功,{failedCount} 项失败,{skippedCount} 项跳过;未完成:{failedNames}", _ => "当前没有可执行的在线设备" }; } @@ -723,7 +733,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value", new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); - // 左右承托模式共用气泵,一键场景不同时启动两侧模式。 + // 左右承托模式共用气泵;一键场景只实际启动左侧,右侧留作页面预选。 if (mattressFirmness == null) { results.Add(new HomeAssistantActionResult @@ -741,6 +751,25 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie new Dictionary { ["option"] = mattressFirmness }, cancellationToken); } } + const string leftModeActionName = "开启床垫左侧智能适配"; + await RunAction(states, results, leftModeActionName, entities.MattressLeftMode, "select", "select_option", + new Dictionary { ["option"] = "智能适配" }, cancellationToken); + var leftModeAction = results.LastOrDefault(item => item.Name == leftModeActionName); + if (leftModeAction?.Status == "Succeeded") + { + try + { + await VerifyMattressModeAsync(entities.MattressLeftMode, "智能适配", + new Dictionary { ["entity_id"] = entities.MattressLeftMode, ["option"] = "智能适配" }, + cancellationToken); + leftModeAction.Message = "已确认左侧智能适配运行,右侧保持待机预选"; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + leftModeAction.Status = "Failed"; + leftModeAction.Message = exception.Message; + } + } // 软硬度调节可能占用床垫气泵;等这些指令完成后,最后启动按摩。 await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); await RunAction(states, results, "停止床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", @@ -787,21 +816,119 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie { 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 { ["option"] = "无" }, cancellationToken); - await RunAction(states, results, "关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", - new Dictionary { ["option"] = "无" }, cancellationToken); + var commands = new List + { + new("关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", "off", null), + new("关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", "off", null), + new("关闭香氛灯", entities.FragranceLight, "light", "turn_off", "off", null), + new("关闭感应夜灯", entities.FragranceAutoLight, "switch", "turn_off", "off", null), + new("关闭人来增香", entities.FragranceAutoRelease, "switch", "turn_off", "off", null), + new("关闭助眠灯光", entities.AmbientLight, "light", "turn_off", "off", null), + new("关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", "off", null), + new("关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option", "无", + new Dictionary { ["option"] = "无" }), + new("关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", "无", + new Dictionary { ["option"] = "无" }) + }; if (_options.SleepScene.EnableMattress) { - await RunAction(states, results, "关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = 0 }, cancellationToken); - await RunAction(states, results, "关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = 0 }, cancellationToken); + commands.Add(new("关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", "0", + new Dictionary { ["value"] = 0 })); + commands.Add(new("关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", "0", + new Dictionary { ["value"] = 0 })); + } + + foreach (var command in commands) + await RunAction(states, results, command.Name, command.EntityId, command.Domain, command.Service, + command.ExtraData, cancellationToken); + await VerifyOffActionsAsync(commands, results, cancellationToken); + } + + private sealed record OffCommand(string Name, string EntityId, string Domain, string Service, + string ExpectedState, IReadOnlyDictionary? ExtraData); + + private async Task VerifyOffActionsAsync( + IReadOnlyList commands, + List results, + CancellationToken cancellationToken) + { + var pending = commands.Where(item => !string.IsNullOrWhiteSpace(item.EntityId)).ToList(); + for (var round = 0; round < 3 && pending.Count > 0; round++) + { + // HA 接受指令并不代表设备已变更,尤其是云端控制的香氛机。 + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + IReadOnlyDictionary freshStates; + try + { + freshStates = (await _homeAssistant.GetStatesAsync(cancellationToken)) + .ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + foreach (var command in pending) + { + var action = results.Last(item => item.Name == command.Name); + action.Status = "Failed"; + action.Message = $"关闭指令已发送,但无法回读确认:{exception.Message}"; + } + return; + } + + foreach (var command in pending.ToArray()) + { + var action = results.Last(item => item.Name == command.Name); + freshStates.TryGetValue(command.EntityId, out var state); + if (state is not null && IsOffState(state.State, command.ExpectedState)) + { + action.Status = "Succeeded"; + action.Message = round == 0 ? "已回读确认关闭" : "重试后已回读确认关闭"; + pending.Remove(command); + continue; + } + + if (round == 2) + { + action.Status = "Failed"; + action.Message = $"关闭后仍未确认目标状态(当前:{state?.State ?? "未读取到"})"; + pending.Remove(command); + continue; + } + + if (state is null || !IsAvailable(state)) continue; + try + { + var data = new Dictionary { ["entity_id"] = command.EntityId }; + if (command.ExtraData is not null) + foreach (var item in command.ExtraData) data[item.Key] = item.Value; + await _homeAssistant.CallServiceAsync(command.Domain, command.Service, data, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + action.Status = "Failed"; + action.Message = $"重试关闭失败:{exception.Message}"; + } + } + } + } + + private static bool IsOffState(string actual, string expected) + => expected == "0" + ? decimal.TryParse(actual, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) && value == 0 + : string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); + + private async Task VerifyMattressModeAsync(string entityId, string expected, + IReadOnlyDictionary serviceData, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 3; attempt++) + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + var actual = (await _homeAssistant.GetStatesAsync(cancellationToken)) + .FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.State; + if (string.Equals(actual, expected, StringComparison.Ordinal)) return; + if (attempt < 2) + await _homeAssistant.CallServiceAsync("select", "select_option", serviceData, cancellationToken); + else + throw new InvalidOperationException($"床垫承托模式未确认切换到{expected}(当前:{actual ?? "未读取到"})"); } } @@ -1423,8 +1550,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie 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%" } + Detail = enableSpeaker ? "体质曲目循环播放 · 音量50%每5秒降1% · 开启睡眠模式" : "体质曲目循环播放 · 音量50%每5秒降1%", + Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "50%→0%" } }); } diff --git a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs index 440fb61..9a7649c 100644 --- a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs @@ -139,7 +139,12 @@ public static class TcmConstitutionRules try { var result = JsonSerializer.Deserialize(profile.TcmResultJson) ?? new TcmAssessmentResult(); - if (result.HasCompleted) result.MusicRecommendation = MusicFor(result.ConstitutionCode); + if (result.HasCompleted) + { + result.MusicRecommendation = MusicFor(result.ConstitutionCode); + result.SceneConfig ??= new PersonalizedSceneConfig(); + result.SceneConfig.SpeakerVolumePercent = 50; + } return result; } catch { return new TcmAssessmentResult(); } @@ -209,7 +214,7 @@ public static class TcmConstitutionRules MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0, MattressMode = "智能适配", EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1, - EnableSpeakerSleepMode = true, SpeakerVolumePercent = 60 + EnableSpeakerSleepMode = true, SpeakerVolumePercent = 50 } }; } diff --git a/README.md b/README.md index 1ed37ba..46efc1e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 当前 MiAirX 管理页已开启密码保护,在启动后端的环境中设置 `MiAirX__WebPassword` 环境变量(Visual Studio 可在本机启动配置中设置)。不要把管理密码写入 JSON 或提交到仓库。若以后关闭了 MiAirX 的密码保护,可将 `RequireWebPassword` 改为 `false`。后端需要能访问 MiAirX 服务地址;首次联调建议先在设备页手动测试“播放音乐”和“停止播放”,再测试一键启动/关闭。MiAirX 返回成功表示指令已送达,不代表已经确认音箱实际发声。 -九体质演示曲目在 `Evergreen.Health.Application/Configuration/TcmMusic.json` 中按体质代码排列,当前 9 首的 OSS URL 已配置。每个体质的列表可按顺序增加多首;一键开启从 60% 音量开始,每 5 秒降低 1%,单曲结束自动播下一首并继承当前音量,末曲结束后从头循环(只有一首时重播该曲),直到音量为 0 时停止。当前按配置的曲目时长切换,MiAirX 并未提供此播放方式的可靠结束事件。手动播放按钮仍使用 `MiAirX.json` 的测试 URL;手动调整音量会停止自动渐弱,但不会停止当前音乐。 +九体质演示曲目在 `Evergreen.Health.Application/Configuration/TcmMusic.json` 中按体质代码排列,当前 9 首的 OSS URL 已配置。每个体质的列表可按顺序增加多首;一键开启从 50% 音量开始,每 5 秒降低 1%,单曲结束自动播下一首并继承当前音量,末曲结束后从头循环(只有一首时重播该曲),直到音量为 0 时停止。页面显示的是 MiAirX 已成功下发的音量,不依赖 Home Assistant 中可能延迟的音量状态。当前按配置的曲目时长切换,MiAirX 并未提供此播放方式的可靠结束事件。手动播放按钮仍使用 `MiAirX.json` 的测试 URL;手动调整音量会停止自动渐弱,但不会停止当前音乐。 ## 健康业务接口