1
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
/// <summary>本版没有异常判定算法;未来规则只产生事件,不直接操作音箱或家具。</summary>
|
||||
public sealed record SleepAnomaly(string Code, string Description, DateTimeOffset ObservedAt);
|
||||
public interface ISleepAnomalyRule
|
||||
{
|
||||
string Key { get; }
|
||||
SleepAnomaly? Evaluate(IReadOnlyList<SleepObservation> observations);
|
||||
}
|
||||
|
||||
/// <summary>每个舒睡会话独立持有;只有语音实际播报后才记录提醒时间。</summary>
|
||||
public sealed class AnomalyReminderGate
|
||||
{
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly Dictionary<string, DateTimeOffset> _delivered = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
public AnomalyReminderGate(SleepWorkflowOptions options) => _interval = options.SameAnomalyReminderInterval;
|
||||
public bool CanRemind(string code, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock) return !_delivered.TryGetValue(code, out var last) || now - last >= _interval;
|
||||
}
|
||||
public void MarkDelivered(string code, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock) _delivered[code] = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public sealed record GuidanceClip(string Key, string Title, string? Url, double? DurationSeconds)
|
||||
{
|
||||
public bool Playable => DurationSeconds is > 0 && double.IsFinite(DurationSeconds.Value) &&
|
||||
Uri.TryCreate(Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https";
|
||||
}
|
||||
|
||||
/// <summary>假数据仅是目录元数据,没有伪造 OSS 地址或播放成功状态。</summary>
|
||||
public sealed class GuidanceCatalog
|
||||
{
|
||||
public GuidanceCatalog(GuidanceClip? initialRelaxation = null)
|
||||
=> InitialRelaxation = initialRelaxation ?? new("initial-relaxation", "首次上床放松引导(待上传)", null, null);
|
||||
public GuidanceClip InitialRelaxation { get; }
|
||||
public GuidanceClip AnomalyReminder { get; } = new("anomaly-reminder", "睡眠状态语音提醒(预留)", null, null);
|
||||
}
|
||||
|
||||
public sealed record GuidancePlaybackResult(bool Completed, string Message);
|
||||
public interface IGuidancePlayer
|
||||
{
|
||||
Task<GuidancePlaybackResult> PlayAsync(GuidanceClip clip, int volumePercent, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ReservedGuidancePlayer : IGuidancePlayer
|
||||
{
|
||||
public Task<GuidancePlaybackResult> PlayAsync(GuidanceClip clip, int volumePercent, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new GuidancePlaybackResult(false, "引导音频尚未接入,本版不执行播放"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum ReservedSleepPhase { Disabled, WaitingForBed, Settling, Guiding, Music, Stopped }
|
||||
public enum SleepWorkflowActionKind { PrepareEnvironment, StartGentleRightMassage, RequestGuidance, RestartMusicAtFifty, StartLightFade }
|
||||
public sealed record SleepWorkflowAction(SleepWorkflowActionKind Kind, GuidanceClip? Clip = null, int? VolumePercent = null);
|
||||
|
||||
/// <summary>
|
||||
/// 只规划未来动作:没有 HostedService、设备命令或 API 路由,本版默认 Disabled。
|
||||
/// 实际接入时由场景协调层消费动作,不能从传感器适配器直接控制家具。
|
||||
/// </summary>
|
||||
public sealed class ReservedSleepWorkflow
|
||||
{
|
||||
private readonly SleepWorkflowOptions _options;
|
||||
private readonly GuidanceCatalog _guidance;
|
||||
private DateTimeOffset? _occupiedSince;
|
||||
private DateTimeOffset? _fadeAt;
|
||||
private GuidanceClip? _pendingClip;
|
||||
private bool _fadeIssued;
|
||||
private bool _initialGuide;
|
||||
public ReservedSleepPhase Phase { get; private set; } = ReservedSleepPhase.Disabled;
|
||||
|
||||
public ReservedSleepWorkflow(SleepWorkflowOptions options, GuidanceCatalog guidance)
|
||||
=> (_options, _guidance) = (options, guidance);
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> Start()
|
||||
{
|
||||
_occupiedSince = null; _fadeAt = null; _pendingClip = null; _fadeIssued = false; _initialGuide = false;
|
||||
Phase = _options.Enabled ? ReservedSleepPhase.WaitingForBed : ReservedSleepPhase.Disabled;
|
||||
return _options.Enabled ? [new(SleepWorkflowActionKind.PrepareEnvironment)] : [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> Observe(SleepObservation observation, DateTimeOffset now)
|
||||
{
|
||||
if (Phase is not (ReservedSleepPhase.WaitingForBed or ReservedSleepPhase.Settling)) return [];
|
||||
var fresh = observation.Availability == SleepDataAvailability.Available && observation.ObservedAt is not null &&
|
||||
now - observation.ObservedAt <= _options.MaximumObservationAge && observation.ObservedAt <= now.AddMinutes(1);
|
||||
if (!fresh || observation.Presence != BedPresence.Occupied)
|
||||
{
|
||||
_occupiedSince = null; Phase = ReservedSleepPhase.WaitingForBed; return [];
|
||||
}
|
||||
_occupiedSince ??= now;
|
||||
Phase = ReservedSleepPhase.Settling;
|
||||
if (now - _occupiedSince < _options.StableBedDuration) return [];
|
||||
_pendingClip = _guidance.InitialRelaxation;
|
||||
_initialGuide = true;
|
||||
Phase = ReservedSleepPhase.Guiding;
|
||||
return [new(SleepWorkflowActionKind.StartGentleRightMassage),
|
||||
new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now)
|
||||
{
|
||||
if (Phase != ReservedSleepPhase.Guiding || !completed || _pendingClip?.Key != clipKey || !_pendingClip.Playable) return [];
|
||||
Phase = ReservedSleepPhase.Music;
|
||||
if (_initialGuide) _fadeAt = now + _options.PostGuidanceFadeDelay;
|
||||
return [new(SleepWorkflowActionKind.RestartMusicAtFifty, VolumePercent: 50)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> RequestReminder(GuidanceClip clip)
|
||||
{
|
||||
if (!_options.Enabled || Phase != ReservedSleepPhase.Music) return [];
|
||||
_initialGuide = false; _pendingClip = clip; Phase = ReservedSleepPhase.Guiding;
|
||||
return [new(SleepWorkflowActionKind.RequestGuidance, clip, _options.GuidanceVolumePercent)];
|
||||
}
|
||||
|
||||
public IReadOnlyList<SleepWorkflowAction> Tick(DateTimeOffset now)
|
||||
{
|
||||
if (Phase != ReservedSleepPhase.Music || _fadeIssued || _fadeAt is null || now < _fadeAt) return [];
|
||||
_fadeIssued = true; return [new(SleepWorkflowActionKind.StartLightFade)];
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Phase = ReservedSleepPhase.Stopped; _occupiedSince = null; _fadeAt = null; _pendingClip = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Evergreen.Health.Application.Health.Sleep;
|
||||
|
||||
public enum BedPresence { Unknown, Empty, Occupied }
|
||||
public enum SleepDataAvailability { Unconfigured, Offline, Stale, Available }
|
||||
public enum ObservedSleepStage { Unknown, Awake, Light, Deep }
|
||||
|
||||
/// <summary>设备读数归一化入口;不凭心率、睡眠阶段 0 或未知值推断有人/无人。</summary>
|
||||
public sealed record SleepObservation(string SourceId, DateTimeOffset? ObservedAt,
|
||||
SleepDataAvailability Availability, BedPresence Presence,
|
||||
ObservedSleepStage Stage = ObservedSleepStage.Unknown,
|
||||
double? HeartRate = null, double? RespiratoryRate = null, bool? Movement = null);
|
||||
|
||||
public interface ISleepObservationSource
|
||||
{
|
||||
string Key { get; }
|
||||
Task<SleepObservation> ReadAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>JE01 尚未接入,不启动轮询,也不将假数据视作“无人”。</summary>
|
||||
public sealed class ReservedJe01Source : ISleepObservationSource
|
||||
{
|
||||
public string Key => "je01";
|
||||
public Task<SleepObservation> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new SleepObservation(Key, null, SleepDataAvailability.Unconfigured, BedPresence.Unknown));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SleepWorkflowOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
public TimeSpan StableBedDuration { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan PostGuidanceFadeDelay { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan MaximumObservationAge { get; init; } = TimeSpan.FromMinutes(2);
|
||||
public TimeSpan SameAnomalyReminderInterval { get; init; } = TimeSpan.FromMinutes(30);
|
||||
public int GuidanceVolumePercent { get; init; } = 50;
|
||||
}
|
||||
Reference in New Issue
Block a user