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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,12 @@ OPENCHARGEMAP_API_KEY=
# TYRE_PRESSURE_LOW_BAR=2.2
# TYRE_PRESSURE_GOOD_BAR=2.6
# TYRE_PRESSURE_HIGH_BAR=3.2

# ── 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
# kWh it reports are right for a BEV or PHEV and far too large for a plain hybrid
# (an MG HS Hybrid+ carries 1.83 kWh and is reported as 72.5). Set your car's real
# figure to correct it. Left unset, a plug-in car keeps the gateway's number and a
# hybrid shows state of charge as a percentage only.
# HV_BATTERY_CAPACITY_KWH=1.83
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GarageStack is a free, open-source vehicle monitoring dashboard for **modern MG

- **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.
- **Trip history** -- Browse past journeys on an interactive map with route playback and heatmap visualisation to identify frequently driven roads.
- **Energy statistics** -- Track daily energy consumption, efficiency (Wh/km), fuel use, electric share, average driving speed, and more over a configurable time window.
- **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.
- **Push notifications** -- Browser and in-app alerts for key events: engine started, low tyre pressure, low EV battery, car left unlocked, and doors or windows left open.
- **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance.
Expand Down Expand Up @@ -37,13 +37,13 @@ Cards are shown or hidden automatically based on vehicle type (HEV / PHEV / BEV)
| Windows | Window and sunroof states | All |
| Sunroof | Sunroof open/closed | All (off by default) |
| Climate | Temperature, seat heating, defroster | All |
| HV Battery | kWh, voltage, current, power | All |
| HV Battery | State of charge, voltage, current, power (kWh too, with `HV_BATTERY_CAPACITY_KWH` set) | All |
| Find My Car | Horn + lights to locate the car | All |
| Lights | Main beam, low beam, sidelights | All |
| Daily Distance | Distance driven today | All |
| Daily Energy | Energy used today (Wh) | All |
| Daily Energy | Energy used today (kWh), or fuel burned today (L) on an HEV | All |
| Since Charge | Distance since last charge session | PHEV, BEV |
| Efficiency | Energy per km (Wh/km) | All |
| Efficiency | Energy per km (Wh/km), or fuel consumption (L/100 km) on an HEV | All |
| Speed | Current vehicle speed | All |
| Top Speed | Highest speed recorded in the most recent completed trip | All |
| Active Trip | Distance covered in the current trip | All |
Expand Down Expand Up @@ -180,6 +180,8 @@ Sign-in is configured separately: the built-in login reuses `SAIC_USER` / `SAIC_

`TYRE_PRESSURE_LOW_BAR` / `TYRE_PRESSURE_GOOD_BAR` / `TYRE_PRESSURE_HIGH_BAR` are optional and default to `2.2` / `2.6` / `3.2` bar; override them to match your vehicle's placarded tyre pressure (see [Push notifications](#push-notifications) below).

`HV_BATTERY_CAPACITY_KWH` is optional and tells GarageStack how big the traction battery really is. The MQTT gateway does not read this off the pack, it scales the BMS percentage by an EV-sized default, so the kWh it reports are right for a BEV or PHEV and far too large for a plain hybrid (an MG HS Hybrid+ carries 1.83 kWh and is reported as 72.5). Left unset, a plug-in car keeps the gateway's figure and a hybrid shows state of charge as a percentage only.

`RATE_LIMIT_GLOBAL_PER_MINUTE` is optional and defaults to `120` requests per minute per client IP. Raise it when several people reach GarageStack through one public address, or when something polls the API frequently; the tighter limits protecting login and the widget endpoint are unaffected.

#### 3. Start the stack
Expand Down Expand Up @@ -371,14 +373,14 @@ The endpoint returns a flat JSON object. Numeric fields are `null` when the vehi
| `isCharging` | string | Charging state: `"Charging"` or `"Not charging"` |
| `chargerConnected` | string | Charger connection state: `"Plugged in"` or `"Unplugged"` |
| `mileageSinceLastCharge` | number | Distance driven since last full charge (km) |
| `hvSocKwh` | number | HV battery energy (kWh) |
| `hvTotalCapacityKwh` | number | HV battery total capacity (kWh) |
| `hvSocKwh` | number | HV battery energy (kWh), as the gateway reports it |
| `hvTotalCapacityKwh` | number | HV battery total capacity (kWh), as the gateway reports it |
| `hvVoltage` | number | HV system voltage (V) |
| `hvCurrent` | number | HV system current (A) |
| `hvPower` | number | HV system power (W) |
| `odometerKm` | number | Total odometer reading (km) |
| `mileageOfTheDayKm` | number | Distance driven today (km) |
| `powerUsageOfDayKwh` | number | Energy used today (kWh) |
| `powerUsageOfDayKwh` | number | Energy used today (kWh). On a plain hybrid this counter holds the trip computer's fuel total in hundredths of a litre instead, so divide by 100 for litres |
| `electricSharePercent` | number | % of today's distance driven on electric power (PHEV) |
| `isLocked` | string | Lock state: `"Locked"` or `"Unlocked"` |
| `engineRunning` | string | Engine state: `"Engine on"` or `"Engine off"` |
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ services:
Auth__CookieSecure: "${AUTH_COOKIE_SECURE:-false}"
Auth__SessionLifetimeHours: "${AUTH_SESSION_LIFETIME_HOURS:-168}"
RateLimits__GlobalPerMinute: "${RATE_LIMIT_GLOBAL_PER_MINUTE:-}"
# The traction battery's real capacity, which the MQTT gateway assumes rather than reads.
# Left unset, a plug-in car keeps the gateway's figure and a hybrid shows charge as a
# percentage only. See .env.example.
HvBattery__CapacityKwh: "${HV_BATTERY_CAPACITY_KWH:-}"
ports:
- "127.0.0.1:${API_PORT:-5000}:8080"
volumes:
Expand Down
1 change: 1 addition & 0 deletions docker/all-in-one/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ By default the web login uses the same `SAIC_USER` and `SAIC_PASSWORD` credentia
| `OVERPASS__BASEURL` | Overpass API endpoint used for the fuel station and motorway service area map overlays. Defaults to the public endpoint (`https://overpass-api.de/api/interpreter`). Set this only if you self-host an Overpass instance. No API key is required for the default public endpoint. |
| `RATE_LIMIT_GLOBAL_PER_MINUTE` | Requests per minute the API accepts per client IP (default: `120`). Raise it when several people share one public address; the tighter limits on login and the widget endpoint apply regardless. |
| `TYRE_PRESSURE_LOW_BAR` / `TYRE_PRESSURE_GOOD_BAR` / `TYRE_PRESSURE_HIGH_BAR` | Colour-coding and notification thresholds (bar) for tyre pressure. Default to `2.2` / `2.6` / `3.2`. Override to match your vehicle's placarded pressure, e.g. `TYRE_PRESSURE_GOOD_BAR=2.55`. |
| `HV_BATTERY_CAPACITY_KWH` | Usable capacity of the traction battery, in kWh. The MQTT gateway assumes an EV-sized pack rather than reading the real one, 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`). Leave empty to keep the gateway's figure on a BEV or PHEV and show charge as a percentage only on a hybrid. |
| `SAIC_REST_URI` | Override for the SAIC gateway API endpoint. Only needed if your region isn't listed in the `SAIC_REGION` row above -- set it directly to your gateway's endpoint. |
| `POSTGRES_DB` | Database name (default: `garagestack`) |
| `POSTGRES_USER` | Database user (default: `garagestack`) |
Expand Down
4 changes: 4 additions & 0 deletions docker/all-in-one/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export TyrePressure__LowBar="${TYRE_PRESSURE_LOW_BAR:-}"
export TyrePressure__GoodBar="${TYRE_PRESSURE_GOOD_BAR:-}"
export TyrePressure__HighBar="${TYRE_PRESSURE_HIGH_BAR:-}"

# The traction battery's real capacity, which the MQTT gateway assumes rather than reads.
# Optional; empty means "trust the gateway on a plug-in car, show a hybrid percentages only".
export HvBattery__CapacityKwh="${HV_BATTERY_CAPACITY_KWH:-}"

# .NET API listens on an internal port; nginx proxies port 80 to it
export ASPNETCORE_URLS="http://127.0.0.1:9000"

Expand Down
18 changes: 17 additions & 1 deletion frontend/src/cards/__tests__/registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
cardHasData,
cardIcon,
defaultCards,
hasEnergyEfficiency,
hasFuelConsumption,
type CardDataContext,
type CardId,
} from '@/cards/registry'
Expand Down Expand Up @@ -55,16 +57,30 @@ describe('card registry', () => {
expect(cardHasData('efficiencyCharge', context(status, 'bev'))).toBe(true)
})

it('accepts either presentation of the efficiency ratio card', () => {
it('accepts any presentation of the efficiency ratio card', () => {
const energy = context({ powerUsageOfDay: 8, mileageOfTheDay: 40 }, 'bev')
const consumption = context({ powerUsageOfDay: 394, mileageOfTheDay: 59 }, 'hev')
const fuel = context({ fuelRangeKm: 400, fuelLevelPercent: 50 }, 'hev')
const neither = context({ powerUsageOfDay: null, mileageOfTheDay: null }, 'bev')

expect(cardHasData('efficiencyRatio', energy)).toBe(true)
expect(cardHasData('efficiencyRatio', consumption)).toBe(true)
expect(cardHasData('efficiencyRatio', fuel)).toBe(true)
expect(cardHasData('efficiencyRatio', neither)).toBe(false)
})

it('splits the efficiency ratio by what the energy counter measures', () => {
const driven = { powerUsageOfDay: 394, mileageOfTheDay: 59 }

// A hybrid's counter is fuel, so Wh/km would be nonsense and L/100 km is the reading
expect(hasEnergyEfficiency(context(driven, 'hev'))).toBe(false)
expect(hasFuelConsumption(context(driven, 'hev'))).toBe(true)

// A plug-in car cycles a real pack, so the counter is kWh and stays Wh/km
expect(hasEnergyEfficiency(context(driven, 'phev'))).toBe(true)
expect(hasFuelConsumption(context(driven, 'phev'))).toBe(false)
})

it('needs a trip with recorded speeds for the top speed card', () => {
expect(cardHasData('topSpeed', context({}, 'bev', null))).toBe(false)
expect(cardHasData('topSpeed', context({}, 'bev', trip([null, null])))).toBe(false)
Expand Down
26 changes: 19 additions & 7 deletions frontend/src/cards/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TelemetrySnapshot, Trip } from '@/services/vehicleApi'
import type { VehicleType } from '@/stores/vehicle'
import { whPerKm } from '@/utils/energy'
import { energyUnit, litresPer100Km, whPerKm } from '@/utils/energy'

/**
* Everything the dashboard needs to know about a card, in one place: its icon, whether it is
Expand Down Expand Up @@ -85,9 +85,10 @@ export const CARD_DEFINITIONS = [
{
id: 'efficiencyRatio',
icon: 'leaf',
// Two presentations share this card: Wh/km while driving data is available, and a fuel
// economy estimate for vehicles that burn fuel. DashboardCardContent picks between them.
hasData: (ctx) => hasEnergyEfficiency(ctx) || hasFuelEconomy(ctx),
// Three presentations share this card: measured L/100 km on a hybrid, Wh/km on a plug-in
// car, and a fuel economy estimate from the range computer for anything that burns fuel but
// has no driving data yet. DashboardCardContent picks between them.
hasData: (ctx) => hasFuelConsumption(ctx) || hasEnergyEfficiency(ctx) || hasFuelEconomy(ctx),
},
{
id: 'speed',
Expand Down Expand Up @@ -161,9 +162,20 @@ export function defaultCards(type: VehicleType = 'unknown'): CardConfig[] {
return [...all.filter((c) => c.visible), ...all.filter((c) => !c.visible)]
}

/** Wh/km over today's driving, the efficiencyRatio card's primary presentation. */
export function hasEnergyEfficiency({ status }: CardDataContext): boolean {
return whPerKm(status.powerUsageOfDay, status.mileageOfTheDay) !== null
/** Wh/km over today's driving, the efficiencyRatio card's presentation for a plug-in car. */
export function hasEnergyEfficiency({ status, vehicleType }: CardDataContext): boolean {
return (
energyUnit(vehicleType) === 'kwh' &&
whPerKm(status.powerUsageOfDay, status.mileageOfTheDay) !== null
)
}

/** L/100 km over today's driving, the efficiencyRatio card's presentation for a hybrid. */
export function hasFuelConsumption({ status, vehicleType }: CardDataContext): boolean {
return (
energyUnit(vehicleType) === 'litres' &&
litresPer100Km(status.powerUsageOfDay, status.mileageOfTheDay) !== null
)
}

/** Fuel economy from the range computer, the efficiencyRatio card's fallback for fuel burners. */
Expand Down
48 changes: 43 additions & 5 deletions frontend/src/components/DashboardCardContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useVehicleStore } from '@/stores/vehicle'
import type { CardId } from '@/cards/registry'
import { cardHasData, cardIcon, hasEnergyEfficiency, hasFuelEconomy } from '@/cards/registry'
import {
cardHasData,
cardIcon,
hasEnergyEfficiency,
hasFuelConsumption,
hasFuelEconomy,
} from '@/cards/registry'
import { useCardData } from '@/cards/useCardData'
import StatusCard from './StatusCard.vue'
import DoorsCard from './DoorsCard.vue'
Expand All @@ -17,7 +23,7 @@ import ChargingSessionCard from './ChargingSessionCard.vue'
import BatteryHeatingCard from './BatteryHeatingCard.vue'
import MaintenanceSummaryCard from './MaintenanceSummaryCard.vue'
import { formatNumber } from '@/utils/format'
import { whPerKm } from '@/utils/energy'
import { energyUnit, hvBatteryReading, litres, litresPer100Km, whPerKm } from '@/utils/energy'

const props = defineProps<{ cardId: CardId }>()

Expand Down Expand Up @@ -52,6 +58,16 @@ const supportsExternalCharge = computed(
() => vehicleType.value === 'phev' || vehicleType.value === 'bev',
)

// A hybrid's energy counters hold fuel, not kWh, so the same telemetry field is labelled and
// scaled differently per drivetrain. See utils/energy.
const reportsFuelCounter = computed(() => energyUnit(vehicleType.value) === 'litres')

const hvBattery = computed(() => {
const s = status.value
if (!s) return null
return hvBatteryReading(s, vehicleType.value, store.hvBatteryCapacityKwh)
})

// Config table for the branches that render nothing but a plain <StatusCard>. Branches that
// dispatch to a dedicated sub-component (doors, climate, hvBattery, etc.) stay in the
// v-else-if chain below since they aren't simple StatusCard-only cases. Whether a card has
Expand All @@ -62,6 +78,8 @@ const simpleCards = computed((): SimpleCardConfig[] => {
const ctx = cardData.value
if (!s || !ctx) return []
const efficiencyWhPerKm = whPerKm(s.powerUsageOfDay, s.mileageOfTheDay)
const fuelUsedLitres = litres(s.powerUsageOfDay)
const consumptionL100Km = litresPer100Km(s.powerUsageOfDay, s.mileageOfTheDay)
return [
{
id: 'fuelLevel',
Expand Down Expand Up @@ -128,18 +146,37 @@ const simpleCards = computed((): SimpleCardConfig[] => {
value: s.mileageOfTheDay !== null ? formatNumber(s.mileageOfTheDay) : null,
unit: t('common.km'),
},
// efficiencyEnergy - kWh through the traction battery on a plug-in car
{
id: 'efficiencyEnergy',
match: !reportsFuelCounter.value && s.powerUsageOfDay !== null,
label: t('vehicle.efficiency.todayEnergy'),
value: s.powerUsageOfDay !== null ? formatNumber(s.powerUsageOfDay) : null,
unit: t('common.kwh'),
},
// efficiencyEnergy - litres burned on a hybrid, whose counter reports fuel
{
id: 'efficiencyEnergy',
match: reportsFuelCounter.value && fuelUsedLitres !== null,
icon: 'gas-pump',
label: t('vehicle.efficiency.todayFuel'),
value: fuelUsedLitres !== null ? formatNumber(fuelUsedLitres) : null,
unit: t('common.litre'),
},
{
id: 'efficiencyCharge',
label: t('vehicle.efficiency.sinceCharge'),
value: s.mileageSinceLastCharge !== null ? formatNumber(s.mileageSinceLastCharge) : null,
unit: t('common.km'),
},
// efficiencyRatio - measured L/100 km on a hybrid, which beats the estimate below
{
id: 'efficiencyRatio',
match: hasFuelConsumption(ctx),
label: t('vehicle.efficiency.consumption'),
value: consumptionL100Km !== null ? formatNumber(consumptionL100Km) : null,
unit: `${t('common.litre')}/100${t('common.km')}`,
},
// efficiencyRatio - Wh/km when driving data is available
{
id: 'efficiencyRatio',
Expand Down Expand Up @@ -244,10 +281,11 @@ const activeSimpleCard = computed(() => {

<!-- hvBattery -->
<HvBatteryCard
v-else-if="cardId === 'hvBattery'"
v-else-if="cardId === 'hvBattery' && hvBattery"
:vin="vin"
:hv-soc-kwh="status.hvSocKwh"
:hv-total-capacity-kwh="status.hvTotalCapacityKwh"
:soc-percent="hvBattery.socPercent"
:stored-kwh="hvBattery.storedKwh"
:capacity-kwh="hvBattery.capacityKwh"
:hv-voltage="status.hvVoltage"
:hv-current="status.hvCurrent"
:hv-power="status.hvPower"
Expand Down
Loading
Loading