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 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; } /// 按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。 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; } /// 异步执行单条联调规则。采集线程只提交归一化事件。 public sealed class BedPresenceAutomation(IOptions options, IEnumerable actions, TimeProvider clock, ILogger logger) : BackgroundService, IBedPresenceSink { private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken); private readonly Channel _queue = Channel.CreateBounded(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); } }