Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions Engine/HubEngine.Alarms.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using LFHub.Api;
using LFHub.Apns;
Expand All @@ -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);

/// <summary>
/// The engine ticks every <see cref="Tick"/>: 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 <see cref="Tick"/> is initialized in another file of this class.
/// </summary>
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;

/// <summary>
/// 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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 <see cref="SingleRing"/> ago.
/// </summary>
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);

/// <param name="selfDisabled">An alarm that turned itself off when it fired; being off does not silence it.</param>
Expand Down
2 changes: 2 additions & 0 deletions Engine/HubEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lfhub.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>LFHub</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
<Version>0.3.0</Version>
<Version>0.4.0</Version>

<!-- `dotnet publish /t:PublishContainer` builds the OCI image without Docker. -->
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0</ContainerBaseImage>
Expand Down
28 changes: 26 additions & 2 deletions tests/LFHub.Tests/Engine/AlarmDeliveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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)]
Expand Down