diff --git a/Engine/HubEngine.Alarms.cs b/Engine/HubEngine.Alarms.cs index d4cf826..b20edf8 100644 --- a/Engine/HubEngine.Alarms.cs +++ b/Engine/HubEngine.Alarms.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using LFHub.Api; using LFHub.Apns; @@ -15,6 +16,19 @@ public sealed partial class HubEngine /// A sounding alarm that repeats is delivered again this often until it is snoozed. private static readonly TimeSpan AlarmRepeatInterval = TimeSpan.FromSeconds(60); + /// + /// The engine ticks every : a check, a repeat or a stop that falls due + /// less than half a tick after one is taken at that tick, so each comes once a minute. + /// A property, since is initialized in another file of this class. + /// + private static TimeSpan DueSlack => Tick / 2; + + /// A sounding alarm is due to be delivered again: one that repeats, or one whose + /// delivery the phone has not confirmed, a minute after its last push. + private static bool RepeatDue([NotNullWhen(true)] ActiveAlarm? active, DateTime now) => + active is { ConditionCleared: false } && (active.Repeat || active.DeliveredAt is null) && + now - active.LastPushAt >= AlarmRepeatInterval - DueSlack; + /// /// A system alarm is not stopped for a cleared condition before this: LoopFollow /// re-checks a sounding alarm on its next minute, and an event alarm (an override @@ -90,7 +104,8 @@ private async Task AlarmsAsync(Device listed, PatientState state, ClientSettings if (!listed.AlarmsEnabled && listed.ActiveAlarm is null) return; var now = UtcNow; if (listed.AlarmsEnabled && !immediate && _lastAlarmCheck.TryGetValue(listed.Id, out var last) && - now - last < AlarmCheckInterval) + now - last < AlarmCheckInterval - DueSlack && !RepeatDue(listed.ActiveAlarm, now) && + !(listed.ActiveAlarm is { } rung && HasRungEnough(rung, now))) return; if (await _store.DeviceAsync(listed.Id) is not { } device) return; @@ -186,8 +201,7 @@ private async Task AlarmsAsync(Device listed, PatientState state, ClientSettings fire.PlaySound && fire.RepeatSound, now, now, null, DisabledWhenFired: disable.Contains(fire.AlarmId)); } - else if (active is not null && !active.ConditionCleared && (active.Repeat || active.DeliveredAt is null) && - now - active.LastPushAt >= AlarmRepeatInterval) + else if (RepeatDue(active, now)) { // A repeating alarm is delivered again; any other alarm until the phone confirms one of its pushes. active = send = active with { LastPushAt = now }; @@ -321,7 +335,7 @@ private async Task SpeakAsync(Device device, PatientState state, ClientSettings /// or its sound does not repeat and the phone received it at least ago. /// internal static bool HasRungEnough(ActiveAlarm alarm, DateTime now) => - (alarm.ConditionCleared && now - alarm.FiredAt >= MinimumRing) || + (alarm.ConditionCleared && now - alarm.FiredAt >= MinimumRing - DueSlack) || (!alarm.Repeat && alarm.DeliveredAt is { } delivered && now - delivered >= SingleRing); /// An alarm that turned itself off when it fired; being off does not silence it. diff --git a/Engine/HubEngine.cs b/Engine/HubEngine.cs index 16bcba9..bcb2398 100644 --- a/Engine/HubEngine.cs +++ b/Engine/HubEngine.cs @@ -612,6 +612,8 @@ private async Task RenewLiveActivityAsync(Device device, JsonObject snapshot, Ca ["attributes-type"] = "GlucoseLiveActivityAttributes", ["attributes"] = new JsonObject { ["title"] = "LoopFollow" }, ["content-state"] = ContentState(snapshot, now), + // The start asks iOS for the activity's update token, which the app reports. + ["input-push-token"] = 1, // iOS drops a start without an alert; empty and passive keeps it silent. ["alert"] = new JsonObject { ["title"] = "", ["body"] = "" }, ["interruption-level"] = "passive", diff --git a/README.md b/README.md index 056fb50..9875b9a 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,8 @@ condition) against the AlarmData LoopFollow would build from the same data and the device's client settings: at most one alarm per check, in LoopFollow's priority order; BG alarms at most once per reading and only on a reading at most 5 minutes old; snoozes, Snooze All, Mute, and day/night windows in the phone's -time zone. Checks run once a minute, and at once on new data, on an alarm upload -and on `handled`. BG and devicestatus are fetched at least every 270 seconds, so +time zone. Checks run once a minute (at the engine's 30-second tick nearest it), and at +once on new data, on an alarm upload and on `handled`. BG and devicestatus are fetched at least every 270 seconds, so the missed-reading and not-looping alarms, which stay silent without a check in the last 6 minutes, keep working when readings stop. @@ -263,7 +263,8 @@ push of an earlier firing, or of a test alarm, uses up its token and records not off) before it sends anything, and only while the app has neither handled an alarm nor uploaded its alarms since that read. Otherwise it sends nothing, and the check that the app's call starts runs from what the app left. -- **Condition clears**: once the alarm has rung for a minute, received or not, the hub sends a +- **Condition clears**: once the alarm has rung for a minute (at the engine's 30-second tick + nearest it), received or not, the hub sends a passive stop push (`lfhubStopAlarmId`) with the alarm's title and the subtitle "Cleared", which tells the extension to stop the system alarm. Auto-snooze on CGM start stops a ringing alarm the same way. diff --git a/lfhub.csproj b/lfhub.csproj index 49efe5c..9dcbdc6 100644 --- a/lfhub.csproj +++ b/lfhub.csproj @@ -6,7 +6,7 @@ enable LFHub true - 0.3.0 + 0.4.0 mcr.microsoft.com/dotnet/aspnet:10.0 diff --git a/tests/LFHub.Tests/Engine/AlarmDeliveryTests.cs b/tests/LFHub.Tests/Engine/AlarmDeliveryTests.cs index 37d9491..28a6dff 100644 --- a/tests/LFHub.Tests/Engine/AlarmDeliveryTests.cs +++ b/tests/LFHub.Tests/Engine/AlarmDeliveryTests.cs @@ -83,6 +83,30 @@ public async Task RepeatingAlarm_IsDeliveredAgainEachCheckUntilSnoozed() Assert.Equal(41, h.Apns.Alarms.Count); } + [Fact] + public async Task RepeatingAlarm_IsDeliveredAgainAtTheTickNearestTheMinute() + { + var (h, _, low) = await FiredLowAsync(); + + // A check the app starts inside the minute delivers nothing and does not hold off + // the repeat; a tick that comes just before the minute delivers it. + h.Advance(TimeSpan.FromSeconds(40)); + await h.FromAppAsync(); + Assert.Single(h.Apns.Alarms); + h.Advance(TimeSpan.FromSeconds(19)); + await h.TickAsync(); + Assert.Equal(2, h.Apns.Alarms.Count); + + // The next ticks keep to the minute although they fall a little early. + h.Advance(TimeSpan.FromSeconds(29)); + await h.TickAsync(); + Assert.Equal(2, h.Apns.Alarms.Count); + h.Advance(TimeSpan.FromSeconds(29)); + await h.TickAsync(); + Assert.Equal(3, h.Apns.Alarms.Count); + Assert.All(h.Apns.Alarms, p => Assert.Equal(low.Id, p.AlarmId)); + } + [Fact] public async Task SnoozeFromTheApp_TakesEffectInsideTheMinuteBetweenChecks() { @@ -312,8 +336,8 @@ public async Task StopThatApnsKeepsRefusing_IsGivenUpAfterFifteenMinutes() } [Theory] - [InlineData(true, true, null, 59, false)] // a cleared condition rings out the minute, received or not - [InlineData(true, true, null, 60, true)] + [InlineData(true, true, null, 44, false)] // a cleared condition rings out the minute (to the nearest tick), received or not + [InlineData(true, true, null, 45, true)] [InlineData(false, false, null, 600, false)] // a single ring lasts 30 s from its delivery receipt [InlineData(false, false, 10, 39, false)] [InlineData(false, false, 10, 40, true)]