inital impl
This commit is contained in:
@@ -203,3 +203,5 @@ cython_debug/
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
notes.md
|
||||||
|
*.json
|
||||||
|
|||||||
@@ -1,3 +1,134 @@
|
|||||||
# vw-eu-data-act-prometheus-exporter
|
# 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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
requests>=2.31
|
||||||
|
beautifulsoup4>=4.12
|
||||||
|
prometheus_client>=0.20
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user