From af596104e2721304b8b970efb6d7b16f81ef14cd Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Mon, 28 Sep 2026 16:36:41 +0800 Subject: [PATCH] 111 --- .gitignore | 3 + Admin.NET.Web.Core/ProjectOptions.cs | 3 + Admin.NET.Web.Core/Startup.cs | 4 + .../Configuration/TcmMusic.json | 35 ++++ .../Integrations/MiAirX/MiAirXClient.cs | 137 ++++++++++++++ .../Integrations/MiAirX/MiAirXOptions.cs | 14 ++ .../Integrations/MiAirX/TcmMusicOptions.cs | 18 ++ .../MiAirX/TcmMusicTransitionService.cs | 162 +++++++++++++++++ .../Services/HealthHomeAssistantService.cs | 172 +++++++++--------- .../Services/HealthTcmAssessmentService.cs | 42 +++-- MiAirX.example.json | 11 ++ README.md | 8 + 12 files changed, 513 insertions(+), 96 deletions(-) create mode 100644 Evergreen.Health.Application/Configuration/TcmMusic.json create mode 100644 Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs create mode 100644 Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXOptions.cs create mode 100644 Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs create mode 100644 Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs create mode 100644 MiAirX.example.json diff --git a/.gitignore b/.gitignore index ec14a38..5c52ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ logs/ # OS files .DS_Store Thumbs.db + +# Local MiAirX credentials; use MiAirX.example.json as a template. +Evergreen.Health.Application/Configuration/MiAirX.json diff --git a/Admin.NET.Web.Core/ProjectOptions.cs b/Admin.NET.Web.Core/ProjectOptions.cs index 077c5fe..a6af5b0 100644 --- a/Admin.NET.Web.Core/ProjectOptions.cs +++ b/Admin.NET.Web.Core/ProjectOptions.cs @@ -7,6 +7,7 @@ using Admin.NET.Core; using AspNetCoreRateLimit; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Integrations.MiAirX; using Furion; using Microsoft.Extensions.DependencyInjection; @@ -38,6 +39,8 @@ public static class ProjectOptions services.AddConfigurableOptions(); services.AddConfigurableOptions(); services.AddConfigurableOptions(); + services.AddConfigurableOptions(); + services.AddConfigurableOptions(); services.Configure(App.Configuration.GetSection("IpRateLimiting")); services.Configure(App.Configuration.GetSection("IpRateLimitPolicies")); services.Configure(App.Configuration.GetSection("ClientRateLimiting")); diff --git a/Admin.NET.Web.Core/Startup.cs b/Admin.NET.Web.Core/Startup.cs index 0085eb9..ea33e01 100644 --- a/Admin.NET.Web.Core/Startup.cs +++ b/Admin.NET.Web.Core/Startup.cs @@ -9,6 +9,7 @@ using Admin.NET.Core.ElasticSearch; using Admin.NET.Core.Service; using AspNetCoreRateLimit; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Integrations.MiAirX; using Furion; using Furion.Logging; using Furion.SpecificationDocument; @@ -76,6 +77,9 @@ public class Startup : AppStartup // 远程请求 services.AddHttpRemote(); services.AddHttpClient(); + services.AddHttpClient(); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddHostedService(provider => provider.GetRequiredService()); // 任务队列 diff --git a/Evergreen.Health.Application/Configuration/TcmMusic.json b/Evergreen.Health.Application/Configuration/TcmMusic.json new file mode 100644 index 0000000..4874e31 --- /dev/null +++ b/Evergreen.Health.Application/Configuration/TcmMusic.json @@ -0,0 +1,35 @@ +{ + "TcmMusic": { + "StartVolume": 60, + "FadeStepSeconds": 5, + "Tracks": { + "balanced": [ + { "Title": "雨声", "FileName": "balanced_01_rain.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/balanced_01_rain.mp3", "DurationSeconds": 20.2 } + ], + "qi_deficient": [ + { "Title": "轻音乐", "FileName": "qi_deficient_01_light_music.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/qi_deficient_01_light_music.mp3", "DurationSeconds": 115.1 } + ], + "yang_deficient": [ + { "Title": "雨声", "FileName": "yang_deficient_01_rain.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/yang_deficient_01_rain.mp3", "DurationSeconds": 20.2 } + ], + "yin_deficient": [ + { "Title": "海浪白噪音", "FileName": "yin_deficient_01_ocean_waves.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/yin_deficient_01_ocean_waves.mp3", "DurationSeconds": 86.5 } + ], + "phlegm_damp": [ + { "Title": "规律鸟鸣白噪音", "FileName": "phlegm_damp_01_birds.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/phlegm_damp_01_birds.mp3", "DurationSeconds": 294.0 } + ], + "damp_heat": [ + { "Title": "自然流水声", "FileName": "damp_heat_01_stream.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/damp_heat_01_stream.mp3", "DurationSeconds": 33.5 } + ], + "blood_stasis": [ + { "Title": "低频舒缓音乐", "FileName": "blood_stasis_01_low_frequency.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/blood_stasis_01_low_frequency.mp3", "DurationSeconds": 60.0 } + ], + "qi_stagnation": [ + { "Title": "冥想古琴曲", "FileName": "qi_stagnation_01_guqin.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/qi_stagnation_01_guqin.mp3", "DurationSeconds": 1242.1 } + ], + "special": [ + { "Title": "田野风声", "FileName": "special_01_field_wind.mp3", "Url": "https://ziliaoku.cnzhyx.cn/App.Live/uploads/_OSS%E4%B8%8A%E4%BC%A0%E7%89%88/special_01_field_wind.mp3", "DurationSeconds": 48.6 } + ] + } + } +} diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs new file mode 100644 index 0000000..5a4d051 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Options; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Integrations.MiAirX; + +public sealed class MiAirXClient +{ + private readonly HttpClient _httpClient; + private readonly MiAirXOptions _options; + private readonly SemaphoreSlim _loginLock = new(1, 1); + private string? _sessionToken; + + public MiAirXClient(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + _httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60)); + } + + public bool IsConfigured => _options.Enabled && + !string.IsNullOrWhiteSpace(_options.BaseUrl) && + !string.IsNullOrWhiteSpace(_options.DeviceId) && + !string.IsNullOrWhiteSpace(_options.MusicUrl) && + (!_options.RequireWebPassword || !string.IsNullOrWhiteSpace(_options.WebPassword)); + + public Task PlayAsync(CancellationToken cancellationToken = default) + => PlayUrlAsync(_options.MusicUrl, cancellationToken); + + public Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default) + { + if (!Uri.TryCreate(musicUrl, UriKind.Absolute, out var url) || + url.Scheme is not ("http" or "https")) + throw new InvalidOperationException("MiAirX 音频 URL 未配置或格式无效"); + return SendCommandAsync("api/play", new { did = _options.DeviceId, url = musicUrl }, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken = default) + => SendCommandAsync("api/stop", new { did = _options.DeviceId }, cancellationToken); + + public Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default) + { + if (volume is < 0 or > 100) + throw new InvalidOperationException("音箱音量应在 0 到 100 之间"); + return SendCommandAsync("api/volume", new { did = _options.DeviceId, volume }, cancellationToken); + } + + private async Task SendCommandAsync(string path, object payload, CancellationToken cancellationToken) + { + var baseUri = GetBaseUri(); + if (!string.IsNullOrWhiteSpace(_options.WebPassword)) + await EnsureLoginAsync(baseUri, cancellationToken); + + using var response = await SendOnceAsync(baseUri, path, payload, cancellationToken); + if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrWhiteSpace(_options.WebPassword)) + { + _sessionToken = null; + await EnsureLoginAsync(baseUri, cancellationToken); + using var retry = await SendOnceAsync(baseUri, path, payload, cancellationToken); + await EnsureCommandSucceededAsync(retry, cancellationToken); + return; + } + + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new InvalidOperationException("MiAirX 要求登录,请在后端配置 MiAirX 管理密码"); + await EnsureCommandSucceededAsync(response, cancellationToken); + } + + private async Task SendOnceAsync(Uri baseUri, string path, object payload, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, path)) + { + Content = JsonContent.Create(payload) + }; + if (!string.IsNullOrWhiteSpace(_sessionToken)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _sessionToken); + return await _httpClient.SendAsync(request, cancellationToken); + } + + private async Task EnsureLoginAsync(Uri baseUri, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_sessionToken)) return; + await _loginLock.WaitAsync(cancellationToken); + try + { + if (!string.IsNullOrWhiteSpace(_sessionToken)) return; + using var response = await _httpClient.PostAsJsonAsync(new Uri(baseUri, "api/auth/login"), + new { password = _options.WebPassword }, cancellationToken); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException("MiAirX 登录失败,请检查管理密码"); + + if (!response.Headers.TryGetValues("Set-Cookie", out var cookies)) + throw new InvalidOperationException("MiAirX 登录成功但没有返回会话凭据"); + var tokenCookie = cookies.Select(cookie => cookie.Split(';', 2)[0]) + .FirstOrDefault(cookie => cookie.StartsWith("miairx_token=", StringComparison.Ordinal)); + _sessionToken = tokenCookie?["miairx_token=".Length..]; + if (string.IsNullOrWhiteSpace(_sessionToken)) + throw new InvalidOperationException("MiAirX 登录成功但会话凭据无效"); + } + finally + { + _loginLock.Release(); + } + } + + private Uri GetBaseUri() + { + if (!_options.Enabled) throw new InvalidOperationException("MiAirX 接入未启用"); + if (string.IsNullOrWhiteSpace(_options.DeviceId)) throw new InvalidOperationException("MiAirX 音箱 DID 未配置"); + if (_options.RequireWebPassword && string.IsNullOrWhiteSpace(_options.WebPassword)) + throw new InvalidOperationException("MiAirX 管理密码未配置"); + if (!Uri.TryCreate(_options.BaseUrl, UriKind.Absolute, out var baseUri) || + baseUri.Scheme is not ("http" or "https")) + throw new InvalidOperationException("MiAirX 服务地址未配置或格式无效"); + return new Uri(baseUri.ToString().TrimEnd('/') + "/"); + } + + private static async Task EnsureCommandSucceededAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new InvalidOperationException("MiAirX 登录已失效,请检查管理密码"); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"MiAirX 返回 HTTP {(int)response.StatusCode}"); + + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), + cancellationToken: cancellationToken); + if (!document.RootElement.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.True) + { + var error = document.RootElement.TryGetProperty("error", out var errorValue) + ? errorValue.GetString() : null; + var detail = string.IsNullOrWhiteSpace(error) ? "" : $":{error[..Math.Min(error.Length, 120)]}"; + throw new InvalidOperationException($"MiAirX 未能完成指令{detail}"); + } + } +} diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXOptions.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXOptions.cs new file mode 100644 index 0000000..e15de83 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXOptions.cs @@ -0,0 +1,14 @@ +using Furion.ConfigurableOptions; + +namespace Evergreen.Health.Application.Health.Integrations.MiAirX; + +public sealed class MiAirXOptions : IConfigurableOptions +{ + public bool Enabled { get; set; } = true; + public string BaseUrl { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string MusicUrl { get; set; } = string.Empty; + public bool RequireWebPassword { get; set; } = true; + public string WebPassword { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 15; +} diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs new file mode 100644 index 0000000..8156908 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicOptions.cs @@ -0,0 +1,18 @@ +using Furion.ConfigurableOptions; + +namespace Evergreen.Health.Application.Health.Integrations.MiAirX; + +public sealed class TcmMusicOptions : IConfigurableOptions +{ + public int StartVolume { get; set; } = 60; + public int FadeStepSeconds { get; set; } = 5; + public Dictionary> Tracks { get; set; } = new(); +} + +public sealed class TcmMusicTrackOptions +{ + public string Title { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public double DurationSeconds { get; set; } +} diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs new file mode 100644 index 0000000..4c23ce6 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs @@ -0,0 +1,162 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Diagnostics; + +namespace Evergreen.Health.Application.Health.Integrations.MiAirX; + +/// Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。 +public sealed class TcmMusicTransitionService : IHostedService +{ + private readonly MiAirXClient _miAirX; + private readonly TcmMusicOptions _options; + private readonly ILogger _logger; + private readonly SemaphoreSlim _operationLock = new(1, 1); + private CancellationTokenSource? _activeCancellation; + private Task? _activeTask; + + public TcmMusicTransitionService( + IHttpClientFactory httpClientFactory, + IOptions miAirXOptions, + IOptions musicOptions, + ILogger logger) + { + _miAirX = new MiAirXClient(httpClientFactory.CreateClient(nameof(TcmMusicTransitionService)), miAirXOptions); + _options = musicOptions.Value; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + try { await StopMusicAsync(cancellationToken); } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "应用退出时未能停止助眠音乐"); + } + } + + public bool HasPlayableTracks(string constitutionCode) + => _miAirX.IsConfigured && + _options.Tracks.TryGetValue(constitutionCode, out var tracks) && tracks.Count > 0 && + tracks.All(track => IsValidTrack(track)); + + public async Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken) + { + if (!HasPlayableTracks(constitutionCode)) + throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整"); + if (_options.StartVolume is < 1 or > 100 || _options.FadeStepSeconds is < 1 or > 60) + throw new InvalidOperationException("体质音乐渐弱参数无效"); + + var tracks = _options.Tracks[constitutionCode].ToArray(); + await _operationLock.WaitAsync(cancellationToken); + try + { + await CancelCurrentLoopAsync(); + await _miAirX.SetVolumeAsync(_options.StartVolume, cancellationToken); + await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken); + _activeCancellation = new CancellationTokenSource(); + _activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token); + } + finally { _operationLock.Release(); } + } + + public async Task PlayTestMusicAsync(CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try + { + await CancelCurrentLoopAsync(); + await _miAirX.PlayAsync(cancellationToken); + } + finally { _operationLock.Release(); } + } + + public async Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try + { + // 手动调音量接管当前播放,避免下一次自动渐弱立刻覆盖用户选择。 + await CancelCurrentLoopAsync(); + await _miAirX.SetVolumeAsync(volume, cancellationToken); + } + finally { _operationLock.Release(); } + } + + public async Task StopMusicAsync(CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try + { + await CancelCurrentLoopAsync(); + await _miAirX.StopAsync(cancellationToken); + } + finally { _operationLock.Release(); } + } + + private async Task RunFadeAsync( + IReadOnlyList tracks, + string constitutionCode, + CancellationToken cancellationToken) + { + var clock = Stopwatch.StartNew(); + var currentVolume = _options.StartVolume; + var nextFadeSecond = (double)_options.FadeStepSeconds; + var trackStartSecond = 0d; + var trackIndex = 0; + try + { + while (true) + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + var elapsed = clock.Elapsed.TotalSeconds; + while (elapsed >= nextFadeSecond && currentVolume > 0) + { + currentVolume--; + await _miAirX.SetVolumeAsync(currentVolume, cancellationToken); + nextFadeSecond += _options.FadeStepSeconds; + } + + if (currentVolume == 0) + { + await _miAirX.StopAsync(cancellationToken); + _logger.LogInformation("体质 {ConstitutionCode} 的助眠音乐已渐弱至 0 并停止", constitutionCode); + return; + } + + if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue; + trackIndex = (trackIndex + 1) % tracks.Count; + await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken); + trackStartSecond = clock.Elapsed.TotalSeconds; + _logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}", + constitutionCode, tracks[trackIndex].Title, currentVolume); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception exception) + { + _logger.LogWarning(exception, "体质音乐渐弱或续播中断"); + try { await _miAirX.StopAsync(CancellationToken.None); } + catch (Exception stopException) { _logger.LogWarning(stopException, "体质音乐中断后停止音箱失败"); } + } + } + + private async Task CancelCurrentLoopAsync() + { + var cancellation = _activeCancellation; + var task = _activeTask; + _activeCancellation = null; + _activeTask = null; + if (cancellation is null) return; + cancellation.Cancel(); + try { if (task is not null) await task; } + finally { cancellation.Dispose(); } + } + + private static bool IsValidTrack(TcmMusicTrackOptions track) + => track.DurationSeconds > 0 && + Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) && + uri.Scheme is "http" or "https"; +} diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 262df09..ffb61c8 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -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 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() - : 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 { ["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 states, List 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 { ["is_volume_muted"] = false }, cancellationToken); - await RunAction(states, results, "设置助眠音量", entities.Speaker, "media_player", "volume_set", - new Dictionary { ["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 { ["is_volume_muted"] = true }, cancellationToken); await RunAction(states, results, "关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option", new Dictionary { ["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 RunMiAirXAction( + List results, + string name, + string entityId, + Func 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 states, List results, @@ -1357,7 +1353,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private List BuildScenePresets( IReadOnlyDictionary 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); diff --git a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs index fa3995c..440fb61 100644 --- a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs @@ -136,10 +136,28 @@ public static class TcmConstitutionRules public static TcmAssessmentResult Read(HealthProfile? profile) { if (string.IsNullOrWhiteSpace(profile?.TcmResultJson)) return new TcmAssessmentResult(); - try { return JsonSerializer.Deserialize(profile.TcmResultJson) ?? new TcmAssessmentResult(); } + try + { + var result = JsonSerializer.Deserialize(profile.TcmResultJson) ?? new TcmAssessmentResult(); + if (result.HasCompleted) result.MusicRecommendation = MusicFor(result.ConstitutionCode); + return result; + } catch { return new TcmAssessmentResult(); } } + private static string MusicFor(string constitutionCode) => constitutionCode switch + { + "balanced" or "yang_deficient" => "自然雨声", + "qi_deficient" => "轻音乐", + "yin_deficient" => "海浪白噪音", + "phlegm_damp" => "规律鸟鸣白噪音", + "damp_heat" => "自然流水声", + "blood_stasis" => "低频舒缓音乐", + "qi_stagnation" => "冥想古琴曲", + "special" => "田野风声", + _ => "自然雨声" + }; + public static TcmAssessmentResult Evaluate(TcmAssessmentInput input) { if (input?.Answers == null) throw Oops.Oh("请完成四诊问卷"); @@ -158,17 +176,17 @@ public static class TcmConstitutionRules var notes = (input.Notes ?? new Dictionary()) .Where(item => new[] { "tongue", "odor", "breath", "pulseNote" }.Contains(item.Key)) .ToDictionary(item => item.Key, item => (item.Value ?? "").Trim()[..Math.Min((item.Value ?? "").Trim().Length, 300)]); - var (aroma, music, room, humidity, kelvin, fragrance, volume) = primary switch + var (aroma, music, room, humidity, kelvin, fragrance) = primary switch { - "qi_deficient" => ("清淡木香", "舒缓自然声", "22–25℃", 55, 3000, true, 14), - "yang_deficient" => ("温暖木质香", "轻柔慢节奏", "24–26℃", 52, 2700, true, 14), - "yin_deficient" => ("淡菊花香", "轻柔海浪声", "21–24℃", 48, 4000, true, 12), - "phlegm_damp" => ("清新柑橘香", "舒缓雨声", "22–24℃", 45, 3500, true, 15), - "damp_heat" => ("清淡薄荷香", "流水白噪音", "21–24℃", 45, 4000, true, 12), - "blood_stasis" => ("淡花木香", "低频舒缓音乐", "23–25℃", 52, 3000, true, 14), - "qi_stagnation" => ("清新柑橘香", "轻柔吉他曲", "22–25℃", 51, 3000, true, 14), - "special" => ("建议不使用香氛,避免过敏原", "自然风声", "22–25℃", 50, 3500, false, 12), - _ => ("淡雅薰衣草香", "自然雨声", "22–25℃", 50, 3000, true, 15) + "qi_deficient" => ("清淡木香", "轻音乐", "22–25℃", 55, 3000, true), + "yang_deficient" => ("温暖木质香", "自然雨声", "24–26℃", 52, 2700, true), + "yin_deficient" => ("淡菊花香", "海浪白噪音", "21–24℃", 48, 4000, true), + "phlegm_damp" => ("清新柑橘香", "规律鸟鸣白噪音", "22–24℃", 45, 3500, true), + "damp_heat" => ("清淡薄荷香", "自然流水声", "21–24℃", 45, 4000, true), + "blood_stasis" => ("淡花木香", "低频舒缓音乐", "23–25℃", 52, 3000, true), + "qi_stagnation" => ("清新柑橘香", "冥想古琴曲", "22–25℃", 51, 3000, true), + "special" => ("建议不使用香氛,避免过敏原", "田野风声", "22–25℃", 50, 3500, false), + _ => ("淡雅薰衣草香", "自然雨声", "22–25℃", 50, 3000, true) }; return new TcmAssessmentResult { @@ -191,7 +209,7 @@ public static class TcmConstitutionRules MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0, MattressMode = "智能适配", EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1, - EnableSpeakerSleepMode = true, SpeakerVolumePercent = volume + EnableSpeakerSleepMode = true, SpeakerVolumePercent = 60 } }; } diff --git a/MiAirX.example.json b/MiAirX.example.json new file mode 100644 index 0000000..025295c --- /dev/null +++ b/MiAirX.example.json @@ -0,0 +1,11 @@ +{ + "MiAirX": { + "Enabled": true, + "BaseUrl": "", + "DeviceId": "", + "MusicUrl": "", + "RequireWebPassword": true, + "WebPassword": "", + "TimeoutSeconds": 15 + } +} diff --git a/README.md b/README.md index d876481..1ed37ba 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ 2. 执行 `dotnet restore Evergreen.Health.sln`。 3. 执行 `dotnet run --project Evergreen.Health.Api/Evergreen.Health.Api.csproj`。 +## MiAirX 助眠音乐 + +音箱播放和停止通过 MiAirX 完成,音箱睡眠模式仍由 Home Assistant 控制。初次部署时,将根目录的 `MiAirX.example.json` 复制为 `Evergreen.Health.Application/Configuration/MiAirX.json`,再填写服务地址、音箱 DID 和手动测试音频 URL。本机 `MiAirX.json` 已加入 `.gitignore`,不会随普通 `git add` 提交。 + +当前 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;手动调整音量会停止自动渐弱,但不会停止当前音乐。 + ## 健康业务接口 - `POST /api/health/auth/register` 用户注册