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