Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/commands/new-branch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Create a new branch from the latest `develop` for a feature or bugfix.

Steps:
1. Fetch and check out `develop`, pull the latest changes.
2. Create and check out a new branch named after the issue or feature. Use the format `feature/<slug>` for features and `fix/<slug>` for bugfixes, where the slug is a short kebab-case description. If the user mentioned an issue number, prefix it: e.g. `fix/455-mg4-urban-battery`.
3. Confirm the new branch name to the user.

Do not push the branch yet.
11 changes: 11 additions & 0 deletions .claude/commands/ship.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Run quality checks, commit staged changes, push, and open a PR targeting `develop`.

Steps:
1. Run `poetry run ruff check . --fix --unsafe-fixes && poetry run ruff format .` and fix any remaining issues.
2. Run `poetry run mypy` — fix all type errors before continuing.
3. Run `poetry run pytest tests` — fix any failures before continuing.
4. Show a `git diff --staged` summary and ask the user to confirm the commit message, or draft one following the repo convention (`feat:`, `fix:`, `chore:`, etc.).
5. Commit, push the current branch, and open a PR with `gh pr create --base develop`. Include "Closes #<N>" in the PR body if an issue number is known.
6. Return the PR URL.

Do not proceed past any failing step — fix the issue first.
44 changes: 44 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
ci:
autofix_prs: false
skip:
# These steps run in the CI workflow already. Keep in sync.
- mypy

default_language_version:
python: python3.13

repos:
- repo: https://github.com/python-poetry/poetry
rev: '2.1.3'
hooks:
- id: poetry-check
- id: poetry-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args:
- --fix
- --unsafe-fixes
- id: ruff-format
- repo: local
hooks:
- id: mypy
name: Check with mypy
entry: poetry run mypy
language: system
types:
- python
pass_filenames: false
require_serial: true
- id: pytest
name: Run pytest
entry: poetry run pytest tests
language: system
pass_filenames: false
stages: [pre-push]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: check-added-large-files
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Change Log

## Unreleased

### Added

* Add `--saic-user-timezone` / `SAIC_USER_TIMEZONE` config option to force
the account timezone instead of relying on the SAIC API value. Useful when
the API reports a wrong DST offset (#438). Discrepancies between the forced
zone and the API value are detected by comparing the current UTC offset and
logged at WARNING level.

### Fixed

* Persist user-set HA gateway entities across gateway restarts by retaining
their `/set` commands on the MQTT broker (refresh mode, all four refresh
periods, and total battery capacity). On reconnect the existing command-
dispatch path replays the retained value before `configure_missing()` would
apply config defaults. A retained one-shot refresh mode (`force`,
`charging_detection`) is dropped on replay so a single-shot poll does not
fire on every restart.

Note: on first upgrade only entities you change *after* the upgrade become
persistent. Existing retained STATE values on the broker are not converted
into retained `/set` commands.

* Republish the effective Total Battery Capacity to its state topic right after
the user updates the HA number. The `_set` handler used to only mutate the
in-memory override and rely on the next vehicle poll to refresh the shared
sensor topic, leaving the HA sensor stuck on the previous (often hardcoded
per-model default) value while the number widget already showed the new
setting. A payload of `0` re-publishes the per-model default via
`real_battery_capacity`.

## 0.11.0

### Added
Expand Down
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Branching strategy

- `main` — stable releases only
- `develop` — beta/integration branch; the default merge target for all feature and bugfix work

**Always branch from `develop` for features and bugfixes.** PRs must target `develop`, not `main`. The only exception is a hotfix that must go directly to `main`.

## Commands

```bash
# Install dependencies (first time or after lockfile changes)
poetry install --no-root

# Type check
poetry run mypy

# Lint (ruff runs with --fix --unsafe-fixes in pre-commit)
poetry run ruff check .
poetry run ruff format .

# Run all tests with coverage
poetry run pytest tests --cov

# Run a single test file or test
poetry run pytest tests/test_vehicle_info.py
poetry run pytest tests/test_vehicle_info.py::TestMg4UrbanRealBatteryCapacity::test_standard_range_43kwh -v
```

Pre-commit hooks run `ruff`, `ruff-format`, `mypy`, and `poetry-check` on every commit. Pytest runs as a **pre-push** hook. Always run mypy and ruff before committing to avoid fixup commits.

## Architecture

### Data flow

The gateway polls the SAIC cloud API on a per-vehicle schedule and bridges results to an MQTT broker. Incoming MQTT `/set` commands are forwarded back to the SAIC API.

```
SAIC Cloud API
↓ (VehicleState.should_refresh() controls timing)
VehicleHandler.__polling()
VehicleState.handle_vehicle_status() → VehicleStatusRespPublisher → MQTT
VehicleState.handle_charge_status() → ChrgMgmtDataRespPublisher → MQTT
extractors.extract_soc/range() (cross-fuses BMS + vehicle status values)
AbrpApi / OsmAndApi / OpenWBIntegration (optional side-effects)

MQTT broker (/set topics)
MqttGateway → VehicleHandler → VehicleCommandHandler → SAIC API
```

### Key modules

**`src/mqtt_gateway.py`** — top-level orchestrator. Implements `MqttCommandListener` (MQTT callbacks) and `VehicleHandlerLocator` (VIN → handler lookup).

**`src/vehicle.py` — `VehicleState`** — the polling state machine. Controls refresh timing via `PollingPhase` and `RefreshMode` enums. Exponential backoff on errors (doubles up to `refresh_period_inactive`). Polling is gated by `is_complete()` — all four refresh periods must be populated before the first poll. They are restored from retained MQTT messages on reconnect or defaulted by `configure_missing()` after a 10-second startup delay.

**`src/handlers/vehicle.py` — `VehicleHandler`** — per-VIN lifecycle. Owns `VehicleState`, `VehicleCommandHandler`, all integrations, and HA discovery. The `handle_vehicle()` coroutine is a long-lived asyncio task.

**`src/vehicle_info.py` — `VehicleInfo`** — static metadata derived from `VinInfo`. Holds series/model identity, vehicle configuration properties (e.g. `BType` for NMC/LFP battery type), battery capacity lookup, AC temperature mapping, and feature flags (`is_ev`, `has_sunroof`, etc.). `is_ev` is determined by series **not** starting with `"ZP22"`.

**`src/publisher/core.py` — `Publisher`** — abstract base with typed publish methods. Handles topic sanitization, data anonymization, and LWT. The `publish(key, Publishable)` dispatcher checks `bool` before `int` (Python's `isinstance(True, int)` is `True`).

**`src/handlers/command/`** — one `CommandHandlerBase` subclass per writable MQTT topic. All registered in `handlers/command/__init__.py::ALL_COMMAND_HANDLERS`.

**`src/status_publisher/`** — stateless publishers for each API response type. Return frozen dataclasses that carry extracted values back up to `VehicleState` for cross-cutting decisions (e.g. BMS vs vehicle SoC reconciliation).

**`src/extractors/__init__.py`** — pure functions that reconcile values present in both API responses. BMS values take precedence over vehicle status.

### Battery capacity (`src/vehicle_info.py`)

`real_battery_capacity` dispatches by `series` prefix to a vehicle-specific property. When adding a new model, add an `elif self.series.startswith(...)` branch and a corresponding `__<model>_real_battery_capacity` property. `supports_target_soc` (`BType == "1"`) distinguishes NMC from LFP where both share a series prefix. Custom capacity via `BATTERY_CAPACITY_MAPPING` (`VIN=kWh`) always overrides the lookup.

### Integrations (`src/integrations/`)

All optional, instantiated per-VIN:
- **Home Assistant**: MQTT auto-discovery. Re-published on broker reconnect or HA `online` LWT.
- **OpenWB**: subscribes to charger MQTT topics; triggers forced vehicle refresh on charge start; publishes SoC/range back to the charger.
- **ABRP / OsmAnd**: REST/HTTP telemetry push after each successful poll.

### MQTT topic structure

```
<prefix>/<saic_user>/vehicles/<vin>/<domain>/<key> # status
<prefix>/<saic_user>/vehicles/<vin>/<domain>/<key>/set # writable
<prefix>/<saic_user>/account/... # account-level
<prefix>/_internal/api/... # raw API debug
```

All topic constants are in `src/mqtt_topics.py`.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ When using combinations of configuration methods, the order of precedence is as
| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 |
| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default |
| --account-refresh-interval | ACCOUNT_REFRESH_INTERVAL | Interval in seconds for refreshing account-level data (vehicle list, timezone). Default is 86400 (24 hours). |
| --saic-user-timezone | SAIC_USER_TIMEZONE | Force the account timezone instead of trusting the SAIC API value. Accepts an IANA name (e.g. `Australia/Sydney`) or `GMT+HH:MM`. Mismatches with the API offset are logged. |
| --publish-raw-api-data | PUBLISH_RAW_API_DATA_ENABLED | Publish raw SAIC API request/response to MQTT. Disabled (False) by default. |

#### API Endpoints
Expand Down
Loading