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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Entities;
|
||||
|
||||
[SugarTable("health_ib43_binding", "IB43床位用户绑定历史")]
|
||||
[SugarIndex("ux_ib43_active_slot", nameof(ActiveSlot), OrderByType.Asc, IsUnique = true)]
|
||||
[SugarIndex("ix_ib43_binding_user", nameof(UserId), OrderByType.Asc)]
|
||||
public sealed class HealthIb43Binding
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, Length = 32)]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
[SugarColumn(Length = 64)]
|
||||
public string DeviceKey { get; set; } = "";
|
||||
[SugarColumn(Length = 128)]
|
||||
public string ProductKey { get; set; } = "";
|
||||
[SugarColumn(Length = 128)]
|
||||
public string SerialNumber { get; set; } = "";
|
||||
[SugarColumn(Length = 5)]
|
||||
public string Side { get; set; } = "";
|
||||
// 活跃时为设备哈希+侧别,解绑时为 NULL。数据库唯一索引防止并发抢占。
|
||||
[SugarColumn(Length = 70, IsNullable = true)]
|
||||
public string ActiveSlot { get; set; }
|
||||
public long UserId { get; set; }
|
||||
[SugarColumn(Length = 80)]
|
||||
public string BedName { get; set; } = "";
|
||||
public bool Simulation { get; set; }
|
||||
public long StartedAtMs { get; set; }
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? EndedAtMs { get; set; }
|
||||
public long AssignedBy { get; set; }
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? ReleasedBy { get; set; }
|
||||
}
|
||||
|
||||
[SugarTable("health_ib43_observation", "IB43按用户侧别保存的观测增量")]
|
||||
[SugarIndex("ux_ib43_report_binding", nameof(ReportId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, IsUnique = true)]
|
||||
[SugarIndex("ix_ib43_obs_owner_time", nameof(UserId), OrderByType.Asc, nameof(BindingId), OrderByType.Asc, nameof(ReceivedAtMs), OrderByType.Asc)]
|
||||
public sealed class HealthIb43Observation
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
[SugarColumn(Length = 32)]
|
||||
public string ReportId { get; set; } = "";
|
||||
[SugarColumn(Length = 32)]
|
||||
public string BindingId { get; set; } = "";
|
||||
public long UserId { get; set; }
|
||||
public long ReceivedAtMs { get; set; }
|
||||
public bool HasHeartRate { get; set; }
|
||||
public bool HasRespiration { get; set; }
|
||||
public bool HasPresence { get; set; }
|
||||
public bool HasSnoring { get; set; }
|
||||
// 只含该侧有效解析字段及来源,不含另一侧、原始完整报文或凭据。
|
||||
[SugarColumn(ColumnDataType = "text")]
|
||||
public string ReadingsJson { get; set; } = "{}";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Entities;
|
||||
|
||||
[SugarTable("health_ib43_maintenance_audit", "IB43本地维护审计")]
|
||||
public sealed class HealthIb43MaintenanceAudit
|
||||
{
|
||||
[SugarColumn(IsPrimaryKey = true, Length = 32)] public string Id { get; set; }
|
||||
[SugarColumn(Length = 32)] public string BindingId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
[SugarColumn(Length = 200)] public string Actor { get; set; }
|
||||
[SugarColumn(Length = 64)] public string Operation { get; set; }
|
||||
public long OccurredAtMs { get; set; }
|
||||
}
|
||||
+15
-4
@@ -30,8 +30,13 @@ public sealed class HomeAssistantLightTransitionService : IHostedService, ILight
|
||||
}
|
||||
|
||||
public ILightTransitionRuntime ForEntity(string entityId)
|
||||
=> _lights.TryGetValue(entityId, out var light) ? light
|
||||
: entityId == _primary.GetStatus().EntityId ? _primary
|
||||
=> ResolveLight(entityId);
|
||||
|
||||
public Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(string entityId, int kelvin, CancellationToken token)
|
||||
=> ResolveLight(entityId).StartSleepIfIdleAsync(kelvin, token);
|
||||
|
||||
private LightTransitionWorker ResolveLight(string entityId)
|
||||
=> !string.IsNullOrWhiteSpace(entityId) && _lights.TryGetValue(entityId, out var light) ? light
|
||||
: throw new InvalidOperationException("该灯未配置为场景灯");
|
||||
|
||||
public HomeAssistantLightTransitionOutput GetStatus()
|
||||
@@ -44,31 +49,37 @@ public sealed class HomeAssistantLightTransitionService : IHostedService, ILight
|
||||
// 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。
|
||||
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken token)
|
||||
{
|
||||
EnsurePrimaryConfigured();
|
||||
await _primary.StartSleepAsync(kelvin, token);
|
||||
return GetStatus();
|
||||
}
|
||||
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken token)
|
||||
{
|
||||
EnsurePrimaryConfigured();
|
||||
await _primary.StartWakeAsync(token);
|
||||
return GetStatus();
|
||||
}
|
||||
public async Task<HomeAssistantLightTransitionOutput> CancelPrimaryAsync(CancellationToken token)
|
||||
{
|
||||
EnsurePrimaryConfigured();
|
||||
await _primary.CancelAsync(token);
|
||||
return GetStatus();
|
||||
}
|
||||
|
||||
private void EnsurePrimaryConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_primary.GetStatus().EntityId)) throw new InvalidOperationException("卧室灯光未配置");
|
||||
}
|
||||
|
||||
// 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。
|
||||
public async Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken token)
|
||||
{
|
||||
foreach (var light in _lights.Values) await light.CancelAsync(token);
|
||||
if (_lights.Count == 0) await _primary.CancelAsync(token);
|
||||
return GetStatus();
|
||||
}
|
||||
public Task StartAsync(CancellationToken token) => Task.CompletedTask;
|
||||
public async Task StopAsync(CancellationToken token)
|
||||
{
|
||||
foreach (var light in _lights.Values) await light.StopAsync(token);
|
||||
if (_lights.Count == 0) await _primary.StopAsync(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,20 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
|
||||
public int TimeoutSeconds { get; set; } = 15;
|
||||
// 匹配已退役设备的明确实体绑定,不按名称或品牌隐藏设备。
|
||||
public List<string> ExcludedDeviceEntityIds { get; set; } = [];
|
||||
public bool OnlyConfiguredDevices { get; set; }
|
||||
public List<DisplayOnlyDeviceOptions> DisplayOnlyDevices { get; set; } = [];
|
||||
public HomeAssistantEntityOptions Entities { get; set; } = new();
|
||||
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
|
||||
public List<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DisplayOnlyDeviceOptions
|
||||
{
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = "Other";
|
||||
}
|
||||
|
||||
/// <summary>明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。</summary>
|
||||
public sealed class SceneLightOptions
|
||||
{
|
||||
|
||||
+8
@@ -71,10 +71,18 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
|
||||
}
|
||||
|
||||
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
||||
=> (await StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: false, cancellationToken))!;
|
||||
|
||||
internal Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
||||
=> StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: true, cancellationToken);
|
||||
|
||||
private async Task<HomeAssistantLightTransitionOutput?> StartSleepCoreAsync(int colorTemperatureKelvin, bool onlyIfIdle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _operationLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (onlyIfIdle && GetStatus().Running) return null;
|
||||
await StopCurrentAsync();
|
||||
var light = await ReadAvailableLightAsync(cancellationToken);
|
||||
var data = BuildSleepStartData(light, colorTemperatureKelvin);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public static class Ib43Configuration
|
||||
{
|
||||
// 在框架的 JSON 扫描和常规绑定后执行,仅覆盖明确提供的 IB43 环境变量。
|
||||
public static void ApplyEnvironmentOverrides(Ib43Options options)
|
||||
{
|
||||
new ConfigurationBuilder().AddEnvironmentVariables("Ib43__").Build().Bind(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Furion.FriendlyException;
|
||||
using SqlSugar;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43DataRepository(ISqlSugarClient db)
|
||||
{
|
||||
public static string DeviceKey(string productKey, string serialNumber) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(productKey + "/" + serialNumber)));
|
||||
|
||||
public Task<bool> UserEnabled(long userId) => db.Queryable<HealthUser>()
|
||||
.AnyAsync(u => u.Id == userId && !u.IsDelete && u.Status == HealthUserStatus.Enabled);
|
||||
|
||||
public async Task<bool> AdminEnabled(long userId) => await db.Queryable<SysUser>()
|
||||
.Where(u => u.Id == userId && u.AccountType == AccountTypeEnum.SuperAdmin && u.Status == StatusEnum.Enable).CountAsync() > 0;
|
||||
|
||||
public Task<List<HealthIb43Binding>> Bindings(long userId) => db.Queryable<HealthIb43Binding>()
|
||||
.Where(b => b.UserId == userId).OrderBy(b => b.StartedAtMs, OrderByType.Desc).ToListAsync();
|
||||
|
||||
public Task<List<HealthIb43Binding>> ActiveBindings(string productKey, string serialNumber)
|
||||
{
|
||||
var key = DeviceKey(productKey, serialNumber);
|
||||
return db.Queryable<HealthIb43Binding>().Where(b => b.DeviceKey == key && b.EndedAtMs == null).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<HealthIb43Binding> OwnedBinding(long userId, string id) =>
|
||||
await db.Queryable<HealthIb43Binding>().Where(b => b.Id == id && b.UserId == userId).FirstAsync()
|
||||
?? throw new AppFriendlyException("床位绑定不存在或无权访问", null) { StatusCode = 404 };
|
||||
|
||||
public async Task Assign(HealthIb43Binding binding)
|
||||
{
|
||||
if (await db.Queryable<HealthIb43Binding>().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
|
||||
throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
|
||||
try { await db.Insertable(binding).ExecuteCommandAsync(); }
|
||||
catch
|
||||
{
|
||||
if (await db.Queryable<HealthIb43Binding>().AnyAsync(b => b.ActiveSlot == binding.ActiveSlot))
|
||||
throw new AppFriendlyException("该侧床位已分配,请先解绑", null) { StatusCode = 409 };
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Release(string id, long adminId, long endedAtMs)
|
||||
{
|
||||
var count = await db.Updateable<HealthIb43Binding>()
|
||||
.SetColumns(b => new HealthIb43Binding { ActiveSlot = null, EndedAtMs = endedAtMs, ReleasedBy = adminId })
|
||||
.Where(b => b.Id == id && b.EndedAtMs == null).ExecuteCommandAsync();
|
||||
if (count == 0) throw new AppFriendlyException("绑定不存在或已解绑", null) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
public async Task<Ib43SavedReport> Save(string reportId, Ib43Binding device, Ib43Report report)
|
||||
{
|
||||
if (!report.Accepted || report.Readings.Count == 0) return new(0, []);
|
||||
var matched = 0;
|
||||
var activeBeds = new List<Ib43SavedBed>();
|
||||
var key = DeviceKey(device.ProductKey, device.SerialNumber);
|
||||
var received = report.ReceivedAt.ToUnixTimeMilliseconds();
|
||||
// 按接收时刻归属;数据库重试期间发生解绑也不转移给新用户。
|
||||
var bindings = await db.Queryable<HealthIb43Binding>().Where(b => b.DeviceKey == key &&
|
||||
b.Simulation == device.Simulation && b.StartedAtMs <= received &&
|
||||
(b.EndedAtMs == null || b.EndedAtMs > received)).ToListAsync();
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
var readings = report.Readings.Where(p => p.Key.StartsWith(binding.Side + ".", StringComparison.Ordinal))
|
||||
.ToDictionary(p => p.Key[(binding.Side.Length + 1)..], p => p.Value);
|
||||
if (readings.Count == 0) continue;
|
||||
matched++;
|
||||
if (binding.EndedAtMs == null) activeBeds.Add(new(binding.Id, binding.Side));
|
||||
if (await db.Queryable<HealthIb43Observation>().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) continue;
|
||||
var observation = new HealthIb43Observation
|
||||
{
|
||||
ReportId = reportId, BindingId = binding.Id, UserId = binding.UserId, ReceivedAtMs = received,
|
||||
HasHeartRate = readings.ContainsKey("heartRate"), HasRespiration = readings.ContainsKey("respiration"),
|
||||
HasPresence = readings.ContainsKey("presence"), HasSnoring = readings.ContainsKey("snoring"),
|
||||
ReadingsJson = JsonSerializer.Serialize(readings)
|
||||
};
|
||||
try { await db.Insertable(observation).ExecuteCommandAsync(); }
|
||||
catch
|
||||
{
|
||||
// 同一进程处理的报告重试幂等;没有设备序号,不能对 MQTT 重投宣称去重。
|
||||
if (!await db.Queryable<HealthIb43Observation>().AnyAsync(o => o.ReportId == reportId && o.BindingId == binding.Id)) throw;
|
||||
}
|
||||
}
|
||||
return new(matched, activeBeds);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, Ib43Reading>> Latest(long userId, string bindingId)
|
||||
{
|
||||
var output = new Dictionary<string, Ib43Reading>();
|
||||
foreach (var metric in new[] { "heartRate", "respiration", "presence", "snoring" })
|
||||
{
|
||||
var query = db.Queryable<HealthIb43Observation>().Where(o => o.UserId == userId && o.BindingId == bindingId);
|
||||
query = metric switch
|
||||
{
|
||||
"heartRate" => query.Where(o => o.HasHeartRate),
|
||||
"respiration" => query.Where(o => o.HasRespiration),
|
||||
"presence" => query.Where(o => o.HasPresence),
|
||||
_ => query.Where(o => o.HasSnoring)
|
||||
};
|
||||
var row = await query.OrderBy(o => o.ReceivedAtMs, OrderByType.Desc).OrderBy(o => o.Id, OrderByType.Desc).FirstAsync();
|
||||
if (row != null) output[metric] = JsonSerializer.Deserialize<Dictionary<string, Ib43Reading>>(row.ReadingsJson)[metric];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public Task<List<HealthIb43Observation>> History(long userId, string bindingId, long fromMs, long toMs, long? beforeId, int take)
|
||||
=> db.Queryable<HealthIb43Observation>().Where(o => o.UserId == userId && o.BindingId == bindingId &&
|
||||
o.ReceivedAtMs >= fromMs && o.ReceivedAtMs < toMs)
|
||||
.WhereIF(beforeId.HasValue, o => o.Id < beforeId.Value)
|
||||
.OrderBy(o => o.Id, OrderByType.Desc).Take(take).ToListAsync();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
// 仅由本地维护程序调用;不是 Controller,也不注册 HTTP 路由。
|
||||
public sealed class Ib43LocalBinding(ISqlSugarClient db, TimeProvider clock)
|
||||
{
|
||||
public async Task<HealthIb43Binding> Run(string phone, Ib43Options options, string actor, bool apply)
|
||||
{
|
||||
var device = options.Devices.SingleOrDefault(d => d.ProductKey == "evergreen-test" && d.SerialNumber == "IB43SIM001");
|
||||
if (device?.Simulation != true) throw new InvalidOperationException("模拟设备白名单不匹配,已停止。");
|
||||
var users = await db.Queryable<HealthUser>().Where(u => u.Phone == phone && !u.IsDelete &&
|
||||
u.Status == HealthUserStatus.Enabled).Select(u => new { u.Id }).ToListAsync();
|
||||
if (users.Count != 1) throw new InvalidOperationException("未找到唯一且已启用的健康账号,已停止。");
|
||||
var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
|
||||
var slot = key + ".left";
|
||||
var existing = await db.Queryable<HealthIb43Binding>().Where(b => b.ActiveSlot == slot).FirstAsync();
|
||||
if (existing != null)
|
||||
{
|
||||
if (existing.UserId != users[0].Id || !existing.Simulation || existing.EndedAtMs != null)
|
||||
throw new InvalidOperationException("左侧已被其他用户或非模拟绑定占用,未覆盖或解绑。");
|
||||
return existing;
|
||||
}
|
||||
var binding = new HealthIb43Binding
|
||||
{
|
||||
DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
|
||||
Side = "left", ActiveSlot = slot, UserId = users[0].Id, BedName = "IB43模拟床垫左侧",
|
||||
Simulation = true, StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = 0
|
||||
};
|
||||
if (!apply) return binding;
|
||||
if (string.IsNullOrWhiteSpace(actor) || actor.Length > 200) throw new InvalidOperationException("维护操作者信息不合法。");
|
||||
var stage = "binding";
|
||||
await db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
await db.Insertable(binding).ExecuteCommandAsync();
|
||||
stage = "audit";
|
||||
await db.Insertable(new HealthIb43MaintenanceAudit
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"), BindingId = binding.Id, UserId = binding.UserId,
|
||||
Actor = actor, OccurredAtMs = binding.StartedAtMs, Operation = "local-bind-simulator-left"
|
||||
}).ExecuteCommandAsync();
|
||||
stage = "commit";
|
||||
await db.Ado.CommitTranAsync();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await db.Ado.RollbackTranAsync();
|
||||
var code = error is MySqlConnector.MySqlException mysql ? $" MySQL {mysql.Number}" : "";
|
||||
throw new InvalidOperationException($"绑定事务失败 ({stage}, {error.GetType().Name}{code}),请重新预检确认数据库状态。", error);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43MqttService(IOptions<Ib43Options> options, IIb43MqttTransportFactory factory,
|
||||
Ib43StateStore store, TimeProvider clock, ILogger<Ib43MqttService> logger, IIb43ReportSink sink = null,
|
||||
IBedPresenceSink presenceSink = null) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var configuration = options.Value;
|
||||
logger.LogInformation("IB43 MQTT startup: Enabled={Enabled}, Host={Host}, Port={Port}, PasswordConfigured={PasswordConfigured}, Devices={Count}",
|
||||
configuration.Enabled, configuration.Host, configuration.Port,
|
||||
!string.IsNullOrWhiteSpace(configuration.Password), configuration.Devices?.Count ?? 0);
|
||||
if (!configuration.Enabled)
|
||||
{
|
||||
logger.LogInformation("IB43 MQTT disabled; set Ib43__Enabled=true to enable");
|
||||
return;
|
||||
}
|
||||
// 即使直接构造(测试/其他宿主)也不带错误配置进入重试。
|
||||
if (!configuration.IsValid()) throw new InvalidOperationException("Invalid Ib43 configuration");
|
||||
var failures = 0;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var connectedAt = clock.GetUtcNow();
|
||||
var connectionId = Guid.NewGuid();
|
||||
using var connection = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
var connectionToken = connection.Token;
|
||||
try
|
||||
{
|
||||
using var transport = factory.Create();
|
||||
var seenKinds = new HashSet<string>(StringComparer.Ordinal);
|
||||
int? lastMatchedBeds = null;
|
||||
transport.Message += async (topic, payload, retained) =>
|
||||
{
|
||||
var report = store.Receive(topic, payload, retained);
|
||||
if (sink != null && report.Accepted && report.Readings.Count > 0)
|
||||
{
|
||||
var binding = configuration.Devices.Single(d => d.PostTopic == topic);
|
||||
var reportId = Guid.NewGuid().ToString("N");
|
||||
Ib43SavedReport saved;
|
||||
// 等待持久化再结束回调(MQTT 自动 ACK 在此之后)。失败时阻塞重试而非静默丢弃。
|
||||
while (true)
|
||||
{
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
saved = await sink.SaveAsync(reportId, binding, report, stoppingToken);
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning("IB43 persistence failed ({Type}); retrying in 5 seconds", error.GetType().Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), clock, stoppingToken);
|
||||
}
|
||||
}
|
||||
if (saved.MatchedCount != lastMatchedBeds)
|
||||
logger.LogInformation("IB43 persistence: matched beds={Count}; zero means no user observation saved", saved.MatchedCount);
|
||||
lastMatchedBeds = saved.MatchedCount;
|
||||
// 发布失败与写库重试分开,联动问题不能导致重写已保存的报文。
|
||||
try { Ib43PresenceAdapter.Publish(presenceSink, binding, report, saved.ActiveBeds, connectionId, connectionToken); }
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning("IB43 presence publication failed ({Type})", error.GetType().Name);
|
||||
}
|
||||
}
|
||||
if (!report.Accepted || report.Issues.Count > 0)
|
||||
logger.LogDebug("IB43 report has {Count} parsing issues", report.Issues.Count);
|
||||
if (report.Accepted && (report.Readings.Count > 0 || report.Fields.Count > 0))
|
||||
foreach (var kind in report.Kinds)
|
||||
if (seenKinds.Add(kind))
|
||||
logger.LogInformation("IB43 first {Kind} report received in this connection", kind);
|
||||
};
|
||||
transport.OversizedMessage += store.RejectOversized;
|
||||
store.SetTransport("connecting");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
await transport.ConnectAndSubscribeAsync(configuration, timeout.Token);
|
||||
store.SetTransport("connected");
|
||||
connectedAt = clock.GetUtcNow();
|
||||
logger.LogInformation("IB43 MQTT connected and subscribed to {Count} configured devices", configuration.Devices.Count);
|
||||
while (transport.IsConnected)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), clock, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 不写密码、连接选项、完整异常文本或原始健康报文。
|
||||
logger.LogWarning("IB43 MQTT connection/subscription failed ({Type}); retrying", exception.GetType().Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.Cancel();
|
||||
store.SetTransport("disconnected");
|
||||
}
|
||||
if (clock.GetUtcNow() - connectedAt > TimeSpan.FromMinutes(1)) failures = 0;
|
||||
var seconds = Math.Min(30, Math.Pow(2, Math.Min(failures++, 5)));
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(seconds), clock, stoppingToken); }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using MQTTnet;
|
||||
using MQTTnet.Client;
|
||||
using MQTTnet.Formatter;
|
||||
using MQTTnet.Protocol;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public interface IIb43MqttTransport : IDisposable
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
event Func<string, string, bool, Task> Message;
|
||||
event Action<string> OversizedMessage;
|
||||
Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IIb43MqttTransportFactory
|
||||
{
|
||||
IIb43MqttTransport Create();
|
||||
}
|
||||
|
||||
public sealed class Ib43MqttTransportFactory : IIb43MqttTransportFactory
|
||||
{
|
||||
public IIb43MqttTransport Create() => new Ib43MqttTransport();
|
||||
}
|
||||
|
||||
public sealed class Ib43MqttTransport : IIb43MqttTransport
|
||||
{
|
||||
private readonly IMqttClient _client = new MqttFactory().CreateMqttClient();
|
||||
public bool IsConnected => _client.IsConnected;
|
||||
public event Func<string, string, bool, Task> Message;
|
||||
public event Action<string> OversizedMessage;
|
||||
|
||||
public Ib43MqttTransport()
|
||||
{
|
||||
_client.ApplicationMessageReceivedAsync += async e =>
|
||||
{
|
||||
var m = e.ApplicationMessage;
|
||||
// 在解码前限制长度;不要将大包全部复制进诊断缓存。
|
||||
if (m.PayloadSegment.Count > Ib43StateStore.MaxPayloadBytes)
|
||||
OversizedMessage?.Invoke(m.Topic);
|
||||
else if (Message != null) await Message.Invoke(m.Topic, m.ConvertPayloadToString(), m.Retain);
|
||||
};
|
||||
}
|
||||
|
||||
public async Task ConnectAndSubscribeAsync(Ib43Options options, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = new MqttClientOptionsBuilder().WithTcpServer(options.Host, options.Port)
|
||||
.WithClientId(options.ClientId).WithCredentials(options.Username, options.Password)
|
||||
.WithProtocolVersion(MqttProtocolVersion.V311).WithCleanSession()
|
||||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30)).WithTimeout(TimeSpan.FromSeconds(10));
|
||||
if (options.UseTls) builder.WithTlsOptions(t => t.UseTls());
|
||||
await _client.ConnectAsync(builder.Build(), cancellationToken);
|
||||
var subscription = new MqttClientSubscribeOptionsBuilder();
|
||||
foreach (var device in options.Devices)
|
||||
subscription.WithTopicFilter(f => f.WithTopic(device.PostTopic)
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce));
|
||||
var result = await _client.SubscribeAsync(subscription.Build(), cancellationToken);
|
||||
if (result.Items.Count != options.Devices.Count || result.Items.Any(i => (int)i.ResultCode >= 128))
|
||||
throw new InvalidOperationException("IB43 subscription rejected");
|
||||
}
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed class Ib43Options
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string Host { get; set; } = "192.168.2.2";
|
||||
public int Port { get; set; } = 1883;
|
||||
public bool UseTls { get; set; }
|
||||
public string Username { get; set; } = "evergreen-backend";
|
||||
public string Password { get; set; } = "";
|
||||
public string ClientId { get; set; } = "evergreen-health-backend";
|
||||
public int StaleAfterSeconds { get; set; } = 150;
|
||||
public List<Ib43Binding> Devices { get; set; } = [];
|
||||
|
||||
public bool IsValid() => !Enabled ||
|
||||
(!string.IsNullOrWhiteSpace(Host) && Port is > 0 and <= 65535 &&
|
||||
!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password) &&
|
||||
!string.IsNullOrWhiteSpace(ClientId) && StaleAfterSeconds > 0 &&
|
||||
Devices is { Count: > 0 } && Devices.All(d => d != null &&
|
||||
ValidSegment(d.ProductKey) && ValidSegment(d.SerialNumber) &&
|
||||
d.PersonLeftIndex is null or 0 or 1) &&
|
||||
Devices.Select(d => d.PostTopic).Distinct(StringComparer.Ordinal).Count() == Devices.Count);
|
||||
|
||||
private static bool ValidSegment(string value) => !string.IsNullOrWhiteSpace(value) &&
|
||||
!value.Any(c => c is '/' or '+' or '#' || char.IsControl(c));
|
||||
}
|
||||
|
||||
public sealed class Ib43Binding
|
||||
{
|
||||
public string ProductKey { get; set; } = "";
|
||||
public string SerialNumber { get; set; } = "";
|
||||
public bool Simulation { get; set; } = true;
|
||||
// 实机验证前保持 null;不可从数组顺序猜左右。
|
||||
public int? PersonLeftIndex { get; set; }
|
||||
public string PostTopic => $"/sys/{ProductKey}/{SerialNumber}/thing/property/post";
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed record Ib43Reading(double? Value, DateTimeOffset ReceivedAt, string Source);
|
||||
|
||||
public sealed class Ib43Report
|
||||
{
|
||||
public bool Accepted { get; internal set; }
|
||||
public string Raw { get; init; }
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public List<string> Issues { get; } = [];
|
||||
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, JsonElement> Fields { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> Kinds { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>只解析协议事实;不产生控制、健康告警或睡眠分期。</summary>
|
||||
public sealed class Ib43Parser
|
||||
{
|
||||
public Ib43Report Parse(string topic, string payload, Ib43Binding binding, DateTimeOffset receivedAt)
|
||||
{
|
||||
var r = new Ib43Report { Raw = payload, ReceivedAt = receivedAt };
|
||||
if (topic != binding.PostTopic) return Reject(r, "topic-mismatch");
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
if (HasDuplicates(root)) return Reject(r, "duplicate-json-key");
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String ||
|
||||
method.GetString() != "thing.property.post" ||
|
||||
!root.TryGetProperty("params", out var p) || p.ValueKind != JsonValueKind.Object)
|
||||
return Reject(r, "invalid-envelope");
|
||||
if (!p.TryGetProperty("deviceName", out var sn) || sn.ValueKind != JsonValueKind.String ||
|
||||
sn.GetString() != binding.SerialNumber ||
|
||||
(p.TryGetProperty("productKey", out var pk) &&
|
||||
(pk.ValueKind != JsonValueKind.String || pk.GetString() != binding.ProductKey)))
|
||||
return Reject(r, "identity-mismatch");
|
||||
if (root.TryGetProperty("version", out var version) &&
|
||||
(version.ValueKind != JsonValueKind.String || version.GetString() != "1.0"))
|
||||
return Reject(r, "unsupported-version");
|
||||
r.Accepted = true;
|
||||
foreach (var key in new[] { "firmwareVer", "characteristic", "deviceMode" })
|
||||
{
|
||||
if (!p.TryGetProperty(key, out var value)) continue;
|
||||
r.Kinds.Add("startup");
|
||||
var valid = key switch
|
||||
{
|
||||
"firmwareVer" => value.ValueKind == JsonValueKind.String,
|
||||
"characteristic" => value.ValueKind == JsonValueKind.Object,
|
||||
_ => value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out _)
|
||||
};
|
||||
if (valid) r.Fields[key] = value.Clone(); else r.Issues.Add(key + ":invalid-type");
|
||||
}
|
||||
if (p.TryGetProperty("HR", out var hr))
|
||||
{
|
||||
r.Kinds.Add("periodic");
|
||||
if (Numbers(hr, 6, "HR", r) is { } values)
|
||||
{
|
||||
Vitals(r, "left", values[0], values[1], values[2], "HR");
|
||||
Vitals(r, "right", values[3], values[4], values[5], "HR");
|
||||
}
|
||||
}
|
||||
if (p.TryGetProperty("airbagsPerson", out var person))
|
||||
{
|
||||
r.Kinds.Add("periodic");
|
||||
if (Numbers(person, 2, "airbagsPerson", r) is { } values)
|
||||
{
|
||||
if (values.All(v => v is 0 or 1))
|
||||
{
|
||||
r.Fields["airbagsPerson"] = person.Clone();
|
||||
if (binding.PersonLeftIndex is int index)
|
||||
{
|
||||
Add(r, "left.presence", values[index], "airbagsPerson");
|
||||
Add(r, "right.presence", values[1 - index], "airbagsPerson");
|
||||
}
|
||||
}
|
||||
else r.Issues.Add("airbagsPerson:invalid-enum");
|
||||
}
|
||||
}
|
||||
foreach (var (key, length) in new[] { ("airbagsPressure", 8), ("motorHeight", 2),
|
||||
("motorStatus", 4), ("motorMode", 2), ("snoreStatus", 2),
|
||||
("LeftRegularTime", 10), ("RightRegularTime", 10) })
|
||||
{
|
||||
if (!p.TryGetProperty(key, out var value)) continue;
|
||||
r.Kinds.Add("periodic");
|
||||
if (Numbers(value, length, key, r) is not { } numbers) continue;
|
||||
if (key == "motorStatus" && numbers.Any(n => n is not (0 or 1 or 2)))
|
||||
r.Issues.Add("motorStatus:invalid-enum");
|
||||
else r.Fields[key] = value.Clone();
|
||||
}
|
||||
if (p.TryGetProperty("adaptive", out var adaptive))
|
||||
{
|
||||
r.Kinds.Add("periodic");
|
||||
if (adaptive.ValueKind == JsonValueKind.Array && adaptive.GetArrayLength() == 2 &&
|
||||
adaptive.EnumerateArray().All(a => a.ValueKind == JsonValueKind.Object))
|
||||
r.Fields["adaptive"] = adaptive.Clone();
|
||||
else r.Issues.Add("adaptive:invalid-shape");
|
||||
}
|
||||
var sleep = Alias(p, "SleepReportNew", r);
|
||||
if (sleep is { } s)
|
||||
{
|
||||
r.Kinds.Add("event");
|
||||
if (s.ValueKind != JsonValueKind.Object) r.Issues.Add("SleepReportNew:invalid-type");
|
||||
else if (Alias(s, "ibNew", r) is { } ib && Numbers(ib, 12, "ibNew", r) is { } values)
|
||||
{
|
||||
Presence(r, "left", values[0], "ibNew");
|
||||
Presence(r, "right", values[6], "ibNew");
|
||||
Vitals(r, "left", values[4], values[3], values[1], "ibNew");
|
||||
Vitals(r, "right", values[10], values[9], values[7], "ibNew");
|
||||
}
|
||||
else r.Issues.Add("ibNew:missing-or-invalid");
|
||||
}
|
||||
if (r.Kinds.Count == 0) r.Issues.Add("unrecognized-report");
|
||||
}
|
||||
catch (JsonException) { return Reject(r, "invalid-json"); }
|
||||
return r;
|
||||
}
|
||||
|
||||
private static Ib43Report Reject(Ib43Report r, string issue)
|
||||
{ r.Accepted = false; r.Issues.Add(issue); return r; }
|
||||
|
||||
private static bool HasDuplicates(JsonElement node) => node.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => node.EnumerateObject().GroupBy(p => p.Name, StringComparer.Ordinal).Any(g => g.Count() > 1) ||
|
||||
node.EnumerateObject().Any(p => HasDuplicates(p.Value)),
|
||||
JsonValueKind.Array => node.EnumerateArray().Any(HasDuplicates),
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static JsonElement? Alias(JsonElement p, string key, Ib43Report r)
|
||||
{
|
||||
var plain = p.TryGetProperty(key, out var a);
|
||||
var spaced = p.TryGetProperty(" " + key, out var b);
|
||||
// 双键即使内容相同也拒绝该字段,避免依赖 JSON 文本格式决定覆盖顺序。
|
||||
if (plain && spaced) { r.Issues.Add(key + ":ambiguous-alias"); return null; }
|
||||
if (spaced) { r.Fields["alias." + key] = JsonSerializer.SerializeToElement(" " + key); return b; }
|
||||
return plain ? a : null;
|
||||
}
|
||||
|
||||
private static double[] Numbers(JsonElement value, int length, string key, Ib43Report r)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() != length)
|
||||
{ r.Issues.Add(key + ":invalid-length"); return null; }
|
||||
var numbers = new double[length];
|
||||
var i = 0;
|
||||
foreach (var item in value.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Number || !item.TryGetDouble(out var number) || !double.IsFinite(number))
|
||||
{ r.Issues.Add(key + ":invalid-number"); return null; }
|
||||
numbers[i++] = number;
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
private static void Add(Ib43Report r, string key, double? value, string source)
|
||||
=> r.Readings[key] = new(value, r.ReceivedAt, source);
|
||||
|
||||
private static void Presence(Ib43Report r, string side, double value, string source)
|
||||
{
|
||||
if (value is 0 or 1) Add(r, side + ".presence", value, source);
|
||||
else r.Issues.Add(side + ".presence:invalid-enum");
|
||||
}
|
||||
|
||||
private static void Vitals(Ib43Report r, string side, double hr, double respiration, double snore, string source)
|
||||
{
|
||||
if (hr >= 0) Add(r, side + ".heartRate", hr == 0 ? null : hr, source);
|
||||
else r.Issues.Add(side + ".heartRate:negative");
|
||||
if (respiration >= 0) Add(r, side + ".respiration", respiration == 0 ? null : respiration, source);
|
||||
else r.Issues.Add(side + ".respiration:negative");
|
||||
if (snore is 0 or 1) Add(r, side + ".snoring", snore, source);
|
||||
else r.Issues.Add(side + ".snoring:invalid-enum");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed record Ib43SavedBed(string BindingId, string Side);
|
||||
public sealed record Ib43SavedReport(int MatchedCount, IReadOnlyList<Ib43SavedBed> ActiveBeds);
|
||||
|
||||
internal static class Ib43PresenceAdapter
|
||||
{
|
||||
public static void Publish(IBedPresenceSink sink, Ib43Binding device, Ib43Report report,
|
||||
IReadOnlyList<Ib43SavedBed> beds, Guid connectionId, CancellationToken connectionToken)
|
||||
{
|
||||
if (sink == null || !report.Accepted || report.Issues.Count != 0) return;
|
||||
foreach (var bed in beds)
|
||||
{
|
||||
if (bed.Side is not ("left" or "right") ||
|
||||
!report.Readings.TryGetValue(bed.Side + ".presence", out var presence) ||
|
||||
presence.Source != "ibNew" || presence.Value is not (0 or 1)) continue;
|
||||
sink.Observe(new(bed.BindingId, device.ProductKey, device.SerialNumber,
|
||||
bed.Side == "left" ? BedSide.Left : BedSide.Right, device.Simulation,
|
||||
presence.Value == 0 ? BedPresence.Empty : BedPresence.Occupied, presence.ReceivedAt, connectionId), connectionToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public interface IIb43ReportSink
|
||||
{
|
||||
Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token);
|
||||
}
|
||||
|
||||
public sealed class Ib43ReportSink(IServiceScopeFactory scopes) : IIb43ReportSink
|
||||
{
|
||||
public async Task<Ib43SavedReport> SaveAsync(string reportId, Ib43Binding binding, Ib43Report report, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
using var scope = scopes.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<Ib43DataRepository>().Save(reportId, binding, report);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
|
||||
public sealed record Ib43Field(JsonElement Value, DateTimeOffset ReceivedAt);
|
||||
public sealed record Ib43Snapshot(string ProductKey, string SerialNumber, bool Simulation,
|
||||
string Transport, string Freshness, DateTimeOffset? LastTelemetryAt,
|
||||
IReadOnlyDictionary<string, Ib43Reading> Readings, IReadOnlyDictionary<string, Ib43Field> Fields);
|
||||
public sealed record Ib43Diagnostic(string Topic, DateTimeOffset ReceivedAt, string Raw, string[] Issues);
|
||||
|
||||
/// <summary>进程内有界缓存。没有设备遗嘱,不能将数据超时等同于已确认设备离线。</summary>
|
||||
public sealed class Ib43StateStore(IOptions<Ib43Options> options, Ib43Parser parser, TimeProvider clock)
|
||||
{
|
||||
public const int MaxPayloadBytes = 65536;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, DeviceState> _states = new(StringComparer.Ordinal);
|
||||
private readonly Queue<Ib43Diagnostic> _diagnostics = new();
|
||||
private string _transport = options.Value.Enabled ? "disconnected" : "disabled";
|
||||
|
||||
private sealed class DeviceState
|
||||
{
|
||||
public DateTimeOffset? LastTelemetryAt;
|
||||
public Dictionary<string, Ib43Reading> Readings { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, Ib43Field> Fields { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public void SetTransport(string state) { lock (_gate) _transport = state; }
|
||||
|
||||
public void RejectOversized(string topic)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_diagnostics.Enqueue(new(topic, clock.GetUtcNow(), "", ["payload-too-large:body-not-retained"]));
|
||||
while (_diagnostics.Count > 32) _diagnostics.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
public Ib43Report Receive(string topic, string payload, bool retained = false)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var binding = options.Value.Devices.FirstOrDefault(d => d.PostTopic == topic);
|
||||
Ib43Report report;
|
||||
if (binding == null || retained || System.Text.Encoding.UTF8.GetByteCount(payload) > MaxPayloadBytes)
|
||||
{
|
||||
report = new() { Raw = payload.Length > 4096 ? payload[..4096] : payload, ReceivedAt = now };
|
||||
report.Issues.Add(binding == null ? "unbound-topic" : retained ? "retained-not-live" : "payload-too-large");
|
||||
}
|
||||
else report = parser.Parse(topic, payload, binding, now);
|
||||
|
||||
_diagnostics.Enqueue(new(topic, now, report.Raw, report.Issues.ToArray()));
|
||||
while (_diagnostics.Count > 32) _diagnostics.Dequeue();
|
||||
if (!report.Accepted) return report;
|
||||
if (!_states.TryGetValue(topic, out var state)) _states[topic] = state = new();
|
||||
foreach (var (key, value) in report.Readings) state.Readings[key] = value;
|
||||
foreach (var (key, value) in report.Fields) state.Fields[key] = new(value, now);
|
||||
// 只有有效实时字段刷新活跃性;能力包、未知包和坏包不能掩盖实时数据停止。
|
||||
if (report.Readings.Count > 0 || report.Fields.Keys.Any(k => k is "airbagsPerson" or
|
||||
"airbagsPressure" or "motorHeight" or "motorStatus" or "snoreStatus"))
|
||||
state.LastTelemetryAt = now;
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
public Ib43Snapshot[] Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
return options.Value.Devices.Select(binding =>
|
||||
{
|
||||
_states.TryGetValue(binding.PostTopic, out var state);
|
||||
var last = state?.LastTelemetryAt;
|
||||
var freshness = last == null ? "unknown" :
|
||||
now - last.Value > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent";
|
||||
return new Ib43Snapshot(binding.ProductKey, binding.SerialNumber, binding.Simulation,
|
||||
_transport, freshness, last,
|
||||
state == null ? new Dictionary<string, Ib43Reading>() : new(state.Readings),
|
||||
state == null ? new Dictionary<string, Ib43Field>() : new(state.Fields));
|
||||
}).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// 不通过公共 H5 API 暴露原始健康数据;后续持久化需绑定用户权限与保留期限。
|
||||
public Ib43Diagnostic[] Diagnostics() { lock (_gate) return _diagnostics.ToArray(); }
|
||||
}
|
||||
@@ -86,22 +86,23 @@ public sealed class DeviceActionRunner
|
||||
}
|
||||
}
|
||||
|
||||
public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
|
||||
public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken, int? verificationAttempts = null)
|
||||
{
|
||||
var results = new List<HomeAssistantActionResult>
|
||||
{
|
||||
new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" }
|
||||
};
|
||||
await VerifyBatchAsync([command], results, cancellationToken);
|
||||
await VerifyBatchAsync([command], results, cancellationToken, verificationAttempts);
|
||||
if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message);
|
||||
}
|
||||
|
||||
public async Task VerifyBatchAsync(IReadOnlyList<DeviceCommand> commands,
|
||||
List<HomeAssistantActionResult> results, CancellationToken cancellationToken)
|
||||
List<HomeAssistantActionResult> results, CancellationToken cancellationToken, int? verificationAttempts = null)
|
||||
{
|
||||
var attempts = verificationAttempts ?? _options.VerificationAttempts;
|
||||
var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) &&
|
||||
command.ExpectedState is not null).ToList();
|
||||
for (var round = 0; round < _options.VerificationAttempts && pending.Count > 0; round++)
|
||||
for (var round = 0; round < attempts && pending.Count > 0; round++)
|
||||
{
|
||||
await DelayAsync(_options.VerificationDelay, cancellationToken);
|
||||
IReadOnlyList<HomeAssistantState> states;
|
||||
@@ -126,7 +127,7 @@ public sealed class DeviceActionRunner
|
||||
action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态";
|
||||
pending.Remove(command); continue;
|
||||
}
|
||||
if (round == _options.VerificationAttempts - 1)
|
||||
if (round == attempts - 1)
|
||||
{
|
||||
action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})";
|
||||
pending.Remove(command); continue;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Furion.FriendlyException;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Services;
|
||||
|
||||
public sealed class AssignIb43BedInput
|
||||
{
|
||||
[Required, MaxLength(128)] public string ProductKey { get; set; }
|
||||
[Required, MaxLength(128)] public string SerialNumber { get; set; }
|
||||
[Required, RegularExpression("^(left|right)$")] public string Side { get; set; }
|
||||
[Range(1, long.MaxValue)] public long UserId { get; set; }
|
||||
[Required, MaxLength(80)] public string BedName { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Ib43BedOutput(string Id, string ProductKey, string SerialNumber, string Side,
|
||||
string BedName, bool Simulation, long StartedAtMs, long? EndedAtMs);
|
||||
public sealed record Ib43AdminBedOutput(Ib43BedOutput Bed, long UserId);
|
||||
public sealed record Ib43MetricOutput(double? Value, DateTimeOffset? ReceivedAt, string Source, string Freshness);
|
||||
public sealed record Ib43CurrentOutput(Ib43BedOutput Bed, string Transport, bool Active,
|
||||
IReadOnlyDictionary<string, Ib43MetricOutput> Readings);
|
||||
public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary<string, Ib43Reading> Readings);
|
||||
public sealed record Ib43HistoryOutput(IReadOnlyList<Ib43HistoryItem> Items, long? NextBeforeId);
|
||||
|
||||
[Authorize]
|
||||
[ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)]
|
||||
public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context,
|
||||
IOptions<Ib43Options> options, Ib43StateStore states, TimeProvider clock) : IDynamicApiController, ITransient
|
||||
{
|
||||
[HttpGet("/api/health/ib43/admin/bindings")]
|
||||
public async Task<Ib43AdminBedOutput[]> ActiveBindings(string productKey, string serialNumber)
|
||||
{
|
||||
await RequireAdmin();
|
||||
if (string.IsNullOrWhiteSpace(productKey) || string.IsNullOrWhiteSpace(serialNumber) || productKey.Length > 128 || serialNumber.Length > 128)
|
||||
throw new AppFriendlyException("请指定产品和设备编号", null) { StatusCode = 400 };
|
||||
return (await repository.ActiveBindings(productKey, serialNumber)).Select(b => new Ib43AdminBedOutput(Map(b), b.UserId)).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("/api/health/ib43/admin/bindings")]
|
||||
public async Task<Ib43BedOutput> Assign(AssignIb43BedInput input)
|
||||
{
|
||||
var admin = await RequireAdmin();
|
||||
if (input == null || input.Side is not ("left" or "right") || string.IsNullOrWhiteSpace(input.BedName) ||
|
||||
input.BedName.Length > 80 || input.UserId <= 0)
|
||||
throw new AppFriendlyException("床位参数不合法", null) { StatusCode = 400 };
|
||||
var device = options.Value.Devices.SingleOrDefault(d => d.ProductKey == input.ProductKey && d.SerialNumber == input.SerialNumber);
|
||||
if (device == null || input.ProductKey.Length > 128 || input.SerialNumber.Length > 128)
|
||||
throw new AppFriendlyException("设备不在后台接入白名单中", null) { StatusCode = 400 };
|
||||
if (!await repository.UserEnabled(input.UserId)) throw new AppFriendlyException("目标健康用户不存在或已停用", null) { StatusCode = 400 };
|
||||
var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber);
|
||||
var binding = new HealthIb43Binding
|
||||
{
|
||||
DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber,
|
||||
Side = input.Side, ActiveSlot = key + "." + input.Side, UserId = input.UserId,
|
||||
BedName = input.BedName.Trim(), Simulation = device.Simulation,
|
||||
StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = admin
|
||||
};
|
||||
await repository.Assign(binding);
|
||||
return Map(binding);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/health/ib43/admin/bindings/{bindingId}")]
|
||||
public async Task Release(string bindingId)
|
||||
{
|
||||
var admin = await RequireAdmin();
|
||||
await repository.Release(bindingId, admin, clock.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings")]
|
||||
public async Task<Ib43BedOutput[]> Bindings()
|
||||
{
|
||||
var user = await RequireUser();
|
||||
return (await repository.Bindings(user)).Select(Map).ToArray();
|
||||
}
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings/{bindingId}/current")]
|
||||
public async Task<Ib43CurrentOutput> Current(string bindingId)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
var binding = await repository.OwnedBinding(user, bindingId);
|
||||
var readings = await repository.Latest(user, bindingId);
|
||||
var now = clock.GetUtcNow();
|
||||
var projected = new Dictionary<string, Ib43MetricOutput>();
|
||||
foreach (var key in new[] { "heartRate", "respiration", "presence", "snoring" })
|
||||
{
|
||||
readings.TryGetValue(key, out var reading);
|
||||
projected[key] = reading == null ? new(null, null, null, "unknown") :
|
||||
new(reading.Value, reading.ReceivedAt, reading.Source,
|
||||
now - reading.ReceivedAt > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent");
|
||||
}
|
||||
var transport = states.Snapshot().FirstOrDefault(s => s.ProductKey == binding.ProductKey &&
|
||||
s.SerialNumber == binding.SerialNumber && s.Simulation == binding.Simulation)?.Transport ?? "unconfigured";
|
||||
return new(Map(binding), transport, binding.EndedAtMs == null, projected);
|
||||
}
|
||||
|
||||
[HttpGet("/api/health/ib43/bindings/{bindingId}/history")]
|
||||
public async Task<Ib43HistoryOutput> History(string bindingId, long fromMs, long toMs, long? beforeId = null, int take = 100)
|
||||
{
|
||||
var user = await RequireUser();
|
||||
await repository.OwnedBinding(user, bindingId);
|
||||
// 固定半开 UTC 毫秒区间;游标按数据库接收记录Id倒序,避免同毫秒漏页。
|
||||
if (fromMs < 0 || toMs <= fromMs || toMs - fromMs > 7L * 86400000 || take is < 1 or > 200 || beforeId is <= 0)
|
||||
throw new AppFriendlyException("查询范围最多7天,每页1至200条", null) { StatusCode = 400 };
|
||||
var rows = await repository.History(user, bindingId, fromMs, toMs, beforeId, take + 1);
|
||||
var more = rows.Count > take;
|
||||
var items = rows.Take(take).Select(r => new Ib43HistoryItem(r.Id, r.ReceivedAtMs,
|
||||
JsonSerializer.Deserialize<Dictionary<string, Ib43Reading>>(r.ReadingsJson))).ToArray();
|
||||
return new(items, more ? items[^1].Id : null);
|
||||
}
|
||||
|
||||
private async Task<long> RequireUser()
|
||||
{
|
||||
var principal = context.HttpContext?.User;
|
||||
if (principal?.Identity?.IsAuthenticated != true ||
|
||||
!long.TryParse(principal.FindFirstValue("health_user_id"), out var user) || user <= 0)
|
||||
throw new AppFriendlyException("请先登录健康端账号", null) { StatusCode = 401 };
|
||||
if (!await repository.UserEnabled(user)) throw new AppFriendlyException("健康用户已停用或不存在", null) { StatusCode = 403 };
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<long> RequireAdmin()
|
||||
{
|
||||
var principal = context.HttpContext?.User;
|
||||
if (principal?.Identity?.IsAuthenticated != true || principal.HasClaim(c => c.Type == "health_user_id") ||
|
||||
principal.FindFirstValue(ClaimConst.AccountType) != ((int)AccountTypeEnum.SuperAdmin).ToString() ||
|
||||
!long.TryParse(principal.FindFirstValue(ClaimConst.UserId), out var admin) || admin <= 0)
|
||||
throw new AppFriendlyException("只有后台超级管理员可以分配床位", null) { StatusCode = 403 };
|
||||
if (!await repository.AdminEnabled(admin)) throw new AppFriendlyException("后台管理员已失效", null) { StatusCode = 403 };
|
||||
return admin;
|
||||
}
|
||||
|
||||
private static Ib43BedOutput Map(HealthIb43Binding b) => new(b.Id, b.ProductKey, b.SerialNumber,
|
||||
b.Side, b.BedName, b.Simulation, b.StartedAtMs, b.EndedAtMs);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum BedSide { Left, Right }
|
||||
public enum BedAutomationMode { Disabled, Preview, Execute }
|
||||
|
||||
public sealed record BedPresenceSample(string BindingId, string ProductKey, string SerialNumber, BedSide Side,
|
||||
bool Simulation, BedPresence Presence, DateTimeOffset ReceivedAt, Guid ConnectionId);
|
||||
|
||||
public interface IBedPresenceSink
|
||||
{
|
||||
void Observe(BedPresenceSample sample, CancellationToken connectionToken);
|
||||
}
|
||||
|
||||
public interface IBedPresenceAction
|
||||
{
|
||||
string Key { get; }
|
||||
string Description { get; }
|
||||
TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(25);
|
||||
bool SupportsTarget(string entityId);
|
||||
Task<string> ExecuteAsync(string entityId, CancellationToken token);
|
||||
}
|
||||
|
||||
public sealed class BedPresenceAutomationOptions
|
||||
{
|
||||
public BedAutomationMode Mode { get; set; }
|
||||
public string BindingId { get; set; } = "";
|
||||
public string ProductKey { get; set; } = "evergreen-test";
|
||||
public string SerialNumber { get; set; } = "IB43SIM001";
|
||||
public BedSide Side { get; set; } = BedSide.Left;
|
||||
public string ActionKey { get; set; } = "";
|
||||
public string TargetEntityId { get; set; } = "";
|
||||
|
||||
public bool IsValid() => Mode == BedAutomationMode.Disabled ||
|
||||
(Mode is BedAutomationMode.Preview or BedAutomationMode.Execute && Guid.TryParseExact(BindingId, "N", out _) &&
|
||||
!string.IsNullOrWhiteSpace(ProductKey) && !string.IsNullOrWhiteSpace(SerialNumber) &&
|
||||
Enum.IsDefined(Side) && !string.IsNullOrWhiteSpace(ActionKey) && !string.IsNullOrWhiteSpace(TargetEntityId));
|
||||
|
||||
public bool Matches(BedPresenceSample sample) => sample.Simulation && sample.BindingId == BindingId &&
|
||||
sample.ProductKey == ProductKey && sample.SerialNumber == SerialNumber && sample.Side == Side;
|
||||
}
|
||||
|
||||
/// <summary>按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。</summary>
|
||||
public sealed class BedPresenceEdge
|
||||
{
|
||||
public static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(60);
|
||||
private BedPresenceSample? _previous;
|
||||
|
||||
public bool Observe(BedPresenceSample sample, DateTimeOffset now)
|
||||
{
|
||||
if (sample.Presence is not (BedPresence.Empty or BedPresence.Occupied) ||
|
||||
sample.ReceivedAt > now || now - sample.ReceivedAt > MaxAge) return false;
|
||||
var previous = _previous;
|
||||
var sameBedConnection = previous != null && previous.ConnectionId == sample.ConnectionId &&
|
||||
previous.BindingId == sample.BindingId && previous.Side == sample.Side;
|
||||
if (sameBedConnection && sample.ReceivedAt < previous!.ReceivedAt) return false;
|
||||
_previous = sample;
|
||||
return sameBedConnection && previous!.Presence == BedPresence.Empty &&
|
||||
sample.Presence == BedPresence.Occupied && sample.ReceivedAt - previous.ReceivedAt <= MaxAge;
|
||||
}
|
||||
|
||||
public void Reset() => _previous = null;
|
||||
}
|
||||
|
||||
/// <summary>异步执行单条联调规则。采集线程只提交归一化事件。</summary>
|
||||
public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions> options, IEnumerable<IBedPresenceAction> actions,
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger) : BackgroundService, IBedPresenceSink
|
||||
{
|
||||
private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken);
|
||||
private readonly Channel<Item> _queue = Channel.CreateBounded<Item>(new BoundedChannelOptions(32)
|
||||
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private long _sequence;
|
||||
private volatile bool _accepting;
|
||||
|
||||
public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
|
||||
{
|
||||
var configuration = options.Value;
|
||||
if (!_accepting || !configuration.Matches(sample) || connectionToken.IsCancellationRequested) return;
|
||||
if (!_queue.Writer.TryWrite(new(Interlocked.Increment(ref _sequence), sample, connectionToken)))
|
||||
logger.LogWarning("Bed presence automation queue full; observation dropped");
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var configuration = options.Value;
|
||||
if (configuration.Mode == BedAutomationMode.Disabled) return;
|
||||
var candidates = actions.Where(action => string.Equals(action.Key, configuration.ActionKey, StringComparison.Ordinal)).Take(2).ToArray();
|
||||
if (!configuration.IsValid() || candidates.Length != 1 || !candidates[0].SupportsTarget(configuration.TargetEntityId))
|
||||
{
|
||||
logger.LogWarning("Bed presence automation inactive: invalid binding, action or target");
|
||||
return;
|
||||
}
|
||||
var action = candidates[0];
|
||||
var target = configuration.TargetEntityId;
|
||||
_accepting = true;
|
||||
logger.LogInformation("Bed presence automation ready: Mode={Mode}, Side={Side}, Action={Action}, Target={EntityId}",
|
||||
configuration.Mode, configuration.Side, action.Key, target);
|
||||
var edge = new BedPresenceEdge();
|
||||
long lastSequence = 0;
|
||||
await foreach (var item in _queue.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
if (item.Sequence != lastSequence + 1 || item.ConnectionToken.IsCancellationRequested) edge.Reset();
|
||||
lastSequence = item.Sequence;
|
||||
if (item.ConnectionToken.IsCancellationRequested || !edge.Observe(item.Sample, clock.GetUtcNow())) continue;
|
||||
if (configuration.Mode == BedAutomationMode.Preview)
|
||||
{
|
||||
logger.LogInformation("Bed presence preview: empty -> occupied, Action={Action}, Plan={Plan}, Target={EntityId}",
|
||||
action.Key, action.Description, target);
|
||||
continue;
|
||||
}
|
||||
if (clock.GetUtcNow() - item.Sample.ReceivedAt > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
logger.LogInformation("Bed presence action skipped: observation aged in queue");
|
||||
continue;
|
||||
}
|
||||
using var timeout = new CancellationTokenSource(action.ExecutionTimeout, clock);
|
||||
using var operation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, item.ConnectionToken, timeout.Token);
|
||||
try
|
||||
{
|
||||
operation.Token.ThrowIfCancellationRequested();
|
||||
var result = await action.ExecuteAsync(target, operation.Token);
|
||||
logger.LogInformation("Bed presence action: Action={Action}, Target={EntityId}, Result={Result}", action.Key, target, result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning("Bed presence action not confirmed: cancelled or timed out for {EntityId}", target);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning("Bed presence action not confirmed: Target={EntityId}, Error={Type}", target, error.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_accepting = false;
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user