This commit is contained in:
fuenmao
2026-11-11 16:11:41 +08:00
parent 3ca90c4cf3
commit 61e25175db
39 changed files with 2176 additions and 31 deletions
@@ -0,0 +1,48 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Curtain;
public sealed class BedPresenceCurtainCloseAction(CurtainModule curtain, DeviceActionRunner runner,
IOptions<HomeAssistantOptions> options) : IBedPresenceAction
{
public string Key => "curtain-close";
public string Description => "关闭窗帘";
public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(55);
public bool SupportsTarget(string entityId) => options.Value.Enabled &&
entityId?.StartsWith("cover.", StringComparison.Ordinal) == true && entityId == curtain.PrimaryEntityId;
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
{
if (!SupportsTarget(entityId)) return "target-not-configured";
var state = await runner.ReadAvailableStateAsync(entityId, token);
if (state.State == "closed") return "already-closed";
if (state.State == "closing") return "already-closing";
if (state.State != "open") return "state-not-open";
var close = curtain.BuildParameterCommand("curtain_action", "close");
var command = close with
{
ExpectedState = "closed",
ValidateCurrentState = current =>
{
close.ValidateCurrentState?.Invoke(current);
if (current.State != "open") throw new InvalidOperationException("窗帘状态已改变,跳过关帘");
}
};
await runner.SendAsync(command, token);
try
{
// 机械行程需要较长回读时间。沿用窗帘命令的只读回读配置,失败不重发关帘。
await runner.VerifyParameterAsync(command, token, verificationAttempts: 20);
return "readback-confirmed";
}
catch (InvalidOperationException)
{
return "sent-readback-unconfirmed";
}
}
}
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Curtain;
/// <summary>窗帘开合不是电源开关;本版只提供单独手动控制,不擅自加入舒睡/全关。</summary>
/// <summary>集中定义窗帘开合动作和能力校验,供手动控制与独立联动复用。</summary>
public sealed class CurtainModule : HealthDeviceModule
{
private readonly string _entityId;
@@ -70,7 +70,7 @@ public sealed class CurtainModule : HealthDeviceModule
{
cancellationToken.ThrowIfCancellationRequested();
context.Results.Add(new() { Name = "窗帘场景联动", EntityId = _entityId, Status = "Skipped",
Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "本版窗帘只支持手动控制,舒睡不自动改变开合位置" });
Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "一键舒睡保持窗帘当前位置" });
return Task.CompletedTask;
}
}
@@ -39,6 +39,10 @@ public sealed class HomeAssistantDeviceCatalog
{
var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
if (regEntities.Count == 0) continue;
var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var module = _modules.ForEntities(entityIds);
var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId));
if (_options.OnlyConfiguredDevices && module is null && displayOnly is null) continue;
var entityOutputs = new List<HomeAssistantEntityOutput>();
foreach (var regEntity in regEntities)
@@ -76,10 +80,9 @@ public sealed class HomeAssistantDeviceCatalog
? an
: null;
var module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId));
var category = module?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
var category = module?.Category ?? displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
var metrics = ExtractDeviceMetrics(category, entityOutputs);
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
var primaryControlEntityId = module is null && displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs);
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
{
@@ -134,6 +137,20 @@ public sealed class HomeAssistantDeviceCatalog
Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs)
});
}
foreach (var device in _options.DisplayOnlyDevices)
{
if (string.IsNullOrWhiteSpace(device.EntityId) || _modules.ForEntities([device.EntityId]) is not null ||
_options.ExcludedDeviceEntityIds.Contains(device.EntityId, StringComparer.OrdinalIgnoreCase)) continue;
stateMap.TryGetValue(device.EntityId, out var state);
var available = state is not null && IsAvailable(state);
list.Add(new()
{
DeviceId = $"fallback_{device.EntityId}", Name = device.Name, Category = device.Category,
Online = available, Entities = [new() { EntityId = device.EntityId, Domain = device.EntityId.Split('.')[0],
Name = state?.GetStringAttribute("friendly_name") ?? device.Name, State = state?.State ?? "unavailable",
Available = available, Attributes = ExtractSafeAttributes(state) }]
});
}
return list;
}
@@ -15,6 +15,7 @@ using Evergreen.Health.Application.Health.Sleep;
using Evergreen.Health.Application.Health.Integrations.Tuya;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Evergreen.Health.Application.Health.Integrations.Ib43;
namespace Evergreen.Health.Application.Health.Devices;
@@ -23,6 +24,20 @@ public static class HealthDeviceRegistration
public static IServiceCollection AddHealthDevices(this IServiceCollection services)
{
services.AddSingleton(TimeProvider.System);
services.AddOptions<Ib43Options>();
services.AddSingleton<Ib43Parser>();
services.AddSingleton<Ib43StateStore>();
services.AddScoped(provider => new Ib43DataRepository(
provider.GetRequiredService<SqlSugar.ISqlSugarClient>().AsTenant().GetConnectionScope(SqlSugarConst.MainConfigId)));
services.AddSingleton<IIb43ReportSink, Ib43ReportSink>();
services.AddSingleton<IIb43MqttTransportFactory, Ib43MqttTransportFactory>();
services.AddOptions<BedPresenceAutomationOptions>();
services.AddSingleton<IBedPresenceAction, BedPresenceSleepLightAction>();
services.AddSingleton<IBedPresenceAction, BedPresenceCurtainCloseAction>();
services.AddSingleton<BedPresenceAutomation>();
services.AddSingleton<IBedPresenceSink>(provider => provider.GetRequiredService<BedPresenceAutomation>());
services.AddHostedService(provider => provider.GetRequiredService<BedPresenceAutomation>());
services.AddHostedService<Ib43MqttService>();
services.AddSingleton(new DeviceExecutionOptions());
// 仅注册扩展契约,不启动传感器轮询、上床流程或占位音频播放。
services.AddSingleton(new SleepWorkflowOptions());
@@ -49,7 +64,6 @@ public static class HealthDeviceRegistration
services.AddSingleton<CurtainModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<CurtainModule>());
services.AddSingleton<OutletModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<OutletModule>());
services.AddSingleton<TcmMusicTransitionService>();
services.AddSingleton<ITcmMusicRuntime>(provider => provider.GetRequiredService<TcmMusicTransitionService>());
services.AddHostedService(provider => provider.GetRequiredService<TcmMusicTransitionService>());
@@ -65,15 +79,20 @@ public static class HealthDeviceRegistration
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.Entities.AmbientLight),
provider.GetRequiredService<LightOffDispatcher>()));
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
services.AddSingleton<AudioModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
services.AddSingleton<LightOffDispatcher>();
services.AddSingleton<DeviceModuleRegistry>(provider => new(
provider.GetServices<IHealthDeviceModule>().Concat(
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.AdditionalSleepLights.Select(light =>
new ConfiguredSleepLightModule(provider.GetRequiredService<DeviceActionRunner>(), light,
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(light.EntityId))))));
services.AddSingleton<DeviceModuleRegistry>(provider =>
{
var options = provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value;
var modules = provider.GetServices<IHealthDeviceModule>().Where(module => module.EntityIds.Any()).ToList();
if (!string.IsNullOrWhiteSpace(options.Entities.Outlet)) modules.Add(provider.GetRequiredService<OutletModule>());
if (!string.IsNullOrWhiteSpace(options.Entities.AmbientLight)) modules.Add(provider.GetRequiredService<LightingModule>());
var lights = provider.GetRequiredService<HomeAssistantLightTransitionService>();
var runner = provider.GetRequiredService<DeviceActionRunner>();
modules.AddRange(options.AdditionalSleepLights.Select(light => new ConfiguredSleepLightModule(runner, light, lights.ForEntity(light.EntityId))));
return new DeviceModuleRegistry(modules);
});
services.AddSingleton<IStandardDeviceControl, StandardLightControl>();
services.AddSingleton<DeviceParameterController>();
services.AddSingleton<HomeAssistantDeviceCatalog>();
@@ -0,0 +1,37 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes;
using Evergreen.Health.Application.Health.Sleep;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Devices.Lighting;
public sealed class BedPresenceSleepLightAction(HomeAssistantLightTransitionService lights, DeviceActionRunner runner,
DeviceExecutionOptions execution, IOptions<HomeAssistantOptions> options) : IBedPresenceAction
{
public string Key => "light-sleep";
public string Description => "启动助眠渐暗";
public bool SupportsTarget(string entityId) => options.Value.Enabled && entityId?.StartsWith("light.", StringComparison.Ordinal) == true &&
(entityId == options.Value.Entities.AmbientLight || options.Value.AdditionalSleepLights.Any(light => light.EntityId == entityId));
public async Task<string> ExecuteAsync(string entityId, CancellationToken token)
{
if (!SupportsTarget(entityId)) return "target-not-configured";
var transition = await lights.StartSleepIfIdleAsync(entityId, options.Value.SleepScene.LightColorTemperatureKelvin, token);
if (transition == null) return "already-running";
for (var attempt = 0; attempt < execution.VerificationAttempts; attempt++)
{
await runner.DelayAsync(execution.VerificationDelay, token);
try
{
var state = await runner.ReadAvailableStateAsync(entityId, token);
var brightness = state.GetDoubleAttribute("brightness");
if (state.State == "on" && brightness is double value &&
Math.Abs(value / 255 * 100 - transition.BrightnessPercent) <= 1.5)
return "readback-confirmed";
}
catch (InvalidOperationException) { /* 实体暂未回报,下一轮只重读状态。 */ }
}
return "sent-readback-unconfirmed";
}
}