Files
Evergreen-Health/Evergreen.Health.Application/Health/Sleep/BedPresenceAutomation.cs
T
2026-11-11 16:11:41 +08:00

145 lines
7.0 KiB
C#

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);
}
}