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, Managed } 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; } bool PreviewOnly => false; string? ConditionLabel => null; TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(25); bool SupportsTarget(string entityId); Task ExecuteAsync(string entityId, CancellationToken token); } public sealed class BedPresenceAutomationOptions : BedPresenceRuleOptions { public List AdditionalRules { get; set; } = []; } public class BedPresenceRuleOptions { 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 string TargetLabel { get; set; } = ""; public bool IsValid() { if (Mode == BedAutomationMode.Disabled) return true; var bindingValid = Mode switch { BedAutomationMode.Managed => string.IsNullOrEmpty(BindingId) || Guid.TryParseExact(BindingId, "N", out _), BedAutomationMode.Preview => Guid.TryParseExact(BindingId, "N", out _), _ => false }; return bindingValid && !string.IsNullOrWhiteSpace(ProductKey) && !string.IsNullOrWhiteSpace(SerialNumber) && Enum.IsDefined(Side) && !string.IsNullOrWhiteSpace(ActionKey) && ActionKey.Length <= 64 && !string.IsNullOrWhiteSpace(TargetEntityId) && TargetEntityId.Length <= 128 && (TargetLabel?.Length ?? 0) <= 128; } public bool Matches(BedPresenceSample sample) => MatchesSource(sample.BindingId, sample.ProductKey, sample.SerialNumber, sample.Side, sample.Simulation); public bool Matches(BedAutomationBinding binding) => MatchesSource(binding.Id, binding.ProductKey, binding.SerialNumber, binding.Side, binding.Simulation); private bool MatchesSource(string id, string productKey, string serialNumber, BedSide side, bool simulation) => simulation && (id == BindingId || Mode == BedAutomationMode.Managed && string.IsNullOrEmpty(BindingId)) && productKey == ProductKey && serialNumber == SerialNumber && 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, IBedPresenceExecutionLog? executionLog = null) : BackgroundService, IBedPresenceSink { private readonly BedPresenceRule[] _rules = CreateRules(options.Value, actions, clock, logger, executionLog); public BedPresenceRuleStatus? PrimaryRuleStatus(BedAutomationBinding binding) => _rules[0].Status(binding); public BedPresenceRuleStatus[] RuleStatuses(BedAutomationBinding binding) => _rules.Select(rule => rule.Status(binding)).OfType().ToArray(); public bool IsEnabled(string bindingId, long userId, string? actionKey = null) => FindRule(actionKey)?.IsEnabled(bindingId, userId) == true; public bool TryEnable(BedAutomationBinding binding, string? actionKey = null) => FindRule(actionKey)?.TryEnable(binding) == true; public bool Disable(string bindingId, long userId, string? actionKey = null) { var rule = FindRule(actionKey); if (rule is null) return false; rule.Disable(bindingId, userId); return true; } public void RevokeBinding(string bindingId) { foreach (var rule in _rules) rule.RevokeBinding(bindingId); } public void Observe(BedPresenceSample sample, CancellationToken connectionToken) { foreach (var rule in _rules) rule.Observe(sample, connectionToken); } protected override Task ExecuteAsync(CancellationToken stoppingToken) => Task.WhenAll(_rules.Select(rule => rule.RunAsync(stoppingToken))); public override Task StopAsync(CancellationToken cancellationToken) { foreach (var rule in _rules) rule.Stop(); return base.StopAsync(cancellationToken); } private BedPresenceRule? FindRule(string? actionKey) => actionKey is null ? _rules[0] : _rules.FirstOrDefault(rule => rule.ActionKey == actionKey); private static BedPresenceRule[] CreateRules(BedPresenceAutomationOptions configuration, IEnumerable actions, TimeProvider clock, ILogger logger, IBedPresenceExecutionLog? executionLog) { var registeredActions = actions.ToArray(); var rules = new List { new(configuration, registeredActions, clock, logger, executionLog) }; if (configuration.Mode != BedAutomationMode.Managed) return rules.ToArray(); var keys = new HashSet(StringComparer.Ordinal) { configuration.ActionKey }; foreach (var rule in configuration.AdditionalRules) { if (rule.Mode != BedAutomationMode.Managed) continue; if (!keys.Add(rule.ActionKey)) { logger.LogWarning("Bed presence automation ignored duplicate action key {Action}", rule.ActionKey); continue; } rules.Add(new(rule, registeredActions, clock, logger, executionLog)); } return rules.ToArray(); } } public sealed record BedPresenceRuleStatus(string ActionKey, string ActionLabel, string TargetLabel, bool Enabled, bool PreviewOnly, string? ConditionLabel); /// 每条规则独立处理上床事件,慢速设备回读不会阻塞其他规则。 internal sealed class BedPresenceRule(BedPresenceRuleOptions configuration, IReadOnlyList actions, TimeProvider clock, ILogger logger, IBedPresenceExecutionLog? executionLog) { private sealed class Session(string bindingId, long userId, DateTimeOffset startedAt) { private readonly CancellationTokenSource _stop = new(); public string BindingId { get; } = bindingId; public long UserId { get; } = userId; public DateTimeOffset StartedAt { get; } = startedAt; public CancellationToken Token => _stop.Token; public void Cancel() => _stop.Cancel(); } private sealed record Item(long Sequence, BedPresenceSample Sample, CancellationToken ConnectionToken, Session? Session); private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(32) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); private long _sequence; private volatile IBedPresenceAction? _activeAction; private readonly object _sessionGate = new(); private volatile Session? _session; public string ActionKey => configuration.ActionKey; private string TargetLabel => string.IsNullOrWhiteSpace(configuration.TargetLabel) ? configuration.TargetEntityId : configuration.TargetLabel; public BedPresenceRuleStatus? Status(BedAutomationBinding binding) => _activeAction is { } action && configuration.Mode == BedAutomationMode.Managed && configuration.Matches(binding) ? new(action.Key, action.Description, TargetLabel, IsEnabled(binding.Id, binding.UserId), action.PreviewOnly, action.ConditionLabel) : null; public bool IsEnabled(string bindingId, long userId) => _session is { } session && session.BindingId == bindingId && session.UserId == userId; public bool TryEnable(BedAutomationBinding binding) { Session? previous; lock (_sessionGate) { if (_activeAction is null || configuration.Mode != BedAutomationMode.Managed || !configuration.Matches(binding)) return false; if (IsEnabled(binding.Id, binding.UserId)) return true; previous = _session; _session = new(binding.Id, binding.UserId, clock.GetUtcNow()); } previous?.Cancel(); return true; } public void Disable(string bindingId, long userId) { Session? previous; lock (_sessionGate) { if (!IsEnabled(bindingId, userId)) return; previous = _session; _session = null; } previous?.Cancel(); } public void RevokeBinding(string bindingId) { if (_session is { } session && session.BindingId == bindingId) Disable(bindingId, session.UserId); } public void Observe(BedPresenceSample sample, CancellationToken connectionToken) { if (_activeAction is null || !configuration.Matches(sample) || connectionToken.IsCancellationRequested) return; var session = _session; if (configuration.Mode == BedAutomationMode.Managed && (session is null || session.BindingId != sample.BindingId || sample.ReceivedAt < session.StartedAt)) return; if (!_queue.Writer.TryWrite(new(Interlocked.Increment(ref _sequence), sample, connectionToken, session))) logger.LogWarning("Bed presence automation queue full; observation dropped for {Action}", ActionKey); } public async Task RunAsync(CancellationToken stoppingToken) { if (configuration.Mode == BedAutomationMode.Disabled) return; if (configuration.Mode == BedAutomationMode.Execute) { logger.LogWarning("Legacy Execute mode is disabled; use Managed and enable the rule in H5"); 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) || configuration.Mode == BedAutomationMode.Managed && executionLog is null) { logger.LogWarning("Bed presence automation inactive: invalid binding, action or target for {Action}", ActionKey); return; } var action = candidates[0]; var target = configuration.TargetEntityId; _activeAction = action; 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(); Session? lastSession = null; long lastSequence = 0; await foreach (var item in _queue.Reader.ReadAllAsync(stoppingToken)) { if (item.Sequence != lastSequence + 1 || item.ConnectionToken.IsCancellationRequested || item.Session != lastSession) edge.Reset(); lastSequence = item.Sequence; lastSession = item.Session; if (configuration.Mode == BedAutomationMode.Managed && (item.Session is null || item.Session != _session)) continue; 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; } await ExecuteManagedAction(item, action, target, stoppingToken); } } private async Task ExecuteManagedAction(Item item, IBedPresenceAction action, string target, CancellationToken stoppingToken) { var session = item.Session!; using var timeout = new CancellationTokenSource(action.ExecutionTimeout, clock); using var operation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, item.ConnectionToken, session.Token, timeout.Token); string? id = null; var result = "failed-unconfirmed"; try { operation.Token.ThrowIfCancellationRequested(); id = await executionLog!.BeginAsync(session.BindingId, session.UserId, action.Key, TargetLabel, item.Sample.ReceivedAt, operation.Token); if (id is null) return; operation.Token.ThrowIfCancellationRequested(); if (session != _session) throw new OperationCanceledException(); if (clock.GetUtcNow() - item.Sample.ReceivedAt > TimeSpan.FromSeconds(10)) { result = "expired"; return; } result = await action.ExecuteAsync(target, operation.Token); logger.LogInformation("Bed presence action: Action={Action}, Target={EntityId}, Result={Result}", action.Key, target, result); } catch (OperationCanceledException) { result = "cancelled-unconfirmed"; 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); } finally { if (id is not null) { try { await executionLog!.CompleteAsync(id, result, clock.GetUtcNow()); } catch (Exception error) { logger.LogWarning("Bed presence execution result could not be saved ({Type})", error.GetType().Name); } } } } public void Stop() { Session? previous; lock (_sessionGate) { _activeAction = null; previous = _session; _session = null; } previous?.Cancel(); } }