inital impl

This commit is contained in:
2026-08-13 17:41:04 +02:00
parent 1c54a6b7b2
commit 05b2ad9f8d
6 changed files with 901 additions and 1 deletions
+2
View File
@@ -203,3 +203,5 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
notes.md
*.json
+132 -1
View File
@@ -1,3 +1,134 @@
# vw-eu-data-act-prometheus-exporter
VW EU Data Act Prometheus Exporter
A Prometheus exporter for vehicle data from the
[VW Group EU Data Act Portal](https://eu-data-act.drivesomethinggreater.com/)
(Volkswagen; not supported for Audi/Skoda/SEAT/CUPRA without adjustments).
For each configured VIN, a dedicated background thread logs into the portal,
downloads the latest available dataset, and derives Prometheus metrics from
it - following the classic multi-target-exporter pattern via a `vin` label on
a shared `/metrics` endpoint (no Docker, plain systemd deployment).
## Features
- Multiple vehicles (VINs) under one VW account, each with its own
scrape-interval timing in the background
- **No persistent state by default**: no ZIPs, no JSON, no values are
written to disk - everything lives in process memory and is empty again
after a restart. Optionally, raw JSON datasets can be persisted to disk via
`config.persist_raw_json` (see [Configuration](#configuration-configpy))
- Fields are only updated when they are present in the current dataset - if
a field is missing, the last known value is kept
- The `health` metric cleanly distinguishes between "portal ok, but no new
data (yet)" (not a failure) and actual failures (login, HTTP, timeout, or
parse errors)
## Metrics
All metrics carry the `vin` label; all names carry the configurable prefix
(default: `vw_eu_data_act_exporter_`).
| Metric | Type | Description |
|---|---|---|
| `mileage_km` | Gauge | Odometer reading |
| `hvsoc_percent` | Gauge | HV battery state of charge in % |
| `driver_present` | Gauge (bool) | Driver detected in vehicle |
| `cruising_range_km` | Gauge | Remaining range in km |
| `hvbattery_temperature_max_celsius` | Gauge | Max. HV battery temperature |
| `hvbattery_temperature_min_celsius` | Gauge | Min. HV battery temperature |
| `charging_state` | Enum | `chargingStatus.currentChargeState` |
| `charge_power_kw` | Gauge | Current charging power in kW |
| `plug_connection_state` | Enum | Plug connection status |
| `target_soc_percent` | Gauge | Charge target in % |
| `position_longitude` / `position_latitude` | Gauge | Last known position (only set when **both** coordinates were present in the same dataset) |
| `position_created_timestamp_seconds` | Gauge | Unix timestamp of the last known position |
| `locked` | Gauge (bool) | Combined lock status of all doors, trunk and hood (only `unlocked` if at least one component actively reports `UNLOCKED`) |
| `is_parked` | Gauge (bool) | Vehicle parked |
| `parking_brake_engaged` | Gauge (bool) | Parking brake engaged |
| `driving_mode` | Enum | Active driving mode |
| `next_service_type` | Enum | Next due service type |
| `service_due_in_days` | Gauge | Remaining days until the next service |
| `last_vehicle_signal_timestamp_seconds` | Gauge | Timestamp of the last vehicle signal (`carCapturedUTCTimestamp`) |
| `uptime_seconds` | Gauge | Uptime of the exporter process |
| `last_successful_scrape_timestamp_seconds` | Gauge | Last scrape with **new** data |
| `health` | Gauge (bool) | `0` initially and after a failure, `1` once new data has been received at least once |
**Note on enum metrics:** The state space (e.g. possible values of
`drivingMode` or `chargingStatus.currentChargeState`) is compiled
best-effort from the sample dataset and comparable projects, and each one
includes an `UNKNOWN` fallback. If an unknown raw value shows up, it is
exported as `UNKNOWN` and additionally logged as `WARNING` - the state list
in `vw-eu-data-act-exporter.py` (the `*_STATES` constants) can then be
extended.
## Installation (systemd)
```bash
# 1. Create directory + user
sudo mkdir -p /opt/vw-eu-data-act-exporter
sudo useradd --system --no-create-home --shell /usr/sbin/nologin vw-exporter
# 2. Copy files
sudo cp vw-eu-data-act-exporter.py config.py requirements.txt /opt/vw-eu-data-act-exporter/
# 3. Virtualenv + dependencies
sudo python3 -m venv /opt/vw-eu-data-act-exporter/venv
sudo /opt/vw-eu-data-act-exporter/venv/bin/pip install -r /opt/vw-eu-data-act-exporter/requirements.txt
# 4. Adjust config.py (VW account, VINs, interval)
sudo nano /opt/vw-eu-data-act-exporter/config.py
# 5. Permissions (config.py contains the VW account password in plain text)
sudo chown -R vw-exporter:vw-exporter /opt/vw-eu-data-act-exporter
sudo chmod 600 /opt/vw-eu-data-act-exporter/config.py
# 6. Install systemd unit
sudo cp vw-eu-data-act-exporter.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now vw-eu-data-act-exporter
sudo systemctl status vw-eu-data-act-exporter
```
## Configuration (`config.py`)
| Field | Meaning |
|---|---|
| `hostName` / `serverPort` | Bind address of the `/metrics` endpoint |
| `exporter_prefix` | Prefix for all metric names (only `[a-zA-Z0-9_:]`, e.g. `vw_eu_data_act_exporter_`) |
| `vw_account` | E-mail + password of the VW account under which **all** configured VINs are registered |
| `vins` | List of vehicle VINs to scrape |
| `scrape_interval_minutes` | Poll interval per vehicle. The portal only delivers new datasets for "continuous" data requests roughly every ~15 min anyway - a shorter interval will not yield fresher data, but will increase the login frequency against the account |
| `persist_raw_json` | `False` by default. When set to `True`, the raw JSON of every downloaded dataset is written to disk (one file per VIN per scrape) - useful while tuning the field mapping in `vw-eu-data-act-exporter.py`. Not needed for normal operation |
| `persist_raw_json_dir` | Directory the raw JSON files are written to when `persist_raw_json` is enabled (default: `./raw_json`, relative to the working directory) |
Prerequisite in the portal (one-time, in a browser): connect vehicle ->
"Get customised data" -> enable continuous, 15-minute frequency (see the
docstring in `vw-eu-data-act-exporter.py`).
## Prometheus integration
```yaml
scrape_configs:
- job_name: vw-eu-data-act-exporter
static_configs:
- targets: ["127.0.0.1:9109"]
```
Since the exporter itself polls in the background (it does not hit the
portal live on every Prometheus scrape), the Prometheus scrape interval can
be independent of, and significantly shorter than, `scrape_interval_minutes`
- it will simply repeatedly return the same cached value.
## Troubleshooting
- `health` stays `0` permanently: check
`journalctl -u vw-eu-data-act-exporter -f` - usually a login failure
(wrong password) or no datasets available in the portal yet (see
prerequisite above).
- A single metric is missing entirely: the corresponding field has never
been present in any dataset fetched so far (e.g.
`position_longitude`/`_latitude`, if both coordinates were never
delivered at the same time).
- `WARNING ... Unknown enum value`: a new, not-yet-listed value for an enum
metric - the state is exported as `UNKNOWN`, extend the list in the
source code if needed.
+52
View File
@@ -0,0 +1,52 @@
"""Configuration for the vw-eu-data-act-exporter."""
# --- HTTP Server -------------------------------------------------------
hostName = "127.0.0.1"
serverPort = 9109
# Prefix for all exported Prometheus metrics.
# Must be a valid Prometheus metric name prefix: [a-zA-Z_:][a-zA-Z0-9_:]*
exporter_prefix = "vw_eu_data_act_exporter_"
# --- VW Group EU Data Act Portal Account --------------------------------
# https://eu-data-act.drivesomethinggreater.com/
# All VINs configured below must belong to this account.
vw_account = {
"email": "you@example.com",
"password": "changeme",
}
# --- Vehicles --------------------------------------------------------------
# List of vehicle VINs to scrape (17 characters).
vins = [
"AAABBCCEEF75854df",
]
# --- HTTP Client -------------------------------------------------------------
# Pool of User-Agent strings. A random entry is picked for each login
# session against the portal.
user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
]
# --- Scrape behavior ---------------------------------------------------------
# Interval in minutes at which each VIN is polled in the background.
# The portal only produces new "continuous" data records roughly every
# ~15 minutes - a shorter interval will not yield fresher data, but will
# increase the login frequency against the VW account.
scrape_interval_minutes = 15
# --- Raw JSON persistence ---------------------------------------------------
# Optional: write every downloaded raw JSON dataset to disk (one file per VIN
# per scrape), e.g. to inspect the portal's payload while improving the field
# mapping in vw-eu-data-act-exporter.py. Disabled by default, since the
# exporter is designed to be stateless (see README.md).
persist_raw_json = False
persist_raw_json_dir = "./raw_json"
# --- Logging ---------------------------------------------------------------
log_level = "INFO"
+3
View File
@@ -0,0 +1,3 @@
requests>=2.31
beautifulsoup4>=4.12
prometheus_client>=0.20
+684
View File
@@ -0,0 +1,684 @@
"""
vw-eu-data-act-exporter
========================
Prometheus exporter for vehicle data from the VW Group EU Data Act Portal
(https://eu-data-act.drivesomethinggreater.com/).
For each configured VIN, a dedicated background thread logs into the portal,
downloads the latest available dataset, and exposes the fields it contains as
Prometheus metrics (label ``vin``) on a shared ``/metrics`` endpoint (the
classic Prometheus multi-target pattern via labels instead of separate
processes).
No raw data or ZIP files are written to disk - all state lives purely in
process memory and is lost on restart (health/last-scrape metrics then start
at 0 again). Optionally, the raw JSON of every downloaded dataset can be
persisted to disk via ``config.persist_raw_json`` to help refine the field
mapping below.
"""
from __future__ import annotations
import io
import json
import logging
import random
import re
import sys
import threading
import time
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode, urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from prometheus_client import Enum, Gauge, start_http_server
from prometheus_client.core import GaugeMetricFamily
from prometheus_client.registry import REGISTRY
import config
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=getattr(logging, config.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("vw_eu_data_act_exporter")
# ---------------------------------------------------------------------------
# Portal & OIDC constants (brand "volkswagen" only)
# ---------------------------------------------------------------------------
BASE_URL = "https://eu-data-act.drivesomethinggreater.com"
IDENTITY_BASE = "https://identity.vwgroup.io"
OIDC_AUTHORIZE = IDENTITY_BASE + "/oidc/v1/authorize"
OIDC_SCOPE = "openid cars profile"
OIDC_REDIRECT = BASE_URL + "/login"
OIDC_CLIENT_ID = "9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com"
OIDC_STATE_KEY = "VOLKSWAGEN_PASSENGER_CARS"
DEFAULT_COUNTRY = "de"
DEFAULT_LANGUAGE = "en"
METADATA_PATH = "/proxy_api/euda-apim/datarequest/vehicles/{vin}/metadata/partial"
LIST_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/list"
DOWNLOAD_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/download"
NO_CONTENT_SUFFIX = "_no_content_found.zip"
HTTP_TIMEOUT = 30
IDENTIFIER_KEYS = {"identifier", "datarequestid", "id"}
# ---------------------------------------------------------------------------
# HTML / JS login-parsing helpers (adopted from vw_eu_data_act_downloader.py)
# ---------------------------------------------------------------------------
def _extract_template_model(html: str) -> dict:
idx = html.find("templateModel")
if idx == -1:
return {}
brace = html.find("{", idx)
if brace == -1:
return {}
depth = 0
for i in range(brace, len(html)):
c = html[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
try:
return json.loads(html[brace : i + 1])
except ValueError:
return {}
return {}
def _extract_csrf(html: str) -> str | None:
m = re.search(r"csrf_token\s*[:=]\s*['\"]([^'\"]+)['\"]", html)
return m.group(1) if m else None
def _parse_form_action(html: str, base_url: str) -> str | None:
soup = BeautifulSoup(html, "html.parser")
form = soup.find("form")
if not form:
return None
action = form.get("action")
if not action:
return None
return urljoin(base_url, action)
def _collect_login_fields(html: str) -> dict[str, str]:
soup = BeautifulSoup(html, "html.parser")
fields: dict[str, str] = {}
form = soup.find("form")
if form:
for inp in form.find_all("input"):
name = inp.get("name")
if name:
fields[name] = inp.get("value") or ""
model = _extract_template_model(html)
if model:
for key in ("hmac", "relayState"):
if model.get(key):
fields[key] = model[key]
email_val = (model.get("emailPasswordForm") or {}).get("email")
if email_val:
fields.setdefault("email", email_val)
csrf = _extract_csrf(html)
if csrf:
fields.setdefault("_csrf", csrf)
return fields
def _extract_login_error(html: str) -> str | None:
model = _extract_template_model(html)
err = model.get("error") or model.get("errorCode")
if isinstance(err, dict):
return err.get("text") or err.get("errorCode") or str(err)
return str(err) if err else None
# ---------------------------------------------------------------------------
# Portal client
# ---------------------------------------------------------------------------
def _build_authorize_url() -> str:
state = f"{DEFAULT_COUNTRY}__{DEFAULT_LANGUAGE}__{OIDC_STATE_KEY}"
params = {
"client_id": OIDC_CLIENT_ID,
"response_type": "code",
"scope": OIDC_SCOPE,
"state": state,
"redirect_uri": OIDC_REDIRECT,
"prompt": "login",
}
return f"{OIDC_AUTHORIZE}?{urlencode(params)}"
def login(session: requests.Session, email: str, password: str) -> None:
"""Runs the full OIDC login flow. Raises RuntimeError on failure."""
try:
session.get(BASE_URL + "/", timeout=15)
except requests.RequestException as exc:
log.debug("Priming request failed (ignored): %s", exc)
authorize_url = _build_authorize_url()
resp = session.get(authorize_url, timeout=20)
resp.raise_for_status()
signin_url = resp.url
signin_html = resp.text
fields = _collect_login_fields(signin_html)
if "hmac" not in fields or "_csrf" not in fields:
raise RuntimeError(
f"Could not parse the sign-in form (fields found: {sorted(fields)})."
)
fields["email"] = email
action = _parse_form_action(signin_html, signin_url)
identifier_action = action or signin_url
resp = session.post(
identifier_action, data=fields, headers={"Referer": signin_url}, timeout=20
)
resp.raise_for_status()
authenticate_url = resp.url
authenticate_html = resp.text
fields2 = _collect_login_fields(authenticate_html)
if "hmac" not in fields2 or "_csrf" not in fields2:
err = _extract_login_error(authenticate_html)
raise RuntimeError(err or "IDP did not return a password form.")
fields2["email"] = email
fields2["password"] = password
action2 = _parse_form_action(authenticate_html, authenticate_url)
authenticate_action = action2 or authenticate_url.split("?", 1)[0]
resp = session.post(
authenticate_action,
data=fields2,
headers={"Referer": authenticate_url},
timeout=20,
)
if resp.status_code >= 400:
err = _extract_login_error(resp.text)
raise RuntimeError(err or f"Login rejected with HTTP {resp.status_code}.")
landing = resp.url
portal_host = urlparse(BASE_URL).netloc
if "signin-service" in landing or "/error" in landing:
err = _extract_login_error(resp.text)
raise RuntimeError(f"Login failed - {err or 'check email/password'}")
if urlparse(landing).netloc != portal_host:
raise RuntimeError(f"Login did not complete - landed at {landing!r}.")
def _api_get(session: requests.Session, url: str, *, headers: dict | None = None):
resp = session.get(url, headers=headers or {}, timeout=HTTP_TIMEOUT)
if resp.status_code >= 400:
raise RuntimeError(
f"API GET {url} -> HTTP {resp.status_code}: {resp.text[:300]}"
)
return resp.json()
def get_metadata(session: requests.Session, vin: str) -> dict:
url = BASE_URL + METADATA_PATH.format(vin=vin)
return _api_get(session, url)
def find_identifier(node) -> str | None:
if isinstance(node, dict):
for k, v in node.items():
if k.lower() in IDENTIFIER_KEYS and isinstance(v, str) and len(v) > 8:
return v
for v in node.values():
r = find_identifier(v)
if r:
return r
elif isinstance(node, list):
for v in node:
r = find_identifier(v)
if r:
return r
return None
def list_datasets(session: requests.Session, vin: str, identifier: str) -> list[dict]:
url = BASE_URL + LIST_PATH.format(vin=vin, identifier=identifier)
data = _api_get(session, url, headers={"type": "partial"})
return data if isinstance(data, list) else data.get("files", [])
def download_zip(
session: requests.Session, vin: str, identifier: str, name: str
) -> bytes:
url = BASE_URL + DOWNLOAD_PATH.format(vin=vin, identifier=identifier)
resp = session.get(url, headers={"filename": name, "type": "partial"}, timeout=60)
if resp.status_code >= 400:
raise RuntimeError(
f"Download {name} -> HTTP {resp.status_code}: {resp.text[:200]}"
)
return resp.content
def unzip_json(raw: bytes, name: str) -> dict:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
members = [m for m in zf.namelist() if m.lower().endswith(".json")]
if not members:
raise ValueError(f"No JSON file in {name}")
with zf.open(members[0]) as fh:
return json.loads(fh.read().decode("utf-8"))
def pick_latest_dataset(datasets: list[dict]) -> dict:
def sort_key(d: dict) -> str:
return d.get("createdOn") or d.get("created") or d.get("name") or ""
return sorted(datasets, key=sort_key)[-1]
def flatten_dataset(record: dict) -> dict[str, str]:
"""Builds a dataFieldName -> value mapping. On duplicates, the last
entry in the Data array wins."""
fields: dict[str, str] = {}
for entry in record.get("Data", []):
name = entry.get("dataFieldName")
if name is None:
continue
fields[name] = entry.get("value")
return fields
def persist_raw_dataset(vin: str, record: dict) -> None:
"""Writes the raw dataset JSON to disk, so it can be inspected later to
refine the field mapping below. Only called when
``config.persist_raw_json`` is enabled."""
out_dir = Path(config.persist_raw_json_dir)
out_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
dest = out_dir / f"{vin}_{timestamp}.json"
dest.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
log.debug("Persisted raw dataset for VIN %s to %s", vin, dest)
def fetch_latest_dataset_fields(vin: str) -> dict[str, str] | None:
"""Logs in, downloads the latest dataset for *vin* and returns the
flattened fields. Returns ``None`` if no datasets are (yet) available -
this is explicitly NOT a failure. Raises an exception on login/HTTP/parse
errors (= failure)."""
session = requests.Session()
session.headers.update({"User-Agent": random.choice(config.user_agents)})
login(session, config.vw_account["email"], config.vw_account["password"])
meta = get_metadata(session, vin)
identifier = find_identifier(meta)
if not identifier:
raise RuntimeError(f"No data-request identifier in metadata for VIN {vin}.")
datasets = list_datasets(session, vin, identifier)
datasets = [
d
for d in datasets
if not (d.get("name") or d.get("fileName") or "").endswith(NO_CONTENT_SUFFIX)
]
if not datasets:
return None
latest = pick_latest_dataset(datasets)
name = latest.get("name") or latest.get("fileName") or str(latest)
raw = download_zip(session, vin, identifier, name)
record = unzip_json(raw, name)
if config.persist_raw_json:
try:
persist_raw_dataset(vin, record)
except OSError as exc:
log.warning("Could not persist raw dataset for VIN %s: %s", vin, exc)
return flatten_dataset(record)
# ---------------------------------------------------------------------------
# Value conversion (VW API strings -> Prometheus types)
# ---------------------------------------------------------------------------
def parse_bool(raw) -> bool:
return str(raw).strip().lower() == "true"
def parse_float(raw) -> float:
return float(raw)
def parse_iso8601_to_unix(raw) -> float:
text = str(raw).strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
return datetime.fromisoformat(text).timestamp()
def parse_bool_as_float(raw) -> float:
return 1.0 if parse_bool(raw) else 0.0
# ---------------------------------------------------------------------------
# Prometheus metrics
# ---------------------------------------------------------------------------
P = config.exporter_prefix
g_mileage_km = Gauge(f"{P}mileage_km", "Odometer reading", ["vin"])
g_hvsoc_percent = Gauge(
f"{P}hvsoc_percent", "HV battery state of charge in percent", ["vin"]
)
g_driver_present = Gauge(
f"{P}driver_present", "Driver detected in vehicle (1=yes)", ["vin"]
)
g_cruising_range_km = Gauge(f"{P}cruising_range_km", "Remaining range in km", ["vin"])
g_hvbattery_temp_max_celsius = Gauge(
f"{P}hvbattery_temperature_max_celsius",
"Max. HV battery temperature in Celsius",
["vin"],
)
g_hvbattery_temp_min_celsius = Gauge(
f"{P}hvbattery_temperature_min_celsius",
"Min. HV battery temperature in Celsius",
["vin"],
)
g_charge_power_kw = Gauge(
f"{P}charge_power_kw", "Current charging power in kW", ["vin"]
)
g_target_soc_percent = Gauge(
f"{P}target_soc_percent", "Charge target in percent", ["vin"]
)
g_position_longitude = Gauge(
f"{P}position_longitude", "Longitude of the last known position", ["vin"]
)
g_position_latitude = Gauge(
f"{P}position_latitude", "Latitude of the last known position", ["vin"]
)
g_position_created_timestamp_seconds = Gauge(
f"{P}position_created_timestamp_seconds",
"Unix timestamp of when the last position was recorded",
["vin"],
)
g_locked = Gauge(
f"{P}locked",
"Combined lock status of all doors, trunk and hood (1=locked)",
["vin"],
)
g_is_parked = Gauge(f"{P}is_parked", "Vehicle parked (1=yes)", ["vin"])
g_parking_brake_engaged = Gauge(
f"{P}parking_brake_engaged", "Parking brake engaged (1=yes)", ["vin"]
)
g_service_due_in_days = Gauge(
f"{P}service_due_in_days", "Remaining days until the next service", ["vin"]
)
g_last_vehicle_signal_timestamp_seconds = Gauge(
f"{P}last_vehicle_signal_timestamp_seconds",
"Unix timestamp of the last vehicle signal (carCapturedUTCTimestamp)",
["vin"],
)
g_last_successful_scrape_timestamp_seconds = Gauge(
f"{P}last_successful_scrape_timestamp_seconds",
"Unix timestamp of the last successful scrape with new data",
["vin"],
)
g_health = Gauge(
f"{P}health",
"1 once new data has been received at least once via the EU Data Act API, "
"0 initially and after every failure (not a failure: portal ok but no new data)",
["vin"],
)
# Enum metrics. The state space is best-effort (compiled from the sample
# dataset and comparable projects) plus an "UNKNOWN" fallback for unknown raw
# values - these are additionally logged as WARNING so the list can be
# extended if needed.
DRIVING_MODE_STATES = ["standard", "eco", "comfort", "sport", "individual", "UNKNOWN"]
CHARGING_STATE_STATES = [
"OFF",
"READY_FOR_CHARGING",
"NOT_READY_FOR_CHARGING",
"CHARGING",
"CONSERVING",
"ERROR",
"UNKNOWN",
]
PLUG_CONNECTION_STATES = ["CONNECTED", "DISCONNECTED", "UNKNOWN"]
SERVICE_TYPE_STATES = [
"SERVICE_TYPE_INSPECTION",
"SERVICE_TYPE_OIL_CHANGE",
"SERVICE_TYPE_BRAKE_FLUID",
"SERVICE_TYPE_TIMING_BELT",
"UNKNOWN",
]
e_driving_mode = Enum(
f"{P}driving_mode", "Active driving mode", ["vin"], states=DRIVING_MODE_STATES
)
e_charging_state = Enum(
f"{P}charging_state",
"Current charging state",
["vin"],
states=CHARGING_STATE_STATES,
)
e_plug_connection_state = Enum(
f"{P}plug_connection_state",
"Plug connection status",
["vin"],
states=PLUG_CONNECTION_STATES,
)
e_service_type = Enum(
f"{P}next_service_type",
"Next due service type",
["vin"],
states=SERVICE_TYPE_STATES,
)
# Field name (dataFieldName in the dataset) -> (Gauge, conversion function)
GAUGE_FIELD_MAP: list[tuple[str, Gauge, callable]] = [
("mileage_info.value", g_mileage_km, parse_float),
("hvsoc_info.value", g_hvsoc_percent, parse_float),
("Driver Presence", g_driver_present, parse_bool_as_float),
("batteryStatus.cruisingRange.range", g_cruising_range_km, parse_float),
(
"hvbatterytemperature_info.max_temperature.value",
g_hvbattery_temp_max_celsius,
parse_float,
),
(
"hvbatterytemperature_info.min_temperature.value",
g_hvbattery_temp_min_celsius,
parse_float,
),
("chargingStatus.chargePower_kW", g_charge_power_kw, parse_float),
("targetSoc_pct", g_target_soc_percent, parse_float),
("positionCreated", g_position_created_timestamp_seconds, parse_iso8601_to_unix),
("isParked", g_is_parked, parse_bool_as_float),
("parking_brake_info.value", g_parking_brake_engaged, parse_bool_as_float),
("service_maintenance_info.due_in_time.value", g_service_due_in_days, parse_float),
(
"carCapturedUTCTimestamp",
g_last_vehicle_signal_timestamp_seconds,
parse_iso8601_to_unix,
),
]
ENUM_FIELD_MAP: list[tuple[str, Enum, list[str]]] = [
("drivingMode", e_driving_mode, DRIVING_MODE_STATES),
("chargingStatus.currentChargeState", e_charging_state, CHARGING_STATE_STATES),
(
"plugStatusItem.plugConnectionState",
e_plug_connection_state,
PLUG_CONNECTION_STATES,
),
("service_maintenance_info.service_type", e_service_type, SERVICE_TYPE_STATES),
]
# Lock components -> internal name. If a component is missing from a dataset
# entirely (even across all scrapes so far), it is treated as "LOCKED" (safe
# default), so a single door actively reporting locked does not on its own
# report "vehicle locked" if another component is still unknown.
LOCK_FIELDS = {
"door_info.front_left.door_lock_status.value": "front_left",
"door_info.front_right.door_lock_status.value": "front_right",
"door_info.rear_left.door_lock_status.value": "rear_left",
"door_info.rear_right.door_lock_status.value": "rear_right",
"trunk_lid_info.trunk_lid_lock_status.value": "trunk",
"hood_info.hood_lock_status.value": "hood",
}
class UptimeCollector:
"""Computes uptime freshly on every scrape instead of maintaining a
ticker thread."""
def __init__(self, start_time: float) -> None:
self._start_time = start_time
def collect(self):
g = GaugeMetricFamily(
f"{P}uptime_seconds", "Uptime of the exporter process in seconds"
)
g.add_metric([], time.time() - self._start_time)
yield g
# ---------------------------------------------------------------------------
# Per-VIN background poller
# ---------------------------------------------------------------------------
class VehiclePoller:
def __init__(self, vin: str) -> None:
self.vin = vin
self.lock_components: dict[str, str] = {
name: "LOCKED" for name in LOCK_FIELDS.values()
}
self.last_signal_ts_raw: str | None = None
def apply_fields(self, fields: dict[str, str]) -> None:
vin = self.vin
for key, gauge, transform in GAUGE_FIELD_MAP:
if key not in fields:
continue
try:
gauge.labels(vin=vin).set(transform(fields[key]))
except (TypeError, ValueError) as exc:
log.warning("Could not parse field %s (VIN %s): %s", key, vin, exc)
for key, enum_metric, states in ENUM_FIELD_MAP:
if key not in fields:
continue
raw = str(fields[key])
state = raw if raw in states else "UNKNOWN"
if state == "UNKNOWN":
log.warning("Unknown enum value for %s (VIN %s): %r", key, vin, raw)
enum_metric.labels(vin=vin).state(state)
for key, comp_name in LOCK_FIELDS.items():
if key in fields:
self.lock_components[comp_name] = str(fields[key])
locked = (
0.0 if any(v == "UNLOCKED" for v in self.lock_components.values()) else 1.0
)
g_locked.labels(vin=vin).set(locked)
if "longitude" in fields and "latitude" in fields:
try:
g_position_longitude.labels(vin=vin).set(
parse_float(fields["longitude"])
)
g_position_latitude.labels(vin=vin).set(parse_float(fields["latitude"]))
except (TypeError, ValueError) as exc:
log.warning("Could not parse position (VIN %s): %s", vin, exc)
new_ts = fields.get("carCapturedUTCTimestamp")
if new_ts is not None and new_ts != self.last_signal_ts_raw:
self.last_signal_ts_raw = new_ts
g_last_successful_scrape_timestamp_seconds.labels(vin=vin).set(time.time())
g_health.labels(vin=vin).set(1.0)
def run_once(self) -> None:
fields = fetch_latest_dataset_fields(self.vin)
if fields is None:
log.info("No new datasets available for VIN %s.", self.vin)
return
self.apply_fields(fields)
def loop(self, stop_event: threading.Event) -> None:
g_health.labels(vin=self.vin).set(0.0)
while not stop_event.is_set():
try:
self.run_once()
except Exception as exc: # login/HTTP/parse errors = failure
log.error("Scrape for VIN %s failed: %s", self.vin, exc)
g_health.labels(vin=self.vin).set(0.0)
stop_event.wait(config.scrape_interval_minutes * 60)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
if not config.vins:
log.error("config.vins is empty - no vehicles configured to scrape.")
return 1
REGISTRY.register(UptimeCollector(time.time()))
stop_event = threading.Event()
pollers = [VehiclePoller(vin) for vin in config.vins]
threads = []
for poller in pollers:
t = threading.Thread(
target=poller.loop,
args=(stop_event,),
daemon=True,
name=f"poll-{poller.vin}",
)
t.start()
threads.append(t)
start_http_server(config.serverPort, addr=config.hostName)
log.info(
"vw-eu-data-act-exporter running on http://%s:%s/metrics (%d VIN(s), interval %d min)",
config.hostName,
config.serverPort,
len(config.vins),
config.scrape_interval_minutes,
)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
log.info("Shutting down...")
stop_event.set()
return 0
if __name__ == "__main__":
sys.exit(main())
+28
View File
@@ -0,0 +1,28 @@
[Unit]
Description=VW EU Data Act Prometheus Exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=vw-exporter
Group=vw-exporter
WorkingDirectory=/opt/vw-eu-data-act-exporter
ExecStart=/opt/vw-eu-data-act-exporter/venv/bin/python3 /opt/vw-eu-data-act-exporter/vw-eu-data-act-exporter.py
Restart=on-failure
RestartSec=30
# Hardening
ReadWritePaths=/opt/vw-eu-data-act-exporter/raw_json
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target