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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions documentation/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/NotificationPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const CATEGORY_ICONS: Record<string, string[]> = {
'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[] {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/SettingsToggle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defineProps<{
inputId?: string
/** FontAwesome icon shown before the label. */
icon?: string
disabled?: boolean
}>()

const model = defineModel<boolean>()
Expand All @@ -31,6 +32,7 @@ const model = defineModel<boolean>()
type="checkbox"
role="switch"
:aria-label="label"
:disabled="disabled"
/>
</div>
</slot>
Expand Down
45 changes: 31 additions & 14 deletions frontend/src/components/settings/NotificationTypeSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationCategoryId, string> = {
'low-tyre': 'lowTyre',
Expand All @@ -27,6 +33,10 @@ const NOTIFICATION_CATEGORY_LABEL_KEYS: Record<NotificationCategoryId, string> =
'windows-open-parked': 'windowsOpenParked',
'vehicle-message': 'vehicleMessage',
'climate-schedule': 'climateSchedule',
'low-12v': 'low12v',
'low-fuel': 'lowFuel',
'gateway-login': 'gatewayLogin',
'data-stale': 'dataStale',
maintenance: 'maintenance',
}

Expand Down Expand Up @@ -67,6 +77,24 @@ function toggleNotificationType(id: NotificationCategoryId, checked: boolean) {
</script>

<template>
<div v-if="pushSupported" class="settings-toggles">
<SettingsToggle
v-model="pushEnabled"
:label="t('push.enable')"
:desc="t('push.enableDesc')"
:disabled="pushSwitchDisabled"
input-id="settings-toggle-push"
/>
</div>

<div v-if="showPermissionDeniedNotice" class="notif-type-filter__denied text-danger text-sm">
{{ t('push.permissionDenied') }}
</div>

<div v-else-if="showSubscribeFailedNotice" class="notif-type-filter__denied text-danger text-sm">
{{ t('push.subscribeFailed') }}
</div>

<div class="notif-type-filter">
<div class="notif-type-filter__header">
<div class="settings-toggle__info">
Expand All @@ -91,17 +119,6 @@ function toggleNotificationType(id: NotificationCategoryId, checked: boolean) {
</div>
</div>

<div v-if="showPermissionDeniedNotice" class="notif-type-filter__denied text-danger text-sm">
{{ t('push.permissionDenied') }}
</div>

<div
v-else-if="showSubscribeFailedNotice"
class="notif-type-filter__denied text-danger text-sm"
>
{{ t('push.subscribeFailed') }}
</div>

<div class="notif-type-checklist">
<label
v-for="opt in notificationTypeOptions"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,34 @@ vi.mock('@/stores/settingsUi', () => ({
useUiSettingsStore: () => ({ notificationTypeExclusions: [] }),
}))

const pushSupported = vi.hoisted(() => ({ value: true }))

vi.mock('@/composables/useNotificationPushSync', () => ({
useNotificationPushSync: () => ({
pushSupported: pushSupported.value,
pushEnabled: ref(false),
pushSwitchDisabled: ref(false),
showPermissionDeniedNotice: ref(false),
showSubscribeFailedNotice: ref(false),
}),
}))

const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false })

function labels() {
function mountSettings() {
return shallowMount(NotificationTypeSettings, { global: { plugins: [i18n] } })
}

function labels() {
return mountSettings()
.findAll('.notif-type-checklist__label')
.map((el) => el.text())
}

describe('NotificationTypeSettings', () => {
beforeEach(() => {
vehicleType.value = 'unknown'
pushSupported.value = true
})

it('hides the charging notification types for a hybrid', () => {
Expand All @@ -48,4 +58,21 @@ describe('NotificationTypeSettings', () => {
expect(labels()).toContain('notifications.categories.chargingComplete')
expect(labels()).toContain('notifications.categories.lowEv')
})

it('hides low fuel for a BEV', () => {
vehicleType.value = 'bev'

expect(labels()).not.toContain('notifications.categories.lowFuel')
expect(labels()).toContain('notifications.categories.low12v')
})

it('offers the push switch where the browser supports push', () => {
expect(mountSettings().findComponent({ name: 'SettingsToggle' }).exists()).toBe(true)
})

it('leaves the push switch out where the browser has no push', () => {
pushSupported.value = false

expect(mountSettings().findComponent({ name: 'SettingsToggle' }).exists()).toBe(false)
})
})
Loading
Loading