111
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user