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
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ The statistics, trip log, maintenance list and light theme are in the [screensho

**Want to try it?** Read [MG iSmart account and session limits](#mg-ismart-account-and-session-limits) first, because GarageStack and the MG app cannot be signed in to the same account at once, then pick an [installation option](#installation). A single `docker run` is enough to get going.

## Contents

- [Features](#features)
- [MG iSmart account and session limits](#mg-ismart-account-and-session-limits)
- [Prerequisites](#prerequisites) and [Installation](#installation)
- [Updating](#updating), [Monitoring](#monitoring) and [Backup and restore](#backup-and-restore)
- [Push notifications](#push-notifications)
- [Trip log](#trip-log)
- [Dutch registration (RDW)](#dutch-registration-rdw)
- [Homepage dashboard widget](#homepage-dashboard-widget) and [Read-only API tokens](#read-only-api-tokens)
- [Map overlays](#map-overlays)
- [Security defaults](#security-defaults), [HTTPS](#https) and [What leaves your network](#what-leaves-your-network)
- [Documentation](#documentation)

## Features

- **Live dashboard** -- Real-time vehicle telemetry displayed as configurable cards. Cards are automatically shown or hidden based on your vehicle type (HEV, PHEV, BEV) and can be reordered or toggled individually in the dashboard's edit mode. The car drawn on the dashboard takes your car's colour (Settings > Car colour), and the vehicle type can be set by hand under Settings > Vehicle type when detection gets it wrong.
Expand Down Expand Up @@ -148,7 +162,7 @@ Before installing, make sure the following are available on your host:
- **Docker** 23.0 or later (or Podman with Docker Compose compatibility); the quick start's relative `./garagestack-data` mount needs 23.0
- **Docker Compose** 2.20 or later (bundled with Docker Desktop; on Linux install the `docker-compose-plugin` package) -- required for Option B only
- An **MG iSmart account** with your vehicle already linked in the official app
- Outbound internet access on the host so the container can reach the SAIC API and (optionally) Overpass / Open Charge Map / the RDW
- Outbound internet access on the host, for the MG servers and the map and lookup services listed under [What leaves your network](#what-leaves-your-network)

---

Expand Down Expand Up @@ -197,7 +211,7 @@ git clone https://github.com/joszz/garagestack.git
cd garagestack
```

**2. Create your `.env` file**
#### 2. Create your `.env` file

```bash
cp .env.example .env
Expand Down Expand Up @@ -475,7 +489,7 @@ Sourced from OpenStreetMap via the free [Overpass API](https://overpass-api.de)
- Shows petrol and diesel stations from OSM data. Accuracy and completeness depend on OSM coverage in your area.
- **Brand filter** -- select one or more brands (e.g. BP, Shell, Total) from the Filters panel. Only stations with a matching `brand` or `operator` OSM tag are shown; untagged stations are hidden when any filter is active. The filter is applied client-side with no additional API call.
- The Worker pre-populates a 100 km radius around your car's last known position every 6 hours, so the layer loads instantly on first view without hitting Overpass.
- If Overpass returns a 429 rate-limit response, the client backs off and retries automatically; existing cached data is shown in the meantime.
- If Overpass answers that it is busy (429, 503 or 504), GarageStack waits as long as Overpass asks (its `Retry-After`) before asking again, and shows the cached data in the meantime. The same goes for every other service GarageStack asks.
- Tile data is cached for 7 days. Zooming or panning to a new area triggers on-demand fetching for uncached tiles.
- Hidden for BEV vehicles (petrol stations are not relevant).

Expand Down Expand Up @@ -546,6 +560,24 @@ A reading counts as over the limit only past 5 km/h above it: speedometers read
- The optional Home Assistant broker login can only use the car's `saic/#` topics and read Home Assistant discovery, so it cannot publish fake discovery configs or reach anything else on the broker.
- The frontend sends a strict Content-Security-Policy with every response, which allows scripts only from GarageStack itself. See [Behind a reverse proxy or CDN](#behind-a-reverse-proxy-or-cdn) for keeping it intact.

### What leaves your network

GarageStack runs on your own hardware, but a few features ask public services for what they know. Each one is listed here with what it sends, and each one except the MG servers can be switched off.

| Service | Asked by | What it receives | Switch it off |
| ------- | -------- | ---------------- | ------------- |
| MG / SAIC iSmart API | the gateway | Your MG login, and the commands you send the car | Cannot be: it is where the car's data comes from |
| [Nominatim](https://nominatim.openstreetmap.org) (place names) | the API | The coordinates of a trip's ends, or of where the car is parked, and the interface language | Settings > Map > Show place names, or `GEOCODING__ENABLED=false` |
| [Valhalla](https://valhalla1.openstreetmap.de) (snapped trips, speed limits) | the API | The GPS fixes of the trip you select on the map | The map's filter panel > Snap to roads, or `MAPMATCHING__ENABLED=false` |
| [Overpass](https://overpass-api.de) (fuel stations, service areas, speed cameras) | the API and the Worker | The map tiles being looked at, and the ones around the car (0.5° squares, not the car's position) | Turn the layers off; `SPEEDCAMERAS__ENABLED=false` removes the camera layer |
| [Open Charge Map](https://openchargemap.org) (charging stations) | the API and the Worker | The map tiles being looked at, and your OCM key | Leave `OPENCHARGEMAP_API_KEY` empty |
| [RDW open data](https://opendata.rdw.nl) (APK, recalls, insurance) | the API and the Worker | The licence plate, and the RDW's own codes for the recalls it lists | Off until `RDW_ENABLED=true` |
| [GitHub](https://github.com/joszz/GarageStack/releases) (update notice) | the API | One request a day for the latest release | `UPDATE_CHECK_ENABLED=false` |
| [OpenFreeMap](https://openfreemap.org) and [OpenStreetMap](https://www.openstreetmap.org) tiles (the basemap) | your browser | The map tiles your browser shows | Serve your own tiles, see [Basemap](#basemap) |
| Your browser's push service (Google, Mozilla, Apple) | the Worker | Each notification, encrypted so only your browser can read it, and `VAPID_SUBJECT` | Leave the VAPID keys empty, or switch push off per device |

Requests to the map, lookup and update services identify themselves as `GarageStack/1.0 (+https://github.com/joszz/GarageStack)`, as Nominatim's usage policy asks. Apart from what the table lists, the car's telemetry, its trips, your notifications and your settings stay on your server, unless you connect something to it yourself, such as Home Assistant.

### HTTPS

GarageStack serves plain HTTP, which is all it needs on your own network, except for three things browsers keep for secure pages: push notifications, installing it as an app, and the service worker behind both. Browsers count `http://localhost` as secure, but not `http://192.168.1.100:8080`. To use those on your phone, give GarageStack an `https://` address: put it behind a reverse proxy that terminates TLS (examples below), or reach it through a VPN that provides HTTPS certificates. Over plain HTTP, Settings > Notifications says why push is not available.
Expand Down Expand Up @@ -591,4 +623,27 @@ Every proxy in front of the API (yours, and with Compose the frontend container)

---

## Documentation

For running GarageStack:

- [`CONFIGURATION.md`](documentation/CONFIGURATION.md): every setting, with its default
- [`AUTHENTICATION.md`](documentation/AUTHENTICATION.md): single sign-on, the built-in login, and provider examples
- [`HOME_ASSISTANT.md`](documentation/HOME_ASSISTANT.md): the car in Home Assistant, through MQTT or an API token
- [`WIDGET.md`](documentation/WIDGET.md): the gethomepage.dev widget
- [`docker/all-in-one/README.md`](docker/all-in-one/README.md): the all-in-one image and Unraid
- [Screenshots](documentation/screenshots/README.md) of the main pages

For working on it:

- [`CONTRIBUTING.md`](.github/CONTRIBUTING.md): setting up, tests, and adding a language
- [`ARCHITECTURE.md`](documentation/ARCHITECTURE.md): how the services fit together
- [`DEMO.md`](documentation/DEMO.md): the app on sample data, without a car, and the API reference
- [`RELEASING.md`](documentation/RELEASING.md): versions, tags and release notes
- [`ACCESSIBILITY.md`](.github/ACCESSIBILITY.md) and [`SECURITY.md`](.github/SECURITY.md), which also says how to report a vulnerability

What changed in each version is on the [releases page](https://github.com/joszz/GarageStack/releases).

---

> **Development note:** This project was built with AI-assisted development (Claude Code). All code was reviewed, directed, and validated by a human developer throughout - AI acted as a coding assistant, not an autonomous agent.
6 changes: 3 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@
"vue-router": "^5.4.0",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1",
"workbox-window": "^7.4.1"
"workbox-strategies": "^7.4.1"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
Expand Down Expand Up @@ -73,7 +72,8 @@
"vite": "^8.3.4",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.5",
"vue-tsc": "^3.3.13"
"vue-tsc": "^3.3.13",
"workbox-window": "^7.4.1"
},
"engines": {
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
Expand Down
6 changes: 3 additions & 3 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ const {
},
onTripCompleted: () => vehicleStore.notifyTripCompleted(),
onCommandResult: (result) => vehicleStore.applyCommandResult(result),
// Telemetry that arrived while the connection was down never reached this page.
onReconnected: () => {
if (vehicleStore.activeVin) void vehicleStore.fetchStatus(vehicleStore.activeVin)
},
})

// Only show "paused" once we've actually attempted a connection, not during the
Expand Down
34 changes: 27 additions & 7 deletions frontend/src/components/CarDiagram.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
<script setup lang="ts">
import '@/assets/carDiagram.css'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { computed, nextTick, ref, shallowRef } from 'vue'
import CardInfoWrap from './CardInfoWrap.vue'
import CarLightBeams from './carDiagram/CarLightBeams.vue'
import LevelBadge from './carDiagram/LevelBadge.vue'
import SpeedGauge from './carDiagram/SpeedGauge.vue'
import TyreIndicators from './carDiagram/TyreIndicators.vue'
import TyreLabels from './carDiagram/TyreLabels.vue'
import TyreHistoryModal from './TyreHistoryModal.vue'
import LevelHistoryModal from './LevelHistoryModal.vue'
import { CAR_SILHOUETTE_VIEWBOX, CAR_SILHOUETTE_MARKUP } from '@/assets/carSilhouette'
import type { LevelKind } from '@/composables/useStatisticsCharts'
import { useTyrePressureThresholds } from '@/composables/useTyrePressureThresholds'
Expand All @@ -26,8 +24,23 @@ const tyreHistoryOpen = ref(false)
const levelHistoryOpen = ref(false)
const levelHistory = ref<LevelKind>('fuel')

function openLevelHistory(level: LevelKind) {
// The dialogs chart with Chart.js, which is as large as the rest of the dashboard together, so
// each is fetched on its first tap rather than with the page. It is mounted closed and opened on
// the next tick: a dialog mounted already open would skip its fade-in and the fetch its watcher
// starts when it opens.
const TyreHistoryModal = shallowRef<typeof import('./TyreHistoryModal.vue').default | null>(null)
const LevelHistoryModal = shallowRef<typeof import('./LevelHistoryModal.vue').default | null>(null)

async function openTyreHistory() {
TyreHistoryModal.value ??= (await import('./TyreHistoryModal.vue')).default
await nextTick()
tyreHistoryOpen.value = true
}

async function openLevelHistory(level: LevelKind) {
LevelHistoryModal.value ??= (await import('./LevelHistoryModal.vue')).default
levelHistory.value = level
await nextTick()
levelHistoryOpen.value = true
}

Expand Down Expand Up @@ -289,7 +302,7 @@ const motionBlurAmount = computed(() => {
<TyreIndicators :pressures="tyrePressures" />
</svg>

<TyreLabels :pressures="tyrePressures" @select="tyreHistoryOpen = true" />
<TyreLabels :pressures="tyrePressures" @select="openTyreHistory" />

<!-- Open panel icon badges – positioned over SVG at each panel location -->
<template v-for="badge in doorBadges" :key="badge.key">
Expand Down Expand Up @@ -333,8 +346,15 @@ const motionBlurAmount = computed(() => {
</div>
</div>
</div>
<TyreHistoryModal :open="tyreHistoryOpen" @close="tyreHistoryOpen = false" />
<LevelHistoryModal
<component
:is="TyreHistoryModal"
v-if="TyreHistoryModal"
:open="tyreHistoryOpen"
@close="tyreHistoryOpen = false"
/>
<component
:is="LevelHistoryModal"
v-if="LevelHistoryModal"
:open="levelHistoryOpen"
:level="levelHistory"
@close="levelHistoryOpen = false"
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/__tests__/CarDiagram.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import CarDiagram from '../CarDiagram.vue'

// The dialogs chart with Chart.js; stand-ins that say whether they are open, and what they show,
// and remember whether they were open the moment they mounted.
const openAtMount = vi.hoisted(() => [] as boolean[])
const modalStub = (name: string) =>
defineComponent({
name,
props: { open: Boolean, level: { type: String, default: null } },
setup: (props) => {
openAtMount.push(props.open)
return () =>
h('div', { class: name, 'data-open': String(props.open), 'data-level': props.level })
},
})

vi.mock('@/components/TyreHistoryModal.vue', () => ({ default: modalStub('tyre-history') }))
vi.mock('@/components/LevelHistoryModal.vue', () => ({ default: modalStub('level-history') }))

vi.mock('@/composables/useTyrePressureThresholds', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useTyrePressureThresholds')>()
const { ref } = await import('vue')
return {
...actual,
useTyrePressureThresholds: () => ref(actual.DEFAULT_TYRE_PRESSURE_THRESHOLDS),
}
})

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

function mountDiagram() {
return mount(CarDiagram, {
props: {
frontLeft: 2.4,
frontRight: 2.4,
rearLeft: 2.4,
rearRight: 2.4,
fuelLevelPercent: 60,
},
global: { plugins: [i18n], stubs: { FontAwesomeIcon: true } },
})
}

beforeEach(() => {
setActivePinia(createPinia())
openAtMount.length = 0
})

describe('CarDiagram', () => {
it('leaves the history dialogs out until one is asked for', () => {
const wrapper = mountDiagram()

expect(wrapper.find('.tyre-history').exists()).toBe(false)
expect(wrapper.find('.level-history').exists()).toBe(false)
})

it("opens the tyre history from a tyre's pressure, mounted closed first", async () => {
const wrapper = mountDiagram()

await wrapper.find('.tyre-label').trigger('click')
await flushPromises()

// Mounted closed, then opened: its fade-in and its fetch both start from the change.
expect(openAtMount).toEqual([false])
expect(wrapper.find('.tyre-history').attributes('data-open')).toBe('true')
})

it('opens the level history for the level that was tapped', async () => {
const wrapper = mountDiagram()

await wrapper.find('.diagram-level--fuel').trigger('click')
await flushPromises()

const dialog = wrapper.find('.level-history')
expect(dialog.attributes('data-open')).toBe('true')
expect(dialog.attributes('data-level')).toBe('fuel')
})

it('closes a dialog it opened and keeps it mounted for the fade-out', async () => {
const wrapper = mountDiagram()
await wrapper.find('.tyre-label').trigger('click')
await flushPromises()

wrapper.findComponent({ name: 'tyre-history' }).vm.$emit('close')
await flushPromises()

expect(wrapper.find('.tyre-history').attributes('data-open')).toBe('false')
})
})
41 changes: 41 additions & 0 deletions frontend/src/composables/__tests__/useSignalR.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const callbacks: SignalRCallbacks = {
onNotificationReceived: vi.fn<SignalRCallbacks['onNotificationReceived']>(),
onTripCompleted: vi.fn<SignalRCallbacks['onTripCompleted']>(),
onCommandResult: vi.fn<SignalRCallbacks['onCommandResult']>(),
onReconnected: vi.fn<NonNullable<SignalRCallbacks['onReconnected']>>(),
}

function mountSignalR() {
Expand All @@ -60,8 +61,14 @@ function mountSignalR() {

afterEach(() => {
hub.connections.length = 0
vi.clearAllMocks()
})

// The handler the composable registered through one of the connection's on... methods.
function handlerOf(connection: Record<string, ReturnType<typeof vi.fn>>, method: string) {
return connection[method]!.mock.calls[0]![0] as () => Promise<void> | void
}

describe('useSignalR', () => {
it('connects and joins the vehicle group', async () => {
const { api } = mountSignalR()
Expand Down Expand Up @@ -107,4 +114,38 @@ describe('useSignalR', () => {

expect(hub.connections[0]!.stop).toHaveBeenCalledOnce()
})

it('does not count the first connection as a reconnect', async () => {
const { api } = mountSignalR()

await api.start(7)

expect(callbacks.onReconnected).not.toHaveBeenCalled()
})

it('reports an automatic reconnect once the vehicle group is joined again', async () => {
const { api } = mountSignalR()
await api.start(7)
const connection = hub.connections[0]!

await handlerOf(connection, 'onreconnected')()

expect(connection.invoke).toHaveBeenLastCalledWith('JoinVehicle', 7)
expect(callbacks.onReconnected).toHaveBeenCalledOnce()
})

it('reports a reconnect after the connection closed and was started again', async () => {
const { api } = mountSignalR()
await api.start(7)
const connection = hub.connections[0]!

handlerOf(connection, 'onclose')()
// The first retry waits no time at all, but still goes through a timer.
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()

expect(connection.start).toHaveBeenCalledTimes(2)
expect(callbacks.onReconnected).toHaveBeenCalledOnce()
expect(api.connected.value).toBe(true)
})
})
Loading
Loading