This commit is contained in:
fuenmao
2026-09-28 16:36:41 +08:00
parent 973efa34cc
commit af596104e2
12 changed files with 513 additions and 96 deletions
+3
View File
@@ -20,3 +20,6 @@ logs/
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local MiAirX credentials; use MiAirX.example.json as a template.
Evergreen.Health.Application/Configuration/MiAirX.json
+3
View File
@@ -7,6 +7,7 @@
using Admin.NET.Core; using Admin.NET.Core;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Furion; using Furion;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -38,6 +39,8 @@ public static class ProjectOptions
services.AddConfigurableOptions<DeepSeekOptions>(); services.AddConfigurableOptions<DeepSeekOptions>();
services.AddConfigurableOptions<LocalizationSettingsOptions>(); services.AddConfigurableOptions<LocalizationSettingsOptions>();
services.AddConfigurableOptions<HomeAssistantOptions>(); services.AddConfigurableOptions<HomeAssistantOptions>();
services.AddConfigurableOptions<MiAirXOptions>();
services.AddConfigurableOptions<TcmMusicOptions>();
services.Configure<IpRateLimitOptions>(App.Configuration.GetSection("IpRateLimiting")); services.Configure<IpRateLimitOptions>(App.Configuration.GetSection("IpRateLimiting"));
services.Configure<IpRateLimitPolicies>(App.Configuration.GetSection("IpRateLimitPolicies")); services.Configure<IpRateLimitPolicies>(App.Configuration.GetSection("IpRateLimitPolicies"));
services.Configure<ClientRateLimitOptions>(App.Configuration.GetSection("ClientRateLimiting")); services.Configure<ClientRateLimitOptions>(App.Configuration.GetSection("ClientRateLimiting"));
+4
View File
@@ -9,6 +9,7 @@ using Admin.NET.Core.ElasticSearch;
using Admin.NET.Core.Service; using Admin.NET.Core.Service;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Furion; using Furion;
using Furion.Logging; using Furion.Logging;
using Furion.SpecificationDocument; using Furion.SpecificationDocument;
@@ -76,6 +77,9 @@ public class Startup : AppStartup
// 远程请求 // 远程请求
services.AddHttpRemote(); services.AddHttpRemote();
services.AddHttpClient<HomeAssistantClient>(); services.AddHttpClient<HomeAssistantClient>();
services.AddHttpClient<MiAirXClient>();
services.AddSingleton<TcmMusicTransitionService>();
services.AddHostedService(provider => provider.GetRequiredService<TcmMusicTransitionService>());
services.AddSingleton<HomeAssistantLightTransitionService>(); services.AddSingleton<HomeAssistantLightTransitionService>();
services.AddHostedService(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>()); services.AddHostedService(provider => provider.GetRequiredService<HomeAssistantLightTransitionService>());
// 任务队列 // 任务队列
@@ -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 }
]
}
}
}
@@ -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<MiAirXOptions> 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<HttpResponseMessage> 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}");
}
}
}
@@ -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;
}
@@ -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<string, List<TcmMusicTrackOptions>> 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; }
}
@@ -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;
/// <summary>Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。</summary>
public sealed class TcmMusicTransitionService : IHostedService
{
private readonly MiAirXClient _miAirX;
private readonly TcmMusicOptions _options;
private readonly ILogger<TcmMusicTransitionService> _logger;
private readonly SemaphoreSlim _operationLock = new(1, 1);
private CancellationTokenSource? _activeCancellation;
private Task? _activeTask;
public TcmMusicTransitionService(
IHttpClientFactory httpClientFactory,
IOptions<MiAirXOptions> miAirXOptions,
IOptions<TcmMusicOptions> musicOptions,
ILogger<TcmMusicTransitionService> 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<TcmMusicTrackOptions> 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";
}
@@ -1,5 +1,6 @@
using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Models;
using Furion.DatabaseAccessor; using Furion.DatabaseAccessor;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -15,6 +16,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
{ {
private const string HealthUserIdClaim = "health_user_id"; private const string HealthUserIdClaim = "health_user_id";
private readonly HomeAssistantClient _homeAssistant; private readonly HomeAssistantClient _homeAssistant;
private readonly TcmMusicTransitionService _musicTransition;
private readonly HomeAssistantLightTransitionService _lightTransition; private readonly HomeAssistantLightTransitionService _lightTransition;
private readonly HomeAssistantRegistryService _registryService; private readonly HomeAssistantRegistryService _registryService;
private readonly HomeAssistantOptions _options; private readonly HomeAssistantOptions _options;
@@ -25,6 +27,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
public HealthHomeAssistantService( public HealthHomeAssistantService(
HomeAssistantClient homeAssistant, HomeAssistantClient homeAssistant,
TcmMusicTransitionService musicTransition,
HomeAssistantLightTransitionService lightTransition, HomeAssistantLightTransitionService lightTransition,
HomeAssistantRegistryService registryService, HomeAssistantRegistryService registryService,
IOptions<HomeAssistantOptions> options, IOptions<HomeAssistantOptions> options,
@@ -34,6 +37,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
{ {
_homeAssistant = homeAssistant; _homeAssistant = homeAssistant;
_musicTransition = musicTransition;
_lightTransition = lightTransition; _lightTransition = lightTransition;
_registryService = registryService; _registryService = registryService;
_options = options.Value; _options = options.Value;
@@ -87,6 +91,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices); var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
PersonalizedSceneConfig? userConfig = null; PersonalizedSceneConfig? userConfig = null;
var constitutionCode = string.Empty;
try try
{ {
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
@@ -94,6 +99,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var tcmResult = TcmConstitutionRules.Read(profile); var tcmResult = TcmConstitutionRules.Read(profile);
if (tcmResult.HasCompleted) if (tcmResult.HasCompleted)
{ {
constitutionCode = tcmResult.ConstitutionCode;
userConfig = tcmResult.SceneConfig; userConfig = tcmResult.SceneConfig;
var comfort = await _mattressComfort.GetAsync(profile, cancellationToken); var comfort = await _mattressComfort.GetAsync(profile, cancellationToken);
MattressComfortService.ApplyTo(userConfig, comfort); MattressComfortService.ApplyTo(userConfig, comfort);
@@ -105,7 +111,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var scenePresets = userConfig is null var scenePresets = userConfig is null
? new List<HomeAssistantScenePresetItem>() ? new List<HomeAssistantScenePresetItem>()
: BuildScenePresets(stateMap, userConfig); : BuildScenePresets(stateMap, userConfig, constitutionCode);
return new HomeAssistantStatusOutput return new HomeAssistantStatusOutput
{ {
@@ -200,6 +206,23 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var domain = candidate.Domain; 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)) if (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState))
{ {
return new HomeAssistantDeviceSwitchOutput return new HomeAssistantDeviceSwitchOutput
@@ -230,85 +253,21 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
} }
var targetOn = input.TurnOn; var targetOn = input.TurnOn;
bool? targetMuted = null;
var service = targetOn ? "turn_on" : "turn_off"; 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?> var serviceData = new Dictionary<string, object?>
{ {
["entity_id"] = targetEntityId ["entity_id"] = targetEntityId
}; };
if (targetMuted.HasValue)
{
serviceData["is_volume_muted"] = targetMuted.Value;
}
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); 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 return new HomeAssistantDeviceSwitchOutput
{ {
Success = true, Success = true,
DeviceId = input.DeviceId, DeviceId = input.DeviceId,
EntityId = targetEntityId, EntityId = targetEntityId,
State = resultState, State = targetOn ? "on" : "off",
IsMuted = resultMuted, Message = $"已成功{(targetOn ? "开启" : "关闭")}设备"
Message = domain == "media_player"
? (targetMuted == true ? "音箱已静音" : "音箱声音已开启")
: $"已成功{(targetOn ? "开启" : "关闭")}设备"
}; };
} }
catch (Exception ex) catch (Exception ex)
@@ -403,8 +362,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
case "volume": case "volume":
targetEntityId = _options.Entities.Speaker; targetEntityId = _options.Entities.Speaker;
domain = "media_player"; domain = "media_player";
service = "volume_set"; service = "";
serviceData["volume_level"] = ReadInt(input.Value, 0, 100, "音箱音量") / 100d; serviceData["volume"] = ReadInt(input.Value, 0, 100, "音箱音量");
break; break;
case "speaker_sleep_mode": case "speaker_sleep_mode":
targetEntityId = _options.Entities.SpeakerSleepMode; targetEntityId = _options.Entities.SpeakerSleepMode;
@@ -522,6 +481,17 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配"); throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配");
if (!targetDevice.Entities.Any(e => string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))) if (!targetDevice.Entities.Any(e => string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("该参数不属于指定设备"); 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)) if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState))
throw new InvalidOperationException("目标参数实体当前离线或不可用"); throw new InvalidOperationException("目标参数实体当前离线或不可用");
@@ -581,7 +551,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken); var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken);
MattressComfortService.ApplyTo(customConfig, mattressComfort); 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] [Authorize]
@@ -602,6 +573,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
try try
{ {
// 关闭场景先停止音乐;即使 HA 暂时不可用,也要尽量避免音箱继续播放。
if (sceneCode == "all-off")
await RunMiAirXAction(actions, "停止助眠音乐", _options.Entities.Speaker,
_musicTransition.StopMusicAsync, cancellationToken);
var states = await _homeAssistant.GetStatesAsync(cancellationToken); var states = await _homeAssistant.GetStatesAsync(cancellationToken);
var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
await executor(stateMap, actions, cancellationToken); await executor(stateMap, actions, cancellationToken);
@@ -654,6 +629,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IReadOnlyDictionary<string, HomeAssistantState> states, IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results, List<HomeAssistantActionResult> results,
PersonalizedSceneConfig? customConfig, PersonalizedSceneConfig? customConfig,
string constitutionCode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var entities = _options.Entities; var entities = _options.Entities;
@@ -670,7 +646,6 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var fragranceDuration = fragranceLevel switch { 1 => 2, 2 => 4, _ => 6 }; var fragranceDuration = fragranceLevel switch { 1 => 2, 2 => 4, _ => 6 };
var fragranceInterval = fragranceLevel switch { 1 => 20, 2 => 15, _ => 10 }; var fragranceInterval = fragranceLevel switch { 1 => 20, 2 => 15, _ => 10 };
var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode; var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode;
var speakerVolume = customConfig?.SpeakerVolumePercent ?? 15;
var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel; var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel;
var mattressFirmness = MattressComfortService.FirmnessName(customConfig?.MattressLeftFirmness ?? 0); 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.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
} }
// 音箱声音与睡眠模式是两种不同能力。即使用户未启用睡眠模式, // 睡眠模式仍由 Home Assistant 控制;体质选曲和渐弱在 MiAirX 后台任务中进行。
// 一键舒睡也应取消静音并应用个性化音量。 await RunMiAirXAction(results, "播放体质助眠音乐并渐弱", entities.Speaker,
await RunAction(states, results, "开启音箱声音", entities.Speaker, "media_player", "volume_mute", token => _musicTransition.StartForConstitutionAsync(constitutionCode, token), cancellationToken);
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);
if (scene.EnableMattress) 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.FragranceLight, "light", "turn_off", null, cancellationToken);
await RunAction(states, results, "关闭助眠灯光", entities.AmbientLight, "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.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", await RunAction(states, results, "关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken); new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
await RunAction(states, results, "关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", 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( private async Task RunAction(
IReadOnlyDictionary<string, HomeAssistantState> states, IReadOnlyDictionary<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results, List<HomeAssistantActionResult> results,
@@ -1357,7 +1353,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
private List<HomeAssistantScenePresetItem> BuildScenePresets( private List<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> stateMap, IReadOnlyDictionary<string, HomeAssistantState> stateMap,
PersonalizedSceneConfig? userConfig = null) PersonalizedSceneConfig? userConfig,
string constitutionCode)
{ {
var defaultScene = _options.SleepScene; var defaultScene = _options.SleepScene;
var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity; var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity;
@@ -1420,15 +1417,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker)) if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker))
{ {
var vol = userConfig?.SpeakerVolumePercent ?? 15;
presets.Add(new HomeAssistantScenePresetItem presets.Add(new HomeAssistantScenePresetItem
{ {
DeviceName = "Xiaomi Sound Pro", DeviceName = "Xiaomi Sound Pro",
Category = nameof(HealthDeviceCategory.Media), Category = nameof(HealthDeviceCategory.Media),
EntityId = _options.Entities.Speaker, EntityId = _options.Entities.Speaker,
Action = enableSpeaker ? "开启音箱声音和睡眠模式" : "开启音箱声音", Action = enableSpeaker ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
Detail = enableSpeaker ? $"取消静音 · 开启睡眠模式 · 音量 {vol}%" : $"取消静音 · 音量 {vol}%", Detail = enableSpeaker ? "体质曲目顺序播放 · 音量60%每5秒降1% · 开启睡眠模式" : "体质曲目顺序播放 · 音量60%每5秒降1%",
Parameters = new() { ["muted"] = false, ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = $"{vol}%" } Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "60%→0%" }
}); });
} }
@@ -1455,6 +1451,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
foreach (var preset in presets) 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) && preset.Available = !string.IsNullOrWhiteSpace(preset.EntityId) &&
stateMap.TryGetValue(preset.EntityId, out var state) && stateMap.TryGetValue(preset.EntityId, out var state) &&
IsAvailable(state); IsAvailable(state);
@@ -136,10 +136,28 @@ public static class TcmConstitutionRules
public static TcmAssessmentResult Read(HealthProfile? profile) public static TcmAssessmentResult Read(HealthProfile? profile)
{ {
if (string.IsNullOrWhiteSpace(profile?.TcmResultJson)) return new TcmAssessmentResult(); if (string.IsNullOrWhiteSpace(profile?.TcmResultJson)) return new TcmAssessmentResult();
try { return JsonSerializer.Deserialize<TcmAssessmentResult>(profile.TcmResultJson) ?? new TcmAssessmentResult(); } try
{
var result = JsonSerializer.Deserialize<TcmAssessmentResult>(profile.TcmResultJson) ?? new TcmAssessmentResult();
if (result.HasCompleted) result.MusicRecommendation = MusicFor(result.ConstitutionCode);
return result;
}
catch { return new TcmAssessmentResult(); } 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) public static TcmAssessmentResult Evaluate(TcmAssessmentInput input)
{ {
if (input?.Answers == null) throw Oops.Oh("请完成四诊问卷"); if (input?.Answers == null) throw Oops.Oh("请完成四诊问卷");
@@ -158,17 +176,17 @@ public static class TcmConstitutionRules
var notes = (input.Notes ?? new Dictionary<string, string>()) var notes = (input.Notes ?? new Dictionary<string, string>())
.Where(item => new[] { "tongue", "odor", "breath", "pulseNote" }.Contains(item.Key)) .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)]); .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), "qi_deficient" => ("清淡木香", "轻音乐", "22–25℃", 55, 3000, true),
"yang_deficient" => ("温暖木质香", "轻柔慢节奏", "24–26℃", 52, 2700, true, 14), "yang_deficient" => ("温暖木质香", "自然雨声", "24–26℃", 52, 2700, true),
"yin_deficient" => ("淡菊花香", "轻柔海浪声", "21–24℃", 48, 4000, true, 12), "yin_deficient" => ("淡菊花香", "海浪白噪音", "21–24℃", 48, 4000, true),
"phlegm_damp" => ("清新柑橘香", "舒缓雨声", "22–24℃", 45, 3500, true, 15), "phlegm_damp" => ("清新柑橘香", "规律鸟鸣白噪音", "22–24℃", 45, 3500, true),
"damp_heat" => ("清淡薄荷香", "流水白噪音", "21–24℃", 45, 4000, true, 12), "damp_heat" => ("清淡薄荷香", "自然流水声", "21–24℃", 45, 4000, true),
"blood_stasis" => ("淡花木香", "低频舒缓音乐", "23–25℃", 52, 3000, true, 14), "blood_stasis" => ("淡花木香", "低频舒缓音乐", "23–25℃", 52, 3000, true),
"qi_stagnation" => ("清新柑橘香", "轻柔吉他曲", "22–25℃", 51, 3000, true, 14), "qi_stagnation" => ("清新柑橘香", "冥想古琴曲", "22–25℃", 51, 3000, true),
"special" => ("建议不使用香氛,避免过敏原", "自然风声", "22–25℃", 50, 3500, false, 12), "special" => ("建议不使用香氛,避免过敏原", "田野风声", "22–25℃", 50, 3500, false),
_ => ("淡雅薰衣草香", "自然雨声", "22–25℃", 50, 3000, true, 15) _ => ("淡雅薰衣草香", "自然雨声", "22–25℃", 50, 3000, true)
}; };
return new TcmAssessmentResult return new TcmAssessmentResult
{ {
@@ -191,7 +209,7 @@ public static class TcmConstitutionRules
MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0, MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0,
MattressMode = "智能适配", MattressMode = "智能适配",
EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1, EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1,
EnableSpeakerSleepMode = true, SpeakerVolumePercent = volume EnableSpeakerSleepMode = true, SpeakerVolumePercent = 60
} }
}; };
} }
+11
View File
@@ -0,0 +1,11 @@
{
"MiAirX": {
"Enabled": true,
"BaseUrl": "",
"DeviceId": "",
"MusicUrl": "",
"RequireWebPassword": true,
"WebPassword": "",
"TimeoutSeconds": 15
}
}
+8
View File
@@ -8,6 +8,14 @@
2. 执行 `dotnet restore Evergreen.Health.sln`。 2. 执行 `dotnet restore Evergreen.Health.sln`。
3. 执行 `dotnet run --project Evergreen.Health.Api/Evergreen.Health.Api.csproj`。 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` 用户注册 - `POST /api/health/auth/register` 用户注册