1
This commit is contained in:
+51
-4
@@ -5,7 +5,7 @@ using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
public sealed class HomeAssistantClient
|
||||
public sealed class HomeAssistantClient : IHomeAssistantGateway
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HomeAssistantOptions _options;
|
||||
@@ -20,9 +20,47 @@ public sealed class HomeAssistantClient
|
||||
public async Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, "api/states");
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
using var response = await SendAsync(request, cancellationToken);
|
||||
await EnsureSuccessAsync(response, cancellationToken);
|
||||
return await response.Content.ReadFromJsonAsync<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? [];
|
||||
var states = await response.Content.ReadFromJsonAsync<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? [];
|
||||
await EnrichTemperatureUnitAsync(states, cancellationToken);
|
||||
return states;
|
||||
}
|
||||
|
||||
// 原生 climate 状态通常省略温度单位,它使用 HA 全局单位系统;不能把缺失直接当作摄氏。
|
||||
private async Task EnrichTemperatureUnitAsync(List<HomeAssistantState> states, CancellationToken cancellationToken)
|
||||
{
|
||||
var targets = states.Where(state => state.EntityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) &&
|
||||
state.Attributes.ValueKind == JsonValueKind.Object &&
|
||||
!state.Attributes.TryGetProperty("temperature_unit", out _)).ToArray();
|
||||
if (targets.Length == 0) return;
|
||||
using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
bounded.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, "api/config");
|
||||
using var response = await SendAsync(request, bounded.Token);
|
||||
await EnsureSuccessAsync(response, bounded.Token);
|
||||
using var config = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(bounded.Token),
|
||||
cancellationToken: bounded.Token);
|
||||
if (!config.RootElement.TryGetProperty("unit_system", out var units) ||
|
||||
units.ValueKind != JsonValueKind.Object || !units.TryGetProperty("temperature", out var temperature) ||
|
||||
temperature.ValueKind != JsonValueKind.String) return;
|
||||
var unit = temperature.GetString();
|
||||
if (unit is not ("°C" or "°F")) return;
|
||||
foreach (var state in targets)
|
||||
{
|
||||
var attributes = state.Attributes.EnumerateObject().ToDictionary(property => property.Name,
|
||||
property => property.Value.Clone(), StringComparer.Ordinal);
|
||||
attributes["temperature_unit"] = JsonSerializer.SerializeToElement(unit);
|
||||
state.Attributes = JsonSerializer.SerializeToElement(attributes);
|
||||
}
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// 单位接口不可读时仍保留其他真实状态;空调温度功能按单位未知禁用,不猜测。
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
public async Task CallServiceAsync(
|
||||
@@ -33,10 +71,19 @@ public sealed class HomeAssistantClient
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}");
|
||||
request.Content = JsonContent.Create(data);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
using var response = await SendAsync(request, cancellationToken);
|
||||
await EnsureSuccessAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
try { return await _httpClient.SendAsync(request, cancellationToken); }
|
||||
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException("Home Assistant 请求超时,未确认操作完成", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string path)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
|
||||
+26
-46
@@ -1,4 +1,5 @@
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -6,13 +7,16 @@ using Microsoft.Extensions.Options;
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
/// <summary>在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。</summary>
|
||||
public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
public sealed class HomeAssistantLightTransitionService : IHostedService, ILightTransitionRuntime
|
||||
{
|
||||
private const int SleepStartPercent = 50;
|
||||
private const int WakeTargetPercent = 70;
|
||||
private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly HomeAssistantClient _homeAssistant;
|
||||
private readonly IHomeAssistantGateway _homeAssistant;
|
||||
private readonly LightOffDispatcher _lightOff;
|
||||
private readonly SceneLightSafetyState _safety;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly HomeAssistantOptions _options;
|
||||
private readonly ILogger<HomeAssistantLightTransitionService> _logger;
|
||||
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
||||
@@ -22,13 +26,19 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
private HomeAssistantLightTransitionOutput _status = new();
|
||||
|
||||
public HomeAssistantLightTransitionService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHomeAssistantGateway homeAssistant,
|
||||
LightOffDispatcher lightOff,
|
||||
IOptions<HomeAssistantOptions> options,
|
||||
ILogger<HomeAssistantLightTransitionService> logger)
|
||||
ILogger<HomeAssistantLightTransitionService> logger,
|
||||
SceneLightSafetyState safety,
|
||||
TimeProvider time)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_homeAssistant = new HomeAssistantClient(httpClientFactory.CreateClient(nameof(HomeAssistantLightTransitionService)), options);
|
||||
_homeAssistant = homeAssistant;
|
||||
_lightOff = lightOff;
|
||||
_safety = safety;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
@@ -70,6 +80,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
["color_temp_kelvin"] = Math.Clamp(colorTemperatureKelvin, 2200, 5000)
|
||||
}, cancellationToken);
|
||||
|
||||
_safety.MarkOn();
|
||||
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
|
||||
Launch("sleep", SleepStartPercent);
|
||||
return GetStatus();
|
||||
@@ -84,7 +95,9 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
{
|
||||
await StopCurrentAsync();
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase);
|
||||
var knownOffAt = _safety.OffAt;
|
||||
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) ||
|
||||
(knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt));
|
||||
var brightness = light.GetDoubleAttribute("brightness");
|
||||
if (!isOff && brightness is null)
|
||||
throw new InvalidOperationException("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒");
|
||||
@@ -92,6 +105,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
|
||||
if (current >= WakeTargetPercent && !isOff)
|
||||
{
|
||||
_safety.MarkOn();
|
||||
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
|
||||
return GetStatus();
|
||||
}
|
||||
@@ -105,6 +119,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
_safety.MarkOn();
|
||||
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
|
||||
Launch("wake", current);
|
||||
return GetStatus();
|
||||
@@ -151,15 +166,16 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(StepInterval, cancellationToken);
|
||||
await Task.Delay(StepInterval, _time, cancellationToken);
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_safety.MarkOff(_time.GetUtcNow());
|
||||
if (mode == "sleep")
|
||||
{
|
||||
try
|
||||
{
|
||||
await StopMattressMassageAsync(cancellationToken);
|
||||
await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
@@ -191,10 +207,11 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
{
|
||||
["entity_id"] = LightEntityId
|
||||
}, cancellationToken);
|
||||
_safety.MarkOff(_time.GetUtcNow());
|
||||
SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩");
|
||||
try
|
||||
{
|
||||
await StopMattressMassageAsync(cancellationToken);
|
||||
await _lightOff.NotifyAsync(cancellationToken);
|
||||
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
@@ -227,43 +244,6 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopMattressMassageAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var massageEntities = new[]
|
||||
{
|
||||
_options.Entities.MattressLeftMassagePart,
|
||||
_options.Entities.MattressRightMassagePart
|
||||
}.Where(entityId => !string.IsNullOrWhiteSpace(entityId)).Distinct().ToArray();
|
||||
var failures = new List<string>();
|
||||
foreach (var entityId in massageEntities)
|
||||
{
|
||||
try
|
||||
{
|
||||
var confirmed = false;
|
||||
for (var attempt = 0; attempt < 2 && !confirmed; attempt++)
|
||||
{
|
||||
await _homeAssistant.CallServiceAsync("select", "select_option", new Dictionary<string, object?>
|
||||
{
|
||||
["entity_id"] = entityId,
|
||||
["option"] = "无"
|
||||
}, cancellationToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
var current = (await _homeAssistant.GetStatesAsync(cancellationToken))
|
||||
.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
||||
confirmed = current?.State == "无";
|
||||
}
|
||||
if (!confirmed) failures.Add(entityId);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(exception, "灯光熄灭后未能关闭床垫按摩 {EntityId}", entityId);
|
||||
failures.Add(entityId);
|
||||
}
|
||||
}
|
||||
if (failures.Count > 0)
|
||||
throw new InvalidOperationException($"灯光已关闭,但床垫按摩未确认关闭:{string.Join("、", failures)}");
|
||||
}
|
||||
|
||||
private async Task StopCurrentAsync()
|
||||
{
|
||||
if (_activeCancellation is null) return;
|
||||
|
||||
+11
-4
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
@@ -17,17 +18,20 @@ public sealed class HomeAssistantState
|
||||
[JsonPropertyName("last_updated")]
|
||||
public DateTimeOffset? LastUpdated { get; set; }
|
||||
|
||||
[JsonPropertyName("last_reported")]
|
||||
public DateTimeOffset? LastReported { get; set; }
|
||||
|
||||
public string? GetStringAttribute(string name)
|
||||
{
|
||||
if (!Attributes.TryGetProperty(name, out var value)) return null;
|
||||
if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
|
||||
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
|
||||
}
|
||||
|
||||
public double? GetDoubleAttribute(string name)
|
||||
{
|
||||
if (!Attributes.TryGetProperty(name, out var value)) return null;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d)) return d;
|
||||
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
|
||||
if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d)) return d;
|
||||
if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +58,9 @@ public sealed class HomeAssistantDeviceRegistryItem
|
||||
|
||||
[JsonPropertyName("disabled_by")]
|
||||
public string? DisabledBy { get; set; }
|
||||
|
||||
[JsonPropertyName("entry_type")]
|
||||
public string? EntryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantEntityRegistryItem
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
|
||||
|
||||
public sealed class HomeAssistantEntityOptions
|
||||
{
|
||||
public string Climate { get; set; } = string.Empty;
|
||||
public string Curtain { get; set; } = string.Empty;
|
||||
public string Outlet { get; set; } = string.Empty;
|
||||
public string Dehumidifier { get; set; } = string.Empty;
|
||||
public string DehumidifierFanLevel { get; set; } = string.Empty;
|
||||
public string DehumidifierUv { get; set; } = string.Empty;
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
|
||||
}
|
||||
return _cachedRegistries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message);
|
||||
return _cachedRegistries;
|
||||
@@ -119,7 +119,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
|
||||
|
||||
try
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", CancellationToken.None);
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", cts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -134,7 +134,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
|
||||
CachedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message);
|
||||
return null;
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
public interface IHomeAssistantGateway
|
||||
{
|
||||
Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default);
|
||||
Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using Evergreen.Health.Application.Health.Models;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
|
||||
public interface ILightTransitionRuntime
|
||||
{
|
||||
HomeAssistantLightTransitionOutput GetStatus();
|
||||
Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken);
|
||||
Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken);
|
||||
Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
|
||||
/// <summary>播放能力契约,未来音箱接入不要求是 MiAirX。</summary>
|
||||
public interface IAudioPlaybackGateway
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
Task PlayAsync(CancellationToken cancellationToken = default);
|
||||
Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ITcmMusicRuntime
|
||||
{
|
||||
TcmMusicTransitionOutput GetStatus();
|
||||
bool HasPlayableTracks(string constitutionCode);
|
||||
Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken);
|
||||
Task PlayTestMusicAsync(CancellationToken cancellationToken);
|
||||
Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken);
|
||||
Task StopMusicAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
|
||||
public sealed class MiAirXClient
|
||||
public sealed class MiAirXClient : IAudioPlaybackGateway
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly MiAirXOptions _options;
|
||||
@@ -23,7 +23,6 @@ public sealed class MiAirXClient
|
||||
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)
|
||||
@@ -48,6 +47,15 @@ public sealed class MiAirXClient
|
||||
}
|
||||
|
||||
private async Task SendCommandAsync(string path, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
try { await SendCommandCoreAsync(path, payload, cancellationToken); }
|
||||
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException("MiAirX 请求超时,未确认音箱操作完成", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendCommandCoreAsync(string path, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUri = GetBaseUri();
|
||||
if (!string.IsNullOrWhiteSpace(_options.WebPassword))
|
||||
|
||||
+22
-20
@@ -6,11 +6,14 @@ using System.Diagnostics;
|
||||
namespace Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
|
||||
/// <summary>Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。</summary>
|
||||
public sealed class TcmMusicTransitionService : IHostedService
|
||||
public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime
|
||||
{
|
||||
private readonly MiAirXClient _miAirX;
|
||||
private const int DemoStartVolume = 50;
|
||||
private const int DemoFadeStepSeconds = 5;
|
||||
private readonly IAudioPlaybackGateway _miAirX;
|
||||
private readonly TcmMusicOptions _options;
|
||||
private readonly ILogger<TcmMusicTransitionService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
||||
private readonly object _statusLock = new();
|
||||
private TcmMusicTransitionOutput _status = new();
|
||||
@@ -18,14 +21,15 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
private Task? _activeTask;
|
||||
|
||||
public TcmMusicTransitionService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<MiAirXOptions> miAirXOptions,
|
||||
IAudioPlaybackGateway audioGateway,
|
||||
IOptions<TcmMusicOptions> musicOptions,
|
||||
ILogger<TcmMusicTransitionService> logger)
|
||||
ILogger<TcmMusicTransitionService> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
_miAirX = new MiAirXClient(httpClientFactory.CreateClient(nameof(TcmMusicTransitionService)), miAirXOptions);
|
||||
_miAirX = audioGateway;
|
||||
_options = musicOptions.Value;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
@@ -53,24 +57,22 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
|
||||
public bool HasPlayableTracks(string constitutionCode)
|
||||
=> _miAirX.IsConfigured &&
|
||||
_options.Tracks.TryGetValue(constitutionCode, out var tracks) && tracks.Count > 0 &&
|
||||
_options.Tracks is not null && _options.Tracks.TryGetValue(constitutionCode, out var tracks) &&
|
||||
tracks is not null && 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.SetVolumeAsync(DemoStartVolume, cancellationToken);
|
||||
await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken);
|
||||
UpdateStatus(true, _options.StartVolume, "体质音乐正在自动渐弱");
|
||||
UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱");
|
||||
_activeCancellation = new CancellationTokenSource();
|
||||
_activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token);
|
||||
}
|
||||
@@ -119,23 +121,23 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
string constitutionCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
var currentVolume = _options.StartVolume;
|
||||
var nextFadeSecond = (double)_options.FadeStepSeconds;
|
||||
var start = _time.GetTimestamp();
|
||||
var currentVolume = DemoStartVolume;
|
||||
var nextFadeSecond = (double)DemoFadeStepSeconds;
|
||||
var trackStartSecond = 0d;
|
||||
var trackIndex = 0;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
var elapsed = clock.Elapsed.TotalSeconds;
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), _time, cancellationToken);
|
||||
var elapsed = _time.GetElapsedTime(start).TotalSeconds;
|
||||
while (elapsed >= nextFadeSecond && currentVolume > 0)
|
||||
{
|
||||
currentVolume--;
|
||||
await _miAirX.SetVolumeAsync(currentVolume, cancellationToken);
|
||||
UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱");
|
||||
nextFadeSecond += _options.FadeStepSeconds;
|
||||
nextFadeSecond += DemoFadeStepSeconds;
|
||||
}
|
||||
|
||||
if (currentVolume == 0)
|
||||
@@ -149,7 +151,7 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue;
|
||||
trackIndex = (trackIndex + 1) % tracks.Count;
|
||||
await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken);
|
||||
trackStartSecond = clock.Elapsed.TotalSeconds;
|
||||
trackStartSecond = _time.GetElapsedTime(start).TotalSeconds;
|
||||
_logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}",
|
||||
constitutionCode, tracks[trackIndex].Title, currentVolume);
|
||||
}
|
||||
@@ -192,7 +194,7 @@ public sealed class TcmMusicTransitionService : IHostedService
|
||||
}
|
||||
|
||||
private static bool IsValidTrack(TcmMusicTrackOptions track)
|
||||
=> track.DurationSeconds > 0 &&
|
||||
=> track is not null && double.IsFinite(track.DurationSeconds) && track.DurationSeconds > 0 &&
|
||||
Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme is "http" or "https";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Tuya;
|
||||
|
||||
/// <summary>未来独立涂鸦模块使用本接口,不要求经过 Home Assistant。</summary>
|
||||
public sealed record TuyaCapabilityWrite(string DeviceId, string Code, object? Value);
|
||||
public interface ITuyaGateway
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
Task WriteAsync(TuyaCapabilityWrite command, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ReservedTuyaGateway : ITuyaGateway
|
||||
{
|
||||
public bool IsConfigured => false;
|
||||
public Task WriteAsync(TuyaCapabilityWrite command, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
throw new NotSupportedException("涂鸦接入仅预留,本版没有发送控制命令");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user