From 91020ba8edc7342ecd0672f046a84e06b87af4a5 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:52:35 +0200 Subject: [PATCH] feat(notifications): alerts you can trust Switching a notification type off only hid it in the panel: the server pushed every alert to every device and the service worker showed them all. Each subscription now carries the types its device switched off (new ExcludedCategories column), and the Worker leaves that device out of the push. The panel still records every alert for everyone. Settings gains a "Push notifications on this device" switch. Before, a fresh device only subscribed when the number of unticked types changed, so turning push on meant unticking and re-ticking a type. The checklist no longer subscribes or unsubscribes; it only tells the server which types to leave out, so ticking a box never brings up a permission prompt. The settings section is now called Notifications. Tyre and low EV alerts went out every hour while the latest data still showed the problem, so a car asleep overnight with a soft tyre pushed all night. Level alerts now go out once per report, judged by the gateway's own poll stamps, so a replayed message cannot re-arm them. New alerts, all switchable per device: - Low 12V battery: below AUX_BATTERY_LOW_VOLT (12.0 V) on several polls in a row while parked. - Low fuel: below LOW_FUEL_PERCENT (15 %), hybrids and plug-in hybrids. - No data from the car: nothing reported for STALE_DATA_HOURS (48). - MG sign-in failed: the gateway's account/lastLoginError, once per outage (until the next account/lastLogin). Account topics were dropped before any handler saw them; MqttTopicParser now parses them. LOW_EV_PERCENT (20) replaces the hardcoded low EV level. Zero switches any of these alerts off. The variables are in compose, .env.example, CONFIGURATION.md, the README and the Unraid template. Co-Authored-By: Claude Opus 5.5 --- .env.example | 10 + README.md | 10 +- docker-compose.yml | 5 + documentation/CONFIGURATION.md | 4 + frontend/src/assets/main.css | 2 +- frontend/src/components/NotificationPanel.vue | 4 + frontend/src/components/SettingsToggle.vue | 2 + .../settings/NotificationTypeSettings.vue | 45 +- .../NotificationTypeSettings.spec.ts | 29 +- .../__tests__/useNotificationPushSync.spec.ts | 164 ++- .../src/composables/__tests__/usePush.spec.ts | 69 +- .../composables/useNotificationPushSync.ts | 54 +- frontend/src/composables/usePush.ts | 34 +- frontend/src/locales/en.json | 14 +- frontend/src/locales/nl.json | 14 +- frontend/src/plugins/icons.ts | 1 + frontend/src/services/pushApi.ts | 9 +- .../__tests__/notificationCategories.spec.ts | 8 +- frontend/src/utils/levels.ts | 3 +- frontend/src/utils/notificationCategories.ts | 16 +- .../Endpoints/PushEndpoints.cs | 21 +- .../Configuration/AlertThresholds.cs | 18 + .../Configuration/EnvironmentAliases.cs | 6 + .../Configuration/HostingExtensions.cs | 33 +- .../Models/NotificationCategories.cs | 38 + .../Models/PushSubscription.cs | 6 + src/GarageStack.Data/AppDbContext.cs | 2 + .../NotificationHistoryExtensions.cs | 5 +- ...SubscriptionExcludedCategories.Designer.cs | 985 ++++++++++++++++++ ...6_AddPushSubscriptionExcludedCategories.cs | 30 + .../Migrations/AppDbContextModelSnapshot.cs | 7 + .../AccountStatusHandlerTests.cs | 110 ++ .../HostingExtensionsTests.cs | 20 + src/GarageStack.Tests/MqttTopicParserTests.cs | 18 + .../NotificationCategoriesTests.cs | 53 + .../PushNotificationCheckServiceTests.cs | 244 ++++- .../PushSubscriptionUpsertTests.cs | 42 + .../Mqtt/AccountStatusHandler.cs | 89 ++ .../Mqtt/MqttConsumerService.cs | 4 +- src/GarageStack.Worker/Mqtt/MqttMessage.cs | 3 + .../Mqtt/MqttTopicParser.cs | 20 + src/GarageStack.Worker/Program.cs | 1 + .../Resources/NotificationStrings.nl.resx | 24 + .../Resources/NotificationStrings.resx | 24 + .../Services/PushNotificationCheckService.cs | 93 +- .../Services/PushSenderService.cs | 6 +- unraid/garagestack.xml | 44 + 47 files changed, 2244 insertions(+), 199 deletions(-) create mode 100644 src/GarageStack.Core/Configuration/AlertThresholds.cs create mode 100644 src/GarageStack.Data/Migrations/20261011201756_AddPushSubscriptionExcludedCategories.Designer.cs create mode 100644 src/GarageStack.Data/Migrations/20261011201756_AddPushSubscriptionExcludedCategories.cs create mode 100644 src/GarageStack.Tests/AccountStatusHandlerTests.cs create mode 100644 src/GarageStack.Tests/NotificationCategoriesTests.cs create mode 100644 src/GarageStack.Worker/Mqtt/AccountStatusHandler.cs diff --git a/.env.example b/.env.example index 0884ff09..63646936 100644 --- a/.env.example +++ b/.env.example @@ -228,6 +228,16 @@ OPENCHARGEMAP_API_KEY= # TYRE_PRESSURE_GOOD_BAR=2.6 # TYRE_PRESSURE_HIGH_BAR=3.2 +# ── Alert levels ─────────────────────────────────────────────────────────────── +# When the low 12V battery, low fuel, low EV battery and "no data from the car" +# notifications fire. The defaults match the dashboard's own warning colours. +# 0 switches an alert off. STALE_DATA_HOURS should stay above the gateway's poll +# of a sleeping car (once a day by default). +# AUX_BATTERY_LOW_VOLT=12.0 +# LOW_FUEL_PERCENT=15 +# LOW_EV_PERCENT=20 +# STALE_DATA_HOURS=48 + # ── High-voltage battery capacity ────────────────────────────────────────────── # Usable capacity of the traction battery, in kWh. The MQTT gateway does not read # this off the pack: it scales the BMS percentage by an EV-sized default, so the diff --git a/README.md b/README.md index 685661fd..c96bff00 100644 --- a/README.md +++ b/README.md @@ -309,14 +309,18 @@ Telemetry older than a year is folded into one row per quarter of an hour, so th ## Push notifications -GarageStack checks your vehicle's state every 5 minutes and sends both a browser push notification and an in-app notification (bell icon) when any of the following conditions are detected. "Engine started" and MG app messages go out the moment they arrive over MQTT instead. Each alert has a 1-hour cooldown per vehicle to avoid repeated notifications. +GarageStack checks your vehicle's state every 5 minutes and sends both a browser push notification and an in-app notification (bell icon) when any of the following conditions are detected. "Engine started", MG app messages and a failed MG sign-in go out the moment they arrive over MQTT instead. Each alert has a 1-hour cooldown per vehicle to avoid repeated notifications, and goes out once per report from the car: a car that sleeps for days with a soft tyre is reported once, not every hour. | Alert | Condition | | ------- | --------- | | Engine started | Engine transitions from off to running | | Low tyre pressure | Any tyre below `TYRE_PRESSURE_LOW_BAR` (default 2.2 bar) | | High tyre pressure | Any tyre above `TYRE_PRESSURE_HIGH_BAR` (default 3.2 bar) | -| Low EV battery | EV state-of-charge below 20 % (plug-in vehicles) | +| Low EV battery | EV state-of-charge below `LOW_EV_PERCENT` (default 20 %, plug-in vehicles) | +| Low fuel | Fuel level below `LOW_FUEL_PERCENT` (default 15 %, hybrids and plug-in hybrids) | +| Low 12V battery | 12V battery below `AUX_BATTERY_LOW_VOLT` (default 12.0 V) on several polls in a row while the engine is off | +| No data from the car | Nothing reported for `STALE_DATA_HOURS` (default 48 hours): the MG session, the gateway or the car's connection needs a look | +| MG sign-in failed | The gateway could not sign in to the MG account, so no new data arrives (sent once per outage, as it happens) | | Car left unlocked | `doors/locked = false` while engine is off | | Door left open | Any door, boot, or bonnet open while engine is off | | Window left open | Any window open while engine is off | @@ -327,7 +331,7 @@ GarageStack checks your vehicle's state every 5 minutes and sends both a browser Push notifications require VAPID keys to be configured (`VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`). Without them, alerts still appear in the in-app notification panel. While GarageStack is open in a browser that allowed notifications but is not subscribed to push, the same alerts show up as system notifications too; a subscribed browser gets them through push instead, so nothing arrives twice. -Settings has a per-type checklist for these alerts, which offers only the types the car's drivetrain can produce: the two plug-in alerts (low EV battery, charging complete) are left out for a plain hybrid. Deselecting every type offered unsubscribes the browser from push entirely; selecting one again resubscribes. +Settings has a push switch for the device you are on and a per-type checklist for these alerts, which offers only the types the car's drivetrain can produce: the two plug-in alerts (low EV battery, charging complete) are left out for a plain hybrid. Both are kept per device: a type switched off on your phone still reaches your laptop, and still shows up in the notification panel. Only the switch turns push on or off; the checklist never asks the browser for permission. Notification texts are written by the background worker, which has no browser to take a language from, so their language is a deployment setting: `NOTIFICATION_LANGUAGE=en` (default) or `nl`. The web UI's own language toggle does not affect them. diff --git a/docker-compose.yml b/docker-compose.yml index caea2003..c7303ed9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,6 +119,11 @@ services: NOTIFICATION_LANGUAGE: "${NOTIFICATION_LANGUAGE:-}" # Days of telemetry kept at full detail before the Worker compacts it. See .env.example. TELEMETRY_FULL_DETAIL_DAYS: "${TELEMETRY_FULL_DETAIL_DAYS:-}" + # When the Worker's level alerts fire. See .env.example. + AUX_BATTERY_LOW_VOLT: "${AUX_BATTERY_LOW_VOLT:-}" + LOW_FUEL_PERCENT: "${LOW_FUEL_PERCENT:-}" + LOW_EV_PERCENT: "${LOW_EV_PERCENT:-}" + STALE_DATA_HOURS: "${STALE_DATA_HOURS:-}" volumes: - worker_logs:/app/logs diff --git a/documentation/CONFIGURATION.md b/documentation/CONFIGURATION.md index 8da7cbb1..91492dac 100644 --- a/documentation/CONFIGURATION.md +++ b/documentation/CONFIGURATION.md @@ -96,6 +96,10 @@ The full guide, with provider examples, is [`AUTHENTICATION.md`](AUTHENTICATION. | Variable | Default | What it does | | --- | --- | --- | | `TYRE_PRESSURE_LOW_BAR` / `TYRE_PRESSURE_GOOD_BAR` / `TYRE_PRESSURE_HIGH_BAR` | `2.2` / `2.6` / `3.2` | Tyre pressure bands in bar, for the diagram's colours and the tyre notifications. Set them from your car's placard. | +| `AUX_BATTERY_LOW_VOLT` | `12.0` | 12V battery voltage a parked car is warned below, once several polls in a row show it. `0` switches the alert off. | +| `LOW_FUEL_PERCENT` | `15` | Fuel level, in percent, a hybrid or plug-in hybrid is warned below. `0` switches the alert off. | +| `LOW_EV_PERCENT` | `20` | Charge level, in percent, a plug-in car is warned below. `0` switches the alert off. | +| `STALE_DATA_HOURS` | `48` | Hours without a report from the car before GarageStack warns that data has stopped arriving. Keep it above the gateway's poll of a sleeping car, once a day by default. `0` switches the alert off. | | `HV_BATTERY_CAPACITY_KWH` | none | Usable size of the traction battery in kWh. The gateway assumes an EV-sized pack, so its kWh figures are far too large on a plain hybrid (an MG HS Hybrid+ carries 1.83 and is reported as 72.5). Empty keeps the gateway's figure on a plug-in car and shows a hybrid's charge as a percentage only. | ## Stored data diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 0329c715..9d64ad42 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1528,7 +1528,7 @@ textarea.form-control { /* Own internal scroll so a long category list never pushes the rest of the settings modal's sections down. minmax(170px, 1fr) against .detail-modal's actual width budget: the mobile bottom-sheet content is ~300-335px wide -> 1 column; the desktop dialog is capped at - max-width: 440px -> 2 columns, where all 9 categories fit in 5 rows with no scrollbar at all. + max-width: 440px -> 2 columns, where the up to 15 categories take 8 rows and scroll a little. max-height keeps the 1-column mobile case to ~5-6 visible rows before scrolling. */ .notif-type-checklist { display: grid; diff --git a/frontend/src/components/NotificationPanel.vue b/frontend/src/components/NotificationPanel.vue index 02fd0aeb..8f53e101 100644 --- a/frontend/src/components/NotificationPanel.vue +++ b/frontend/src/components/NotificationPanel.vue @@ -19,6 +19,10 @@ const CATEGORY_ICONS: Record = { 'windows-open-parked': ['fas', 'wind'], 'vehicle-message': ['fas', 'message'], 'climate-schedule': ['fas', 'calendar-days'], + 'low-12v': ['fas', 'car-battery'], + 'low-fuel': ['fas', 'gas-pump'], + 'gateway-login': ['fas', 'triangle-exclamation'], + 'data-stale': ['fas', 'clock'], } function categoryIcon(category: string | null): string[] { diff --git a/frontend/src/components/SettingsToggle.vue b/frontend/src/components/SettingsToggle.vue index d1582ac3..00b180c1 100644 --- a/frontend/src/components/SettingsToggle.vue +++ b/frontend/src/components/SettingsToggle.vue @@ -5,6 +5,7 @@ defineProps<{ inputId?: string /** FontAwesome icon shown before the label. */ icon?: string + disabled?: boolean }>() const model = defineModel() @@ -31,6 +32,7 @@ const model = defineModel() type="checkbox" role="switch" :aria-label="label" + :disabled="disabled" /> diff --git a/frontend/src/components/settings/NotificationTypeSettings.vue b/frontend/src/components/settings/NotificationTypeSettings.vue index ddc43d6b..d8abbc4a 100644 --- a/frontend/src/components/settings/NotificationTypeSettings.vue +++ b/frontend/src/components/settings/NotificationTypeSettings.vue @@ -4,17 +4,23 @@ import { useI18n } from 'vue-i18n' import { useUiSettingsStore } from '@/stores/settingsUi' import { useVehicleStore } from '@/stores/vehicle' import { useNotificationPushSync } from '@/composables/useNotificationPushSync' +import SettingsToggle from '../SettingsToggle.vue' import { notificationCategoryIdsFor, type NotificationCategoryId, } from '@/utils/notificationCategories' -// The notification types to show and to be pushed. This also drives the push subscription: -// deselecting every type unsubscribes, selecting at least one (re)subscribes. +// Push for this device, and the notification types it shows and gets pushed. const { t } = useI18n() const settings = useUiSettingsStore() const vehicleStore = useVehicleStore() -const { showPermissionDeniedNotice, showSubscribeFailedNotice } = useNotificationPushSync() +const { + pushSupported, + pushEnabled, + pushSwitchDisabled, + showPermissionDeniedNotice, + showSubscribeFailedNotice, +} = useNotificationPushSync() const NOTIFICATION_CATEGORY_LABEL_KEYS: Record = { 'low-tyre': 'lowTyre', @@ -27,6 +33,10 @@ const NOTIFICATION_CATEGORY_LABEL_KEYS: Record = 'windows-open-parked': 'windowsOpenParked', 'vehicle-message': 'vehicleMessage', 'climate-schedule': 'climateSchedule', + 'low-12v': 'low12v', + 'low-fuel': 'lowFuel', + 'gateway-login': 'gatewayLogin', + 'data-stale': 'dataStale', maintenance: 'maintenance', } @@ -67,6 +77,24 @@ function toggleNotificationType(id: NotificationCategoryId, checked: boolean) {