Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc424310b5 | ||
|
|
63e807bdb0 | ||
|
|
4dcf2ee6aa | ||
|
|
0b68bcdef8 | ||
|
|
26904d9afc | ||
|
|
99b34f0b5e | ||
|
|
53004e5a2d | ||
|
|
49bc272461 | ||
|
|
b5c7e66c94 | ||
|
|
ff6a43746a | ||
|
|
5b1de48b75 | ||
|
|
ca459c5ce9 | ||
|
|
5230b304a2 | ||
|
|
423e6d3027 | ||
|
|
cc2c52a827 | ||
|
|
eea4bdc3ba |
@@ -3,3 +3,5 @@ credentials/
|
||||
vehicle_data.json
|
||||
logs/
|
||||
__pycache__/
|
||||
.venv/
|
||||
client/run.sh
|
||||
|
||||
@@ -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.py — JSON 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 1–24 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` |
|
||||
|
||||
@@ -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()
|
||||
Executable
+82
@@ -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,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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user