Author SHA1 Message Date
jensandClaude Sonnet 4.6 bc424310b5 server: replace carconnectivity with volkswagencarnet
Switches the VW API backend from carconnectivity + connector-volkswagen
to volkswagencarnet (local fork with the cariad-hybrid-auth-fix patch
applied), fixing authentication against the current CARIAD BFF endpoints.

- requirements.txt: point at local volkswagencarnet git repo via
  git+file:// URL (editable path no longer needed now the patch is
  committed there)
- we_connect.py: rewrite around the async volkswagencarnet Connection
  class; a persistent asyncio event loop held in _Session lets the rest
  of the code stay synchronous
- data_model.py: remap all extractors to volkswagencarnet Vehicle
  properties and Paths constants; output shape preserved so
  apply_procedural and the GUI client continue to work unchanged
- collect.py: swap carconnectivity error import, drop
  TemporaryAuthenticationError (no equivalent), fix vehicle.vin.value →
  vehicle.vin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:50:57 +02:00
jensandClaude Sonnet 4.6 63e807bdb0 data_model: split windows out of doors into own domain
- Add extract_windows() and register 'windows' in ALL_DOMAINS
- Remove windows extraction from extract_doors; no more double keys
- extract_windows also captures the overall windows.open_state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:53:26 +02:00
jensandClaude Sonnet 4.6 4dcf2ee6aa data_model: fix extract_doors — filter None values, fix structure
- Don't store attributes whose .value is None; avoids spurious "None"
  strings in the snapshot (lock_state, open_state were unset)
- Flatten the structure: overall lock_state/open_state now live directly
  under 'doors' instead of the double-nested 'doors.doors.overallState'
- Give vehicle.windows its own try/except, independent of vehicle.doors
- Capture doors.open_state (overall open state) which was missing before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:40:47 +02:00
jensandClaude Sonnet 4.6 0b68bcdef8 server/main.py: use absolute imports for collect and data_model
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:28:40 +02:00
jensandClaude Sonnet 4.6 26904d9afc gui_client: fix dashboard dropping plain-value fields
DashboardTab.update() had no else-branch for non-dict, non-physical
values, so string fields like charging.state/type, connectivity.state,
vehicle.state and numeric fields like position.lat/lon were silently
skipped.  Mirrors the existing else-branch in _add_fields().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:27:52 +02:00
jensandClaude Sonnet 4.6 99b34f0b5e gitignore: add .venv/ and client/run.sh
run.sh is generated by install.sh with machine-specific absolute paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:00:23 +02:00
jensandClaude Sonnet 4.6 53004e5a2d Update README: we_monitor_gui system command after client install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:57:15 +02:00
jensandClaude Sonnet 4.6 49bc272461 client/install.sh: install we_monitor_gui command to ~/.local/bin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:55:14 +02:00
jensandClaude Sonnet 4.6 b5c7e66c94 Update README: add client/install.sh and run.sh to layout and Client section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:53:09 +02:00
jensandClaude Sonnet 4.6 ff6a43746a Add client/install.sh; fix server/install.sh paths after move
server/install.sh was broken after moving into server/: SCRIPT_DIR now
resolves to the server/ subdirectory, so requirements path, log dir, and
systemd WorkingDirectory all pointed at wrong locations. Introduce REPO_DIR
(parent of SCRIPT_DIR) and use it throughout.

client/install.sh creates a venv at client/.venv, installs
client/requirements.txt, and writes a client/run.sh launcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:48:34 +02:00
jensandClaude Sonnet 4.6 5b1de48b75 Update README for server/client split
Reflect new layout (server/, client/), separate requirements.txt files,
python -m server.main entry point, and server/install.sh location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:35:03 +02:00
jensandClaude Sonnet 4.6 ca459c5ce9 Move install.sh to server/; clarify it is server-only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:33:41 +02:00
jensandClaude Sonnet 4.6 5230b304a2 Move collect.py into server/; split main() into server/main.py
run() stays in server/collect.py with relative imports.
main() (CLI arg parsing, credential loading) moves to server/main.py.
install.sh updated to use `python -m server.main` (required for relative
imports to resolve correctly when invoked as a module).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:30:39 +02:00
jensandClaude Sonnet 4.6 423e6d3027 Split into server/ and client/ with separate requirements.txt
Move client.py and gui_client.py into client/, split root requirements.txt
into server/requirements.txt and client/requirements.txt, update install.sh
to reference server/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:26:19 +02:00
jensandClaude Sonnet 4.6 cc2c52a827 Expand abbreviated trace labels for clarity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:30:58 +02:00
jens eea4bdc3ba Merge branch 'lib_carconnectivity'
Migrate we_monitor from weconnect to CarConnectivity/VW connector,
fix two jay_diff correctness bugs, and replace utils/jay_diff.py
with the shared jaydiff package from jayfield/jaypy.
2026-05-27 14:01:51 +02:00
12 changed files with 518 additions and 328 deletions
+2
View File
@@ -3,3 +3,5 @@ credentials/
vehicle_data.json vehicle_data.json
logs/ logs/
__pycache__/ __pycache__/
.venv/
client/run.sh
+61 -58
View File
@@ -7,45 +7,41 @@ A PyQt5 GUI client visualises the data in real time.
## Repository layout ## Repository layout
``` ```
collect.py — collector daemon (entry point)
gui_client.py — PyQt5 GUI client
client.py — minimal CLI test client
server/ server/
main.py — collector entry point (CLI + credential loading)
collect.py — run() main loop
data_model.py — domain extractors, collect_snapshot() data_model.py — domain extractors, collect_snapshot()
network.py — TCP push server (accept loop + broadcast) network.py — TCP push server (accept loop + broadcast)
storage_helpers.py — load / save JSON store, midnight rotation helpers storage_helpers.py — load / save JSON store, midnight rotation helpers
we_connect.py — CarConnectivity login and vehicle selection we_connect.py — CarConnectivity login and vehicle selection
log_config.py — logging setup log_config.py — logging setup
utils/ requirements.txt — server dependencies
jay_diff.pyJSON diff / merge library install.sh systemd user service installer (server only)
client/
gui_client.py — PyQt5 GUI client
client.py — minimal CLI test client
requirements.txt — client dependencies
install.sh — client installer (creates venv + run.sh launcher)
credentials/ — JSON config files (gitignored) credentials/ — JSON config files (gitignored)
requirements.txt
``` ```
## Requirements ## Server
``` ### Install (systemd user service)
pip install carconnectivity carconnectivity-connector-volkswagen PyQt5 pyqtgraph
```
Or use the installer which also sets up a systemd user service:
```bash ```bash
cd server
chmod +x install.sh
./install.sh ./install.sh
# then edit ~/.config/we_monitor/credentials.json
systemctl --user start we_monitor
``` ```
--- ### Manual start
## Collector (`collect.py`)
Connects via CarConnectivity, polls selected domains at a configurable interval, and
appends timestamped records to a rotating JSON log. Simultaneously runs a TCP
push server so clients receive every new snapshot the moment it is collected.
### Quick start
```bash ```bash
python collect.py -c credentials/my_car.json pip install -r server/requirements.txt
python -m server.main -c credentials/my_car.json
``` ```
Username and password can also be supplied via environment variables Username and password can also be supplied via environment variables
@@ -54,7 +50,7 @@ Username and password can also be supplied via environment variables
```bash ```bash
export WC_USER=me@example.com export WC_USER=me@example.com
export WC_PASS=secret export WC_PASS=secret
python collect.py -c credentials/my_car.json python -m server.main -c credentials/my_car.json
``` ```
### Credentials file ### Credentials file
@@ -166,27 +162,47 @@ The first message sent to a new client is always `jay_diff_full({}, record[0])`,
i.e. an `update_add` containing every field of the first record. Subsequent i.e. an `update_add` containing every field of the first record. Subsequent
messages contain only the fields that changed. messages contain only the fields that changed.
Enable `--diff` on the server **and** on the client (`client.py --diff` or the Enable `--diff` on the server **and** on the client (`client/client.py --diff` or the
**Diff mode** checkbox in the GUI Connector tab). Both sides must agree — a **Diff mode** checkbox in the GUI Connector tab). Both sides must agree — a
mismatch produces garbled output. mismatch produces garbled output.
--- ---
## GUI client (`gui_client.py`) ## Client
### Install
```bash ```bash
python gui_client.py cd client
chmod +x install.sh
./install.sh
```
This creates `client/.venv`, writes a `client/run.sh` launcher, and installs
`~/.local/bin/we_monitor_gui` so the GUI is available system-wide.
### Manual start
```bash
pip install -r client/requirements.txt
python client/gui_client.py
```
### GUI client (`client/gui_client.py`)
```bash
we_monitor_gui # after install.sh
``` ```
Dark-theme PyQt5 application. Three tabs: **Connector**, **Dashboard**, Dark-theme PyQt5 application. Three tabs: **Connector**, **Dashboard**,
**Plot**. Connection settings, trace selections, and time windows are persisted **Plot**. Connection settings, trace selections, and time windows are persisted
to `~/.config/we_monitor/plot_settings.json` and restored on the next launch. to `~/.config/we_monitor/plot_settings.json` and restored on the next launch.
### Connector tab #### Connector tab
| Control | Description | | Control | Description |
|---------|-------------| |---------|-------------|
| **Host / Port** | Address of the `collect.py` push server | | **Host / Port** | Address of the push server |
| **Diff mode** checkbox | Must match the server's `--diff` setting | | **Diff mode** checkbox | Must match the server's `--diff` setting |
| **Connect / Disconnect** | Opens or closes the TCP connection | | **Connect / Disconnect** | Opens or closes the TCP connection |
| **Status** | Shows `Connected`, `Disconnected`, or the error message | | **Status** | Shows `Connected`, `Disconnected`, or the error message |
@@ -195,18 +211,18 @@ Clicking **Connect** saves the current host, port, and diff-mode setting. All
plot data is cleared on each connect so the fresh server history is never mixed plot data is cleared on each connect so the fresh server history is never mixed
with stale local data. with stale local data.
### Dashboard tab #### Dashboard tab
Live tree view of the most recent snapshot. The hierarchy mirrors the JSON Live tree view of the most recent snapshot. The hierarchy mirrors the JSON
structure: domain → status object → field, with value and unit shown inline. structure: domain → status object → field, with value and unit shown inline.
Expand / collapse state is preserved across updates. Expand / collapse state is preserved across updates.
### Plot tab #### Plot tab
Eight plots arranged in a 4 × 2 scrollable grid. Each plot is independent and Eight plots arranged in a 4 × 2 scrollable grid. Each plot is independent and
has its own control bar. has its own control bar.
#### Control bar (per plot) **Control bar (per plot)**
| Control | Description | | Control | Description |
|---------|-------------| |---------|-------------|
@@ -216,7 +232,7 @@ has its own control bar.
| **Time window spinner** | Show the last 124 hours of data (per plot) | | **Time window spinner** | Show the last 124 hours of data (per plot) |
| **Date label** (right-aligned) | Shows the calendar date(s) of the visible window — single date or `YYYY-MM-DD YYYY-MM-DD` when the view spans midnight | | **Date label** (right-aligned) | Shows the calendar date(s) of the visible window — single date or `YYYY-MM-DD YYYY-MM-DD` when the view spans midnight |
#### Plot area **Plot area**
* **X axis** — time of day in `HH:MM:SS` format; no locale-dependent date prefix. * **X axis** — time of day in `HH:MM:SS` format; no locale-dependent date prefix.
* **Y axis** — auto-zooms to fit the visible data on the first draw after connect * **Y axis** — auto-zooms to fit the visible data on the first draw after connect
@@ -227,7 +243,7 @@ has its own control bar.
sampled value for every active trace, with a colored bullet and unit. sampled value for every active trace, with a colored bullet and unit.
* **Legend** — trace names and colors, shown inside the plot area. * **Legend** — trace names and colors, shown inside the plot area.
#### Mouse interaction **Mouse interaction**
| Action | Effect | | Action | Effect |
|--------|--------| |--------|--------|
@@ -236,13 +252,11 @@ has its own control bar.
| Click + drag | Pan the view | | Click + drag | Pan the view |
| Right-click | pyqtgraph context menu (export, auto-range, …) | | Right-click | pyqtgraph context menu (export, auto-range, …) |
--- ### CLI test client (`client/client.py`)
## CLI test client (`client.py`)
```bash ```bash
python client.py --host 192.168.1.10 --port 9999 python client/client.py --host 192.168.1.10 --port 9999
python client.py --diff # reconstruct from diff stream python client/client.py --diff # reconstruct from diff stream
``` ```
Connects to the push server and prints every incoming snapshot as Connects to the push server and prints every incoming snapshot as
@@ -258,11 +272,15 @@ pretty-printed JSON with a running record counter.
## Module reference ## Module reference
### `collect.py` ### `server/main.py`
Entry point for the collector daemon. Parses CLI / credentials-file arguments, Entry point for the collector daemon. Parses CLI / credentials-file arguments
establishes the CarConnectivity session, starts the push server, and runs the main and calls `run()`.
poll loop.
### `server/collect.py`
`run(args)` — establishes the CarConnectivity session, starts the push server,
and runs the main poll loop.
### `server/data_model.py` ### `server/data_model.py`
@@ -315,22 +333,7 @@ Authenticates and returns a connected `CarConnectivity` instance.
`select_vehicle(wc, vin) → (vehicle, error)` `select_vehicle(wc, vin) → (vehicle, error)`
Returns the target vehicle (first available if `vin` is empty) or an error string. Returns the target vehicle (first available if `vin` is empty) or an error string.
### `utils/jay_diff.py` ### `client/gui_client.py` — class overview
Lightweight JSON diff / patch library.
`jay_diff_full(old, new, combine_upd_add=True) → dict`
Computes a diff between two dicts. With `combine_upd_add=True` (the default
used everywhere in this project) the result uses `update_add` for all additions
and updates, plus a separate `delete` key. Returns `{}` when nothing changed.
`jay_merge_full(old, diff) → dict`
Applies a `jay_diff_full` diff to `old`. The diff structure is self-describing:
if `diff` contains none of the known diff keys (`update`, `update_add`, `add`,
`delete`) and `old` is empty, `diff` is returned as-is (full-snapshot
passthrough for bootstrap).
### `gui_client.py` — class overview
| Class | Role | | Class | Role |
|-------|------| |-------|------|
View File
+12 -8
View File
@@ -95,16 +95,16 @@ _SETTINGS_PATH = Path.home() / ".config" / "we_monitor" / "plot_settings.json"
# cursor readout. Falls back to the last path component for unknown keys. # cursor readout. Falls back to the last path component for unknown keys.
_TRACE_LABELS: dict[str, str] = { _TRACE_LABELS: dict[str, str] = {
"charging.power": "charge power", "charging.power": "charge power",
"charging.remain": "time to full", "charging.remain": "charge time remain",
"charging.settings.target_level": "target SOC", "charging.settings.target_level": "battery SOC set",
"climatisation.remain": "clim. time", "climatisation.remain": "clima time remain",
"climatisation.settings.target_temperature": "target temp", "climatisation.settings.target_temperature": "clima temperature set",
"climatisation.environment.temperature_outside": "outside temp", "climatisation.environment.temperature_outside": "outside temperature",
"electric_drive.battery.soc": "SOC", "electric_drive.battery.soc": "battery soc",
"electric_drive.range": "range", "electric_drive.range": "range",
"electric_drive.range_full": "range (full)", "electric_drive.range_full": "range (full)",
"electric_drive.battery.temperature_max": "batt. temp max", "electric_drive.battery.temperature_max": "battery temperature (max)",
"electric_drive.battery.temperature_min": "batt. temp min", "electric_drive.battery.temperature_min": "battery temperature (min)",
"electric_drive.odometer.odometer": "odometer", "electric_drive.odometer.odometer": "odometer",
"procedural.range_at_100": "range 100%", "procedural.range_at_100": "range 100%",
} }
@@ -342,6 +342,10 @@ class DashboardTab(QWidget):
obj_item.setFont(0, bold) obj_item.setFont(0, bold)
self._add_fields(obj_item, fields, f"{domain}/{obj_name}") self._add_fields(obj_item, fields, f"{domain}/{obj_name}")
d_item.addChild(obj_item) d_item.addChild(obj_item)
else:
item = QTreeWidgetItem([obj_name, str(fields), ""])
item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
d_item.addChild(item)
self._tree.addTopLevelItem(d_item) self._tree.addTopLevelItem(d_item)
self._tree.expandAll() self._tree.expandAll()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# CLIENT installer — sets up the we_monitor GUI client.
# Does NOT install the server (see server/install.sh).
#
# Fresh install:
# chmod +x install.sh
# ./install.sh
#
# Update (code already pulled via git):
# ./install.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
VENV="$SCRIPT_DIR/.venv"
PYTHON="$VENV/bin/python"
IS_UPDATE=false
if [[ -x "$PYTHON" ]]; then
IS_UPDATE=true
fi
if $IS_UPDATE; then
echo "==> we_monitor GUI updater"
else
echo "==> we_monitor GUI installer"
fi
echo " Project : $REPO_DIR"
echo " User : $USER"
echo ""
# ── 1. virtual environment ────────────────────────────────────────────────────
if [[ ! -x "$PYTHON" ]]; then
echo "==> Creating virtual environment : $VENV"
python3 -m venv "$VENV"
else
echo "==> Virtual environment exists : $VENV"
fi
echo "==> Installing / updating dependencies"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet --upgrade -r "$SCRIPT_DIR/requirements.txt"
echo " Python : $PYTHON"
echo ""
# ── 2. launcher script ────────────────────────────────────────────────────────
LAUNCHER="$SCRIPT_DIR/run.sh"
cat > "$LAUNCHER" <<EOF
#!/usr/bin/env bash
exec "$PYTHON" "$SCRIPT_DIR/gui_client.py" "\$@"
EOF
chmod +x "$LAUNCHER"
echo "==> Launcher written : $LAUNCHER"
# ── 3. system command ─────────────────────────────────────────────────────────
BIN_DIR="$HOME/.local/bin"
CMD="$BIN_DIR/we_monitor_gui"
mkdir -p "$BIN_DIR"
cat > "$CMD" <<EOF
#!/usr/bin/env bash
exec "$PYTHON" "$SCRIPT_DIR/gui_client.py" "\$@"
EOF
chmod +x "$CMD"
echo "==> System command : $CMD"
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo " Note: $BIN_DIR is not in your PATH."
echo " Add to ~/.bashrc or ~/.profile: export PATH=\"\$HOME/.local/bin:\$PATH\""
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if $IS_UPDATE; then
echo " Update complete."
else
echo " Installation complete."
echo ""
echo " Start the GUI:"
echo " we_monitor_gui"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+1 -4
View File
@@ -1,6 +1,3 @@
carconnectivity
carconnectivity-connector-volkswagen
jaydiff @ git+http://192.168.22.90:3001/jayfield/jaypy.git
PyQt5 PyQt5
pyqtgraph pyqtgraph
pytest jaydiff @ git+http://192.168.22.90:3001/jayfield/jaypy.git
+109
View File
@@ -0,0 +1,109 @@
import argparse
import logging
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from . import log_config, network, we_connect
from .data_model import ALL_DOMAINS, collect_snapshot, apply_procedural
from .storage_helpers import auto_out_path, load_last_24h_records, load_store, save_store
from jaydiff.diff import diff_full as jay_diff_full
from volkswagencarnet.vw_exceptions import AuthenticationError
log = logging.getLogger(__name__)
def run(args: argparse.Namespace) -> None:
log_config.setup(args.verbose)
raw = [d.strip() for d in args.domains.split(",")]
domains = list(ALL_DOMAINS) if raw == ["all"] else raw
unknown = [d for d in domains if d not in ALL_DOMAINS]
if unknown:
log.error("Unknown domain(s): %s. Available: %s", unknown, list(ALL_DOMAINS))
sys.exit(1)
if args.dry:
log.info("Dry-run mode — skipping volkswagencarnet login")
wc = None
vehicle = None
vin = args.vin or "UNKNOWN"
else:
log.info("Connecting via volkswagencarnet…")
wc = we_connect.connect(args.username, args.password)
vehicle, err = we_connect.select_vehicle(wc, args.vin)
if err:
log.error(err)
sys.exit(1)
vin = vehicle.vin
log.info("Vehicle: %s", vin)
logs_dir = Path(args.log_dir)
logs_dir.mkdir(parents=True, exist_ok=True)
out = auto_out_path(logs_dir, vin)
push_clients, push_lock, push_store_ref = network.start_push_server(args.host, args.port)
push_store_ref["diff_mode"] = args.diff
push_store_ref["preloaded"] = load_last_24h_records(logs_dir, vin)
for _r in push_store_ref["preloaded"]:
if "procedural" not in _r:
apply_procedural(_r)
if push_store_ref["preloaded"]:
log.info("Pre-loaded %d record(s) from the last 24 h", len(push_store_ref["preloaded"]))
current_day = datetime.now(timezone.utc).date()
store = load_store(out, vin, args.interval)
for _r in store["records"]:
if "procedural" not in _r:
apply_procedural(_r)
push_store_ref["current"] = store
last_broadcast: dict = {}
if args.dry:
log.info("Push server running on %s:%d — no live collection (Ctrl-C to stop)", args.host, args.port)
else:
log.info(
"Collecting [%s] every %ds → %s (Ctrl-C to stop)",
", ".join(domains),
args.interval,
out,
)
try:
while True:
if not args.dry:
today = datetime.now(timezone.utc).date()
if today != current_day:
out = auto_out_path(logs_dir, vin)
store = load_store(out, vin, args.interval)
push_store_ref["current"] = store
current_day = today
log.info("Midnight UTC rotation → %s", out)
try:
we_connect.update(wc)
snapshot = collect_snapshot(vehicle, domains)
apply_procedural(snapshot)
store["records"].append(snapshot)
save_store(out, store, args.max_records)
if args.diff and last_broadcast:
payload = jay_diff_full(last_broadcast, snapshot, combine_upd_add=True)
else:
payload = snapshot
network.broadcast(payload, push_clients, push_lock)
last_broadcast = snapshot
log.info("Record #%d saved at %s", len(store["records"]), snapshot["ts"])
except KeyboardInterrupt:
raise
except AuthenticationError:
log.warning("Authentication error — will retry at next interval")
except Exception:
log.exception("Collection failed — will retry at next interval")
time.sleep(args.interval)
except KeyboardInterrupt:
log.info("Stopped by user. %d records in %s", len(store["records"]), out)
+181 -97
View File
@@ -2,68 +2,83 @@ import logging
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from volkswagencarnet.vw_const import Paths
from volkswagencarnet.vw_utilities import find_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# ── value helpers ───────────────────────────────────────────────────────────── # ── value helpers ─────────────────────────────────────────────────────────────
def _str(attr) -> str:
"""Return the string value of a CarConnectivity attribute."""
return str(attr)
def _phys(value, unit: str) -> dict: def _phys(value, unit: str) -> dict:
return {"value": value, "unit": unit} return {"value": value, "unit": unit}
def _remaining_min(estimated_date_reached_attr) -> float | None:
"""Compute remaining minutes from an estimated_date_reached DateAttribute."""
edr = estimated_date_reached_attr.value
if edr is None:
return None
return max(0.0, (edr - datetime.now(timezone.utc)).total_seconds() / 60)
# ── domain extractors ───────────────────────────────────────────────────────── # ── domain extractors ─────────────────────────────────────────────────────────
def extract_charging(vehicle) -> dict | None: def extract_charging(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
ch = vehicle.charging result["state"] = vehicle.charging_state
result["state"] = _str(ch.state)
result["type"] = _str(ch.type)
try:
result["power"] = _phys(ch.power.value, "kW")
except Exception: except Exception:
pass log.debug("charging.state unavailable", exc_info=True)
try: try:
rem = _remaining_min(ch.estimated_date_reached) if vehicle.is_charger_type_supported:
if rem is not None: result["type"] = vehicle.charger_type
result["remain"] = _phys(round(rem), "min")
except Exception: except Exception:
pass pass
try:
power = vehicle.charging_power
if power is not None:
result["power"] = _phys(power, "kW")
except Exception: except Exception:
log.debug("vehicle.charging unavailable", exc_info=True) log.debug("charging.power unavailable", exc_info=True)
try: try:
cfg = vehicle.charging.settings remain = vehicle.charging_time_left
result["settings"] = { if remain is not None:
"target_level": _phys(cfg.target_level.value, "%"), result["remain"] = _phys(remain, "min")
"maximum_current": _phys(cfg.maximum_current.value, "A"),
"auto_unlock": _str(cfg.auto_unlock),
}
except Exception: except Exception:
log.debug("vehicle.charging.settings unavailable", exc_info=True) log.debug("charging.remain unavailable", exc_info=True)
settings: dict = {}
try:
target = vehicle.battery_target_charge_level
if target is not None:
settings["target_level"] = _phys(target, "%")
except Exception:
pass
try:
max_a = vehicle.charge_max_ac_ampere
if max_a is not None:
settings["maximum_current"] = _phys(max_a, "A")
except Exception:
pass
try:
auto_unlock = vehicle.auto_release_ac_connector_state
if auto_unlock is not None:
settings["auto_unlock"] = auto_unlock
except Exception:
pass
if settings:
result["settings"] = settings
try: try:
conn = vehicle.charging.connector plug: dict = {}
result["plugStatus"] = { conn_state = find_path(vehicle.attrs, Paths.PLUG_CONN)
"connection_state": _str(conn.connection_state), if conn_state is not None:
"lock_state": _str(conn.lock_state), plug["connection_state"] = conn_state
"external_power": _str(conn.external_power), lock_state = find_path(vehicle.attrs, Paths.PLUG_LOCK)
} if lock_state is not None:
plug["lock_state"] = lock_state
ext_pwr = find_path(vehicle.attrs, Paths.PLUG_EXT_PWR)
if ext_pwr is not None:
plug["external_power"] = ext_pwr
if plug:
result["plugStatus"] = plug
except Exception: except Exception:
log.debug("plugStatus unavailable", exc_info=True) log.debug("plugStatus unavailable", exc_info=True)
@@ -74,40 +89,39 @@ def extract_climatisation(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
cl = vehicle.climatization state = vehicle.climatisation_state
result["state"] = _str(cl.state) if state is not None:
result["state"] = state
except Exception:
log.debug("climatisation.state unavailable", exc_info=True)
try: try:
rem = _remaining_min(cl.estimated_date_reached) remain = vehicle.electric_remaining_climatisation_time
if rem is not None: if remain is not None:
result["remain"] = _phys(round(rem), "min") result["remain"] = _phys(remain, "min")
except Exception: except Exception:
pass pass
except Exception:
log.debug("vehicle.climatization unavailable", exc_info=True)
try: try:
cs = vehicle.climatization.settings target = vehicle.climatisation_target_temperature
result["settings"] = { if target is not None:
"target_temperature": _phys(cs.target_temperature.value, "degC"), result["settings"] = {"target_temperature": _phys(target, "degC")}
}
except Exception: except Exception:
log.debug("climatization.settings unavailable", exc_info=True) log.debug("climatisation.settings unavailable", exc_info=True)
try: try:
wh = vehicle.window_heatings window_status = find_path(vehicle.attrs, Paths.CLIMATISATION_WINDOW_HEATING_STATUS)
result["window_heatings"] = { if window_status:
name: _str(win.heating_state) heatings: dict = {}
for name, win in wh.heatings.items() for entry in window_status:
} loc = entry.get("windowLocation")
state = entry.get("windowHeatingState")
if loc and state is not None:
heatings[loc] = state
if heatings:
result["window_heatings"] = heatings
except Exception: except Exception:
log.debug("vehicle.window_heatings unavailable", exc_info=True) log.debug("window_heatings unavailable", exc_info=True)
try:
result["environment"] = {
"temperature_outside": _phys(vehicle.outside_temperature.value, "degC"),
}
except Exception:
log.debug("vehicle.outside_temperature unavailable", exc_info=True)
return result or None return result or None
@@ -116,23 +130,45 @@ def extract_electric_drive(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
ed = vehicle.get_electric_drive() rng = vehicle.electric_range
result["range"] = _phys(ed.range.value, "km") if rng is not None:
result["range_full"] = _phys(round(float(ed.range_estimated_full.value), 1), "km") result["range"] = _phys(rng, "km")
try:
bat = ed.battery
result["battery"] = {
"soc": _phys(ed.level.value, "%"),
"temperature_max": _phys(round(float(bat.temperature_max.value) - 273.15, 1), "degC"),
"temperature_min": _phys(round(float(bat.temperature_min.value) - 273.15, 1), "degC"),
}
except Exception: except Exception:
log.debug("electric_drive/battery unavailable", exc_info=True) log.debug("electric_range unavailable", exc_info=True)
except Exception:
log.debug("electric_drive unavailable", exc_info=True)
try: try:
result["odometer"] = {"odometer": _phys(vehicle.odometer.value, "km")} rng_full = vehicle.battery_cruising_range
if rng_full is not None:
result["range_full"] = _phys(round(float(rng_full), 1), "km")
except Exception:
log.debug("battery_cruising_range unavailable", exc_info=True)
battery: dict = {}
try:
soc = vehicle.battery_level
if soc is not None:
battery["soc"] = _phys(soc, "%")
except Exception:
log.debug("battery.soc unavailable", exc_info=True)
try:
tmax = vehicle.hv_battery_max_temperature
if tmax is not None:
battery["temperature_max"] = _phys(round(tmax, 1), "degC")
except Exception:
pass
try:
tmin = vehicle.hv_battery_min_temperature
if tmin is not None:
battery["temperature_min"] = _phys(round(tmin, 1), "degC")
except Exception:
pass
if battery:
result["battery"] = battery
try:
odo = vehicle.distance
if odo is not None:
result["odometer"] = {"odometer": _phys(odo, "km")}
except Exception: except Exception:
log.debug("odometer unavailable", exc_info=True) log.debug("odometer unavailable", exc_info=True)
@@ -143,32 +179,43 @@ def extract_connectivity(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
state = str(vehicle.connection_state) is_online = vehicle.connection_state_is_online
result["state"] = state is_active = vehicle.connection_state_is_active
if is_online:
result["state"] = "online"
elif is_active:
result["state"] = "active"
else:
result["state"] = "offline"
except Exception: except Exception:
log.debug("connectivity.state unavailable", exc_info=True) log.debug("connectivity.state unavailable", exc_info=True)
return result or None return result or None
def extract_vehicle(vehicle) -> dict | None: def extract_vehicle(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
state = str(vehicle.state) overall = find_path(vehicle.attrs, Paths.ACCESS_OVERALL_STATUS)
result["state"] = state if overall is not None:
result["state"] = overall
except Exception: except Exception:
log.debug("vehicle.state unavailable", exc_info=True) log.debug("vehicle.state unavailable", exc_info=True)
return result or None return result or None
def extract_position(vehicle) -> dict | None: def extract_position(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
pos = vehicle.position pos = vehicle.position
result["lat"] = pos.latitude.value lat = pos.get("lat")
result["lon"] = pos.longitude.value lng = pos.get("lng")
if lat is not None and lat != "?" and lng is not None and lng != "?":
result["lat"] = lat
result["lon"] = lng # volkswagencarnet uses "lng" internally; we keep "lon" for compat
except Exception: except Exception:
log.debug("position unavailable", exc_info=True) log.debug("position unavailable", exc_info=True)
@@ -179,27 +226,63 @@ def extract_doors(vehicle) -> dict | None:
result: dict = {} result: dict = {}
try: try:
doors = vehicle.doors lock_state = find_path(vehicle.attrs, Paths.ACCESS_DOOR_LOCK)
result["doors"] = { if lock_state is not None:
"overallState": _str(doors.lock_state), result["lock_state"] = lock_state
"doors": {
name: { doors_list = find_path(vehicle.attrs, Paths.ACCESS_DOORS) or []
"lockState": _str(door.lock_state), door_entries: dict = {}
"openState": _str(door.open_state), all_closed: bool | None = None
} for door in doors_list:
for name, door in doors.doors.items() name = door.get("name")
}, status = door.get("status") or []
"windows": { if not name:
name: {"openState": _str(win.open_state)} continue
for name, win in vehicle.windows.windows.items() entry: dict = {}
}, if "closed" in status or "open" in status:
} open_state = "closed" if "closed" in status else "open"
entry["open_state"] = open_state
all_closed = open_state == "closed" if all_closed is None else (all_closed and open_state == "closed")
if "locked" in status or "unlocked" in status:
entry["lock_state"] = "locked" if "locked" in status else "unlocked"
if entry:
door_entries[name] = entry
if all_closed is not None:
result["open_state"] = "closed" if all_closed else "open"
if door_entries:
result["doors"] = door_entries
except Exception: except Exception:
log.debug("vehicle.doors unavailable", exc_info=True) log.debug("vehicle.doors unavailable", exc_info=True)
return result or None return result or None
def extract_windows(vehicle) -> dict | None:
result: dict = {}
try:
windows_list = find_path(vehicle.attrs, Paths.ACCESS_WINDOWS) or []
window_entries: dict = {}
all_closed: bool | None = None
for win in windows_list:
name = win.get("name")
status = win.get("status") or []
if not name or "unsupported" in status:
continue
if "closed" in status or "open" in status:
open_state = "closed" if "closed" in status else "open"
window_entries[name] = {"open_state": open_state}
all_closed = open_state == "closed" if all_closed is None else (all_closed and open_state == "closed")
if all_closed is not None:
result["open_state"] = "closed" if all_closed else "open"
if window_entries:
result["windows"] = window_entries
except Exception:
log.debug("vehicle.windows unavailable", exc_info=True)
return result or None
ALL_DOMAINS: dict[str, Callable] = { ALL_DOMAINS: dict[str, Callable] = {
"charging": extract_charging, "charging": extract_charging,
"climatisation": extract_climatisation, "climatisation": extract_climatisation,
@@ -208,6 +291,7 @@ ALL_DOMAINS: dict[str, Callable] = {
"vehicle": extract_vehicle, "vehicle": extract_vehicle,
"position": extract_position, "position": extract_position,
"doors": extract_doors, "doors": extract_doors,
"windows": extract_windows,
} }
+7 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install or update we_monitor as a systemd user service. # SERVER installer — installs the we_monitor collector as a systemd user service.
# Does NOT install the GUI client (see client/requirements.txt).
# #
# Fresh install: # Fresh install:
# chmod +x install.sh # chmod +x install.sh
@@ -14,12 +15,13 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
SERVICE_NAME="we_monitor" SERVICE_NAME="we_monitor"
CREDS_DIR="$HOME/.config/we_monitor" CREDS_DIR="$HOME/.config/we_monitor"
CREDS_FILE="$CREDS_DIR/credentials.json" CREDS_FILE="$CREDS_DIR/credentials.json"
SYSTEMD_DIR="$HOME/.config/systemd/user" SYSTEMD_DIR="$HOME/.config/systemd/user"
SERVICE_FILE="$SYSTEMD_DIR/$SERVICE_NAME.service" SERVICE_FILE="$SYSTEMD_DIR/$SERVICE_NAME.service"
LOG_DIR="$SCRIPT_DIR/logs" LOG_DIR="$REPO_DIR/logs"
VENV="$SCRIPT_DIR/.venv" VENV="$SCRIPT_DIR/.venv"
PYTHON="$VENV/bin/python" PYTHON="$VENV/bin/python"
@@ -39,7 +41,7 @@ if $IS_UPDATE; then
else else
echo "==> we_monitor installer" echo "==> we_monitor installer"
fi fi
echo " Project : $SCRIPT_DIR" echo " Project : $REPO_DIR"
echo " User : $USER" echo " User : $USER"
echo "" echo ""
@@ -99,8 +101,8 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=$SCRIPT_DIR WorkingDirectory=$REPO_DIR
ExecStart=$PYTHON $SCRIPT_DIR/collect.py -c $CREDS_FILE ExecStart=$PYTHON -m server.main -c $CREDS_FILE
Restart=on-failure Restart=on-failure
RestartSec=60 RestartSec=60
StandardOutput=journal StandardOutput=journal
+6 -113
View File
@@ -9,16 +9,16 @@ the domain hierarchy: domain → status-object → field.
Usage examples Usage examples
-------------- --------------
# Credentials file with all settings: # Credentials file with all settings:
python collect.py -c credentials/alex.json python -m server.main -c credentials/alex.json
# Override username/password via env vars: # Override username/password via env vars:
export WC_USER=me@example.com WC_PASS=secret export WC_USER=me@example.com WC_PASS=secret
python collect.py -c credentials/alex.json python -m server.main -c credentials/alex.json
# Override domains on the CLI: # Override domains on the CLI:
python collect.py -c credentials/alex.json -d charging,measurements python -m server.main -c credentials/alex.json -d charging,measurements
Available domains: charging, climatisation, electric_drive, connectivity, vehicle, position, doors Available domains: charging, climatisation, electric_drive, connectivity, vehicle, position, doors, windows
Credentials file format (JSON) Credentials file format (JSON)
------------------------------- -------------------------------
@@ -45,119 +45,13 @@ Connect with: nc localhost 9999 or any TCP client that reads lines.
import argparse import argparse
import json import json
import logging
import os import os
import sys import sys
import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from server import log_config, network, we_connect from server.collect import run
from carconnectivity.errors import AuthenticationError, TemporaryAuthenticationError from server.data_model import ALL_DOMAINS
from server.data_model import ALL_DOMAINS, collect_snapshot, apply_procedural
from server.storage_helpers import auto_out_path, load_last_24h_records, load_store, save_store
from jaydiff.diff import diff_full as jay_diff_full
log = logging.getLogger(__name__)
# ── main loop ────────────────────────────────────────────────────────────────
def run(args: argparse.Namespace) -> None:
log_config.setup(args.verbose)
raw = [d.strip() for d in args.domains.split(",")]
domains = list(ALL_DOMAINS) if raw == ["all"] else raw
unknown = [d for d in domains if d not in ALL_DOMAINS]
if unknown:
log.error("Unknown domain(s): %s. Available: %s", unknown, list(ALL_DOMAINS))
sys.exit(1)
if args.dry:
log.info("Dry-run mode — skipping CarConnectivity login")
wc = None
vehicle = None
vin = args.vin or "UNKNOWN"
else:
log.info("Connecting via CarConnectivity…")
wc = we_connect.connect(args.username, args.password)
vehicle, err = we_connect.select_vehicle(wc, args.vin)
if err:
log.error(err)
sys.exit(1)
vin = vehicle.vin.value
log.info("Vehicle: %s", vin)
logs_dir = Path(args.log_dir)
logs_dir.mkdir(parents=True, exist_ok=True)
out = auto_out_path(logs_dir, vin)
push_clients, push_lock, push_store_ref = network.start_push_server(args.host, args.port)
push_store_ref["diff_mode"] = args.diff
push_store_ref["preloaded"] = load_last_24h_records(logs_dir, vin)
for _r in push_store_ref["preloaded"]:
if "procedural" not in _r:
apply_procedural(_r)
if push_store_ref["preloaded"]:
log.info("Pre-loaded %d record(s) from the last 24 h", len(push_store_ref["preloaded"]))
current_day = datetime.now(timezone.utc).date()
store = load_store(out, vin, args.interval)
for _r in store["records"]:
if "procedural" not in _r:
apply_procedural(_r)
push_store_ref["current"] = store
last_broadcast: dict = {}
if args.dry:
log.info("Push server running on %s:%d — no live collection (Ctrl-C to stop)", args.host, args.port)
else:
log.info(
"Collecting [%s] every %ds → %s (Ctrl-C to stop)",
", ".join(domains),
args.interval,
out,
)
try:
while True:
if not args.dry:
today = datetime.now(timezone.utc).date()
if today != current_day:
out = auto_out_path(logs_dir, vin)
store = load_store(out, vin, args.interval)
push_store_ref["current"] = store
current_day = today
log.info("Midnight UTC rotation → %s", out)
try:
we_connect.update(wc)
snapshot = collect_snapshot(vehicle, domains)
apply_procedural(snapshot)
store["records"].append(snapshot)
save_store(out, store, args.max_records)
if args.diff and last_broadcast:
payload = jay_diff_full(last_broadcast, snapshot, combine_upd_add=True)
else:
payload = snapshot
network.broadcast(payload, push_clients, push_lock)
last_broadcast = snapshot
log.info("Record #%d saved at %s", len(store["records"]), snapshot["ts"])
except KeyboardInterrupt:
raise
except (AuthenticationError, TemporaryAuthenticationError):
log.warning("Authentication error — will retry at next interval")
except Exception:
log.exception("Collection failed — will retry at next interval")
time.sleep(args.interval)
except KeyboardInterrupt:
log.info("Stopped by user. %d records in %s", len(store["records"]), out)
# ── CLI ──────────────────────────────────────────────────────────────────────
def main() -> None: def main() -> None:
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
@@ -259,7 +153,6 @@ def main() -> None:
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
p.error(f"Invalid JSON in credentials file: {exc}") p.error(f"Invalid JSON in credentials file: {exc}")
# username / password: env / CLI flag > credentials.username > credentials file root
nested = creds_data.get("credentials", {}) nested = creds_data.get("credentials", {})
if args.username is None: if args.username is None:
args.username = nested.get("username") or creds_data.get("username") args.username = nested.get("username") or creds_data.get("username")
+2
View File
@@ -0,0 +1,2 @@
volkswagencarnet @ git+file:///home/jens/work/repos/volkswagencarnet
jaydiff @ git+http://192.168.22.90:3001/jayfield/jaypy.git
+47 -35
View File
@@ -1,52 +1,64 @@
import asyncio
import logging import logging
import sys
try: import aiohttp
from carconnectivity.carconnectivity import CarConnectivity from volkswagencarnet.vw_connection import Connection
except ImportError: from volkswagencarnet.vw_exceptions import AuthenticationError # noqa: F401 — re-exported for collect.py
print(
"carconnectivity is not installed. Run: "
"pip install carconnectivity carconnectivity-connector-volkswagen",
file=sys.stderr,
)
sys.exit(1)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def connect(username: str, password: str) -> CarConnectivity: class _Session:
config = { """Holds the asyncio event loop, aiohttp session, and Connection together."""
"carConnectivity": {
"connectors": [ def __init__(
{ self,
"type": "volkswagen", loop: asyncio.AbstractEventLoop,
"config": { session: aiohttp.ClientSession,
"username": username, connection: Connection,
"password": password, ) -> None:
}, self._loop = loop
} self._http_session = session
] self.connection = connection
}
} def run(self, coro):
cc = CarConnectivity(config=config) return self._loop.run_until_complete(coro)
cc.startup()
cc.fetch_all() @property
return cc def vehicles(self):
return self.connection.vehicles
def select_vehicle(cc: CarConnectivity, vin: str | None): def connect(username: str, password: str) -> _Session:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def _do():
session = aiohttp.ClientSession(headers={"Connection": "keep-alive"})
conn = Connection(session, username, password)
ok = await conn.doLogin()
if not ok:
await session.close()
raise AuthenticationError("Login failed")
return session, conn
session, conn = loop.run_until_complete(_do())
return _Session(loop, session, conn)
def select_vehicle(wc: _Session, vin: str | None):
"""Return (vehicle, error_message). error_message is None on success.""" """Return (vehicle, error_message). error_message is None on success."""
vehicles = list(cc.get_garage().list_vehicles()) vehicles = wc.vehicles
if not vehicles: if not vehicles:
return None, "No vehicles found in this CarConnectivity account" return None, "No vehicles found in this account"
if vin: if vin:
vehicle = next((v for v in vehicles if v.vin.value == vin), None) vehicle = next((v for v in vehicles if v.vin == vin), None)
if not vehicle: if not vehicle:
available = [v.vin.value for v in vehicles] available = [v.vin for v in vehicles]
return None, f"VIN {vin} not found. Available: {available}" return None, f"VIN {vin} not found. Available: {available}"
return vehicle, None return vehicle, None
return vehicles[0], None return vehicles[0], None
def update(cc: CarConnectivity) -> None: def update(wc: _Session) -> None:
cc.fetch_all() wc.run(wc.connection.update())