This commit is contained in:
fuenmao
2026-10-09 16:46:30 +08:00
parent 4a634ef4c2
commit e6b90a06ab
63 changed files with 4170 additions and 1518 deletions
@@ -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;
}