This commit is contained in:
fuenmao
2026-10-09 16:46:30 +08:00
parent 4a634ef4c2
commit e6b90a06ab
63 changed files with 4170 additions and 1518 deletions
@@ -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)
@@ -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;
@@ -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;
@@ -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;
@@ -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);
}
@@ -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))
@@ -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("涂鸦接入仅预留,本版没有发送控制命令");
}
}