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); /// /// 只规划未来动作:没有 HostedService、设备命令或 API 路由,本版默认 Disabled。 /// 实际接入时由场景协调层消费动作,不能从传感器适配器直接控制家具。 /// 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 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 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 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 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 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; } }