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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ GarageStack is a free, open-source web app for **modern MG cars**, the ones buil
- **Relive every drive.** Trips are saved automatically, named by where they went and drawn on the map along the roads you took, with heatmaps and the speed limits along the way.
- **Keep a trip log for the taxman.** Mark trips as business, commute or private, add notes, and export a month or a year as a spreadsheet.
- **Control it from anywhere.** Pre-condition the cabin, lock the car or flash its lights to find it, and see whether the car actually carried the command out.
- **Have it warm when you leave.** Schedule climate for weekday mornings or a one-off trip, with seat heating, the defrosters, and a skip for mild days.
- **Hear about it when something is off.** Push notifications for low tyre pressure, a car left unlocked, a window left open, charging complete and more.
- **Fit it into your homelab.** Home Assistant through MQTT discovery, a gethomepage.dev widget, and single sign-on through Authentik, Authelia, Keycloak or any other OpenID Connect provider.
- **Keep it yours.** Everything runs in Docker on your own hardware, as one all-in-one container or a Compose stack. No subscription, and no account needed beyond your MG login.
Expand Down Expand Up @@ -46,8 +47,9 @@ The statistics, trip log, maintenance list and light theme are in the [screensho
- **Trip history** -- Browse past journeys on an interactive map with route playback and heatmap visualisation to identify frequently driven roads. Each trip is saved to the database a few minutes after the car parks. After upgrading from a version that did not save trips, the existing history is saved in the background on the Worker's first start.
- **Trip log** -- Mark each trip as business, commute or private, add notes, and export a month or a year as a spreadsheet with the addresses and odometer readings a tax trip log (such as the Dutch *rittenregistratie*) asks for. See [Trip log](#trip-log).
- **Energy statistics** -- Track daily energy consumption, efficiency (Wh/km on a plug-in car, L/100 km on a hybrid), fuel use, electric share, average driving speed, and more over a configurable time window.
- **Remote commands** -- Trigger climate pre-conditioning, lock or unlock the car, and activate the horn and lights remotely from the dashboard. Each command reports whether the car carried it out, and if it refused, the reason the MG servers gave.
- **Push notifications** -- Browser and in-app alerts for key events: engine started, low tyre pressure, low EV battery, car left unlocked, doors or windows left open, and the messages the official MG app receives.
- **Remote commands** -- Trigger climate pre-conditioning (normal climate, fan only or front defrost), lock or unlock the car, and activate the horn and lights remotely from the dashboard. Each command reports whether the car carried it out, and if it refused, the reason the MG servers gave.
- **Climate schedules** -- Switch climate on at a set time on chosen weekdays, or once. A schedule sets the temperature or mode, seat heating and the rear defroster, and can skip mild days by the car's outside temperature. Schedules run on the server, so no browser needs to be open, and each run sends a push notification saying whether the car started climate.
- **Push notifications** -- Browser and in-app alerts for key events: engine started, low tyre pressure, low EV battery, car left unlocked, doors or windows left open, a climate schedule that ran, and the messages the official MG app receives.
- **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance.
- **Home Assistant** -- Your car appears in Home Assistant automatically through MQTT discovery, sharing GarageStack's broker and MG session instead of needing a second integration. See [`HOME_ASSISTANT.md`](documentation/HOME_ASSISTANT.md).
- **Progressive Web App (PWA)** -- Installable on mobile or desktop for a native app-like experience, complete with a home screen icon and push notification support.
Expand Down Expand Up @@ -321,6 +323,7 @@ GarageStack checks your vehicle's state every 5 minutes and sends both a browser
| Charging complete | Charging stops while the cable is still connected (plug-in vehicles) |
| Maintenance due | A maintenance item reaches 90 % of its interval, or passes it (checked every 6 hours, 7-day cooldown per item) |
| MG app message | The official MG app receives a message, such as an alarm or a reminder (sent as it arrives, once per message) |
| Scheduled climate | A climate schedule ran: climate started, the car refused, or it did not answer (nothing is sent for a run skipped because the car was in use or the day was mild) |

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.

Expand Down
4 changes: 3 additions & 1 deletion docker/all-in-one/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ WORKDIR /app
# OS_PATCH_DATE busts the layer cache so apk upgrade actually re-runs instead of
# reusing a stale cached layer from before an OS package fix was published.
ARG OS_PATCH_DATE=0
# tzdata -- IANA time zones, which climate schedules keep their start time in. Named
# explicitly: trixie no longer counts it as a required package.
RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
# No version here: npm installs the one in package.json's packageManager field, so the pnpm
Expand Down Expand Up @@ -87,7 +89,7 @@ RUN echo "os patch date: ${OS_PATCH_DATE}" \
&& apt-get install -y --no-install-recommends \
bash curl ca-certificates gosu supervisor \
nginx mosquitto mosquitto-clients \
libgssapi-krb5-2 libicu76 \
libgssapi-krb5-2 libicu76 tzdata \
&& rm -rf /var/lib/apt/lists/*

# Dedicated non-root user for the .NET API/Worker and the SAIC gateway. Postgres and
Expand Down
16 changes: 13 additions & 3 deletions documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,22 @@ Messages from the official MG app are the one notification the Worker does not w

## Sending a command to the car (the reverse path)

The frontend calls `POST /api/vehicles/{vin}/commands/{command}` on the Api, which publishes an MQTT message via `MqttPublisher` ([src/GarageStack.Api/Services/MqttPublisher.cs](../src/GarageStack.Api/Services/MqttPublisher.cs)) - a separate MQTT client from the Worker's, since the Api only ever publishes and the Worker only ever consumes. `saic-mqtt-gateway` picks the message up and relays it to the SAIC cloud API. `VehicleCommandGate` ([src/GarageStack.Api/VehicleCommandGate.cs](../src/GarageStack.Api/VehicleCommandGate.cs)) serializes commands per VIN server-side, since the real gateway only processes one command at a time. The command names and their gateway topics live in `VehicleCommands`, one table for both directions.
The frontend calls `POST /api/vehicles/{vin}/commands/{command}` on the Api, which publishes an MQTT message via `MqttPublisher` ([src/GarageStack.Api/Services/MqttPublisher.cs](../src/GarageStack.Api/Services/MqttPublisher.cs)) - a separate MQTT client from the Worker's, since the Api only ever publishes and the Worker only ever consumes. `saic-mqtt-gateway` picks the message up and relays it to the SAIC cloud API. `VehicleCommandGate` ([src/GarageStack.Api/VehicleCommandGate.cs](../src/GarageStack.Api/VehicleCommandGate.cs)) serializes commands per VIN server-side, since the real gateway only processes one command at a time. The command names and their gateway topics live in `VehicleCommands` ([src/GarageStack.Core/Helpers/VehicleCommands.cs](../src/GarageStack.Core/Helpers/VehicleCommands.cs)), one table for both directions, and `VehicleCommandSender` is the one place a command is checked and published.

The gateway answers every command on the same topic with `/set` swapped for `/result`: `Success`, or `Failed: <reason>` once the SAIC API gives up (it polls for up to 30 seconds). The Worker parses that (`GatewayCommandResult`) and raises `command_result`. The Api releases the command gate for that VIN and pushes a `commandResult` event over SignalR, named by the command the browser sent. `useVehicleCommand` then settles the matching pending command: a refusal clears it and shows the reason. A success clears a command that no telemetry reports (the horn, the charge current limit). A command that telemetry does report stays pending until the telemetry shows it, but a batch can already move on to its next command. Without an answer, the 45-second hold on both sides still applies, which is also what happens in demo mode.

Two details matter here. The gateway publishes results retained, so the broker replays the last one per command on every subscribe; the Worker drops those replays, or a restart would show an old failure as new. And an answer names a command but not a request, so a browser only takes an answer for a command it is still waiting on.

### Climate schedules

The gateway has charging and battery-heating schedules of its own, but none for climate, so GarageStack runs climate schedules itself. The Api keeps them (`ClimateScheduleEndpoints`) and works out each one's next run on every save (`ClimateScheduleCalendar`): a schedule is a time on the clock in the IANA time zone the browser had when it was made, so 07:30 stays 07:30 across daylight saving time. A time skipped when the clocks go forward runs just after the jump, and a time that happens twice when they go back runs the first time only.

The Worker runs them. `ClimateScheduleService` looks every 15 seconds for schedules that are due and claims each one before it runs (a repeating schedule moves on to its next run, a one-off switches itself off), so a restart in the middle never runs a schedule twice. A run found more than 10 minutes late is recorded as missed instead: a car warmed long after its driver left is no use. `ClimateScheduleRunner` skips a run while the engine runs, or when the schedule only wants cold or hot days and an outside temperature from the last three hours says otherwise. Otherwise it sends the commands one at a time (temperature first, since the gateway only stores it while climate is off, then the climate mode, then the extras), stops at the first one refused or unanswered, records the outcome on the schedule, and sends one push in the `climate-schedule` category.

Only the Api publishes commands, through the same `VehicleCommandSender` and gate as the browser. So the Worker asks for each command on the `vehicle_command_requested` channel, the one pg channel that is a request rather than news, and waits for the gateway's answer itself (`CommandAnswerWaiter`, completed by `CommandResultHandler`). The Api handles each request apart from its LISTEN loop, because a request may wait up to 90 seconds for the gate, and the `command_result` that frees the gate must not queue up behind it. A request the Api never sees (it is down, or reconnecting to Postgres) ends as no answer after 150 seconds rather than as a late start. Because answers carry no request id, a command sent by hand at the same moment as a scheduled run can count as that run's answer; the command still reaches the car, only the recorded outcome can be off.

The Api (edits) and the Worker (claims and outcomes) both write a schedule, so `ClimateSchedule.Version` is a concurrency token: an edit is applied again on top of a claim it raced with, and a claim on a schedule edited since it was read is left for the next look.

## Trips

The gateway reports positions, not trips. `TripSegmenter` in Core cuts GPS fixes into trips. A trip ends when the car stands still for five minutes or no fix arrives for half an hour. A trip containing a jump faster than 250 km/h is thrown out as a positioning glitch, and one under 0.1 km never went anywhere. The Worker and the Api both use this one class, so they cannot disagree about where a trip starts and ends.
Expand Down Expand Up @@ -231,8 +241,8 @@ The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does t
chosen units live in the UI settings store and are applied at the last moment by
`UnitFormatter` in `utils/units.ts`, reached through the `useUnits` composable: a value on screen
goes through `units.format(quantity, metricValue)` (or `measure` when the number and its unit are
styled apart), never through a hand-written `km` or `bar`. A distance typed into a form goes back
through `useDistanceField`, which returns the untouched kilometres it loaded rather than a
styled apart), never through a hand-written `km` or `bar`. A distance or temperature typed into a form goes back
through `useMeasureField`, which returns the untouched metric value it loaded rather than a
round-tripped approximation.

Dashboard cards are described once, in `frontend/src/cards/registry.ts`: each entry carries the card's icon, whether it is visible by default for a given drivetrain, and whether the current telemetry has anything to show. The card ids, the default layout and the "does this card have data" checks are all derived from that list, so a new card is one entry plus its markup in `DashboardCardContent.vue`. The map's point-of-interest layers work the same way: `composables/poiTileLayer.ts` holds the fetch-by-tile, cache and cluster logic, and charging stations, fuel stations, service areas and speed cameras are four configurations of it.
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ watch(
<span>{{ t('nav.tripLog') }}</span>
</RouterLink>
</li>
<li>
<RouterLink to="/climate-schedules" active-class="active">
<font-awesome-icon icon="calendar-days" />
<span>{{ t('nav.climateSchedules') }}</span>
</RouterLink>
</li>
<li>
<RouterLink to="/maintenance" active-class="active">
<font-awesome-icon icon="screwdriver-wrench" />
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/assets/climateSchedules.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* ── Last run, in the schedule form ───────────────────────── */

.climate-schedule-last-run {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
}

/* The car's reason takes its own line under the badge. */
.climate-schedule-last-run__detail {
flex-basis: 100%;
}
165 changes: 165 additions & 0 deletions frontend/src/assets/main.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
@import url('@vueform/multiselect/themes/default.css');

:root {
/* Tells the browser which palette its own widgets use: the time and date pickers, select
dropdowns and the clock and calendar icons inside inputs. */
color-scheme: dark;

--color-bg: #0f1117;
--color-surface: #1a1d27;
--color-surface-2: #252836;
Expand All @@ -23,6 +27,8 @@
}

[data-theme='light'] {
color-scheme: light;

--color-bg: #f0f4f8;
--color-surface: #fff;
--color-surface-2: #e8edf3;
Expand Down Expand Up @@ -323,6 +329,13 @@ body,

/* ── Status card grid ─────────────────────────────────────── */

/* Status cards as a single-column list of items (maintenance, climate schedules). */
.status-card-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}

.status-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
Expand Down Expand Up @@ -391,6 +404,14 @@ body,
overflow: hidden;
}

.status-card__note {
font-size: 0.78rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.status-card__unit {
font-size: 0.78rem;
color: var(--color-text-muted);
Expand Down Expand Up @@ -523,6 +544,21 @@ body,
border-color: var(--color-warning);
}

/* A row that leads somewhere else: the whole row is the button. */
.detail-list__item--link {
width: 100%;
font-family: inherit;
color: var(--color-text);
text-align: left;
cursor: pointer;
}

.detail-list__item--link:hover,
.detail-list__item--link:focus-visible {
border-color: var(--color-primary);
outline: none;
}

.detail-list__item-icon {
width: 1rem;
color: var(--color-text-muted);
Expand Down Expand Up @@ -1104,6 +1140,122 @@ body,
left: 23px;
}

/* Labelled fields stacked in a modal form: a group is a label above its field, a row puts
two groups side by side (one above the other on phones, see the mobile breakpoint). */
.form-stack {
display: flex;
flex-direction: column;
gap: 0.9rem;
}

.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}

.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}

.form-group label,
.form-group__label {
font-size: 0.78rem;
color: var(--color-text-muted);
}

.form-control {
width: 100%;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background-color: var(--color-surface-2);
color: var(--color-text);
}

textarea.form-control {
resize: vertical;
font-family: inherit;
}

/* A time of day as selects (TimeOfDaySelect): hour and minute on a 24-hour clock. */
.time-select {
display: flex;
align-items: center;
gap: 0.35rem;
}

.time-select .form-control {
flex: 1;
min-width: 0;
}

.time-select__separator {
color: var(--color-text-muted);
}

/* ── Toggle chips (one-of-N or several-of-N choices) ──────── */

.toggle-chip-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}

.toggle-chip {
--chip-color: var(--color-primary);

display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
font-size: 0.78rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-2);
color: var(--color-text-muted);
cursor: pointer;
}

.toggle-chip--active {
border-color: var(--chip-color);
background: color-mix(in srgb, var(--chip-color) 18%, var(--color-surface-2));
color: var(--color-text);
font-weight: 600;
}

.toggle-chip:disabled {
cursor: not-allowed;
opacity: 0.6;
}

/* Seven day chips share the row evenly, so they fit a phone without wrapping. */
.weekday-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.weekday-picker .toggle-chip-group {
flex-wrap: nowrap;
}

.weekday-picker .toggle-chip {
flex: 1;
min-width: 0;
padding-inline: 0.2rem;
}

.weekday-picker__presets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}

/* ── Settings ─────────────────────────────────────────────── */

.settings-section {
Expand Down Expand Up @@ -1433,6 +1585,11 @@ body,
border: 2px solid var(--color-surface);
}

.range-control input[type='range']:disabled {
cursor: not-allowed;
opacity: 0.45;
}

.range-control__labels {
display: flex;
justify-content: space-between;
Expand All @@ -1451,6 +1608,10 @@ body,
gap: 0.7rem;
}

.range-control--grow {
flex: 1;
}

/* ── Card info overlay ────────────────────────────────────── */

.card-info-wrap {
Expand Down Expand Up @@ -1670,6 +1831,10 @@ body,
/* ── Mobile breakpoint ────────────────────────────────────── */

@media (width <= 767px) {
.form-row {
grid-template-columns: 1fr;
}

/* Show topbar, hide desktop sidebar brand */
.mobile-topbar {
display: flex;
Expand Down
Loading
Loading