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
67 changes: 64 additions & 3 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ name: Build and Push lfhub Image
# linux-arm64 and pushed to ghcr.io/<owner>/<repository>: main as `latest`, dev as
# `dev`, each also under its commit's SHA. A manual run on another branch runs the
# tests alone.
#
# Versions: `<Version>` in lfhub.csproj is MAJOR.MINOR.PATCH. A release on main is
# X.Y.0 (set by release.sh); every push to dev raises PATCH by one in a commit of its
# own ("CI: Bump dev version to X.Y.Z [skip ci]"), and the dev image is built from
# that commit, so the version a hub reports matches lfhub.csproj on its branch. A push
# that sets the version itself (a release sync) is built as it is. Forks build without
# bumping. The bump pushes with the LFHUB_TOKEN_AUTOBUMP secret when it exists (needed
# once dev is protected), otherwise with the workflow's token.

on:
push:
Expand Down Expand Up @@ -52,9 +60,59 @@ jobs:
LFHUB_TEST_DATABASE: Host=localhost;Port=5432;Username=postgres
run: dotnet test --solution lfhub.slnx -c Release --no-build

build-and-push:
bump-version:
needs: test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev'
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && !github.event.repository.fork
runs-on: ubuntu-latest
concurrency:
group: bump-${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: write
outputs:
sha: ${{ steps.bump.outputs.sha }}

steps:
- name: Check out dev
uses: actions/checkout@v6
with:
ref: dev
fetch-depth: 0
token: ${{ secrets.LFHUB_TOKEN_AUTOBUMP || github.token }}

- name: Bump the patch version
id: bump
env:
BEFORE: ${{ github.event.before }}
run: |
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] &&
git diff "$BEFORE..${GITHUB_SHA}" -- lfhub.csproj | grep -qE '^\+ *<Version>'; then
echo "The version was set in this push; building it as it is."
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
exit 0
fi

CURRENT=$(sed -nE 's|^ *<Version>([0-9]+\.[0-9]+\.[0-9]+)</Version>.*|\1|p' lfhub.csproj)
if [ -z "$CURRENT" ]; then
echo "::error::No <Version>MAJOR.MINOR.PATCH</Version> in lfhub.csproj"
exit 1
fi
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
NEW="$MAJOR.$MINOR.$((PATCH + 1))"
sed -i -E "s|<Version>${CURRENT//./\\.}</Version>|<Version>$NEW</Version>|" lfhub.csproj
echo "Version: $CURRENT -> $NEW"

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "CI: Bump dev version to $NEW [skip ci]" lfhub.csproj
git push origin HEAD:dev
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

build-and-push:
needs: [test, bump-version]
if: >-
!cancelled() && needs.test.result == 'success' && needs.bump-version.result != 'failure' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
runs-on: ubuntu-latest
concurrency:
group: build-${{ github.workflow }}
Expand All @@ -63,6 +121,8 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v6
with:
ref: ${{ needs.bump-version.outputs.sha || github.sha }}

- name: Set up .NET
uses: actions/setup-dotnet@v5
Expand All @@ -86,4 +146,5 @@ jobs:
-p:ContainerRegistry=ghcr.io
-p:ContainerRepository=${REPOSITORY,,}
-p:RepositoryUrl=${{ github.server_url }}/${{ github.repository }}
"-p:ContainerImageTags=\"${CHANNEL};${{ github.sha }}\""
-p:HubChannel=${CHANNEL}
"-p:ContainerImageTags=\"${CHANNEL};$(git rev-parse HEAD)\""
52 changes: 52 additions & 0 deletions .github/workflows/tag_on_main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Tags a release when main reaches a release version (X.Y.0): the version is
# `<Version>` in lfhub.csproj and the tag is `v<version>`. Other versions, an
# existing tag and forks are skipped. A manual run against main creates a missing tag.

name: Tag release on main

on:
push:
branches: [main]
workflow_dispatch:

jobs:
tag:
if: ${{ !github.event.repository.fork }}
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Check out code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Read the version
id: version
run: |
VERSION=$(sed -nE 's|^ *<Version>([^<]+)</Version>.*|\1|p' lfhub.csproj)
if [ -z "$VERSION" ]; then
echo "::error::No <Version> in lfhub.csproj"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
echo "release=true" >> "$GITHUB_OUTPUT"
else
echo "release=false" >> "$GITHUB_OUTPUT"
echo "$VERSION is not a release version (X.Y.0); not tagging."
fi

- name: Create and push the tag
if: steps.version.outputs.release == 'true'
run: |
TAG="v${{ steps.version.outputs.version }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "$TAG exists."
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
fi
25 changes: 25 additions & 0 deletions .github/workflows/warn_main_pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Warn PR targets main

on:
pull_request_target:
types: [opened, edited]
branches: [main]

jobs:
warn:
if: ${{ !github.event.repository.fork && !startsWith(github.head_ref, 'release/') }}
runs-on: ubuntu-latest
permissions:
pull-requests: write

steps:
- name: Comment on PR
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: 'This PR targets `main`, which only takes releases. Please retarget it to `dev`.'
});
2 changes: 1 addition & 1 deletion Api/HubApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ await Results.Problem($"the request body is larger than {MaxBodyBytes / 1024} KB
var alarmCount = engine.AlarmExport(device)?.Alarms.Count;
return Results.Ok(new
{
hub = new { version = HubInfo.Version, api = HubInfo.Api, features = HubInfo.Features },
hub = new { version = HubInfo.Version, api = HubInfo.Api, features = HubInfo.Features, channel = HubInfo.Channel },
device = new
{
id = device.Id,
Expand Down
10 changes: 9 additions & 1 deletion Api/HubInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ public static class HubInfo
(typeof(HubInfo).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0")
.Split('+')[0];

/// <summary>
/// The image tag the hub was published under: <c>latest</c> (built from <c>main</c>) or
/// <c>dev</c> (built from <c>dev</c>); null for a build of its own.
/// </summary>
public static readonly string? Channel =
typeof(HubInfo).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "Channel")?.Value;

/// <summary>Features this hub provides.</summary>
public static readonly string[] Features = ["receipts", "speak", "heartbeat", "bundle", "latestBg", "devices", "roles", "invites", "sourceRevision", "sharedApnsKey"];
public static readonly string[] Features = ["receipts", "speak", "bundle", "latestBg", "devices", "roles", "invites", "sourceRevision", "sharedApnsKey"];

/// <summary>The app posts a delivery receipt for every alarm push (<c>POST /v1/receipt/{token}</c>).</summary>
public const string Receipts = "receipts";
Expand Down
28 changes: 0 additions & 28 deletions Apns/ApnsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,34 +202,6 @@ internal static (JsonObject Payload, Dictionary<string, string> Headers) Speak(s
return (payload, headers);
}

/// <summary>
/// The heartbeat sent every 20 minutes to a device with alarms on: a background push
/// whose <c>lfhubHeartbeat</c> (Unix seconds) tells the app when it was sent. It goes at
/// priority 10; iOS may still hold a background push back or drop it.
/// </summary>
public Task<PushResult> SendHeartbeatAsync(ApnsConfig cfg, DateTime nowUtc, CancellationToken ct)
{
var (payload, headers) = Heartbeat(nowUtc);
return SendAsync(cfg, cfg.Token, cfg.BundleId, payload, "background", "10", ct, headers);
}

/// <summary>The heartbeat's payload and its collapse and expiration headers; it expires after 20 minutes.</summary>
internal static (JsonObject Payload, Dictionary<string, string> Headers) Heartbeat(DateTime nowUtc)
{
var unix = new DateTimeOffset(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
var payload = new JsonObject
{
["aps"] = new JsonObject { ["content-available"] = 1 },
["lfhubHeartbeat"] = unix,
};
var headers = new Dictionary<string, string>
{
["apns-collapse-id"] = "lfhub-heartbeat",
["apns-expiration"] = (unix + 20 * 60).ToString(System.Globalization.CultureInfo.InvariantCulture),
};
return (payload, headers);
}

private static JsonObject Alert(string title, string subtitle, string? body)
{
var alert = new JsonObject { ["title"] = title, ["subtitle"] = subtitle };
Expand Down
2 changes: 0 additions & 2 deletions Apns/IApnsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ Task<PushResult> SendAlarmAsync(ApnsConfig cfg, Guid alarmId, string title, stri
Task<PushResult> SendAlarmStopAsync(ApnsConfig cfg, Guid alarmId, string title, string subtitle, string? body,
AlarmStopReason reason, CancellationToken ct);

Task<PushResult> SendHeartbeatAsync(ApnsConfig cfg, DateTime nowUtc, CancellationToken ct);

Task<PushResult> SendLatestBgAsync(ApnsConfig cfg, string subtitle, CancellationToken ct);

Task<PushResult> SendSpeakAsync(ApnsConfig cfg, string title, int current, int previous, string language,
Expand Down
4 changes: 4 additions & 0 deletions Engine/Alarms/AlarmType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ namespace LFHub.Engine.Alarms;
/// <summary>
/// LoopFollow's alarm types (Alarm/AlarmType/AlarmType.swift). Declaration
/// order is priority order: a lower value is checked first.
/// The app keeps its own list of the types each hub API version checks
/// (HubClient.hubAlarmTypes) and checks the other types itself. A change to the types
/// the hub checks needs a new <see cref="LFHub.Api.HubInfo.Api"/> version and that
/// version's list in the app.
/// </summary>
public enum AlarmType
{
Expand Down
27 changes: 0 additions & 27 deletions Engine/HubEngine.Alarms.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,6 @@ public sealed partial class HubEngine
/// </summary>
private static readonly TimeSpan SingleRing = TimeSpan.FromSeconds(30);

/// <summary>
/// How often a device with alarms on gets a heartbeat push. The app logs how late each
/// one arrives; the heartbeat drives no alarm.
/// </summary>
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromMinutes(20);

private readonly ConcurrentDictionary<Guid, DateTime> _lastHeartbeat = new();
private readonly ConcurrentDictionary<Guid, DateTime> _lastAlarmCheck = new();
/// Devices with alarms on that cannot get them, logged once until that changes.
private readonly ConcurrentDictionary<Guid, bool> _alarmsUnsentWarned = new();
Expand Down Expand Up @@ -245,26 +238,6 @@ private async Task StopForAlarmsOffAsync(Device device, ActiveAlarm sounding, Ca
await StopRingingAsync(device, sounding, AlarmStopReason.Silenced, ct);
}

/// <summary>
/// Sends the device a heartbeat at its first check with alarms on and APNs registered,
/// then every 20 minutes while that holds. The time of the last one is kept in memory.
/// </summary>
private async Task HeartbeatAsync(Device device, CancellationToken ct)
{
if (!device.AlarmsEnabled || device.Apns is null)
{
_lastHeartbeat.TryRemove(device.Id, out _);
return;
}
var now = UtcNow;
if (_lastHeartbeat.TryGetValue(device.Id, out var last) && now - last < HeartbeatInterval) return;
_lastHeartbeat[device.Id] = now;
var result = await _apns.SendHeartbeatAsync(device.Apns, now, ct);
await TrackPushAsync(device.Id, result, PushKind.Device);
if (!result.Delivered)
_log.LogWarning("Heartbeat to {Device} failed: {Status}", device.Id, result.Status);
}

/// <summary>
/// The persistent "Latest BG" notification, sent once per new reading when no
/// alarm fired, as AlarmManager.checkAlarms does it.
Expand Down
5 changes: 2 additions & 3 deletions Engine/HubEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ internal async Task EvaluateAsync(Guid patientId, bool refresh, bool newData, Ca
using var alarmCheck = new CancellationTokenSource(Timeout.InfiniteTimeSpan, _time);
using var onStop = ct.Register(() => alarmCheck.CancelAfter(AlarmCheckShutdownGrace));

// Each device's badge, Live Activity, alarms, speech and heartbeat run on their own, so a
// Each device's badge, Live Activity, alarms and speech run on their own, so a
// failure in one never holds back another.
foreach (var device in devices)
{
Expand All @@ -412,7 +412,6 @@ internal async Task EvaluateAsync(Guid patientId, bool refresh, bool newData, Ca
await GuardAsync("Alarm check", device.Id,
() => AlarmsAsync(device, rt.State, settings[device.Id], newData, alarmCheck.Token), alarmCheck.Token);
await GuardAsync("Speak", device.Id, () => SpeakAsync(device, rt.State, settings[device.Id], ct), ct);
await GuardAsync("Heartbeat", device.Id, () => HeartbeatAsync(device, ct), ct);
}
}
catch (Exception ex) when (!ct.IsCancellationRequested)
Expand Down Expand Up @@ -482,7 +481,7 @@ private ClientSettings ClientSettingsFor(Device device)
/// </summary>
internal enum PushKind
{
/// A badge, alarm stop, Latest BG, speech or heartbeat.
/// A badge, alarm stop, Latest BG or speech.
Device,
/// An alarm.
Alarm,
Expand Down
8 changes: 2 additions & 6 deletions Engine/State/ClientSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@ public sealed record ClientSettings
/// "units": "mg/dL" or "mmol/L"; the snapshot's `unit`.
public bool Mmol { get; init; }

/// "predictionToLoad", hours: the OpenAPS min/max window, the length of Loop's
/// stored forecast, the carbs horizon and the override graph's future edge.
/// "predictionToLoad", hours: the OpenAPS min/max window, the carbs horizon
/// and the override graph's future edge.
public double PredictionToLoad { get; init; } = 1.0;

/// "downloadPrediction": Loop's forecast and min/max are stored only when on.
public bool DownloadPrediction { get; init; } = true;

/// "downloadTreatments": off leaves every treatments-derived value unset.
public bool DownloadTreatments { get; init; } = true;

Expand Down Expand Up @@ -118,7 +115,6 @@ public static ClientSettings FromJson(JsonObject? json)
{
Mmol = mmol,
PredictionToLoad = Swift.Dbl(json["predictionToLoad"]) ?? d.PredictionToLoad,
DownloadPrediction = StrictBool(json, "downloadPrediction") ?? d.DownloadPrediction,
DownloadTreatments = StrictBool(json, "downloadTreatments") ?? d.DownloadTreatments,
DownloadDays = (int?)Swift.Int(json["downloadDays"]) is { } days and >= 1
? Math.Min(days, MaxDownloadDays)
Expand Down
12 changes: 5 additions & 7 deletions Engine/State/DeviceStatusState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ internal sealed partial class DeviceStatusState
public double? IsfMgdl { get; private set; }
public double? CarbRatio { get; private set; }

/// Loop's forecast as MainViewController.predictionData holds it with downloadPrediction on:
/// the raw values and the loop time the first point is placed at.
/// MainViewController.loopPredBGs and loopPredUpdatedTime: Loop's full forecast
/// and the loop time the first point is placed at.
public (List<double> Values, double Start)? LoopForecast { get; private set; }
/// Storage.lastMinBgMgdl/lastMaxBgMgdl as Loop last set them (downloadPrediction on), with a write sequence.
/// Storage.lastMinBgMgdl/lastMaxBgMgdl as Loop last set them, with a write sequence.
public (double Min, double Max, long Seq)? LoopMinMax { get; private set; }
/// The predBGs OpenAPS last set lastMinBg/lastMaxBg from; the window depends on predictionToLoad.
public (Dictionary<string, List<double>> PredBGs, long Seq)? OpenApsMinMax { get; private set; }
Expand Down Expand Up @@ -162,10 +162,8 @@ private void ApplyLoop(JsonObject loop, double previousLoopTime, double lastLoop
if (values is { Count: > 0 })
{
if (previousLoopTime < lastLoopTime || LoopForecast is null)
{
LoopForecast = (values, lastLoopTime);
LoopMinMax = (values.Min(), values.Max(), ++_seq);
}
LoopForecast = (values, lastLoopTime);
}
else
{
Expand Down Expand Up @@ -244,7 +242,7 @@ private void ApplyOpenAps(JsonObject ds, JsonObject openaps, DateTime now, NsPro
/// The min/max the snapshot shows for a device with these prediction settings.
public (double Min, double Max)? MinMax(ClientSettings settings)
{
var loop = settings.DownloadPrediction ? LoopMinMax : null;
var loop = LoopMinMax;
var openAps = OpenApsMinMax;
if (loop is { } l && (openAps is null || l.Seq > openAps.Value.Seq)) return (l.Min, l.Max);
if (openAps is not { } o) return null;
Expand Down
Loading