111
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"MiAirX": {
|
||||||
|
"Enabled": true,
|
||||||
|
"BaseUrl": "",
|
||||||
|
"DeviceId": "",
|
||||||
|
"MusicUrl": "",
|
||||||
|
"RequireWebPassword": true,
|
||||||
|
"WebPassword": "",
|
||||||
|
"TimeoutSeconds": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` 用户注册
|
||||||
|
|||||||
Reference in New Issue
Block a user