This commit is contained in:
fuenmao
2026-10-08 10:16:43 +08:00
parent af596104e2
commit 4a634ef4c2
8 changed files with 261 additions and 25 deletions
@@ -1,6 +1,6 @@
{
"TcmMusic": {
"StartVolume": 60,
"StartVolume": 50,
"FadeStepSeconds": 5,
"Tracks": {
"balanced": [
@@ -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<string>();
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<string, object?>
{
["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;
@@ -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<string, List<TcmMusicTrackOptions>> Tracks { get; set; } = new();
}
@@ -12,6 +12,8 @@ public sealed class TcmMusicTransitionService : IHostedService
private readonly TcmMusicOptions _options;
private readonly ILogger<TcmMusicTransitionService> _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;
}
@@ -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
@@ -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<HomeAssistantLightTransitionOutput> 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<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
@@ -741,6 +751,25 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
new Dictionary<string, object?> { ["option"] = mattressFirmness }, cancellationToken);
}
}
const string leftModeActionName = "开启床垫左侧智能适配";
await RunAction(states, results, leftModeActionName, entities.MattressLeftMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "智能适配" }, cancellationToken);
var leftModeAction = results.LastOrDefault(item => item.Name == leftModeActionName);
if (leftModeAction?.Status == "Succeeded")
{
try
{
await VerifyMattressModeAsync(entities.MattressLeftMode, "智能适配",
new Dictionary<string, object?> { ["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<string, object?> { ["option"] = "无" }, cancellationToken);
await RunAction(states, results, "关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
var commands = new List<OffCommand>
{
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<string, object?> { ["option"] = "无" }),
new("关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", "无",
new Dictionary<string, object?> { ["option"] = "无" })
};
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);
commands.Add(new("关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", "0",
new Dictionary<string, object?> { ["value"] = 0 }));
commands.Add(new("关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", "0",
new Dictionary<string, object?> { ["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<string, object?>? ExtraData);
private async Task VerifyOffActionsAsync(
IReadOnlyList<OffCommand> commands,
List<HomeAssistantActionResult> 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<string, HomeAssistantState> 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<string, object?> { ["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<string, object?> 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%" }
});
}
@@ -139,7 +139,12 @@ public static class TcmConstitutionRules
try
{
var result = JsonSerializer.Deserialize<TcmAssessmentResult>(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
}
};
}
+1 -1
View File
@@ -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;手动调整音量会停止自动渐弱,但不会停止当前音乐。
## 健康业务接口