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
logs/
__pycache__/
.venv/
client/run.sh
+62 -59
View File
@@ -7,45 +7,41 @@ A PyQt5 GUI client visualises the data in real time.
## Repository layout
```
collect.py — collector daemon (entry point)
gui_client.py — PyQt5 GUI client
client.py — minimal CLI test client
server/
main.py — collector entry point (CLI + credential loading)
collect.py — run() main loop
data_model.py — domain extractors, collect_snapshot()
network.py — TCP push server (accept loop + broadcast)
storage_helpers.py — load / save JSON store, midnight rotation helpers
we_connect.py — CarConnectivity login and vehicle selection
log_config.py — logging setup
utils/
jay_diff.pyJSON diff / merge library
requirements.txt — server dependencies
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)
requirements.txt
```
## Requirements
## Server
```
pip install carconnectivity carconnectivity-connector-volkswagen PyQt5 pyqtgraph
```
Or use the installer which also sets up a systemd user service:
### Install (systemd user service)
```bash
cd server
chmod +x install.sh
./install.sh
# then edit ~/.config/we_monitor/credentials.json
systemctl --user start we_monitor
```
---
## 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
### Manual start
```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
@@ -54,7 +50,7 @@ Username and password can also be supplied via environment variables
```bash
export WC_USER=me@example.com
export WC_PASS=secret
python collect.py -c credentials/my_car.json
python -m server.main -c credentials/my_car.json
```
### 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
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
mismatch produces garbled output.
---
## GUI client (`gui_client.py`)
## Client
### Install
```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**,
**Plot**. Connection settings, trace selections, and time windows are persisted
to `~/.config/we_monitor/plot_settings.json` and restored on the next launch.
### Connector tab
#### Connector tab
| 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 |
| **Connect / Disconnect** | Opens or closes the TCP connection |
| **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
with stale local data.
### Dashboard tab
#### Dashboard tab
Live tree view of the most recent snapshot. The hierarchy mirrors the JSON
structure: domain → status object → field, with value and unit shown inline.
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
has its own control bar.
#### Control bar (per plot)
**Control bar (per plot)**
| Control | Description |
|---------|-------------|
@@ -216,7 +232,7 @@ has its own control bar.
| **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 |
#### Plot area
**Plot area**
* **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
@@ -227,7 +243,7 @@ has its own control bar.
sampled value for every active trace, with a colored bullet and unit.
* **Legend** — trace names and colors, shown inside the plot area.
#### Mouse interaction
**Mouse interaction**
| Action | Effect |
|--------|--------|
@@ -236,13 +252,11 @@ has its own control bar.
| Click + drag | Pan the view |
| Right-click | pyqtgraph context menu (export, auto-range, …) |
---
## CLI test client (`client.py`)
### CLI test client (`client/client.py`)
```bash
python client.py --host 192.168.1.10 --port 9999
python client.py --diff # reconstruct from diff stream
python client/client.py --host 192.168.1.10 --port 9999
python client/client.py --diff # reconstruct from diff stream
```
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
### `collect.py`
### `server/main.py`
Entry point for the collector daemon. Parses CLI / credentials-file arguments,
establishes the CarConnectivity session, starts the push server, and runs the main
poll loop.
Entry point for the collector daemon. Parses CLI / credentials-file arguments
and calls `run()`.
### `server/collect.py`
`run(args)` — establishes the CarConnectivity session, starts the push server,
and runs the main poll loop.
### `server/data_model.py`
@@ -315,22 +333,7 @@ Authenticates and returns a connected `CarConnectivity` instance.
`select_vehicle(wc, vin) → (vehicle, error)`
Returns the target vehicle (first available if `vin` is empty) or an error string.
### `utils/jay_diff.py`
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
### `client/gui_client.py` — class overview
| Class | Role |
|-------|------|
@@ -341,4 +344,4 @@ passthrough for bootstrap).
| `TimeAxisItem` | `pg.AxisItem` subclass; formats x-axis tick labels as `HH:MM:SS` |
| `PlotViewBox` | `pg.ViewBox` subclass; routes plain scroll to X zoom and Shift+scroll to Y zoom |
| `PlotTooltip` | Frameless child `QWidget` per plot; shows nearest sampled values for all active traces at the cursor position |
| `MainWindow` | `QMainWindow`; wires the three tabs together and owns the `TcpReader` |
| `MainWindow` | `QMainWindow`; wires the three tabs together and owns the `TcpReader` |
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.
_TRACE_LABELS: dict[str, str] = {
"charging.power": "charge power",
"charging.remain": "time to full",
"charging.settings.target_level": "target SOC",
"climatisation.remain": "clim. time",
"climatisation.settings.target_temperature": "target temp",
"climatisation.environment.temperature_outside": "outside temp",
"electric_drive.battery.soc": "SOC",
"charging.remain": "charge time remain",
"charging.settings.target_level": "battery SOC set",
"climatisation.remain": "clima time remain",
"climatisation.settings.target_temperature": "clima temperature set",
"climatisation.environment.temperature_outside": "outside temperature",
"electric_drive.battery.soc": "battery soc",
"electric_drive.range": "range",
"electric_drive.range_full": "range (full)",
"electric_drive.battery.temperature_max": "batt. temp max",
"electric_drive.battery.temperature_min": "batt. temp min",
"electric_drive.battery.temperature_max": "battery temperature (max)",
"electric_drive.battery.temperature_min": "battery temperature (min)",
"electric_drive.odometer.odometer": "odometer",
"procedural.range_at_100": "range 100%",
}
@@ -342,6 +342,10 @@ class DashboardTab(QWidget):
obj_item.setFont(0, bold)
self._add_fields(obj_item, fields, f"{domain}/{obj_name}")
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.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
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)
+186 -102
View File
@@ -2,68 +2,83 @@ import logging
from collections.abc import Callable
from datetime import datetime, timezone
from volkswagencarnet.vw_const import Paths
from volkswagencarnet.vw_utilities import find_path
log = logging.getLogger(__name__)
# ── value helpers ─────────────────────────────────────────────────────────────
def _str(attr) -> str:
"""Return the string value of a CarConnectivity attribute."""
return str(attr)
def _phys(value, unit: str) -> dict:
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 ─────────────────────────────────────────────────────────
def extract_charging(vehicle) -> dict | None:
result: dict = {}
try:
ch = vehicle.charging
result["state"] = _str(ch.state)
result["type"] = _str(ch.type)
try:
result["power"] = _phys(ch.power.value, "kW")
except Exception:
pass
try:
rem = _remaining_min(ch.estimated_date_reached)
if rem is not None:
result["remain"] = _phys(round(rem), "min")
except Exception:
pass
result["state"] = vehicle.charging_state
except Exception:
log.debug("vehicle.charging unavailable", exc_info=True)
log.debug("charging.state unavailable", exc_info=True)
try:
cfg = vehicle.charging.settings
result["settings"] = {
"target_level": _phys(cfg.target_level.value, "%"),
"maximum_current": _phys(cfg.maximum_current.value, "A"),
"auto_unlock": _str(cfg.auto_unlock),
}
if vehicle.is_charger_type_supported:
result["type"] = vehicle.charger_type
except Exception:
log.debug("vehicle.charging.settings unavailable", exc_info=True)
pass
try:
conn = vehicle.charging.connector
result["plugStatus"] = {
"connection_state": _str(conn.connection_state),
"lock_state": _str(conn.lock_state),
"external_power": _str(conn.external_power),
}
power = vehicle.charging_power
if power is not None:
result["power"] = _phys(power, "kW")
except Exception:
log.debug("charging.power unavailable", exc_info=True)
try:
remain = vehicle.charging_time_left
if remain is not None:
result["remain"] = _phys(remain, "min")
except Exception:
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:
plug: dict = {}
conn_state = find_path(vehicle.attrs, Paths.PLUG_CONN)
if conn_state is not None:
plug["connection_state"] = conn_state
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:
log.debug("plugStatus unavailable", exc_info=True)
@@ -74,40 +89,39 @@ def extract_climatisation(vehicle) -> dict | None:
result: dict = {}
try:
cl = vehicle.climatization
result["state"] = _str(cl.state)
try:
rem = _remaining_min(cl.estimated_date_reached)
if rem is not None:
result["remain"] = _phys(round(rem), "min")
except Exception:
pass
state = vehicle.climatisation_state
if state is not None:
result["state"] = state
except Exception:
log.debug("vehicle.climatization unavailable", exc_info=True)
log.debug("climatisation.state unavailable", exc_info=True)
try:
cs = vehicle.climatization.settings
result["settings"] = {
"target_temperature": _phys(cs.target_temperature.value, "degC"),
}
remain = vehicle.electric_remaining_climatisation_time
if remain is not None:
result["remain"] = _phys(remain, "min")
except Exception:
log.debug("climatization.settings unavailable", exc_info=True)
pass
try:
wh = vehicle.window_heatings
result["window_heatings"] = {
name: _str(win.heating_state)
for name, win in wh.heatings.items()
}
target = vehicle.climatisation_target_temperature
if target is not None:
result["settings"] = {"target_temperature": _phys(target, "degC")}
except Exception:
log.debug("vehicle.window_heatings unavailable", exc_info=True)
log.debug("climatisation.settings unavailable", exc_info=True)
try:
result["environment"] = {
"temperature_outside": _phys(vehicle.outside_temperature.value, "degC"),
}
window_status = find_path(vehicle.attrs, Paths.CLIMATISATION_WINDOW_HEATING_STATUS)
if window_status:
heatings: dict = {}
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:
log.debug("vehicle.outside_temperature unavailable", exc_info=True)
log.debug("window_heatings unavailable", exc_info=True)
return result or None
@@ -116,23 +130,45 @@ def extract_electric_drive(vehicle) -> dict | None:
result: dict = {}
try:
ed = vehicle.get_electric_drive()
result["range"] = _phys(ed.range.value, "km")
result["range_full"] = _phys(round(float(ed.range_estimated_full.value), 1), "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:
log.debug("electric_drive/battery unavailable", exc_info=True)
rng = vehicle.electric_range
if rng is not None:
result["range"] = _phys(rng, "km")
except Exception:
log.debug("electric_drive unavailable", exc_info=True)
log.debug("electric_range unavailable", exc_info=True)
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:
log.debug("odometer unavailable", exc_info=True)
@@ -143,32 +179,43 @@ def extract_connectivity(vehicle) -> dict | None:
result: dict = {}
try:
state = str(vehicle.connection_state)
result["state"] = state
is_online = vehicle.connection_state_is_online
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:
log.debug("connectivity.state unavailable", exc_info=True)
return result or None
def extract_vehicle(vehicle) -> dict | None:
result: dict = {}
try:
state = str(vehicle.state)
result["state"] = state
overall = find_path(vehicle.attrs, Paths.ACCESS_OVERALL_STATUS)
if overall is not None:
result["state"] = overall
except Exception:
log.debug("vehicle.state unavailable", exc_info=True)
return result or None
def extract_position(vehicle) -> dict | None:
result: dict = {}
try:
pos = vehicle.position
result["lat"] = pos.latitude.value
result["lon"] = pos.longitude.value
lat = pos.get("lat")
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:
log.debug("position unavailable", exc_info=True)
@@ -179,27 +226,63 @@ def extract_doors(vehicle) -> dict | None:
result: dict = {}
try:
doors = vehicle.doors
result["doors"] = {
"overallState": _str(doors.lock_state),
"doors": {
name: {
"lockState": _str(door.lock_state),
"openState": _str(door.open_state),
}
for name, door in doors.doors.items()
},
"windows": {
name: {"openState": _str(win.open_state)}
for name, win in vehicle.windows.windows.items()
},
}
lock_state = find_path(vehicle.attrs, Paths.ACCESS_DOOR_LOCK)
if lock_state is not None:
result["lock_state"] = lock_state
doors_list = find_path(vehicle.attrs, Paths.ACCESS_DOORS) or []
door_entries: dict = {}
all_closed: bool | None = None
for door in doors_list:
name = door.get("name")
status = door.get("status") or []
if not name:
continue
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:
log.debug("vehicle.doors unavailable", exc_info=True)
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] = {
"charging": extract_charging,
"climatisation": extract_climatisation,
@@ -208,6 +291,7 @@ ALL_DOMAINS: dict[str, Callable] = {
"vehicle": extract_vehicle,
"position": extract_position,
"doors": extract_doors,
"windows": extract_windows,
}
@@ -258,4 +342,4 @@ def apply_procedural(record: dict) -> dict:
if proc:
record["procedural"] = proc
return record
return record
+7 -5
View File
@@ -1,5 +1,6 @@
#!/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:
# chmod +x install.sh
@@ -14,12 +15,13 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
SERVICE_NAME="we_monitor"
CREDS_DIR="$HOME/.config/we_monitor"
CREDS_FILE="$CREDS_DIR/credentials.json"
SYSTEMD_DIR="$HOME/.config/systemd/user"
SERVICE_FILE="$SYSTEMD_DIR/$SERVICE_NAME.service"
LOG_DIR="$SCRIPT_DIR/logs"
LOG_DIR="$REPO_DIR/logs"
VENV="$SCRIPT_DIR/.venv"
PYTHON="$VENV/bin/python"
@@ -39,7 +41,7 @@ if $IS_UPDATE; then
else
echo "==> we_monitor installer"
fi
echo " Project : $SCRIPT_DIR"
echo " Project : $REPO_DIR"
echo " User : $USER"
echo ""
@@ -99,8 +101,8 @@ Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$SCRIPT_DIR
ExecStart=$PYTHON $SCRIPT_DIR/collect.py -c $CREDS_FILE
WorkingDirectory=$REPO_DIR
ExecStart=$PYTHON -m server.main -c $CREDS_FILE
Restart=on-failure
RestartSec=60
StandardOutput=journal
+7 -114
View File
@@ -9,16 +9,16 @@ the domain hierarchy: domain → status-object → field.
Usage examples
--------------
# 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:
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:
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)
-------------------------------
@@ -45,119 +45,13 @@ Connect with: nc localhost 9999 or any TCP client that reads lines.
import argparse
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from server import log_config, network, we_connect
from carconnectivity.errors import AuthenticationError, TemporaryAuthenticationError
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
from server.collect import run
from server.data_model import ALL_DOMAINS
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:
p = argparse.ArgumentParser(
@@ -259,7 +153,6 @@ def main() -> None:
except json.JSONDecodeError as 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", {})
if args.username is None:
args.username = nested.get("username") or creds_data.get("username")
@@ -296,4 +189,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
+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
+48 -36
View File
@@ -1,52 +1,64 @@
import asyncio
import logging
import sys
try:
from carconnectivity.carconnectivity import CarConnectivity
except ImportError:
print(
"carconnectivity is not installed. Run: "
"pip install carconnectivity carconnectivity-connector-volkswagen",
file=sys.stderr,
)
sys.exit(1)
import aiohttp
from volkswagencarnet.vw_connection import Connection
from volkswagencarnet.vw_exceptions import AuthenticationError # noqa: F401 — re-exported for collect.py
log = logging.getLogger(__name__)
def connect(username: str, password: str) -> CarConnectivity:
config = {
"carConnectivity": {
"connectors": [
{
"type": "volkswagen",
"config": {
"username": username,
"password": password,
},
}
]
}
}
cc = CarConnectivity(config=config)
cc.startup()
cc.fetch_all()
return cc
class _Session:
"""Holds the asyncio event loop, aiohttp session, and Connection together."""
def __init__(
self,
loop: asyncio.AbstractEventLoop,
session: aiohttp.ClientSession,
connection: Connection,
) -> None:
self._loop = loop
self._http_session = session
self.connection = connection
def run(self, coro):
return self._loop.run_until_complete(coro)
@property
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."""
vehicles = list(cc.get_garage().list_vehicles())
vehicles = wc.vehicles
if not vehicles:
return None, "No vehicles found in this CarConnectivity account"
return None, "No vehicles found in this account"
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:
available = [v.vin.value for v in vehicles]
return None, f"VIN {vin} not found. Available: {available}"
available = [v.vin for v in vehicles]
return None, f"VIN {vin} not found. Available: {available}"
return vehicle, None
return vehicles[0], None
def update(cc: CarConnectivity) -> None:
cc.fetch_all()
def update(wc: _Session) -> None:
wc.run(wc.connection.update())