111111
This commit is contained in:
@@ -6,7 +6,7 @@ using System.Threading.Channels;
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum BedSide { Left, Right }
|
||||
public enum BedAutomationMode { Disabled, Preview, Execute }
|
||||
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);
|
||||
@@ -20,12 +20,19 @@ 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<string> ExecuteAsync(string entityId, CancellationToken token);
|
||||
}
|
||||
|
||||
public sealed class BedPresenceAutomationOptions
|
||||
public sealed class BedPresenceAutomationOptions : BedPresenceRuleOptions
|
||||
{
|
||||
public List<BedPresenceRuleOptions> AdditionalRules { get; set; } = [];
|
||||
}
|
||||
|
||||
public class BedPresenceRuleOptions
|
||||
{
|
||||
public BedAutomationMode Mode { get; set; }
|
||||
public string BindingId { get; set; } = "";
|
||||
@@ -34,14 +41,29 @@ public sealed class BedPresenceAutomationOptions
|
||||
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() => 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 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) => sample.Simulation && sample.BindingId == BindingId &&
|
||||
sample.ProductKey == ProductKey && sample.SerialNumber == SerialNumber && sample.Side == Side;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>按连接识别连续的离床到在床,首条、重复和超时事件只建立基线。</summary>
|
||||
@@ -66,45 +88,180 @@ public sealed class BedPresenceEdge
|
||||
public void Reset() => _previous = null;
|
||||
}
|
||||
|
||||
/// <summary>异步执行单条联调规则。采集线程只提交归一化事件。</summary>
|
||||
public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions> options, IEnumerable<IBedPresenceAction> actions,
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger) : BackgroundService, IBedPresenceSink
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> logger, IBedPresenceExecutionLog? executionLog = null) : 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;
|
||||
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<BedPresenceRuleStatus>().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)
|
||||
{
|
||||
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");
|
||||
foreach (var rule in _rules) rule.Observe(sample, connectionToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
|
||||
Task.WhenAll(_rules.Select(rule => rule.RunAsync(stoppingToken)));
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
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))
|
||||
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<IBedPresenceAction> actions, TimeProvider clock, ILogger<BedPresenceAutomation> logger,
|
||||
IBedPresenceExecutionLog? executionLog)
|
||||
{
|
||||
var registeredActions = actions.ToArray();
|
||||
var rules = new List<BedPresenceRule> { new(configuration, registeredActions, clock, logger, executionLog) };
|
||||
if (configuration.Mode != BedAutomationMode.Managed) return rules.ToArray();
|
||||
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal) { configuration.ActionKey };
|
||||
foreach (var rule in configuration.AdditionalRules)
|
||||
{
|
||||
logger.LogWarning("Bed presence automation inactive: invalid binding, action or target");
|
||||
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);
|
||||
|
||||
/// <summary>每条规则独立处理上床事件,慢速设备回读不会阻塞其他规则。</summary>
|
||||
internal sealed class BedPresenceRule(BedPresenceRuleOptions configuration, IReadOnlyList<IBedPresenceAction> actions,
|
||||
TimeProvider clock, ILogger<BedPresenceAutomation> 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<Item> _queue = Channel.CreateBounded<Item>(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;
|
||||
_accepting = true;
|
||||
_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) edge.Reset();
|
||||
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)
|
||||
{
|
||||
@@ -117,28 +274,60 @@ public sealed class BedPresenceAutomation(IOptions<BedPresenceAutomationOptions>
|
||||
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
|
||||
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))
|
||||
{
|
||||
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);
|
||||
result = "expired";
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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 override Task StopAsync(CancellationToken cancellationToken)
|
||||
public void Stop()
|
||||
{
|
||||
_accepting = false;
|
||||
return base.StopAsync(cancellationToken);
|
||||
Session? previous;
|
||||
lock (_sessionGate)
|
||||
{
|
||||
_activeAction = null;
|
||||
previous = _session;
|
||||
_session = null;
|
||||
}
|
||||
previous?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user