From 5500200116bf30e3fe44ab66ca70715d78252b73 Mon Sep 17 00:00:00 2001 From: Steven Hosking Date: Sun, 29 Mar 2026 09:16:36 +1100 Subject: [PATCH] feat: capability-based entity filtering, trim detection, frontend fixes (v0.5.0) --- .github/agents/copilot-instructions.md | 4 +- .vscode/settings.json | 3 +- API/README.md | 8 +- API/common-patterns.md | 172 +++++++++++ API/endpoints/capabilities.md | 156 +++++++++- API/endpoints/list-vehicles.md | 103 ++++++- API/endpoints/vehicle-status.md | 86 +++++- API/entities.md | 288 ++++++++++++------ API/index.md | 25 +- API/models.md | 114 ++++++- CHANGELOG.md | 46 +++ custom_components/hello_smart/__init__.py | 64 +++- custom_components/hello_smart/api.py | 127 +++++++- .../hello_smart/binary_sensor.py | 97 +++++- custom_components/hello_smart/button.py | 28 +- custom_components/hello_smart/climate.py | 19 +- custom_components/hello_smart/const.py | 26 ++ custom_components/hello_smart/coordinator.py | 133 +++++--- .../frontend/hello-smart-charge-card.js | 29 ++ .../frontend/hello-smart-vehicle-card.js | 30 +- custom_components/hello_smart/lock.py | 23 +- custom_components/hello_smart/manifest.json | 2 +- custom_components/hello_smart/models.py | 107 +++++++ custom_components/hello_smart/number.py | 19 +- custom_components/hello_smart/select.py | 60 +++- custom_components/hello_smart/sensor.py | 143 ++++++++- custom_components/hello_smart/strings.json | 2 + custom_components/hello_smart/switch.py | 24 +- custom_components/hello_smart/time.py | 24 +- .../hello_smart/translations/en.json | 2 + dashboards/smart-vehicle-basic.yaml | 22 +- dashboards/smart-vehicle.yaml | 33 +- .../dashboards/smart-vehicle-basic.yaml | 24 +- .../ha-config/dashboards/smart-vehicle.yaml | 181 +++++------ .../checklists/requirements.md | 36 +++ .../contracts/capability-api-response.md | 113 +++++++ .../contracts/entity-filtering.md | 54 ++++ .../data-model.md | 205 +++++++++++++ specs/006-capability-entity-filtering/plan.md | 88 ++++++ .../quickstart.md | 63 ++++ .../research.md | 236 ++++++++++++++ specs/006-capability-entity-filtering/spec.md | 158 ++++++++++ .../006-capability-entity-filtering/tasks.md | 158 ++++++++++ 43 files changed, 3007 insertions(+), 328 deletions(-) create mode 100644 specs/006-capability-entity-filtering/checklists/requirements.md create mode 100644 specs/006-capability-entity-filtering/contracts/capability-api-response.md create mode 100644 specs/006-capability-entity-filtering/contracts/entity-filtering.md create mode 100644 specs/006-capability-entity-filtering/data-model.md create mode 100644 specs/006-capability-entity-filtering/plan.md create mode 100644 specs/006-capability-entity-filtering/quickstart.md create mode 100644 specs/006-capability-entity-filtering/research.md create mode 100644 specs/006-capability-entity-filtering/spec.md create mode 100644 specs/006-capability-entity-filtering/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 4c6d5cb..bcad993 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -8,6 +8,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-07 - YAML (Home Assistant Lovelace dashboard format) + Home Assistant 2025.x+, HACS (optional), mushroom cards (optional), card-mod (optional) (004-lovelace-dashboard) - N/A — static YAML files and PNG image assets (004-lovelace-dashboard) - N/A — no production Python code changes; only JSON, YAML, and text files + HACS validation action (`hacs/action`), hassfest action (`home-assistant/actions/hassfest`), GitHub Actions (005-hacs-packaging) +- Python 3.13+ (Home Assistant 2025.x minimum) + `aiohttp` (HA-bundled HTTP client), `homeassistant` core APIs (`DataUpdateCoordinator`, `Entity`, `ConfigEntry`) (006-capability-entity-filtering) +- In-memory caching on `SmartDataCoordinator` instance; no persistent storage needed (006-capability-entity-filtering) - Python 3.13+ (matching Home Assistant Core 2025.x minimum) + `aiohttp` (via HA's `async_get_clientsession`), `homeassistant` core APIs (config_flow, DataUpdateCoordinator, entity platforms, device registry, diagnostics) (001-hello-smart-foundation) @@ -27,9 +29,9 @@ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLO Python 3.13+ (matching Home Assistant Core 2025.x minimum): Follow standard conventions ## Recent Changes +- 006-capability-entity-filtering: Added Python 3.13+ (Home Assistant 2025.x minimum) + `aiohttp` (HA-bundled HTTP client), `homeassistant` core APIs (`DataUpdateCoordinator`, `Entity`, `ConfigEntry`) - 005-hacs-packaging: Added N/A — no production Python code changes; only JSON, YAML, and text files + HACS validation action (`hacs/action`), hassfest action (`home-assistant/actions/hassfest`), GitHub Actions - 004-lovelace-dashboard: Added YAML (Home Assistant Lovelace dashboard format) + Home Assistant 2025.x+, HACS (optional), mushroom cards (optional), card-mod (optional) -- 003-api-command-controls: Added Python 3.13+ (matching Home Assistant Core 2025.x minimum) + `aiohttp` (via HA's `async_get_clientsession`), `homeassistant` core APIs (config_flow, DataUpdateCoordinator, entity platforms, device registry) diff --git a/.vscode/settings.json b/.vscode/settings.json index d454aa6..31f3f6b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "chat.tools.terminal.autoApprove": { ".specify/scripts/bash/": true, ".specify/scripts/powershell/": true - } + }, + "java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx4G -Xms100m -Xlog:disable" } diff --git a/API/README.md b/API/README.md index cca7199..3fdb853 100644 --- a/API/README.md +++ b/API/README.md @@ -1,8 +1,6 @@ # Smart Vehicle Cloud API Reference -Complete documentation of all GET API endpoints consumed by the Hello Smart Home Assistant integration. These endpoints are reverse-engineered from the Smart mobile app APKs (EU and INTL variants) and validated against the live API. - -> **Read-only endpoints only.** POST/PUT command endpoints (lock, unlock, climate control, etc.) are not yet implemented. +Complete documentation of all API endpoints consumed by the Hello Smart Home Assistant integration. These endpoints are reverse-engineered from the Smart mobile app APKs (EU and INTL variants) and validated against the live API. --- @@ -10,8 +8,8 @@ Complete documentation of all GET API endpoints consumed by the Hello Smart Home | Document | Description | |----------|-------------| -| [Common Patterns](common-patterns.md) | Base URLs, request signing, response envelope, error codes, data types | -| [Endpoint Index](index.md) | Quick-reference table for all 22 endpoints | +| [Common Patterns](common-patterns.md) | Base URLs, request signing, response envelope, error codes, command controls | +| [Endpoint Index](index.md) | Quick-reference table for all 22+ endpoints | | [Data Models](models.md) | Enumerations, dataclasses, and type definitions | | [Entity Mapping](entities.md) | How API data maps to Home Assistant entities | diff --git a/API/common-patterns.md b/API/common-patterns.md index a563b0a..d082f14 100644 --- a/API/common-patterns.md +++ b/API/common-patterns.md @@ -173,3 +173,175 @@ ota.srv.smart.com ``` Only HTTPS requests are permitted. Requests to unlisted hosts are rejected with `SmartAPIError`. + +--- + +## Vehicle Commands (PUT) + +Vehicle control commands are sent via PUT to the telematics endpoint: + +```http +PUT {base_url}/remote-control/vehicle/telematics/{vin} +``` + +Headers: Standard [signed headers](#required-headers) with `authorization` token. The request body must be serialised with **no spaces** (`json.dumps(payload, separators=(",",":"))`). + +### Command Payload Envelope + +```json +{ + "creator": "tc", + "command": "start", + "serviceId": "RDL_2", + "timestamp": "1706028240000", + "operationScheduling": { + "duration": 6, + "interval": 0, + "occurs": 1, + "recurrentOperation": false + }, + "serviceParameters": [ + {"key": "param.name", "value": "param_value"} + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `creator` | string | Always `"tc"` | +| `command` | string | `"start"` or `"stop"` | +| `serviceId` | string | Service identifier (see table below) | +| `timestamp` | string | Epoch milliseconds | +| `operationScheduling.duration` | int | Command duration in minutes | +| `operationScheduling.interval` | int | Always `0` | +| `operationScheduling.occurs` | int | Always `1` | +| `operationScheduling.recurrentOperation` | bool/int | `false` (or `0` for charging) | +| `serviceParameters` | array | Key/value pairs specific to each service | + +> **Charging exception**: The `rcs` service uses `"timeStamp"` (camelCase) and `"recurrentOperation": 0` (integer) instead of the standard `"timestamp"` and `false`. + +### Service IDs & Parameters + +#### Door Lock / Unlock + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RDL_2` | `start` | *(none)* | Lock all doors | +| `RDU_2` | `start` | *(none)* | Unlock all doors | + +#### Climate Control + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RCE_2` | `start` | `rce.conditioner=1`, `rce.temp={temp}` | Start HVAC at target temperature | +| `RCE_2` | `stop` | `rce.conditioner=1` | Stop HVAC | + +- Temperature is in °C as a string (e.g., `"22"`) +- Default duration is 30 minutes + +#### Seat Heating + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RSH` | `start` | `rsh.seat={seat}`, `rsh.level={level}` | Set seat heating level | + +**Seat keys**: `front-left`, `front-right`, `steering_wheel` + +**Level values**: `0` (off), `1` (low), `2` (medium), `3` (high) + +#### Seat Ventilation + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RSV` | `start` | `rsv.seat={seat}`, `rsv.level={level}` | Set seat ventilation level | + +**Seat keys**: `front-left` + +**Level values**: `0` (off), `1` (low), `2` (medium), `3` (high) + +#### Horn & Lights (Find My Car) + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RHL` | `start` | `rhl.horn=1` | Sound horn | +| `RHL` | `start` | `rhl.flash=1` | Flash lights | +| `RHL` | `start` | `rhl.horn=1`, `rhl.flash=1` | Find my car (horn + flash) | + +#### Windows + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RWS_2` | `start` | `rws.close=1` | Close all windows | + +#### Charging + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `rcs` | `start` | `operation=1`, `rcs.restart=1` | Start charging | +| `rcs` | `start` | `operation=0`, `rcs.terminate=1` | Stop charging | + +> Note: Charging uses `command: "start"` for **both** start and stop — the `operation` parameter controls the action. + +#### Mini-Fridge + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `UFR` | `start` | `ufr.status=1` | Turn on fridge | +| `UFR` | `stop` | `ufr.status=0` | Turn off fridge | + +#### Fragrance Diffuser + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RFD_2` | `start` | `rfd.status=1` | Turn on fragrance | +| `RFD_2` | `stop` | `rfd.status=0` | Turn off fragrance | + +#### Vehicle Tracking (VTM) + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `VTM` | `start` | `vtm.enabled=1` | Enable vehicle tracking | +| `VTM` | `stop` | `vtm.enabled=0` | Disable vehicle tracking | + +#### Locker + +| Service ID | Command | Parameters | Description | +|------------|---------|------------|-------------| +| `RPC` | `start` | `rpc.lock=1` | Lock storage locker | +| `RPC` | `start` | `rpc.unlock=1` | Unlock storage locker | + +### Command Response + +```json +{ + "code": 1000, + "data": { ... }, + "success": true, + "message": "operation succeed" +} +``` + +The integration checks top-level `success` first, then falls back to `data.success`. + +### Command Flow + +1. **Select vehicle** — `POST /device-platform/user/session/update` with the target VIN +2. **Send command** — `PUT /remote-control/vehicle/telematics/{vin}` with the payload +3. **Cooldown** — 5-second per-VIN cooldown between commands +4. **Delayed refresh** — 8-second delay before polling for updated state + +### Charging Reservation (PUT) + +```http +PUT {base_url}/remote-control/charging/reservation/{vin} +``` + +Updates the scheduled charging configuration. Body is the updated reservation object — see [Charging Reservation](endpoints/charging-reservation.md). + +### Climate Schedule (PUT) + +```http +PUT {base_url}/remote-control/schedule/{vin} +``` + +Updates the climate pre-conditioning schedule. Body is the updated schedule object — see [Climate Schedule](endpoints/climate-schedule.md). diff --git a/API/endpoints/capabilities.md b/API/endpoints/capabilities.md index 59cb411..c19c1e3 100644 --- a/API/endpoints/capabilities.md +++ b/API/endpoints/capabilities.md @@ -1,6 +1,6 @@ # Capabilities -Feature flags indicating which services are enabled for the vehicle. +Feature flags indicating which services and functions are enabled for the vehicle. [← Back to API Reference](../README.md) · [Common Patterns](../common-patterns.md) @@ -24,33 +24,78 @@ Standard [signed headers](../common-patterns.md#required-headers) with `authoriz ## Response +The API returns capability data in one of two formats. The integration detects and parses both. + +### Primary Format: `data.list[]` (APK Model) + +Based on the APK `Capability.java` / `TscVehicleCapability` Retrofit model: + ```json { "code": 1000, "data": { - "capabilities": [ + "list": [ { - "serviceId": "RCE_2", - "enabled": true, - "version": "1.0" + "functionId": "remote_control_lock", + "valueEnable": true, + "functionCategory": "remote_control", + "name": "Remote Lock", + "showType": 1, + "tips": "", + "valueEnum": "", + "valueRange": "", + "paramsJson": "", + "configCode": "", + "platform": "TSP", + "priority": 1 }, { - "serviceId": "FOTA", - "enabled": true, - "version": "2.0" + "functionId": "charging_status", + "valueEnable": true, + "functionCategory": "vehicle_status", + "name": "Charging Status", + "showType": 1 } ] } } ``` -### Response Fields +#### Capability Entry (Primary) | Field | Type | Description | |-------|------|-------------| -| `capabilities` | array | List of capability entries | +| `functionId` | string | Function identifier (e.g., `"remote_control_lock"`, `"charging_status"`) | +| `valueEnable` | bool | Whether the function is enabled for this vehicle | +| `functionCategory` | string | Category grouping (e.g., `"remote_control"`, `"vehicle_status"`) | +| `name` | string | Human-readable name | +| `showType` | int | Display type hint from app UI | +| `tips` | string | Tooltip text (often empty) | +| `valueEnum` | string | Enum value options (if applicable) | +| `valueRange` | string | Valid value range (if applicable) | +| `paramsJson` | string | Additional parameters as JSON string | +| `configCode` | string | Configuration code | +| `platform` | string | Source platform (e.g., `"TSP"`) | +| `priority` | int | Sort priority | + +### Fallback Format: `data.capabilities[]` (Legacy) + +```json +{ + "code": 1000, + "data": { + "capabilities": [ + { + "serviceId": "RCE_2", + "enabled": true, + "version": "1.0" + } + ] + } +} +``` -### Capability Entry +#### Capability Entry (Legacy) | Field | Type | Description | |-------|------|-------------| @@ -66,7 +111,84 @@ Returns: [`VehicleCapabilities`](../models.md#vehiclecapabilities) | Model Field | Source | |-------------|--------| -| `service_ids` | List of `serviceId` values where `enabled == true` | +| `capability_flags` | Dict of `functionId` → `valueEnable` from primary format | +| `service_ids` | List of `serviceId` values where `enabled == true` (legacy format, or `serviceId` from primary if present) | + +### Function ID Reference + +These function IDs are used for entity filtering (defined in `const.py`): + +| Constant | `functionId` Value | Controls | +|----------|--------------------|----------| +| `FUNCTION_ID_REMOTE_LOCK` | `remote_control_lock` | Lock entity | +| `FUNCTION_ID_REMOTE_UNLOCK` | `remote_control_unlock` | (paired with lock) | +| `FUNCTION_ID_CLIMATE` | `remote_air_condition_switch` | Climate entity | +| `FUNCTION_ID_WINDOW_CLOSE` | `remote_window_close` | Close windows button | +| `FUNCTION_ID_WINDOW_OPEN` | `remote_window_open` | (future: open windows) | +| `FUNCTION_ID_TRUNK_OPEN` | `remote_trunk_open` | (future: trunk button) | +| `FUNCTION_ID_HONK_FLASH` | `honk_flash` | Horn, flash, find-my-car buttons | +| `FUNCTION_ID_SEAT_HEAT` | `remote_seat_preheat_switch` | Seat heating sensors & selects | +| `FUNCTION_ID_SEAT_VENT` | `seat_ventilation_status` | Seat ventilation sensors & selects | +| `FUNCTION_ID_FRAGRANCE` | `remote_control_fragrance` | Fragrance switch & sensors | +| `FUNCTION_ID_CHARGING` | `charging_status` | Charging sensors (voltage, current, etc.) | +| `FUNCTION_ID_DOOR_STATUS` | `door_lock_switch_status` | Door lock binary sensors | +| `FUNCTION_ID_TRUNK_STATUS` | `trunk_status` | Trunk binary sensor | +| `FUNCTION_ID_WINDOW_STATUS` | `windows_rolling_status` | Window binary sensors | +| `FUNCTION_ID_SKYLIGHT_STATUS` | `skylight_rolling_status` | Sunroof binary sensor | +| `FUNCTION_ID_TYRE_PRESSURE` | `tyre_pressure` | Tyre pressure/temp sensors & warnings | +| `FUNCTION_ID_VEHICLE_POSITION` | `vehicle_position` | (future: device tracker) | +| `FUNCTION_ID_TOTAL_MILEAGE` | `total_mileage` | Odometer sensor | +| `FUNCTION_ID_HOOD_STATUS` | `engine_compartment_cover_status` | Engine hood binary sensor | +| `FUNCTION_ID_CHARGE_PORT_STATUS` | `recharge_lid_status` | Charge lid binary sensors | +| `FUNCTION_ID_CURTAIN_STATUS` | `curtain_status` | Curtain binary sensors | +| `FUNCTION_ID_DOORS_STATUS` | `vehiecle_doors_status` | Door open/close binary sensors (note: typo in APK) | +| `FUNCTION_ID_CLIMATE_STATUS` | `remote_air_condition_status` | Climate schedule sensors | +| `FUNCTION_ID_CHARGING_RESERVATION` | `recharge_appointment` | Charging schedule entities | + +--- + +## Parsing Strategy + +The integration uses a dual-format parser: + +1. **Primary**: Try `data.list[]` with `functionId`/`valueEnable` fields +2. **Fallback**: Try `data.capabilities[]` with `serviceId`/`enabled` fields +3. **Empty**: If neither format has data, return empty `VehicleCapabilities` + +This ensures backward compatibility if the API format changes between regions or firmware versions. + +### V2 → V1 Function ID Mapping + +The API may return **v2 capability IDs** (with `_2` suffix or renamed keys) while the integration's entity descriptions reference the **v1 function IDs** from the APK's original `FunctionId.java`. The integration propagates v2 values to their v1 aliases so entity filtering works regardless of API version. + +| V2 API Key (returned by API) | V1 Alias(es) (used by entities) | +|------------------------------|-------------------------------| +| `charging_status_2` | `charging_status` | +| `remote_climate_control_2` | `remote_air_condition_switch`, `climate_status` | +| `curtain_status_2` | `curtain_status` | +| `sunroof_automatic_close` | `skylight_rolling_status` | +| `recharge_lid_status_2` | `recharge_lid_status` | +| `remote_control_lock_2` | `remote_control_lock` | +| `remote_control_unlock_2` | `remote_control_unlock` | +| `remote_control_window_2` | `remote_window_close`, `remote_window_open` | +| `remote_control_ventilate_2` | `seat_ventilation_status` | +| `tire_pressure_warning_2` | `tyre_pressure` | + +### Inferred Capabilities + +Some v1 function IDs have no direct v2 equivalent in the API response. These are inferred from related capabilities: + +| V1 Function ID | Inferred From | Logic | +|----------------|---------------|-------| +| `remote_control_fragrance` | `fragrance_exhausted_warning_2` | If fragrance warning exists → fragrance control is available | +| `remote_seat_preheat_switch` | `remote_climate_control_2` | If climate control exists → seat preheat is available | +| `remote_trunk_open` | `trunk_status` | If trunk status exists → trunk open is available | + +### Default Behaviour + +When a `functionId` is **not present** in the capability flags (neither directly nor via mapping), the integration defaults to `False` — meaning the entity is **not created**. This matches the APK's behaviour where missing capabilities indicate the feature is unavailable. + +> **Note:** Earlier versions defaulted to `True` (fail-open), which caused entities to appear for unsupported features. The current default of `False` is deliberate and matches the APK's `FunctionId` checking logic. --- @@ -80,11 +202,17 @@ Returns: [`VehicleCapabilities`](../models.md#vehiclecapabilities) ## Notes -- The capability list can be used to determine which other endpoints are available for the vehicle -- The integration currently only exposes the count, not individual capabilities +- Capability flags are fetched **once per session** and cached in `_static_cache` to reduce API calls +- When a `functionId` has `valueEnable: false`, all entities requiring that capability are not created +- If capabilities cannot be fetched, all entities default to being created (fail-open) +- The integration preserves the APK's typo in `vehiecle_doors_status` to match the actual API response +- The API currently returns **89 capability entries**, all set to `true` — meaning capability-based filtering alone may not be sufficient for all vehicles +- For hardware-level filtering (e.g., sunroof not installed), see the [sentinel value documentation](vehicle-status.md#sentinel-value-101-not-equipped) +- For trim-level filtering (e.g., Pure trim lacks seat heating), see the [edition feature matrix](list-vehicles.md#edition-feature-matrix) --- ## Related - Source: [`api.py → async_get_capabilities()`](../../custom_components/hello_smart/api.py) +- APK source: `Capability.java`, `TscVehicleCapability.java` (Retrofit model) diff --git a/API/endpoints/list-vehicles.md b/API/endpoints/list-vehicles.md index aebe321..db91ad5 100644 --- a/API/endpoints/list-vehicles.md +++ b/API/endpoints/list-vehicles.md @@ -31,10 +31,31 @@ Standard [signed headers](../common-patterns.md#required-headers) with `authoriz "data": { "list": [ { - "vin": "HESCA2...", - "modelName": "CM590 HC11 Performance 4WD RHD APAC", + "vin": "HESCA2C42RS234118", + "modelName": "CM590_HC11_Performance_4WD_RHD_APAC", "modelYear": "2025", - "seriesCodeVs": "HC11" + "seriesCodeVs": "HC11_IL", + "colorName": "B07 MOYU BLACK", + "colorCode": "026", + "modelCode": "HC1H2D3B6213-01_IL", + "factoryCode": "6105", + "vehiclePhotoSmall": "", + "vehiclePhotoBig": "", + "plateNo": "fsp06d", + "engineNo": "R9WK3A4AK", + "matCode": "HC1H2D3B6213001257", + "seriesName": "HC11", + "vehicleType": 0, + "fuelTankCapacity": "0", + "ihuPlatform": "tsp", + "tboxPlatform": "tsp", + "defaultVehicle": true, + "shareStatus": "N", + "iccid": "89882390000852030884", + "msisdn": "882399992240690", + "temId": "V89882390000852030883088", + "ihuId": "9997226608181950R9P00214", + "temType": "" } ] } @@ -46,9 +67,79 @@ Standard [signed headers](../common-patterns.md#required-headers) with `authoriz | Field | Type | Description | |-------|------|-------------| | `vin` | string | Vehicle Identification Number | -| `modelName` | string | Full model name | +| `modelName` | string | Full model name (e.g., `CM590_HC11_Performance_4WD_RHD_APAC`) | | `modelYear` | string | Model year | -| `seriesCodeVs` | string | Series/variant code | +| `seriesCodeVs` | string | Series code with region suffix (e.g., `HC11_IL`) | +| `colorName` | string | Paint colour name (e.g., `B07 MOYU BLACK`) | +| `colorCode` | string | Paint colour code | +| `modelCode` | string | Full model code used by VC service | +| `factoryCode` | string | Factory / production plant code | +| `vehiclePhotoSmall` | string | Small vehicle photo URL (may be empty) | +| `vehiclePhotoBig` | string | Large vehicle photo URL (may be empty) | +| `plateNo` | string | Registration plate number | +| `engineNo` | string | Motor/engine serial number | +| `matCode` | string | Material code — **encodes model and trim** (see below) | +| `seriesName` | string | Series name (e.g., `HC11`) | +| `vehicleType` | int | Vehicle type identifier | +| `fuelTankCapacity` | string | Fuel tank capacity (always `"0"` for BEV) | +| `ihuPlatform` | string | IHU (infotainment) platform type | +| `tboxPlatform` | string | T-Box (telematics) platform type | +| `defaultVehicle` | bool | Whether this is the user's primary vehicle | +| `shareStatus` | string | Share status: `"N"` = not shared, `"Y"` = shared | +| `iccid` | string | SIM ICCID | +| `msisdn` | string | SIM phone number | +| `temId` | string | Telematics unit identifier | +| `ihuId` | string | IHU hardware identifier | +| `temType` | string | Telematics type (may be empty) | + +--- + +## Material Code (`matCode`) Decoding + +The `matCode` field encodes both the **vehicle model** and **trim/edition**. This is derived from the APK sources `VehicleModel.java`, `VehicleEdition.java`, and `VehicleInfoConstants.java`. + +Example: `HC1H2D3B6213001257` + +### Model (positions 0–2) + +| Prefix | Model | Marketing Name | +|--------|-------|----------------| +| `HX1` | Smart #1 | Compact SUV | +| `HC1` | Smart #3 | Mid-size SUV | +| `HY1` | Smart #5 | Full-size SUV | + +Alternative detection via `seriesCodeVs` (strip regional suffix): + +| Series Code | Model | +|-------------|-------| +| `HX11` | Smart #1 | +| `HC11` | Smart #3 | +| `HY11` | Smart #5 | + +### Edition/Trim (positions 5–6) + +| Code | Edition | Notes | +|------|---------|-------| +| `80` | Pure | Entry-level | +| `D1` | Pro | Mid-range | +| `GN` | Pulse | Mid-range (EU naming) | +| `D2` | Premium | Upper-range | +| `D3` | BRABUS | Performance / top-range | +| `01` | Launch Edition | Limited launch variant | + +### Edition Feature Matrix + +Not all editions have all hardware. The APK defines feature gates in `ClimateFragment.java` and `VehicleEdition.java`: + +| Feature | Pure | Pro | Pulse | Premium | BRABUS | Launch | +|---------|------|-----|-------|---------|--------|--------| +| Driver seat heating | No | Yes | Yes | Yes | Yes | Yes | +| PM2.5 air quality sensor | No | No | Yes | Yes | Yes | Yes | +| Steering wheel heating | Market-dependent | Market-dependent | Market-dependent | Market-dependent | Market-dependent | Market-dependent | + +> Steering wheel heating availability is additionally gated by market ("GD markets") per APK `hasSteeringWheelHeat()` — not purely edition-based. + +The integration uses these gates to automatically exclude entities that don't apply to the vehicle's trim level. --- @@ -65,6 +156,8 @@ See [Vehicle model](../models.md#vehicle) - This is typically the first endpoint called after authentication - Returns all vehicles including shared ones when `needSharedCar=1` - The VIN from this response is used in all subsequent vehicle-specific endpoints +- The `matCode` field is essential for determining model and trim — see [Material Code Decoding](#material-code-matcode-decoding) above +- The `modelCode` field is used as a path parameter for the [Vehicle Ability](vehicle-ability.md) endpoint --- diff --git a/API/endpoints/vehicle-status.md b/API/endpoints/vehicle-status.md index e7a4b7a..9642da9 100644 --- a/API/endpoints/vehicle-status.md +++ b/API/endpoints/vehicle-status.md @@ -59,7 +59,29 @@ Standard [signed headers](../common-patterns.md#required-headers) with `authoriz "climateActive": false, "fragActive": "0", "interiorTemp": "22.5", - "exteriorTemp": "18.0" + "exteriorTemp": "18.0", + "winPosDriver": "0", + "winPosPassenger": "0", + "winPosDriverRear": "0", + "winPosPassengerRear": "0", + "sunroofPos": "101", + "sunroofOpenStatus": "0", + "curtainPos": "101", + "curtainOpenStatus": "0", + "sunCurtainRearPos": "101", + "sunCurtainRearOpenStatus": "0", + "drvHeatSts": "0", + "passHeatingSts": "0", + "rlHeatingSts": "0", + "rrHeatingSts": "0", + "drvVentSts": "0", + "passVentSts": "0", + "rlVentSts": "0", + "rrVentSts": "0", + "steerWhlHeatingSts": "0", + "preClimateActive": false, + "defrost": false, + "airBlowerActive": false }, "maintenanceStatus": { "odometer": "500.000", @@ -142,6 +164,58 @@ Standard [signed headers](../common-patterns.md#required-headers) with `authoriz | `interiorTemp` | string → float | °C | Cabin temperature | | `exteriorTemp` | string → float | °C | Outside temperature | +### Climate Detailed (also in climateStatus) + +| Field | Type | Unit | Description | +|-------|------|------|-------------| +| `winPosDriver` | string → int | % | Driver window position (0 = closed, 100 = fully open) | +| `winPosPassenger` | string → int | % | Passenger window position | +| `winPosDriverRear` | string → int | % | Rear-left window position | +| `winPosPassengerRear` | string → int | % | Rear-right window position | +| `sunroofPos` | string → int | % | Sunroof position (**`101` = not equipped**) | +| `sunroofOpenStatus` | string → bool | — | `"1"` = sunroof open | +| `curtainPos` | string → int | % | Sun curtain position (**`101` = not equipped**) | +| `curtainOpenStatus` | string → bool | — | `"1"` = curtain open | +| `sunCurtainRearPos` | string → int | % | Rear sun curtain position (**`101` = not equipped**) | +| `sunCurtainRearOpenStatus` | string → bool | — | `"1"` = rear curtain open | +| `drvHeatSts` | string → int | level | Driver seat heating (0=off, 1=low, 2=medium, 3=high) | +| `passHeatingSts` | string → int | level | Passenger seat heating | +| `rlHeatingSts` | string → int | level | Rear-left seat heating | +| `rrHeatingSts` | string → int | level | Rear-right seat heating | +| `drvVentSts` | string → int | level | Driver seat ventilation (0=off, 1=low, 2=medium, 3=high) | +| `passVentSts` | string → int | level | Passenger seat ventilation | +| `rlVentSts` | string → int | level | Rear-left seat ventilation | +| `rrVentSts` | string → int | level | Rear-right seat ventilation | +| `steerWhlHeatingSts` | string → int | level | Steering wheel heating | +| `preClimateActive` | bool | — | Pre-conditioning active | +| `defrost` | bool | — | Defrost active | +| `airBlowerActive` | bool | — | Air blower active | + +### Sentinel Value: `101` (Not Equipped) + +Position fields (`sunroofPos`, `curtainPos`, `sunCurtainRearPos`, and window position fields) use **`101`** as a sentinel value meaning **"hardware not equipped"** on this vehicle. + +When a position field equals `101`: +- The position is normalised to `None` (unavailable) +- The corresponding open/closed boolean (e.g., `sunroofOpenStatus`) is also set to `None` +- The integration will **not create entities** for that hardware at all + +This applies to vehicles without sunroofs, sun curtains, or (less commonly) powered rear windows. The sentinel is consistent across all Smart models (#1, #3, #5) and all trim levels. + +Example — a Smart #3 BRABUS **without** a sunroof: +```json +{ + "sunroofPos": "101", + "sunroofOpenStatus": "0", + "curtainPos": "101", + "curtainOpenStatus": "0", + "sunCurtainRearPos": "101", + "sunCurtainRearOpenStatus": "0" +} +``` + +> **Integration behaviour**: On first data fetch, entities with `equipped_fn` callbacks check whether the underlying field is `None`. If it is, the entity is never registered. Any previously-registered stale entities are cleaned up from the entity registry. + ### Maintenance Status | Field | Type | Unit | Description | @@ -229,8 +303,18 @@ Returns: [`VehicleStatus`](../models.md#vehiclestatus) | `days_to_service` | sensor | duration | | `distance_to_service` | sensor | distance | | `power_mode` | sensor | enum | +| `window_position_*` | sensor | — | +| `sunroof_position` | sensor | — | +| `curtain_position` | sensor | — | +| `sun_curtain_rear_position` | sensor | — | +| `driver_seat_heating` / `passenger_seat_heating` | sensor | — | +| `rear_left_seat_heating` / `rear_right_seat_heating` | sensor | — | +| `driver_seat_ventilation` / `passenger_seat_ventilation` | sensor | — | +| `steering_wheel_heating` | sensor | — | | `driver_door` / `passenger_door` / etc. | binary_sensor | door | | `driver_window` / etc. | binary_sensor | window | +| `sunroof_open` | binary_sensor | opening | +| `curtain_open` / `sun_curtain_rear_open` | binary_sensor | opening | | `charger_connected` | binary_sensor | plug | | `tyre_warning_*` | binary_sensor | problem | | `brake_fluid_ok` | binary_sensor | problem | diff --git a/API/entities.md b/API/entities.md index 9f2af2c..648d95a 100644 --- a/API/entities.md +++ b/API/entities.md @@ -4,100 +4,192 @@ How API data maps to Home Assistant entities. [← Back to API Reference](README.md) · [Data Models](models.md) +> **Capability Filtering**: Entities with a `Required Capability` value are only created when the vehicle's capability API reports that function as enabled (`valueEnable: true`). Entities marked `—` are always created. + --- ## Sensors (53 entities) -| Entity Key | Device Class | Unit | Source | -|------------|-------------|------|--------| -| `battery_level` | battery | % | `status.battery_level` | -| `range_remaining` | distance | km | `status.range_remaining` | -| `charging_status` | enum | — | `status.charging_state` | -| `charge_voltage` | voltage | V | `status.charge_voltage` | -| `charge_current` | current | A | `status.charge_current` | -| `time_to_full` | duration | min | `status.time_to_full` | -| `current_firmware_version` | — | — | `ota.current_version` | -| `target_firmware_version` | — | — | `ota.target_version` | -| `tyre_pressure_fl` | pressure | kPa | `status.tyre_pressure_fl` | -| `tyre_pressure_fr` | pressure | kPa | `status.tyre_pressure_fr` | -| `tyre_pressure_rl` | pressure | kPa | `status.tyre_pressure_rl` | -| `tyre_pressure_rr` | pressure | kPa | `status.tyre_pressure_rr` | -| `tyre_temp_fl` | temperature | °C | `status.tyre_temp_fl` | -| `tyre_temp_fr` | temperature | °C | `status.tyre_temp_fr` | -| `tyre_temp_rl` | temperature | °C | `status.tyre_temp_rl` | -| `tyre_temp_rr` | temperature | °C | `status.tyre_temp_rr` | -| `odometer` | distance | km | `status.odometer` | -| `days_to_service` | duration | days | `status.days_to_service` | -| `distance_to_service` | distance | km | `status.distance_to_service` | -| `battery_12v_voltage` | voltage | V | `status.battery_12v_voltage` | -| `battery_12v_level` | battery | % | `status.battery_12v_level` | -| `interior_temp` | temperature | °C | `status.interior_temp` | -| `exterior_temp` | temperature | °C | `status.exterior_temp` | -| `power_mode` | enum | — | `running_state.power_mode` | -| `speed` | speed | km/h | `running_state.speed` | -| `charging_schedule_status` | enum | — | `charging_reservation.active` | -| `charging_schedule_start` | — | — | `charging_reservation.start_time` | -| `charging_schedule_end` | — | — | `charging_reservation.end_time` | -| `charging_target_soc` | battery | % | `charging_reservation.target_soc` | -| `climate_schedule_status` | enum | — | `climate_schedule.enabled` | -| `climate_schedule_time` | — | — | `climate_schedule.scheduled_time` | -| `climate_schedule_temp` | temperature | °C | `climate_schedule.temperature` | -| `climate_schedule_duration` | duration | s | `climate_schedule.duration` | -| `last_trip_distance` | distance | km | `last_trip.distance` | -| `last_trip_duration` | duration | s | `last_trip.duration` | -| `last_trip_energy` | energy | kWh | `last_trip.energy_consumption` | -| `last_trip_avg_consumption` | — | kWh/100km | `last_trip.avg_energy_consumption` | -| `last_trip_avg_speed` | speed | km/h | `last_trip.avg_speed` | -| `last_trip_max_speed` | speed | km/h | `last_trip.max_speed` | -| `total_distance` | distance | km | `total_distance` | -| `energy_ranking` | — | — | `energy_ranking.my_ranking` | -| `fridge_temperature` | temperature | °C | `fridge.temperature` | -| `fridge_mode` | enum | — | `fridge.mode` | -| `fragrance_level` | — | — | `fragrance.level` | -| `geofence_count` | — | — | `geofence.count` | -| `diagnostic_status` | — | — | `diagnostic.status` | -| `diagnostic_code` | — | — | `diagnostic.dtc_code` | -| `backup_battery_voltage` | voltage | V | `telematics.backup_battery_voltage` | -| `backup_battery_level` | battery | % | `telematics.backup_battery_level` | -| `capability_count` | — | — | `len(capabilities.service_ids)` | -| `washer_fluid_low` | — | — | `status.washer_fluid_low` | -| `fota_pending_count` | — | — | `fota_notification.pending_count` | -| `vehicle_image_path` | — | — | Local path to downloaded side-view image | +| Entity Key | Device Class | Unit | Source | Required Capability | +|------------|-------------|------|--------|---------------------| +| `battery_level` | battery | % | `status.battery_level` | — | +| `range_remaining` | distance | km | `status.range_remaining` | — | +| `charging_status` | enum | — | `status.charging_state` | `FUNCTION_ID_CHARGING` | +| `charge_voltage` | voltage | V | `status.charge_voltage` | `FUNCTION_ID_CHARGING` | +| `charge_current` | current | A | `status.charge_current` | `FUNCTION_ID_CHARGING` | +| `time_to_full` | duration | min | `status.time_to_full` | `FUNCTION_ID_CHARGING` | +| `current_firmware_version` | — | — | `ota.current_version` | — | +| `target_firmware_version` | — | — | `ota.target_version` | — | +| `tyre_pressure_fl` | pressure | kPa | `status.tyre_pressure_fl` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_pressure_fr` | pressure | kPa | `status.tyre_pressure_fr` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_pressure_rl` | pressure | kPa | `status.tyre_pressure_rl` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_pressure_rr` | pressure | kPa | `status.tyre_pressure_rr` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_temp_fl` | temperature | °C | `status.tyre_temp_fl` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_temp_fr` | temperature | °C | `status.tyre_temp_fr` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_temp_rl` | temperature | °C | `status.tyre_temp_rl` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_temp_rr` | temperature | °C | `status.tyre_temp_rr` | `FUNCTION_ID_TYRE_PRESSURE` | +| `odometer` | distance | km | `status.odometer` | `FUNCTION_ID_TOTAL_MILEAGE` | +| `days_to_service` | duration | days | `status.days_to_service` | — | +| `distance_to_service` | distance | km | `status.distance_to_service` | — | +| `battery_12v_voltage` | voltage | V | `status.battery_12v_voltage` | — | +| `battery_12v_level` | battery | % | `status.battery_12v_level` | — | +| `interior_temp` | temperature | °C | `status.interior_temp` | — | +| `exterior_temp` | temperature | °C | `status.exterior_temp` | — | +| `power_mode` | enum | — | `running_state.power_mode` | — | +| `speed` | speed | km/h | `running_state.speed` | — | +| `charging_schedule_status` | enum | — | `charging_reservation.active` | `FUNCTION_ID_CHARGING_RESERVATION` | +| `charging_schedule_start` | — | — | `charging_reservation.start_time` | `FUNCTION_ID_CHARGING_RESERVATION` | +| `charging_schedule_end` | — | — | `charging_reservation.end_time` | `FUNCTION_ID_CHARGING_RESERVATION` | +| `charging_target_soc` | battery | % | `charging_reservation.target_soc` | `FUNCTION_ID_CHARGING_RESERVATION` | +| `climate_schedule_status` | enum | — | `climate_schedule.enabled` | `FUNCTION_ID_CLIMATE_STATUS` | +| `climate_schedule_time` | — | — | `climate_schedule.scheduled_time` | `FUNCTION_ID_CLIMATE_STATUS` | +| `climate_schedule_temp` | temperature | °C | `climate_schedule.temperature` | `FUNCTION_ID_CLIMATE_STATUS` | +| `climate_schedule_duration` | duration | s | `climate_schedule.duration` | `FUNCTION_ID_CLIMATE_STATUS` | +| `last_trip_distance` | distance | km | `last_trip.distance` | — | +| `last_trip_duration` | duration | s | `last_trip.duration` | — | +| `last_trip_energy` | energy | kWh | `last_trip.energy_consumption` | — | +| `last_trip_avg_consumption` | — | kWh/100km | `last_trip.avg_energy_consumption` | — | +| `last_trip_avg_speed` | speed | km/h | `last_trip.avg_speed` | — | +| `last_trip_max_speed` | speed | km/h | `last_trip.max_speed` | — | +| `total_distance` | distance | km | `total_distance` | — | +| `energy_ranking` | — | — | `energy_ranking.my_ranking` | — | +| `fridge_temperature` | temperature | °C | `fridge.temperature` | — | +| `fridge_mode` | enum | — | `fridge.mode` | — | +| `fragrance_level` | — | — | `fragrance.level` | `FUNCTION_ID_FRAGRANCE` | +| `geofence_count` | — | — | `geofence.count` | — | +| `diagnostic_status` | — | — | `diagnostic.status` | — | +| `diagnostic_code` | — | — | `diagnostic.dtc_code` | — | +| `backup_battery_voltage` | voltage | V | `telematics.backup_battery_voltage` | — | +| `backup_battery_level` | battery | % | `telematics.backup_battery_level` | — | +| `capability_count` | — | — | `len(capabilities.service_ids)` | — | +| `washer_fluid_low` | — | — | `status.washer_fluid_low` | — | +| `fota_pending_count` | — | — | `fota_notification.pending_count` | — | +| `vehicle_image_path` | — | — | Local path to downloaded side-view image | — | +| `driver_seat_heating` | — | — | `status.driver_seat_heating` | `FUNCTION_ID_SEAT_HEAT` | +| `passenger_seat_heating` | — | — | `status.passenger_seat_heating` | `FUNCTION_ID_SEAT_HEAT` | +| `rear_left_seat_heating` | — | — | `status.rear_left_seat_heating` | `FUNCTION_ID_SEAT_HEAT` | +| `rear_right_seat_heating` | — | — | `status.rear_right_seat_heating` | `FUNCTION_ID_SEAT_HEAT` | +| `driver_seat_ventilation` | — | — | `status.driver_seat_ventilation` | `FUNCTION_ID_SEAT_VENT` | +| `passenger_seat_ventilation` | — | — | `status.passenger_seat_ventilation` | `FUNCTION_ID_SEAT_VENT` | +| `rear_left_seat_ventilation` | — | — | `status.rear_left_seat_ventilation` | `FUNCTION_ID_SEAT_VENT` | +| `rear_right_seat_ventilation` | — | — | `status.rear_right_seat_ventilation` | `FUNCTION_ID_SEAT_VENT` | +| `dc_charge_current` | current | A | `status.dc_charge_current` | `FUNCTION_ID_CHARGING` | +| `charging_power` | power | kW | `status.charging_power` | `FUNCTION_ID_CHARGING` | + +--- + +## Binary Sensors (28+ entities) + +| Entity Key | Device Class | Source | Required Capability | +|------------|-------------|--------|---------------------| +| `driver_door` | door | `status.doors["driver"]` | `FUNCTION_ID_DOORS_STATUS` | +| `passenger_door` | door | `status.doors["passenger"]` | `FUNCTION_ID_DOORS_STATUS` | +| `rear_left_door` | door | `status.doors["rear_left"]` | `FUNCTION_ID_DOORS_STATUS` | +| `rear_right_door` | door | `status.doors["rear_right"]` | `FUNCTION_ID_DOORS_STATUS` | +| `trunk` | door | `status.doors["trunk"]` | `FUNCTION_ID_TRUNK_STATUS` | +| `driver_window` | window | `status.windows["driver"]` | `FUNCTION_ID_WINDOW_STATUS` | +| `passenger_window` | window | `status.windows["passenger"]` | `FUNCTION_ID_WINDOW_STATUS` | +| `rear_left_window` | window | `status.windows["rear_left"]` | `FUNCTION_ID_WINDOW_STATUS` | +| `rear_right_window` | window | `status.windows["rear_right"]` | `FUNCTION_ID_WINDOW_STATUS` | +| `charger_connected` | plug | `status.charger_connected` | — | +| `update_available` | update | `ota.update_available` | — | +| `tyre_warning_fl` | problem | `status.tyre_warning_fl` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_warning_fr` | problem | `status.tyre_warning_fr` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_warning_rl` | problem | `status.tyre_warning_rl` | `FUNCTION_ID_TYRE_PRESSURE` | +| `tyre_warning_rr` | problem | `status.tyre_warning_rr` | `FUNCTION_ID_TYRE_PRESSURE` | +| `telematics_connected` | connectivity | `telematics.connected` | — | +| `brake_fluid_ok` | problem | `!status.brake_fluid_ok` | — | +| `washer_fluid_low` | problem | `status.washer_fluid_low` | — | +| `fridge_active` | running | `fridge.active` | — | +| `fragrance_active` | — | `fragrance.active` | `FUNCTION_ID_FRAGRANCE` | +| `locker_open` | opening | `locker.open` | — | +| `vtm_enabled` | — | `vtm.enabled` | — | +| `vtm_notification_enabled` | — | `vtm.notification_enabled` | — | +| `vtm_geofence_alert` | — | `vtm.geofence_alert_enabled` | — | +| `locker_locked` | lock | `!locker.locked` | — | +| `diagnostic_active` | problem | `diagnostic.status == "active"` | — | +| `locker_secret_set` | — | `locker_secret.secret_set` | — | +| `fota_available` | update | `fota_notification.has_notification` | — | +| `door_lock_driver` | lock | `status.door_lock_driver` | `FUNCTION_ID_DOOR_STATUS` | +| `door_lock_passenger` | lock | `status.door_lock_passenger` | `FUNCTION_ID_DOOR_STATUS` | +| `door_lock_rear_left` | lock | `status.door_lock_driver_rear` | `FUNCTION_ID_DOOR_STATUS` | +| `door_lock_rear_right` | lock | `status.door_lock_passenger_rear` | `FUNCTION_ID_DOOR_STATUS` | +| `trunk_locked` | lock | `status.trunk_locked` | `FUNCTION_ID_TRUNK_STATUS` | +| `engine_hood` | opening | `status.engine_hood_open` | `FUNCTION_ID_HOOD_STATUS` | +| `charge_lid_ac` | opening | `status.charge_lid_ac_open` | `FUNCTION_ID_CHARGE_PORT_STATUS` | +| `charge_lid_dc` | opening | `status.charge_lid_dc_open` | `FUNCTION_ID_CHARGE_PORT_STATUS` | +| `sunroof_open` | opening | `status.sunroof_open` | `FUNCTION_ID_SKYLIGHT_STATUS` | +| `sun_curtain_rear_open` | opening | `status.sun_curtain_rear_open` | `FUNCTION_ID_CURTAIN_STATUS` | +| `curtain_open` | opening | `status.curtain_open` | `FUNCTION_ID_CURTAIN_STATUS` | + +--- + +## Locks (2 entities) + +| Entity Key | Source | Required Capability | +|------------|--------|---------------------| +| `smart_door_lock` | `status.door_lock_*` | `FUNCTION_ID_REMOTE_LOCK` | +| `smart_trunk_locker` | `locker.locked` | — | + +--- + +## Switches (5 entities) + +| Entity Key | Source | Required Capability | +|------------|--------|---------------------| +| `smart_charging` | `status.charging_state` | — | +| `smart_fridge` | `fridge.active` | — | +| `smart_fragrance` | `fragrance.active` | `FUNCTION_ID_FRAGRANCE` | +| `smart_vtm` | `vtm.enabled` | — | +| `smart_climate_schedule` | `climate_schedule.enabled` | `FUNCTION_ID_CHARGING_RESERVATION` | + +--- + +## Buttons (4 entities) + +| Entity Key | Action | Required Capability | +|------------|--------|---------------------| +| `smart_horn` | Honk horn | `FUNCTION_ID_HONK_FLASH` | +| `smart_flash_lights` | Flash lights | `FUNCTION_ID_HONK_FLASH` | +| `smart_find_my_car` | Horn + flash | `FUNCTION_ID_HONK_FLASH` | +| `smart_close_windows` | Close all windows | `FUNCTION_ID_WINDOW_CLOSE` | + +--- + +## Selects (4 entities) + +| Entity Key | Options | Required Capability | +|------------|---------|---------------------| +| `driver_seat_heating_control` | off/low/medium/high | `FUNCTION_ID_SEAT_HEAT` | +| `passenger_seat_heating_control` | off/low/medium/high | `FUNCTION_ID_SEAT_HEAT` | +| `steering_wheel_heating_control` | off/low/medium/high | `FUNCTION_ID_SEAT_HEAT` | +| `driver_seat_ventilation_control` | off/low/medium/high | `FUNCTION_ID_SEAT_VENT` | + +--- + +## Climate (1 entity) + +| Entity Key | Source | Required Capability | +|------------|--------|---------------------| +| `climate` | Pre-conditioning control | `FUNCTION_ID_CLIMATE` | --- -## Binary Sensors (28 entities) - -| Entity Key | Device Class | Source | Notes | -|------------|-------------|--------|-------| -| `driver_door` | door | `status.doors["driver"]` | | -| `passenger_door` | door | `status.doors["passenger"]` | | -| `rear_left_door` | door | `status.doors["rear_left"]` | | -| `rear_right_door` | door | `status.doors["rear_right"]` | | -| `trunk` | door | `status.doors["trunk"]` | | -| `driver_window` | window | `status.windows["driver"]` | | -| `passenger_window` | window | `status.windows["passenger"]` | | -| `rear_left_window` | window | `status.windows["rear_left"]` | | -| `rear_right_window` | window | `status.windows["rear_right"]` | | -| `charger_connected` | plug | `status.charger_connected` | | -| `update_available` | update | `ota.update_available` | | -| `tyre_warning_fl` | problem | `status.tyre_warning_fl` | | -| `tyre_warning_fr` | problem | `status.tyre_warning_fr` | | -| `tyre_warning_rl` | problem | `status.tyre_warning_rl` | | -| `tyre_warning_rr` | problem | `status.tyre_warning_rr` | | -| `telematics_connected` | connectivity | `telematics.connected` | | -| `brake_fluid_ok` | problem | `!status.brake_fluid_ok` | Inverted — on = problem | -| `washer_fluid_low` | problem | `status.washer_fluid_low` | on = low fluid | -| `fridge_active` | running | `fridge.active` | | -| `fragrance_active` | — | `fragrance.active` | | -| `locker_open` | opening | `locker.open` | | -| `vtm_enabled` | — | `vtm.enabled` | | -| `vtm_notification_enabled` | — | `vtm.notification_enabled` | | -| `vtm_geofence_alert` | — | `vtm.geofence_alert_enabled` | | -| `locker_locked` | lock | `!locker.locked` | Inverted — on = unlocked | -| `diagnostic_active` | problem | `diagnostic.status == "active"` | | -| `locker_secret_set` | — | `locker_secret.secret_set` | | -| `fota_available` | update | `fota_notification.has_notification` | | +## Number (1 entity) + +| Entity Key | Range | Required Capability | +|------------|-------|---------------------| +| `target_soc` | 20-100% | `FUNCTION_ID_CHARGING_RESERVATION` | + +--- + +## Time (3 entities) + +| Entity Key | Source | Required Capability | +|------------|--------|---------------------| +| `smart_charging_start` | `charging_reservation.start_time` | `FUNCTION_ID_CHARGING_RESERVATION` | +| `smart_charging_end` | `charging_reservation.end_time` | `FUNCTION_ID_CHARGING_RESERVATION` | +| `smart_climate_schedule_time` | `climate_schedule.scheduled_time` | — | --- @@ -109,6 +201,17 @@ How API data maps to Home Assistant entities. --- +## Capability Filtering + +Entities are filtered at setup time based on vehicle capability flags from the [Capabilities API](endpoints/capabilities.md): + +- **Fail-open**: If the capability API is unavailable or returns no data, all entities are created (default `True`) +- **Per-entity control**: Only entities with a `required_capability` value are filtered; entities without one are always created +- **One-time**: Filtering happens only during entity setup, not on each poll +- **Logged**: Skipped entities are logged at `DEBUG` level; per-vehicle filter counts at `INFO` level + +--- + ## Dynamic Visibility Entities are **dynamically hidden** when their data source returns `None`. The `async_setup_entry` function in each platform skips entities where the `value_fn` returns `None` for the current vehicle data. This means: @@ -116,14 +219,3 @@ Entities are **dynamically hidden** when their data source returns `None`. The ` - Vehicles without a fridge won't show fridge entities - Vehicles without VTM won't show VTM entities - Endpoints that fail (403, 1405, 8153) result in their entities being hidden - ---- - -## Summary - -| Platform | Count | -|----------|-------| -| Sensor | 54 | -| Binary Sensor | 28 | -| Device Tracker | 1 | -| **Total** | **83** | diff --git a/API/index.md b/API/index.md index 5c54b76..6190a96 100644 --- a/API/index.md +++ b/API/index.md @@ -1,14 +1,12 @@ # Smart Vehicle Cloud API Reference -Complete documentation of all GET API endpoints consumed by the Hello Smart Home Assistant integration. These endpoints are reverse-engineered from the Smart mobile app APKs (EU and INTL variants) and validated against the live API. - -> **Read-only endpoints only.** POST/PUT command endpoints (lock, unlock, climate control, etc.) are not yet implemented. +Complete documentation of all API endpoints consumed by the Hello Smart Home Assistant integration. These endpoints are reverse-engineered from the Smart mobile app APKs (EU and INTL variants) and validated against the live API. --- ## Quick Reference -- [Common Patterns](common-patterns.md) — Base URLs, request signing, response envelope, error codes, data types +- [Common Patterns](common-patterns.md) — Base URLs, request signing, response envelope, error codes, command controls --- @@ -94,6 +92,25 @@ Complete documentation of all GET API endpoints consumed by the Hello Smart Home | [OTA Info](endpoints/ota-info.md) | GET | `https://ota.srv.smart.com/app/info/{vin}` | Firmware versions (different host) | | [FOTA Notification](endpoints/fota-notification.md) | GET | `/fota/geea/assignment/notification` | Pending update notifications | +### Vehicle Commands + +| Service | Method | Service ID | Description | +|---------|--------|------------|-------------| +| Door Lock | PUT | `RDL_2` | Lock all doors | +| Door Unlock | PUT | `RDU_2` | Unlock all doors | +| Climate Start/Stop | PUT | `RCE_2` | HVAC pre-conditioning | +| Seat Heating | PUT | `RSH` | Set seat heater level | +| Seat Ventilation | PUT | `RSV` | Set seat vent level | +| Horn & Lights | PUT | `RHL` | Horn, flash, find my car | +| Window Close | PUT | `RWS_2` | Close all windows | +| Charging Start/Stop | PUT | `rcs` | Start or stop charging | +| Mini-Fridge | PUT | `UFR` | Fridge on/off | +| Fragrance | PUT | `RFD_2` | Fragrance diffuser on/off | +| Vehicle Tracking | PUT | `VTM` | VTM on/off | +| Locker | PUT | `RPC` | Lock/unlock storage locker | + +> All commands are sent via `PUT /remote-control/vehicle/telematics/{vin}`. See [Command Controls](common-patterns.md#vehicle-commands-put) for payload structure and parameters. + --- ## Reference diff --git a/API/models.md b/API/models.md index cc091a7..91ec899 100644 --- a/API/models.md +++ b/API/models.md @@ -55,6 +55,44 @@ Vehicle ignition power state. | `on` | `"2"` | Ignition on / ready to drive | | `cranking` | `"3"` | Engine starting | +### VehicleModel + +Smart vehicle model line, derived from `matCode[0:3]` or `seriesCodeVs`. + +Source: APK `VehicleModel.java`, `VehicleInfoConstants.java` + +| Value | matCode Prefix | Series Code | Marketing Name | +|-------|---------------|-------------|----------------| +| `#1` | `HX1` | `HX11` | Smart #1 (compact SUV) | +| `#3` | `HC1` | `HC11` | Smart #3 (mid-size SUV) | +| `#5` | `HY1` | `HY11` | Smart #5 (full-size SUV) | +| `Unknown` | — | — | Unrecognised model | + +### VehicleEdition + +Vehicle trim/edition level, derived from `matCode[5:7]`. + +Source: APK `VehicleEdition.java`, `ClimateFragment.java` + +| Value | matCode[5:7] | Description | +|-------|-------------|-------------| +| `Pure` | `80` | Entry-level | +| `Pro` | `D1` | Mid-range | +| `Pulse` | `GN` | Mid-range (EU naming) | +| `Premium` | `D2` | Upper-range | +| `BRABUS` | `D3` | Performance / top-range | +| `Launch Edition` | `01` | Limited launch variant | +| `Unknown` | — | Unrecognised trim code | + +#### Feature Availability by Edition + +| Property | Method | Pure | Pro | Pulse | Premium | BRABUS | Launch | +|----------|--------|------|-----|-------|---------|--------|--------| +| Driver seat heating | `has_driver_seat_heating` | No | Yes | Yes | Yes | Yes | Yes | +| PM2.5 sensor | `has_pm25` | No | No | Yes | Yes | Yes | Yes | + +> These gates are used by the integration to automatically exclude entities that the vehicle hardware doesn't support. + --- ## Dataclasses @@ -80,9 +118,30 @@ Vehicle identity information from [List Vehicles](endpoints/list-vehicles.md). | Field | Type | Description | |-------|------|-------------| | `vin` | `str` | Vehicle Identification Number | -| `model_name` | `str` | Full model name | +| `model_name` | `str` | Full model name (e.g., `CM590_HC11_Performance_4WD_RHD_APAC`) | | `model_year` | `str` | Model year | -| `series_code` | `str` | Series/variant code | +| `series_code` | `str` | Series/variant code with region suffix (e.g., `HC11_IL`) | +| `color_name` | `str` | Paint colour name | +| `color_code` | `str` | Paint colour code | +| `model_code` | `str` | Full model code (used by VC endpoint) | +| `factory_code` | `str` | Factory / production plant code | +| `mat_code` | `str` | Material code — encodes model and trim | +| `series_name` | `str` | Series name (e.g., `HC11`) | +| `vehicle_type` | `str` | Vehicle type identifier | +| `fuel_tank_capacity` | `str` | Fuel tank capacity (always `"0"` for BEV) | +| `ihu_platform` | `str` | IHU (infotainment) platform type | +| `tbox_platform` | `str` | T-Box (telematics) platform type | +| `plate_no` | `str` | Registration plate number | +| `engine_no` | `str` | Motor/engine serial number | +| `default_vehicle` | `bool` | Whether this is the user's primary vehicle | +| `share_status` | `str` | Share status: `"N"` / `"Y"` | + +#### Derived Properties + +| Property | Type | Description | +|----------|------|-------------| +| `edition` | `VehicleEdition` | Trim level derived from `mat_code[5:7]` | +| `smart_model` | `VehicleModel` | Model line derived from `mat_code[0:3]` / `series_code` | ### VehicleStatus @@ -126,6 +185,31 @@ Comprehensive vehicle state from [Full Vehicle Status](endpoints/vehicle-status. | `battery_12v_level` | `float` | % | Yes | | `power_mode` | `PowerMode` | — | Yes | | `last_updated` | `datetime` | — | Yes | +| `window_position_driver` | `int \| None` | % | Yes | +| `window_position_passenger` | `int \| None` | % | Yes | +| `window_position_driver_rear` | `int \| None` | % | Yes | +| `window_position_passenger_rear` | `int \| None` | % | Yes | +| `sunroof_position` | `int \| None` | % | Yes | +| `sunroof_open` | `bool \| None` | — | Yes | +| `curtain_position` | `int \| None` | % | Yes | +| `curtain_open` | `bool \| None` | — | Yes | +| `sun_curtain_rear_position` | `int \| None` | % | Yes | +| `sun_curtain_rear_open` | `bool \| None` | — | Yes | +| `driver_seat_heating` | `int \| None` | level | Yes | +| `passenger_seat_heating` | `int \| None` | level | Yes | +| `rear_left_seat_heating` | `int \| None` | level | Yes | +| `rear_right_seat_heating` | `int \| None` | level | Yes | +| `driver_seat_ventilation` | `int \| None` | level | Yes | +| `passenger_seat_ventilation` | `int \| None` | level | Yes | +| `rear_left_seat_ventilation` | `int \| None` | level | Yes | +| `rear_right_seat_ventilation` | `int \| None` | level | Yes | +| `steering_wheel_heating` | `int \| None` | level | Yes | +| `pre_climate_active` | `bool \| None` | — | Yes | +| `defrost_active` | `bool \| None` | — | Yes | +| `air_blower_active` | `bool \| None` | — | Yes | +| `climate_overheat_protection` | `bool \| None` | — | Yes | + +> **Note on `None` values**: Position fields (`sunroof_position`, `curtain_position`, `sun_curtain_rear_position`, window positions) return `None` when the API reports the sentinel value `101`, indicating the hardware is not equipped. The corresponding boolean fields (e.g., `sunroof_open`) are also set to `None`. See [Sentinel Values](endpoints/vehicle-status.md#sentinel-value-101-not-equipped). ### OTAInfo @@ -340,3 +424,29 @@ Vehicle visual configuration and remote control capabilities from the VC service | `vehicle_nickname` | `str` | No | User-assigned nickname | | `side_logo_light_name` | `str` | No | Side logo light style | | `license_plate_number` | `str` | No | Registration plate | + +--- + +### VehicleCapabilities + +Vehicle capability flags from the [Capabilities API](endpoints/capabilities.md). + +| Field | Type | Description | +|-------|------|-------------| +| `service_ids` | `list[str]` | Legacy service identifiers where `enabled == true` (backward compat) | +| `capability_flags` | `dict[str, bool]` | Function ID → enabled mapping from `data.list[].functionId`/`valueEnable` | + +The `capability_flags` dict is used by entity platforms to filter entities at setup time. Keys are `functionId` strings (e.g., `"remote_control_lock"`, `"charging_status"`). Values are `True` (enabled) or `False` (disabled). + +--- + +### StaticVehicleData + +Cached static vehicle data fetched once per session. Used by `SmartDataCoordinator._static_cache` to avoid re-fetching data that doesn't change between polls. + +| Field | Type | Description | +|-------|------|-------------| +| `capabilities` | `VehicleCapabilities \| None` | Vehicle capability flags | +| `ability` | `VehicleAbility \| None` | Vehicle visual config and images | +| `plant_no` | `str` | Plant number for device info | +| `vehicle_image_path` | `str` | Local path to downloaded vehicle image | diff --git a/CHANGELOG.md b/CHANGELOG.md index 118b76a..8cb4d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ All notable changes to this project will be documented in this file. +## [0.5.0] — 2026-03-29 + +### Feature: Capability-Based Entity Filtering (`006-capability-entity-filtering`) + +Intelligent entity filtering based on vehicle hardware capabilities, trim level, and equipment detection. Vehicles only show entities for features they actually have — no more phantom sensors for sunroofs on cars without sunroofs, or seat heating controls on base-trim vehicles. + +### Added + +- **Capability-based entity filtering** — all 9 entity platforms (sensor, binary_sensor, lock, climate, switch, button, select, number, time) now check vehicle capability flags before creating entities; unsupported features are silently excluded +- **Sentinel value detection** — position fields returning `101` ("not equipped" hardware) are normalised to `None` in the API layer; sunroof, curtain, and rear sun curtain positions correctly report as unavailable on non-equipped vehicles +- **V2→V1 capability alias mapping** — API returns v2 function IDs; 10-entry mapping dict translates to the v1 constants used by entity descriptions; 3 inferred capabilities for IDs with no direct v2 equivalent +- **Vehicle model detection** — `VehicleModel` enum (Smart #1/#3/#5) derived from `matCode` prefix (HX1/HC1/HY1) +- **Vehicle edition detection** — `VehicleEdition` enum (Pure/Pro/Pulse/Premium/BRABUS/Launch) derived from `matCode` positions [5:7], with feature-gate properties (`has_driver_seat_heating`, `has_pm25`) matching APK logic +- **Edition-based entity filtering** — `edition_check` callback on sensor/select entity descriptions excludes PM2.5 sensors (Pure/Pro only) and seat heating controls (Pure only) based on trim level +- **Hardware equipment filtering** — `equipped_fn` callback on sensor/binary_sensor descriptions excludes entities when hardware reports "not equipped" via sentinel values +- **Stale entity cleanup** — entity registry cleanup in `async_setup_entry` for sensor, binary_sensor, and select platforms; removes orphaned entity entries after filtering changes +- **Vehicle model/edition diagnostic sensors** — two new diagnostic sensors showing detected model (#1/#3/#5) and edition (Pure/Pro/Pulse/Premium/BRABUS/Launch) +- **Static data caching** — capabilities, vehicle ability, and plant number endpoints cached after first fetch; eliminates 3 API calls per poll cycle per vehicle +- **Lovelace resource registration** — custom card JS files registered as proper Lovelace storage resources (same mechanism as HACS) instead of deferred script injection; eliminates intermittent "configuration error" on page refresh +- **Card loading resilience** — `connectedCallback` lifecycle hook and loading placeholder in both custom cards; cards show "Loading…" state instead of empty DOM while waiting for `hass` +- **Frontend early registration** — `async_setup()` hook registers static paths at platform level before entry setup, ensuring JS files are servable before any dashboard renders + +### Changed + +- **Capability default changed** — `cap_flags.get(key, True)` → `cap_flags.get(key, False)` across all 9 entity platforms; unknown capabilities now default to disabled (safe-by-default) +- **Coordinator DeviceInfo** — model label now shows "Smart #3 BRABUS" style device names derived from matCode +- **Dashboard sunroof cards** — wrapped in `conditional` cards that hide when entity is unavailable (both enhanced and basic dashboards) +- **Frontend registration order** — moved from `async_setup_entry` (after 19s API refresh) to `async_setup` (before any entry loads); version-aware cleanup removes stale resource entries on upgrade + +### Fixed + +- **Phantom sunroof entities** — sunroof, curtain, and rear sun curtain sensors/binary sensors no longer created on vehicles without sunroof hardware +- **Custom card race condition** — "configuration error" on hard refresh caused by JS loaded via `add_extra_js_url` (deferred script) racing against card instantiation; fixed by registering as Lovelace resources which load before card rendering + +### API Documentation + +- **capabilities.md** — V2→V1 function ID mapping table, inferred capabilities, default behaviour docs +- **list-vehicles.md** — full response JSON, matCode decoding (model prefix table, edition code table, feature matrix) +- **vehicle-status.md** — climate detail fields, sentinel value 101 section +- **models.md** — VehicleModel/VehicleEdition enums, expanded Vehicle/VehicleStatus dataclasses +- **common-patterns.md** — vehicle commands PUT section with all 12 service IDs and parameters +- **index.md** — vehicle commands quick reference +- **README.md** — removed "read-only only" notice + +--- + ## [0.4.4] — 2026-03-09 ### Fixed diff --git a/custom_components/hello_smart/__init__.py b/custom_components/hello_smart/__init__.py index 7841251..3002915 100644 --- a/custom_components/hello_smart/__init__.py +++ b/custom_components/hello_smart/__init__.py @@ -7,6 +7,8 @@ from homeassistant.components.frontend import add_extra_js_url from homeassistant.components.http import StaticPathConfig +from homeassistant.components.lovelace import LOVELACE_DATA +from homeassistant.components.lovelace.resources import ResourceStorageCollection from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -39,11 +41,21 @@ FRONTEND_CHARGE_URL = f"{FRONTEND_URL}/{FRONTEND_CHARGE_JS}" # Version from manifest.json for cache-busting -_FRONTEND_VERSION = "0.4.5" +_FRONTEND_VERSION = "0.5.0" + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the Hello Smart integration (platform-level, before entries).""" + # Register the static path early so the JS files are servable immediately. + await _async_register_static_path(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: SmartConfigEntry) -> bool: """Set up Hello Smart from a config entry.""" + # Register as Lovelace resources (Lovelace is ready by entry setup time). + await _async_register_lovelace_resources(hass) + coordinator = SmartDataCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -51,9 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartConfigEntry) -> boo hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - # Register the custom frontend card (once, shared across entries) - await _async_register_frontend(hass) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_options_updated)) @@ -61,19 +70,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartConfigEntry) -> boo return True -async def _async_register_frontend(hass: HomeAssistant) -> None: - """Register the custom Lovelace card JS as a static resource.""" - if f"{DOMAIN}_frontend_registered" in hass.data: +async def _async_register_static_path(hass: HomeAssistant) -> None: + """Register the static path for serving card JS files.""" + if f"{DOMAIN}_static_registered" in hass.data: return frontend_dir = Path(__file__).parent / "frontend" await hass.http.async_register_static_paths( [StaticPathConfig(FRONTEND_URL, str(frontend_dir), cache_headers=False)] ) - add_extra_js_url(hass, f"{FRONTEND_RESOURCE_URL}?v={_FRONTEND_VERSION}") - add_extra_js_url(hass, f"{FRONTEND_CHARGE_URL}?v={_FRONTEND_VERSION}") + hass.data[f"{DOMAIN}_static_registered"] = True + _LOGGER.debug("Static path registered: %s", FRONTEND_URL) + + +async def _async_register_lovelace_resources(hass: HomeAssistant) -> None: + """Register card JS as Lovelace resources so they load before cards.""" + if f"{DOMAIN}_frontend_registered" in hass.data: + return + + # Ensure the static path is registered (idempotent). + await _async_register_static_path(hass) + + card_urls = [ + f"{FRONTEND_RESOURCE_URL}?v={_FRONTEND_VERSION}", + f"{FRONTEND_CHARGE_URL}?v={_FRONTEND_VERSION}", + ] + + lovelace_data = hass.data.get(LOVELACE_DATA) + resources = getattr(lovelace_data, "resources", None) if lovelace_data else None + + if isinstance(resources, ResourceStorageCollection): + existing_urls = {item["url"] for item in resources.async_items()} + # Remove old-version entries for our cards + for item in resources.async_items(): + url = item["url"] + if FRONTEND_URL in url and url not in card_urls: + await resources.async_delete_item(item["id"]) + _LOGGER.debug("Removed stale Lovelace resource: %s", url) + for url in card_urls: + if url not in existing_urls: + await resources.async_create_item( + {"res_type": "module", "url": url} + ) + _LOGGER.debug("Added Lovelace resource: %s", url) + else: + _LOGGER.debug("Lovelace not in storage mode, using add_extra_js_url") + for url in card_urls: + add_extra_js_url(hass, url) hass.data[f"{DOMAIN}_frontend_registered"] = True + _LOGGER.debug("Frontend card resources registered") async def _async_options_updated( diff --git a/custom_components/hello_smart/api.py b/custom_components/hello_smart/api.py index 7ab44f1..9936462 100644 --- a/custom_components/hello_smart/api.py +++ b/custom_components/hello_smart/api.py @@ -563,9 +563,87 @@ async def async_get_capabilities( base_url = self._get_base_url(account) url = f"{base_url}/geelyTCAccess/tcservices/capability/{vin}" data = await self._signed_request("GET", url, account) - caps = data.get("data", {}).get("capabilities", []) - service_ids = [c.get("serviceId", "") for c in caps if c.get("enabled")] - return VehicleCapabilities(service_ids=service_ids) + inner = data.get("data", {}) + + # Primary format: APK model (TscVehicleCapability) with data.list[] + cap_list = inner.get("list", []) + if cap_list: + _LOGGER.debug( + "Capability response uses 'list' format (%d entries) for %s", + len(cap_list), + vin[:6] + "...", + ) + capability_flags = { + c["functionId"]: c.get("valueEnable", False) + for c in cap_list + if c.get("functionId") + } + + # The API may return v2 capability IDs (with _2 suffix or + # renamed keys) while the APK FunctionId constants use v1 + # names. Propagate v2 values to their v1 aliases so entity + # filtering works regardless of API version. + _V2_TO_V1: dict[str, list[str]] = { + "charging_status_2": ["charging_status"], + "remote_climate_control_2": [ + "remote_air_condition_switch", + "climate_status", + ], + "curtain_status_2": ["curtain_status"], + "sunroof_automatic_close": ["skylight_rolling_status"], + "recharge_lid_status_2": ["recharge_lid_status"], + "remote_control_lock_2": ["remote_control_lock"], + "remote_control_unlock_2": ["remote_control_unlock"], + "remote_control_window_2": [ + "remote_window_close", + "remote_window_open", + ], + "remote_control_ventilate_2": ["seat_ventilation_status"], + "tire_pressure_warning_2": ["tyre_pressure"], + } + for v2_key, v1_aliases in _V2_TO_V1.items(): + if v2_key in capability_flags: + for v1_key in v1_aliases: + capability_flags.setdefault(v1_key, capability_flags[v2_key]) + + # IDs with no v2 equivalent in the API: assume enabled when + # the broader feature category is present. + # - remote_control_fragrance: enabled if fragrance warning exists + # - remote_seat_preheat_switch: enabled if climate control exists + # - remote_trunk_open: enabled if trunk_status exists + _INFER: dict[str, str] = { + "remote_control_fragrance": "fragrance_exhausted_warning_2", + "remote_seat_preheat_switch": "remote_climate_control_2", + "remote_trunk_open": "trunk_status", + } + for v1_key, indicator in _INFER.items(): + if v1_key not in capability_flags and indicator in capability_flags: + capability_flags[v1_key] = capability_flags[indicator] + + # Also extract service_ids if present in this format + service_ids = [ + c.get("serviceId", "") + for c in cap_list + if c.get("serviceId") and c.get("enabled") + ] + return VehicleCapabilities( + service_ids=service_ids, + capability_flags=capability_flags, + ) + + # Fallback: legacy format with data.capabilities[] + caps = inner.get("capabilities", []) + if caps: + _LOGGER.debug( + "Capability response uses 'capabilities' format (%d entries) for %s", + len(caps), + vin[:6] + "...", + ) + service_ids = [c.get("serviceId", "") for c in caps if c.get("enabled")] + return VehicleCapabilities(service_ids=service_ids) + + _LOGGER.debug("Capability response empty for %s", vin[:6] + "...") + return VehicleCapabilities() async def async_get_energy_ranking( self, account: Account, vin: str, @@ -1074,16 +1152,49 @@ def _safe_bool(val: Any) -> bool | None: light_flash = _safe_bool(running_status.get("flash")) # ── Climate detailed ──────────────────────────────────────────── + # Position value 101 is a sentinel meaning "not equipped". + # Normalise to None so entities show as unavailable. + _NOT_EQUIPPED = 101 + window_position_driver = _safe_int(climate_raw.get("winPosDriver")) + if window_position_driver == _NOT_EQUIPPED: + window_position_driver = None window_position_passenger = _safe_int(climate_raw.get("winPosPassenger")) - window_position_driver_rear = _safe_int(climate_raw.get("winPosDriverRear")) - window_position_passenger_rear = _safe_int(climate_raw.get("winPosPassengerRear")) + if window_position_passenger == _NOT_EQUIPPED: + window_position_passenger = None + window_position_driver_rear = _safe_int( + climate_raw.get("winPosDriverRear") + ) + if window_position_driver_rear == _NOT_EQUIPPED: + window_position_driver_rear = None + window_position_passenger_rear = _safe_int( + climate_raw.get("winPosPassengerRear") + ) + if window_position_passenger_rear == _NOT_EQUIPPED: + window_position_passenger_rear = None + sunroof_position = _safe_int(climate_raw.get("sunroofPos")) - sunroof_open = _safe_bool(climate_raw.get("sunroofOpenStatus")) + if sunroof_position == _NOT_EQUIPPED: + sunroof_position = None + sunroof_open = None + else: + sunroof_open = _safe_bool(climate_raw.get("sunroofOpenStatus")) + sun_curtain_rear_position = _safe_int(climate_raw.get("sunCurtainRearPos")) - sun_curtain_rear_open = _safe_bool(climate_raw.get("sunCurtainRearOpenStatus")) + if sun_curtain_rear_position == _NOT_EQUIPPED: + sun_curtain_rear_position = None + sun_curtain_rear_open = None + else: + sun_curtain_rear_open = _safe_bool( + climate_raw.get("sunCurtainRearOpenStatus") + ) + curtain_position = _safe_int(climate_raw.get("curtainPos")) - curtain_open = _safe_bool(climate_raw.get("curtainOpenStatus")) + if curtain_position == _NOT_EQUIPPED: + curtain_position = None + curtain_open = None + else: + curtain_open = _safe_bool(climate_raw.get("curtainOpenStatus")) driver_seat_heating = _safe_int(climate_raw.get("drvHeatSts")) passenger_seat_heating = _safe_int(climate_raw.get("passHeatingSts")) rear_left_seat_heating = _safe_int(climate_raw.get("rlHeatingSts")) diff --git a/custom_components/hello_smart/binary_sensor.py b/custom_components/hello_smart/binary_sensor.py index 9d51293..bdda1e2 100644 --- a/custom_components/hello_smart/binary_sensor.py +++ b/custom_components/hello_smart/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -14,19 +15,36 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import ( + DOMAIN, + FUNCTION_ID_CHARGE_PORT_STATUS, + FUNCTION_ID_CURTAIN_STATUS, + FUNCTION_ID_DOOR_STATUS, + FUNCTION_ID_DOORS_STATUS, + FUNCTION_ID_FRAGRANCE, + FUNCTION_ID_HOOD_STATUS, + FUNCTION_ID_SKYLIGHT_STATUS, + FUNCTION_ID_TYRE_PRESSURE, + FUNCTION_ID_TRUNK_STATUS, + FUNCTION_ID_WINDOW_STATUS, +) from .coordinator import SmartDataCoordinator from .models import VehicleData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Hello Smart binary sensor entity.""" is_on_fn: Callable[[VehicleData], bool | None] + required_capability: str | None = None + equipped_fn: Callable[[VehicleData], bool] | None = None BINARY_SENSOR_DESCRIPTIONS: tuple[SmartBinarySensorEntityDescription, ...] = ( @@ -37,6 +55,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, is_on_fn=lambda data: data.status.doors.get("driver"), + required_capability=FUNCTION_ID_DOORS_STATUS, ), SmartBinarySensorEntityDescription( key="passenger_door", @@ -44,6 +63,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, is_on_fn=lambda data: data.status.doors.get("passenger"), + required_capability=FUNCTION_ID_DOORS_STATUS, ), SmartBinarySensorEntityDescription( key="rear_left_door", @@ -51,6 +71,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, is_on_fn=lambda data: data.status.doors.get("rear_left"), + required_capability=FUNCTION_ID_DOORS_STATUS, ), SmartBinarySensorEntityDescription( key="rear_right_door", @@ -58,6 +79,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, is_on_fn=lambda data: data.status.doors.get("rear_right"), + required_capability=FUNCTION_ID_DOORS_STATUS, ), SmartBinarySensorEntityDescription( key="trunk", @@ -65,6 +87,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-back", device_class=BinarySensorDeviceClass.DOOR, is_on_fn=lambda data: data.status.doors.get("trunk"), + required_capability=FUNCTION_ID_TRUNK_STATUS, ), # ── Windows ─────────────────────────────────────────────────── SmartBinarySensorEntityDescription( @@ -73,6 +96,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.WINDOW, is_on_fn=lambda data: data.status.windows.get("driver"), + required_capability=FUNCTION_ID_WINDOW_STATUS, ), SmartBinarySensorEntityDescription( key="passenger_window", @@ -80,6 +104,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.WINDOW, is_on_fn=lambda data: data.status.windows.get("passenger"), + required_capability=FUNCTION_ID_WINDOW_STATUS, ), SmartBinarySensorEntityDescription( key="rear_left_window", @@ -87,6 +112,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.WINDOW, is_on_fn=lambda data: data.status.windows.get("rear_left"), + required_capability=FUNCTION_ID_WINDOW_STATUS, ), SmartBinarySensorEntityDescription( key="rear_right_window", @@ -94,6 +120,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-door", device_class=BinarySensorDeviceClass.WINDOW, is_on_fn=lambda data: data.status.windows.get("rear_right"), + required_capability=FUNCTION_ID_WINDOW_STATUS, ), # ── Charging ────────────────────────────────────────────────── SmartBinarySensorEntityDescription( @@ -119,6 +146,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-tire-alert", device_class=BinarySensorDeviceClass.PROBLEM, is_on_fn=lambda data: data.status.tyre_warning_fl, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartBinarySensorEntityDescription( key="tyre_warning_fr", @@ -126,6 +154,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-tire-alert", device_class=BinarySensorDeviceClass.PROBLEM, is_on_fn=lambda data: data.status.tyre_warning_fr, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartBinarySensorEntityDescription( key="tyre_warning_rl", @@ -133,6 +162,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-tire-alert", device_class=BinarySensorDeviceClass.PROBLEM, is_on_fn=lambda data: data.status.tyre_warning_rl, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartBinarySensorEntityDescription( key="tyre_warning_rr", @@ -140,6 +170,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car-tire-alert", device_class=BinarySensorDeviceClass.PROBLEM, is_on_fn=lambda data: data.status.tyre_warning_rr, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), # ── Connectivity ────────────────────────────────────────────── SmartBinarySensorEntityDescription( @@ -189,6 +220,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): data.fragrance.active if data.fragrance else (data.status.fragrance_active if data.status.fragrance_active is not None else False) ), + required_capability=FUNCTION_ID_FRAGRANCE, ), SmartBinarySensorEntityDescription( key="locker_open", @@ -454,6 +486,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): if data.status.door_lock_driver is not None else False ), + required_capability=FUNCTION_ID_DOOR_STATUS, ), SmartBinarySensorEntityDescription( key="door_lock_passenger", @@ -465,6 +498,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): if data.status.door_lock_passenger is not None else False ), + required_capability=FUNCTION_ID_DOOR_STATUS, ), SmartBinarySensorEntityDescription( key="door_lock_rear_left", @@ -476,6 +510,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): if data.status.door_lock_driver_rear is not None else False ), + required_capability=FUNCTION_ID_DOOR_STATUS, ), SmartBinarySensorEntityDescription( key="door_lock_rear_right", @@ -487,6 +522,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): if data.status.door_lock_passenger_rear is not None else False ), + required_capability=FUNCTION_ID_DOOR_STATUS, ), SmartBinarySensorEntityDescription( key="trunk_locked", @@ -498,6 +534,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): if data.status.trunk_locked is not None else False ), + required_capability=FUNCTION_ID_TRUNK_STATUS, ), SmartBinarySensorEntityDescription( key="engine_hood", @@ -505,6 +542,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:car", device_class=BinarySensorDeviceClass.OPENING, is_on_fn=lambda data: data.status.engine_hood_open, + required_capability=FUNCTION_ID_HOOD_STATUS, ), SmartBinarySensorEntityDescription( key="electric_park_brake", @@ -566,6 +604,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:ev-plug-type2", device_class=BinarySensorDeviceClass.OPENING, is_on_fn=lambda data: data.status.charge_lid_ac_open, + required_capability=FUNCTION_ID_CHARGE_PORT_STATUS, ), SmartBinarySensorEntityDescription( key="charge_lid_dc", @@ -573,6 +612,7 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:ev-plug-ccs2", device_class=BinarySensorDeviceClass.OPENING, is_on_fn=lambda data: data.status.charge_lid_dc_open, + required_capability=FUNCTION_ID_CHARGE_PORT_STATUS, ), # ── Climate Status ──────────────────────────────────────────────── SmartBinarySensorEntityDescription( @@ -609,6 +649,8 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): icon="mdi:window-open-variant", device_class=BinarySensorDeviceClass.OPENING, is_on_fn=lambda data: data.status.sunroof_open, + required_capability=FUNCTION_ID_SKYLIGHT_STATUS, + equipped_fn=lambda data: data.status.sunroof_position is not None, ), SmartBinarySensorEntityDescription( key="sun_curtain_rear_open", @@ -617,6 +659,8 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): device_class=BinarySensorDeviceClass.OPENING, entity_registry_enabled_default=False, is_on_fn=lambda data: data.status.sun_curtain_rear_open, + required_capability=FUNCTION_ID_CURTAIN_STATUS, + equipped_fn=lambda data: data.status.sun_curtain_rear_position is not None, ), SmartBinarySensorEntityDescription( key="curtain_open", @@ -625,6 +669,8 @@ class SmartBinarySensorEntityDescription(BinarySensorEntityDescription): device_class=BinarySensorDeviceClass.OPENING, entity_registry_enabled_default=False, is_on_fn=lambda data: data.status.curtain_open, + required_capability=FUNCTION_ID_CURTAIN_STATUS, + equipped_fn=lambda data: data.status.curtain_position is not None, ), ) @@ -639,7 +685,36 @@ async def async_setup_entry( entities: list[SmartBinarySensorEntity] = [] for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) + skipped = 0 for description in BINARY_SENSOR_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + skipped += 1 + _LOGGER.debug( + "Skipping binary sensor '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue + if ( + description.equipped_fn is not None + and not description.equipped_fn(vehicle_data) + ): + skipped += 1 + _LOGGER.debug( + "Skipping binary sensor '%s' for %s: hardware not equipped", + description.key, + vin[:6] + "...", + ) + continue entities.append( SmartBinarySensorEntity( coordinator=coordinator, @@ -647,9 +722,29 @@ async def async_setup_entry( vin=vin, ) ) + if skipped: + _LOGGER.info( + "Filtered %d binary sensor(s) for %s based on capabilities", + skipped, + vin[:6] + "...", + ) async_add_entities(entities) + # Clean up stale entity registry entries for filtered-out entities + created_unique_ids = {e.unique_id for e in entities} + ent_reg = er.async_get(hass) + for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + if ( + reg_entry.domain == "binary_sensor" + and reg_entry.platform == DOMAIN + and reg_entry.unique_id not in created_unique_ids + ): + _LOGGER.debug( + "Removing stale binary sensor entity: %s", reg_entry.entity_id, + ) + ent_reg.async_remove(reg_entry.entity_id) + class SmartBinarySensorEntity( CoordinatorEntity[SmartDataCoordinator], BinarySensorEntity diff --git a/custom_components/hello_smart/button.py b/custom_components/hello_smart/button.py index 02fc84a..f8688ba 100644 --- a/custom_components/hello_smart/button.py +++ b/custom_components/hello_smart/button.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any @@ -12,15 +13,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_ID_HORN_LIGHT, SERVICE_ID_WINDOW_SET +from .const import DOMAIN, FUNCTION_ID_HONK_FLASH, FUNCTION_ID_WINDOW_CLOSE, SERVICE_ID_HORN_LIGHT, SERVICE_ID_WINDOW_SET from .coordinator import SmartDataCoordinator +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class SmartButtonEntityDescription(ButtonEntityDescription): """Describes a Hello Smart button entity.""" press_fn: Callable[[SmartDataCoordinator, str], Coroutine[Any, Any, None]] + required_capability: str | None = None async def _press_horn(coordinator: SmartDataCoordinator, vin: str) -> None: @@ -68,24 +72,28 @@ async def _press_close_windows(coordinator: SmartDataCoordinator, vin: str) -> N translation_key="smart_horn", icon="mdi:bullhorn", press_fn=_press_horn, + required_capability=FUNCTION_ID_HONK_FLASH, ), SmartButtonEntityDescription( key="smart_flash_lights", translation_key="smart_flash_lights", icon="mdi:car-light-high", press_fn=_press_flash, + required_capability=FUNCTION_ID_HONK_FLASH, ), SmartButtonEntityDescription( key="smart_find_my_car", translation_key="smart_find_my_car", icon="mdi:car-search", press_fn=_press_find_my_car, + required_capability=FUNCTION_ID_HONK_FLASH, ), SmartButtonEntityDescription( key="smart_close_windows", translation_key="smart_close_windows", icon="mdi:window-closed", press_fn=_press_close_windows, + required_capability=FUNCTION_ID_WINDOW_CLOSE, ), ] @@ -99,8 +107,24 @@ async def async_setup_entry( coordinator: SmartDataCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[SmartButton] = [] - for vin in coordinator.data: + for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) for description in BUTTON_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + _LOGGER.debug( + "Skipping button '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue entities.append( SmartButton( coordinator=coordinator, diff --git a/custom_components/hello_smart/climate.py b/custom_components/hello_smart/climate.py index 2f8ccd9..a98c00e 100644 --- a/custom_components/hello_smart/climate.py +++ b/custom_components/hello_smart/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.climate import ( @@ -15,9 +16,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_ID_CLIMATE +from .const import DOMAIN, FUNCTION_ID_CLIMATE, SERVICE_ID_CLIMATE from .coordinator import SmartDataCoordinator +_LOGGER = logging.getLogger(__name__) + MIN_TEMP = 16.0 MAX_TEMP = 30.0 CLIMATE_DURATION = 180 @@ -32,7 +35,19 @@ async def async_setup_entry( coordinator: SmartDataCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[SmartClimate] = [] - for vin in coordinator.data: + for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) + if not cap_flags.get(FUNCTION_ID_CLIMATE, False): + _LOGGER.debug( + "Skipping climate entity for %s: capability '%s' disabled", + vin[:6] + "...", + FUNCTION_ID_CLIMATE, + ) + continue entities.append(SmartClimate(coordinator=coordinator, vin=vin)) async_add_entities(entities) diff --git a/custom_components/hello_smart/const.py b/custom_components/hello_smart/const.py index 5baf5f6..b81ccc2 100644 --- a/custom_components/hello_smart/const.py +++ b/custom_components/hello_smart/const.py @@ -115,6 +115,32 @@ # --- Command settings --- COMMAND_COOLDOWN_SECONDS = 5 +# --- Capability function IDs (from APK FunctionId.java) --- +FUNCTION_ID_REMOTE_LOCK = "remote_control_lock" +FUNCTION_ID_REMOTE_UNLOCK = "remote_control_unlock" +FUNCTION_ID_CLIMATE = "remote_air_condition_switch" +FUNCTION_ID_WINDOW_CLOSE = "remote_window_close" +FUNCTION_ID_WINDOW_OPEN = "remote_window_open" +FUNCTION_ID_TRUNK_OPEN = "remote_trunk_open" +FUNCTION_ID_HONK_FLASH = "honk_flash" +FUNCTION_ID_SEAT_HEAT = "remote_seat_preheat_switch" +FUNCTION_ID_SEAT_VENT = "seat_ventilation_status" +FUNCTION_ID_FRAGRANCE = "remote_control_fragrance" +FUNCTION_ID_CHARGING = "charging_status" +FUNCTION_ID_DOOR_STATUS = "door_lock_switch_status" +FUNCTION_ID_TRUNK_STATUS = "trunk_status" +FUNCTION_ID_WINDOW_STATUS = "windows_rolling_status" +FUNCTION_ID_SKYLIGHT_STATUS = "skylight_rolling_status" +FUNCTION_ID_TYRE_PRESSURE = "tyre_pressure" +FUNCTION_ID_VEHICLE_POSITION = "vehicle_position" +FUNCTION_ID_TOTAL_MILEAGE = "total_mileage" +FUNCTION_ID_HOOD_STATUS = "engine_compartment_cover_status" +FUNCTION_ID_CHARGE_PORT_STATUS = "recharge_lid_status" +FUNCTION_ID_CURTAIN_STATUS = "curtain_status" +FUNCTION_ID_DOORS_STATUS = "vehiecle_doors_status" +FUNCTION_ID_CLIMATE_STATUS = "climate_status" +FUNCTION_ID_CHARGING_RESERVATION = "remote_appointment_charging" + # --- API URL paths --- API_SESSION_PATH = "/auth/account/session/secure" API_CARS_PATH = "/device-platform/user/vehicle/secure" diff --git a/custom_components/hello_smart/coordinator.py b/custom_components/hello_smart/coordinator.py index 6423d78..14dac3d 100644 --- a/custom_components/hello_smart/coordinator.py +++ b/custom_components/hello_smart/coordinator.py @@ -18,13 +18,34 @@ from .api import SmartAPI, TokenExpiredError from .auth import AuthenticationError, async_login_eu, async_login_intl from .const import COMMAND_COOLDOWN_SECONDS, DEFAULT_SCAN_INTERVAL, DOMAIN -from .models import Account, AuthState, ChargingReservation, CommandResult, OTAInfo, Region, VehicleData +from .models import ( + Account, + AuthState, + ChargingReservation, + CommandResult, + OTAInfo, + Region, + StaticVehicleData, + Vehicle, + VehicleData, + VehicleEdition, +) _LOGGER = logging.getLogger(__name__) CONF_SCAN_INTERVAL = "scan_interval" +def _build_model_label(vehicle: Vehicle) -> str: + """Build a display label like 'Smart #3 BRABUS' from vehicle data.""" + edition = vehicle.edition + model = vehicle.smart_model + if model.value != "Unknown" and edition != VehicleEdition.UNKNOWN: + return f"Smart {model.value} {edition.value}" + # Fall back to API model_name or generic label + return vehicle.model_name or "Smart Vehicle" + + class SmartDataCoordinator(DataUpdateCoordinator[dict[str, VehicleData]]): """Coordinator that polls Smart API for vehicle data.""" @@ -48,6 +69,7 @@ def __init__( self._api = SmartAPI(self._session) self._account: Account | None = None self._device_infos: dict[str, DeviceInfo] = {} + self._static_cache: dict[str, StaticVehicleData] = {} @property def account(self) -> Account | None: @@ -220,11 +242,12 @@ async def _async_fetch_all_vehicles( continue # Register device in HA + model_label = _build_model_label(vehicle) self._device_infos[vin] = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Smart", - model=vehicle.model_name or "Smart Vehicle", - name=vehicle.model_name or f"Smart {vin[-6:]}", + model=model_label, + name=model_label, model_id=vehicle.series_code or None, hw_version=vehicle.model_year or None, serial_number=vin, @@ -351,12 +374,62 @@ async def _async_fetch_all_vehicles( except Exception: _LOGGER.debug("Geofences unavailable for %s", vin[:6] + "...", exc_info=True) - # Fetch capabilities - capabilities = None - try: - capabilities = await self._api.async_get_capabilities(account, vin) - except Exception: - _LOGGER.debug("Capabilities unavailable for %s", vin[:6] + "...", exc_info=True) + # Fetch static data (capabilities, plant_no, ability) — cached after first poll + if vin not in self._static_cache: + capabilities = None + try: + capabilities = await self._api.async_get_capabilities(account, vin) + except Exception: + _LOGGER.debug("Capabilities unavailable for %s", vin[:6] + "...", exc_info=True) + + plant_no = None + try: + plant_no = await self._api.async_get_plant_no(account, vin) + except Exception: + _LOGGER.debug("Plant number unavailable for %s", vin[:6] + "...", exc_info=True) + + ability = None + vehicle_image_path = "" + try: + ability = await self._api.async_get_vehicle_ability( + account, vin, vehicle.model_code or "" + ) + if ability: + import os + www_dir = self.hass.config.path("www", "hello_smart") + if ability.images_path: + dest_file = os.path.join(www_dir, f"{vin}_side.png") + downloaded = await self._api.async_download_image( + ability.images_path, dest_file + ) + if downloaded: + vehicle_image_path = f"/local/hello_smart/{vin}_side.png" + if ability.interior_images_path: + dest_interior = os.path.join(www_dir, f"{vin}_interior.png") + await self._api.async_download_image( + ability.interior_images_path, dest_interior + ) + if ability.top_images_path: + dest_top = os.path.join(www_dir, f"{vin}_top.png") + await self._api.async_download_image( + ability.top_images_path, dest_top + ) + except Exception: + _LOGGER.debug("Vehicle ability unavailable for %s", vin[:6] + "...", exc_info=True) + + self._static_cache[vin] = StaticVehicleData( + capabilities=capabilities, + ability=ability, + plant_no=plant_no or "", + vehicle_image_path=vehicle_image_path, + ) + _LOGGER.debug("Cached static data for %s", vin[:6] + "...") + + cached = self._static_cache[vin] + capabilities = cached.capabilities + ability = cached.ability + plant_no = cached.plant_no + vehicle_image_path = cached.vehicle_image_path # Fetch energy ranking energy_ranking = None @@ -379,43 +452,6 @@ async def _async_fetch_all_vehicles( except Exception: _LOGGER.debug("FOTA notification unavailable for %s", vin[:6] + "...", exc_info=True) - # Fetch plant number for DeviceInfo - plant_no = None - try: - plant_no = await self._api.async_get_plant_no(account, vin) - except Exception: - _LOGGER.debug("Plant number unavailable for %s", vin[:6] + "...", exc_info=True) - - # Fetch vehicle ability (image URLs, color/trim config) - ability = None - vehicle_image_path = "" - try: - ability = await self._api.async_get_vehicle_ability( - account, vin, vehicle.model_code or "" - ) - if ability: - import os - www_dir = self.hass.config.path("www", "hello_smart") - if ability.images_path: - dest_file = os.path.join(www_dir, f"{vin}_side.png") - downloaded = await self._api.async_download_image( - ability.images_path, dest_file - ) - if downloaded: - vehicle_image_path = f"/local/hello_smart/{vin}_side.png" - if ability.interior_images_path: - dest_interior = os.path.join(www_dir, f"{vin}_interior.png") - await self._api.async_download_image( - ability.interior_images_path, dest_interior - ) - if ability.top_images_path: - dest_top = os.path.join(www_dir, f"{vin}_top.png") - await self._api.async_download_image( - ability.top_images_path, dest_top - ) - except Exception: - _LOGGER.debug("Vehicle ability unavailable for %s", vin[:6] + "...", exc_info=True) - except Exception as err: _LOGGER.warning( "Failed to fetch status for vehicle %s: %s", @@ -427,11 +463,12 @@ async def _async_fetch_all_vehicles( # Update DeviceInfo with sw_version from OTA and configuration_url if ota and ota.current_version: + model_label = _build_model_label(vehicle) self._device_infos[vin] = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Smart", - model=vehicle.model_name or "Smart Vehicle", - name=vehicle.model_name or f"Smart {vin[-6:]}", + model=model_label, + name=model_label, model_id=vehicle.series_code or None, hw_version=vehicle.model_year or None, sw_version=ota.current_version, diff --git a/custom_components/hello_smart/frontend/hello-smart-charge-card.js b/custom_components/hello_smart/frontend/hello-smart-charge-card.js index a7b151b..e2d5f75 100644 --- a/custom_components/hello_smart/frontend/hello-smart-charge-card.js +++ b/custom_components/hello_smart/frontend/hello-smart-charge-card.js @@ -49,6 +49,24 @@ class HelloSmartChargeCard extends HTMLElement { this._hass = null; this._keyMap = {}; this._deviceId = null; + this._connected = false; + } + + connectedCallback() { + this._connected = true; + // Re-render when attached to DOM — hass may have arrived before connection + if (this._hass && this._config.entity) { + try { + this._buildDeviceEntityMap(); + this._render(); + } catch (err) { + this._renderError("connectedCallback", err); + } + } + } + + disconnectedCallback() { + this._connected = false; } static getStubConfig(hass) { @@ -76,6 +94,17 @@ class HelloSmartChargeCard extends HTMLElement { show_12v: false, ...config, }; + // Show loading placeholder until hass arrives + if (!this._hass) { + this.shadowRoot.innerHTML = ` + +
+ +

Loading charge status…

+
+
`; + return; + } try { this._render(); } catch (err) { diff --git a/custom_components/hello_smart/frontend/hello-smart-vehicle-card.js b/custom_components/hello_smart/frontend/hello-smart-vehicle-card.js index f42f2c1..49a01c8 100644 --- a/custom_components/hello_smart/frontend/hello-smart-vehicle-card.js +++ b/custom_components/hello_smart/frontend/hello-smart-vehicle-card.js @@ -73,6 +73,24 @@ class HelloSmartVehicleCard extends HTMLElement { // Map of translation_key → entity_id, built from device registry this._keyMap = {}; this._deviceId = null; + this._connected = false; + } + + connectedCallback() { + this._connected = true; + // Re-render when attached to DOM — hass may have arrived before connection + if (this._hass && this._config.entity) { + try { + this._buildDeviceEntityMap(); + this._render(); + } catch (err) { + this._renderError("connectedCallback", err); + } + } + } + + disconnectedCallback() { + this._connected = false; } static getStubConfig(hass) { @@ -103,7 +121,17 @@ class HelloSmartVehicleCard extends HTMLElement { show_locks: true, ...config, }; - // Auto-detect will happen in the hass setter when hass is available + // Show loading placeholder until hass arrives + if (!this._hass) { + this.shadowRoot.innerHTML = ` + +
+ +

Loading vehicle status…

+
+
`; + return; + } try { this._render(); } catch (err) { diff --git a/custom_components/hello_smart/lock.py b/custom_components/hello_smart/lock.py index 2320d4e..c4d2e61 100644 --- a/custom_components/hello_smart/lock.py +++ b/custom_components/hello_smart/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any @@ -12,10 +13,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_ID_DOOR_LOCK, SERVICE_ID_DOOR_UNLOCK +from .const import DOMAIN, FUNCTION_ID_REMOTE_LOCK, SERVICE_ID_DOOR_LOCK, SERVICE_ID_DOOR_UNLOCK from .coordinator import SmartDataCoordinator from .models import VehicleData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class SmartLockEntityDescription(LockEntityDescription): @@ -25,6 +28,7 @@ class SmartLockEntityDescription(LockEntityDescription): unlock_fn: Callable[[SmartDataCoordinator, str], Coroutine[Any, Any, None]] is_locked_fn: Callable[[VehicleData], bool | None] available_fn: Callable[[VehicleData], bool] + required_capability: str | None = None async def _lock_doors(coordinator: SmartDataCoordinator, vin: str) -> None: @@ -82,6 +86,7 @@ async def _unlock_locker(coordinator: SmartDataCoordinator, vin: str) -> None: data.status.door_lock_passenger_rear, ) ), + required_capability=FUNCTION_ID_REMOTE_LOCK, ), SmartLockEntityDescription( key="smart_trunk_locker", @@ -105,7 +110,23 @@ async def async_setup_entry( entities: list[SmartLock] = [] for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) for description in LOCK_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + _LOGGER.debug( + "Skipping lock '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue if not description.available_fn(vehicle_data): continue entities.append( diff --git a/custom_components/hello_smart/manifest.json b/custom_components/hello_smart/manifest.json index c578fe3..f28fc55 100644 --- a/custom_components/hello_smart/manifest.json +++ b/custom_components/hello_smart/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/onpremcloudguy/HelloSmart_HomeAssistant/issues", "requirements": [], - "version": "0.4.5" + "version": "0.5.0" } diff --git a/custom_components/hello_smart/models.py b/custom_components/hello_smart/models.py index 0dbd20e..2e8711d 100644 --- a/custom_components/hello_smart/models.py +++ b/custom_components/hello_smart/models.py @@ -24,6 +24,92 @@ class AuthState(enum.StrEnum): AUTH_FAILED = "auth_failed" +class VehicleModel(enum.StrEnum): + """Smart vehicle model derived from matCode[0:3] or seriesCodeVs. + + From APK VehicleModel.java and VehicleInfoConstants.java: + HX1 / HX11 = Smart #1 + HC1 / HC11 = Smart #3 + HY1 / HY11 = Smart #5 + """ + + HASHTAG_ONE = "#1" + HASHTAG_THREE = "#3" + HASHTAG_FIVE = "#5" + UNKNOWN = "Unknown" + + @staticmethod + def from_codes(mat_code: str, series_code: str = "") -> VehicleModel: + """Derive model from matCode[0:3] or seriesCodeVs.""" + _PREFIX_MAP = { + "HX1": VehicleModel.HASHTAG_ONE, + "HC1": VehicleModel.HASHTAG_THREE, + "HY1": VehicleModel.HASHTAG_FIVE, + } + if len(mat_code) >= 3: + model = _PREFIX_MAP.get(mat_code[:3]) + if model: + return model + _SERIES_MAP = { + "HX11": VehicleModel.HASHTAG_ONE, + "HC11": VehicleModel.HASHTAG_THREE, + "HY11": VehicleModel.HASHTAG_FIVE, + } + # Strip regional suffix (e.g. "HC11_IL" → "HC11") + base = series_code.split("_")[0] if series_code else "" + return _SERIES_MAP.get(base, VehicleModel.UNKNOWN) + + +class VehicleEdition(enum.StrEnum): + """Vehicle trim / edition derived from matCode[5:7]. + + Feature availability per edition (from APK VehicleEdition.java): + - PURE: no driver seat heating, no PM2.5 sensor + - PRO: no PM2.5 sensor + - PULSE: all features + - PREMIUM: all features + - BRABUS: all features (top of range) + - LAUNCH: all features + - UNKNOWN: treat as fully equipped (no filtering) + + Applies identically across all models (#1, #3, #5). + """ + + PURE = "Pure" + PRO = "Pro" + PULSE = "Pulse" + PREMIUM = "Premium" + BRABUS = "BRABUS" + LAUNCH = "Launch Edition" + UNKNOWN = "Unknown" + + @property + def has_driver_seat_heating(self) -> bool: + """Pure trim lacks driver seat heating.""" + return self != VehicleEdition.PURE + + @property + def has_pm25(self) -> bool: + """Pure and Pro trims lack PM2.5 air quality sensor.""" + return self not in (VehicleEdition.PURE, VehicleEdition.PRO) + + @staticmethod + def from_mat_code(mat_code: str) -> VehicleEdition: + """Extract edition from matCode positions [5:7].""" + if len(mat_code) < 7: + return VehicleEdition.UNKNOWN + code = mat_code[5:7] + _MAP = { + "D3": VehicleEdition.BRABUS, + "D2": VehicleEdition.PREMIUM, + "D1": VehicleEdition.PRO, + "GN": VehicleEdition.PULSE, + "80": VehicleEdition.PURE, + "01": VehicleEdition.LAUNCH, + } + return _MAP.get(code, VehicleEdition.UNKNOWN) + + class ChargingState(enum.StrEnum): """Human-readable charging states mapped from API chargerState codes.""" @@ -134,6 +220,16 @@ class Vehicle: ihu_id: str = "" tem_type: str = "" + @property + def edition(self) -> VehicleEdition: + """Derive the vehicle edition/trim from matCode.""" + return VehicleEdition.from_mat_code(self.mat_code) + + @property + def smart_model(self) -> VehicleModel: + """Derive the Smart model (#1/#3/#5) from matCode or seriesCode.""" + return VehicleModel.from_codes(self.mat_code, self.series_code) + @dataclass class VehicleStatus: @@ -408,6 +504,17 @@ class VehicleCapabilities: """Feature capability flags for dynamic entity registration.""" service_ids: list[str] = field(default_factory=list) + capability_flags: dict[str, bool] = field(default_factory=dict) + + +@dataclass +class StaticVehicleData: + """Cached static vehicle data fetched once per session.""" + + capabilities: VehicleCapabilities | None = None + ability: VehicleAbility | None = None + plant_no: str = "" + vehicle_image_path: str = "" @dataclass diff --git a/custom_components/hello_smart/number.py b/custom_components/hello_smart/number.py index f41e2fb..d7dbfcc 100644 --- a/custom_components/hello_smart/number.py +++ b/custom_components/hello_smart/number.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.number import NumberEntity, NumberEntityDescription @@ -11,10 +12,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, FUNCTION_ID_CHARGING_RESERVATION from .coordinator import SmartDataCoordinator from .models import VehicleData +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -26,8 +29,20 @@ async def async_setup_entry( entities: list[SmartNumber] = [] for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) if vehicle_data.charging_reservation is not None: - entities.append(SmartTargetSOC(coordinator=coordinator, vin=vin)) + if not cap_flags.get(FUNCTION_ID_CHARGING_RESERVATION, False): + _LOGGER.debug( + "Skipping number 'target_soc' for %s: capability '%s' disabled", + vin[:6] + "...", + FUNCTION_ID_CHARGING_RESERVATION, + ) + else: + entities.append(SmartTargetSOC(coordinator=coordinator, vin=vin)) async_add_entities(entities) diff --git a/custom_components/hello_smart/select.py b/custom_components/hello_smart/select.py index 905e695..9b51545 100644 --- a/custom_components/hello_smart/select.py +++ b/custom_components/hello_smart/select.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any @@ -9,12 +10,15 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_ID_SEAT_HEAT, SERVICE_ID_SEAT_VENT +from .const import DOMAIN, FUNCTION_ID_SEAT_HEAT, FUNCTION_ID_SEAT_VENT, SERVICE_ID_SEAT_HEAT, SERVICE_ID_SEAT_VENT from .coordinator import SmartDataCoordinator -from .models import VehicleData +from .models import VehicleData, VehicleEdition + +_LOGGER = logging.getLogger(__name__) SEAT_HEAT_OPTIONS = ["off", "low", "medium", "high"] SEAT_HEAT_LEVEL_MAP = {"off": "0", "low": "1", "medium": "2", "high": "3"} @@ -34,6 +38,8 @@ class SmartSelectEntityDescription(SelectEntityDescription): ] current_option_fn: Callable[[VehicleData], str | None] seat_key: str + required_capability: str | None = None + edition_check: Callable[[VehicleEdition], bool] | None = None async def _set_seat_heat( @@ -79,6 +85,8 @@ async def _set_seat_vent( current_option_fn=lambda data: SEAT_HEAT_REVERSE_MAP.get( data.status.driver_seat_heating or 0, "off", ), + required_capability=FUNCTION_ID_SEAT_HEAT, + edition_check=lambda e: e.has_driver_seat_heating, ), SmartSelectEntityDescription( key="passenger_seat_heating_control", @@ -92,6 +100,8 @@ async def _set_seat_vent( current_option_fn=lambda data: SEAT_HEAT_REVERSE_MAP.get( data.status.passenger_seat_heating or 0, "off", ), + required_capability=FUNCTION_ID_SEAT_HEAT, + edition_check=lambda e: e.has_driver_seat_heating, ), SmartSelectEntityDescription( key="steering_wheel_heating_control", @@ -105,6 +115,7 @@ async def _set_seat_vent( current_option_fn=lambda data: SEAT_HEAT_REVERSE_MAP.get( data.status.steering_wheel_heating or 0, "off", ), + required_capability=FUNCTION_ID_SEAT_HEAT, ), # ── Seat Ventilation ──────────────────────────────────────────────── SmartSelectEntityDescription( @@ -119,6 +130,7 @@ async def _set_seat_vent( current_option_fn=lambda data: SEAT_VENT_REVERSE_MAP.get( data.status.driver_seat_ventilation or 0, "off", ), + required_capability=FUNCTION_ID_SEAT_VENT, ), ) @@ -132,8 +144,36 @@ async def async_setup_entry( coordinator: SmartDataCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[SmartSelectEntity] = [] - for vin in coordinator.data: + for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) + edition = vehicle_data.vehicle.edition for description in SELECT_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + _LOGGER.debug( + "Skipping select '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue + if ( + description.edition_check is not None + and not description.edition_check(edition) + ): + _LOGGER.debug( + "Skipping select '%s' for %s: not available on %s edition", + description.key, + vin[:6] + "...", + edition.value, + ) + continue entities.append( SmartSelectEntity( coordinator=coordinator, @@ -144,6 +184,20 @@ async def async_setup_entry( async_add_entities(entities) + # Clean up stale entity registry entries for filtered-out entities + created_unique_ids = {e.unique_id for e in entities} + ent_reg = er.async_get(hass) + for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + if ( + reg_entry.domain == "select" + and reg_entry.platform == DOMAIN + and reg_entry.unique_id not in created_unique_ids + ): + _LOGGER.debug( + "Removing stale select entity: %s", reg_entry.entity_id, + ) + ent_reg.async_remove(reg_entry.entity_id) + class SmartSelectEntity(CoordinatorEntity[SmartDataCoordinator], SelectEntity): """Representation of a Hello Smart select entity.""" diff --git a/custom_components/hello_smart/sensor.py b/custom_components/hello_smart/sensor.py index 6b1e7f2..fd08702 100644 --- a/custom_components/hello_smart/sensor.py +++ b/custom_components/hello_smart/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -27,12 +28,25 @@ UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import ( + DOMAIN, + FUNCTION_ID_CHARGING, + FUNCTION_ID_CHARGING_RESERVATION, + FUNCTION_ID_CLIMATE_STATUS, + FUNCTION_ID_FRAGRANCE, + FUNCTION_ID_SEAT_HEAT, + FUNCTION_ID_SEAT_VENT, + FUNCTION_ID_TOTAL_MILEAGE, + FUNCTION_ID_TYRE_PRESSURE, +) from .coordinator import SmartDataCoordinator -from .models import ChargingState, PowerMode, VehicleData +from .models import ChargingState, PowerMode, VehicleData, VehicleEdition + +_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) @@ -40,6 +54,9 @@ class SmartSensorEntityDescription(SensorEntityDescription): """Describes a Hello Smart sensor entity.""" value_fn: Callable[[VehicleData], Any] + required_capability: str | None = None + edition_check: Callable[[VehicleEdition], bool] | None = None + equipped_fn: Callable[[VehicleData], bool] | None = None SENSOR_DESCRIPTIONS: tuple[SmartSensorEntityDescription, ...] = ( @@ -70,6 +87,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, options=[state.value for state in ChargingState], value_fn=lambda data: data.status.charging_state.value, + required_capability=FUNCTION_ID_CHARGING, ), SmartSensorEntityDescription( key="charge_voltage", @@ -80,6 +98,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.status.charge_voltage or 0, + required_capability=FUNCTION_ID_CHARGING, ), SmartSensorEntityDescription( key="charge_current", @@ -90,6 +109,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.status.charge_current or 0, + required_capability=FUNCTION_ID_CHARGING, ), SmartSensorEntityDescription( key="time_to_full", @@ -99,6 +119,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.status.time_to_full or 0, + required_capability=FUNCTION_ID_CHARGING, ), # ── Firmware ─────────────────────────────────────────────────────── SmartSensorEntityDescription( @@ -125,6 +146,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, value_fn=lambda data: data.status.tyre_pressure_fl, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_pressure_fr", @@ -135,6 +157,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, value_fn=lambda data: data.status.tyre_pressure_fr, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_pressure_rl", @@ -145,6 +168,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, value_fn=lambda data: data.status.tyre_pressure_rl, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_pressure_rr", @@ -155,6 +179,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, value_fn=lambda data: data.status.tyre_pressure_rr, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_temp_fl", @@ -166,6 +191,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, entity_registry_enabled_default=False, value_fn=lambda data: data.status.tyre_temp_fl, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_temp_fr", @@ -177,6 +203,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, entity_registry_enabled_default=False, value_fn=lambda data: data.status.tyre_temp_fr, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_temp_rl", @@ -188,6 +215,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, entity_registry_enabled_default=False, value_fn=lambda data: data.status.tyre_temp_rl, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), SmartSensorEntityDescription( key="tyre_temp_rr", @@ -199,6 +227,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, entity_registry_enabled_default=False, value_fn=lambda data: data.status.tyre_temp_rr, + required_capability=FUNCTION_ID_TYRE_PRESSURE, ), # ── Maintenance ──────────────────────────────────────────────────── SmartSensorEntityDescription( @@ -210,6 +239,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=0, value_fn=lambda data: data.status.odometer, + required_capability=FUNCTION_ID_TOTAL_MILEAGE, ), SmartSensorEntityDescription( key="days_to_service", @@ -301,6 +331,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): "active" if data.charging_reservation and data.charging_reservation.active else "inactive" ), + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), SmartSensorEntityDescription( key="charging_schedule_start", @@ -311,6 +342,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.charging_reservation and data.charging_reservation.start_time else "" ), + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), SmartSensorEntityDescription( key="charging_schedule_end", @@ -321,6 +353,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.charging_reservation and data.charging_reservation.end_time else "" ), + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), SmartSensorEntityDescription( key="charging_target_soc", @@ -332,6 +365,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.charging_reservation and data.charging_reservation.target_soc is not None else 0 ), + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), # ── Climate Schedule ────────────────────────────────────────────── SmartSensorEntityDescription( @@ -344,6 +378,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): "enabled" if data.climate_schedule and data.climate_schedule.enabled else "disabled" ), + required_capability=FUNCTION_ID_CLIMATE_STATUS, ), SmartSensorEntityDescription( key="climate_schedule_time", @@ -354,6 +389,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.climate_schedule and data.climate_schedule.scheduled_time else "" ), + required_capability=FUNCTION_ID_CLIMATE_STATUS, ), # ── Trip Journal ──────────────────────────────────────────────────── SmartSensorEntityDescription( @@ -428,6 +464,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.fragrance and data.fragrance.level else "" ), + required_capability=FUNCTION_ID_FRAGRANCE, ), # ── Security & Geofences ─────────────────────────────────────────── SmartSensorEntityDescription( @@ -467,6 +504,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.climate_schedule else 0 ), + required_capability=FUNCTION_ID_CLIMATE_STATUS, ), SmartSensorEntityDescription( key="climate_schedule_duration", @@ -479,6 +517,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): if data.climate_schedule else 0 ), + required_capability=FUNCTION_ID_CLIMATE_STATUS, ), SmartSensorEntityDescription( key="fridge_mode", @@ -612,6 +651,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.status.dc_charge_current or 0, + required_capability=FUNCTION_ID_CHARGING, ), SmartSensorEntityDescription( key="charging_power", @@ -622,6 +662,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, value_fn=lambda data: data.status.charging_power or 0, + required_capability=FUNCTION_ID_CHARGING, ), # ── Climate Detailed ─────────────────────────────────────────────── SmartSensorEntityDescription( @@ -632,6 +673,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.window_position_driver, + equipped_fn=lambda data: data.status.window_position_driver is not None, ), SmartSensorEntityDescription( key="window_position_passenger", @@ -641,6 +683,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.window_position_passenger, + equipped_fn=lambda data: data.status.window_position_passenger is not None, ), SmartSensorEntityDescription( key="window_position_driver_rear", @@ -650,6 +693,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.window_position_driver_rear, + equipped_fn=lambda data: data.status.window_position_driver_rear is not None, ), SmartSensorEntityDescription( key="window_position_passenger_rear", @@ -659,6 +703,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.window_position_passenger_rear, + equipped_fn=lambda data: data.status.window_position_passenger_rear is not None, ), SmartSensorEntityDescription( key="sunroof_position", @@ -668,6 +713,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.sunroof_position, + equipped_fn=lambda data: data.status.sunroof_position is not None, ), SmartSensorEntityDescription( key="curtain_position", @@ -677,6 +723,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.curtain_position, + equipped_fn=lambda data: data.status.curtain_position is not None, ), SmartSensorEntityDescription( key="sun_curtain_rear_position", @@ -686,6 +733,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.sun_curtain_rear_position, + equipped_fn=lambda data: data.status.sun_curtain_rear_position is not None, ), # ── Seat Heating / Ventilation ───────────────────────────────────── SmartSensorEntityDescription( @@ -694,6 +742,8 @@ class SmartSensorEntityDescription(SensorEntityDescription): icon="mdi:car-seat-heater", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.status.driver_seat_heating, + required_capability=FUNCTION_ID_SEAT_HEAT, + edition_check=lambda e: e.has_driver_seat_heating, ), SmartSensorEntityDescription( key="passenger_seat_heating", @@ -701,6 +751,8 @@ class SmartSensorEntityDescription(SensorEntityDescription): icon="mdi:car-seat-heater", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.status.passenger_seat_heating, + required_capability=FUNCTION_ID_SEAT_HEAT, + edition_check=lambda e: e.has_driver_seat_heating, ), SmartSensorEntityDescription( key="rear_left_seat_heating", @@ -709,6 +761,8 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.rear_left_seat_heating, + required_capability=FUNCTION_ID_SEAT_HEAT, + edition_check=lambda e: e.has_driver_seat_heating, ), SmartSensorEntityDescription( key="rear_right_seat_heating", @@ -717,6 +771,8 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.rear_right_seat_heating, + required_capability=FUNCTION_ID_SEAT_HEAT, + edition_check=lambda e: e.has_driver_seat_heating, ), SmartSensorEntityDescription( key="driver_seat_ventilation", @@ -724,6 +780,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): icon="mdi:car-seat-cooler", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.status.driver_seat_ventilation, + required_capability=FUNCTION_ID_SEAT_VENT, ), SmartSensorEntityDescription( key="passenger_seat_ventilation", @@ -731,6 +788,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): icon="mdi:car-seat-cooler", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.status.passenger_seat_ventilation, + required_capability=FUNCTION_ID_SEAT_VENT, ), SmartSensorEntityDescription( key="rear_left_seat_ventilation", @@ -739,6 +797,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.rear_left_seat_ventilation, + required_capability=FUNCTION_ID_SEAT_VENT, ), SmartSensorEntityDescription( key="rear_right_seat_ventilation", @@ -747,6 +806,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.rear_right_seat_ventilation, + required_capability=FUNCTION_ID_SEAT_VENT, ), SmartSensorEntityDescription( key="steering_wheel_heating", @@ -763,6 +823,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement="µg/m³", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.status.interior_pm25, + edition_check=lambda e: e.has_pm25, ), SmartSensorEntityDescription( key="interior_pm25_level", @@ -771,6 +832,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.interior_pm25_level, + edition_check=lambda e: e.has_pm25, ), SmartSensorEntityDescription( key="exterior_pm25_level", @@ -779,6 +841,7 @@ class SmartSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, value_fn=lambda data: data.status.exterior_pm25_level, + edition_check=lambda e: e.has_pm25, ), SmartSensorEntityDescription( key="relative_humidity", @@ -869,6 +932,20 @@ class SmartSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=lambda data: data.vehicle.mat_code or None, ), + SmartSensorEntityDescription( + key="vehicle_edition", + translation_key="vehicle_edition", + icon="mdi:car-info", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.vehicle.edition.value, + ), + SmartSensorEntityDescription( + key="vehicle_model", + translation_key="vehicle_model", + icon="mdi:car-info", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.vehicle.smart_model.value, + ), SmartSensorEntityDescription( key="fuel_tank_capacity", translation_key="fuel_tank_capacity", @@ -985,7 +1062,49 @@ async def async_setup_entry( entities: list[SmartSensorEntity] = [] for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) + edition = vehicle_data.vehicle.edition + skipped = 0 for description in SENSOR_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + skipped += 1 + _LOGGER.debug( + "Skipping sensor '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue + if ( + description.edition_check is not None + and not description.edition_check(edition) + ): + skipped += 1 + _LOGGER.debug( + "Skipping sensor '%s' for %s: not available on %s edition", + description.key, + vin[:6] + "...", + edition.value, + ) + continue + if ( + description.equipped_fn is not None + and not description.equipped_fn(vehicle_data) + ): + skipped += 1 + _LOGGER.debug( + "Skipping sensor '%s' for %s: hardware not equipped", + description.key, + vin[:6] + "...", + ) + continue entities.append( SmartSensorEntity( coordinator=coordinator, @@ -993,9 +1112,29 @@ async def async_setup_entry( vin=vin, ) ) + if skipped: + _LOGGER.info( + "Filtered %d sensor(s) for %s based on capabilities", + skipped, + vin[:6] + "...", + ) async_add_entities(entities) + # Clean up stale entity registry entries for filtered-out entities + created_unique_ids = {e.unique_id for e in entities} + ent_reg = er.async_get(hass) + for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + if ( + reg_entry.domain == "sensor" + and reg_entry.platform == DOMAIN + and reg_entry.unique_id not in created_unique_ids + ): + _LOGGER.debug( + "Removing stale sensor entity: %s", reg_entry.entity_id, + ) + ent_reg.async_remove(reg_entry.entity_id) + class SmartSensorEntity(CoordinatorEntity[SmartDataCoordinator], SensorEntity): """Representation of a Hello Smart sensor.""" diff --git a/custom_components/hello_smart/strings.json b/custom_components/hello_smart/strings.json index 1d68fa8..63564f6 100644 --- a/custom_components/hello_smart/strings.json +++ b/custom_components/hello_smart/strings.json @@ -121,6 +121,8 @@ "series_name": { "name": "Series" }, "vehicle_type": { "name": "Vehicle type" }, "mat_code": { "name": "Material code" }, + "vehicle_edition": { "name": "Edition" }, + "vehicle_model": { "name": "Model" }, "fuel_tank_capacity": { "name": "Fuel tank capacity" }, "ihu_platform": { "name": "IHU platform" }, "tbox_platform": { "name": "T-Box platform" }, diff --git a/custom_components/hello_smart/switch.py b/custom_components/hello_smart/switch.py index 2fd2f39..8629dfd 100644 --- a/custom_components/hello_smart/switch.py +++ b/custom_components/hello_smart/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any @@ -12,10 +13,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_ID_CHARGING, SERVICE_ID_FRIDGE +from .const import DOMAIN, FUNCTION_ID_CHARGING_RESERVATION, FUNCTION_ID_FRAGRANCE, SERVICE_ID_CHARGING, SERVICE_ID_FRIDGE from .coordinator import SmartDataCoordinator from .models import ChargingState, VehicleData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class SmartSwitchEntityDescription(SwitchEntityDescription): @@ -25,6 +28,7 @@ class SmartSwitchEntityDescription(SwitchEntityDescription): turn_off_fn: Callable[[SmartDataCoordinator, str], Coroutine[Any, Any, None]] is_on_fn: Callable[[VehicleData], bool | None] available_fn: Callable[[VehicleData], bool] + required_capability: str | None = None async def _charging_start(coordinator: SmartDataCoordinator, vin: str) -> None: @@ -148,6 +152,7 @@ async def _climate_schedule_off(coordinator: SmartDataCoordinator, vin: str) -> turn_off_fn=_fragrance_off, is_on_fn=lambda data: data.fragrance.active if data.fragrance else None, available_fn=lambda data: data.fragrance is not None, + required_capability=FUNCTION_ID_FRAGRANCE, ), SmartSwitchEntityDescription( key="smart_vtm", @@ -166,6 +171,7 @@ async def _climate_schedule_off(coordinator: SmartDataCoordinator, vin: str) -> turn_off_fn=_climate_schedule_off, is_on_fn=lambda data: data.climate_schedule.enabled if data.climate_schedule else None, available_fn=lambda data: data.climate_schedule is not None, + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), ] @@ -180,7 +186,23 @@ async def async_setup_entry( entities: list[SmartSwitch] = [] for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) for description in SWITCH_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + _LOGGER.debug( + "Skipping switch '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue if not description.available_fn(vehicle_data): continue entities.append( diff --git a/custom_components/hello_smart/time.py b/custom_components/hello_smart/time.py index f53e2d5..80f57be 100644 --- a/custom_components/hello_smart/time.py +++ b/custom_components/hello_smart/time.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time as dt_time @@ -13,10 +14,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, FUNCTION_ID_CHARGING_RESERVATION from .coordinator import SmartDataCoordinator from .models import VehicleData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class SmartTimeEntityDescription(TimeEntityDescription): @@ -27,6 +30,7 @@ class SmartTimeEntityDescription(TimeEntityDescription): ] native_value_fn: Callable[[VehicleData], dt_time | None] available_fn: Callable[[VehicleData], bool] + required_capability: str | None = None def _parse_time_str(time_str: str) -> dt_time | None: @@ -97,6 +101,7 @@ async def _set_climate_schedule_time( if data.charging_reservation else None, available_fn=lambda data: data.charging_reservation is not None, + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), SmartTimeEntityDescription( key="smart_charging_end", @@ -109,6 +114,7 @@ async def _set_climate_schedule_time( if data.charging_reservation else None, available_fn=lambda data: data.charging_reservation is not None, + required_capability=FUNCTION_ID_CHARGING_RESERVATION, ), SmartTimeEntityDescription( key="smart_climate_schedule_time", @@ -135,7 +141,23 @@ async def async_setup_entry( entities: list[SmartTime] = [] for vin, vehicle_data in coordinator.data.items(): + cap_flags = ( + vehicle_data.capabilities.capability_flags + if vehicle_data.capabilities + else {} + ) for description in TIME_DESCRIPTIONS: + if ( + description.required_capability is not None + and not cap_flags.get(description.required_capability, False) + ): + _LOGGER.debug( + "Skipping time '%s' for %s: capability '%s' disabled", + description.key, + vin[:6] + "...", + description.required_capability, + ) + continue if not description.available_fn(vehicle_data): continue entities.append( diff --git a/custom_components/hello_smart/translations/en.json b/custom_components/hello_smart/translations/en.json index 1d68fa8..63564f6 100644 --- a/custom_components/hello_smart/translations/en.json +++ b/custom_components/hello_smart/translations/en.json @@ -121,6 +121,8 @@ "series_name": { "name": "Series" }, "vehicle_type": { "name": "Vehicle type" }, "mat_code": { "name": "Material code" }, + "vehicle_edition": { "name": "Edition" }, + "vehicle_model": { "name": "Model" }, "fuel_tank_capacity": { "name": "Fuel tank capacity" }, "ihu_platform": { "name": "IHU platform" }, "tbox_platform": { "name": "T-Box platform" }, diff --git a/dashboards/smart-vehicle-basic.yaml b/dashboards/smart-vehicle-basic.yaml index 0c0eb9a..8e7f5b2 100644 --- a/dashboards/smart-vehicle-basic.yaml +++ b/dashboards/smart-vehicle-basic.yaml @@ -246,12 +246,22 @@ views: - entity: sensor.smart_234118_window_position_passenger_rear name: Rear Right Position icon: mdi:arrow-up-down - - entity: binary_sensor.smart_234118_sunroof_open - name: Sunroof - icon: mdi:car-select - - entity: sensor.smart_234118_sunroof_position - name: Sunroof Position - icon: mdi:arrow-up-down + - type: conditional + conditions: + - entity: binary_sensor.smart_234118_sunroof_open + state_not: unavailable + row: + entity: binary_sensor.smart_234118_sunroof_open + name: Sunroof + icon: mdi:car-select + - type: conditional + conditions: + - entity: sensor.smart_234118_sunroof_position + state_not: unavailable + row: + entity: sensor.smart_234118_sunroof_position + name: Sunroof Position + icon: mdi:arrow-up-down - entity: button.smart_234118_smart_close_windows name: Close All Windows icon: mdi:window-closed diff --git a/dashboards/smart-vehicle.yaml b/dashboards/smart-vehicle.yaml index bebf5e4..dc7b6b4 100644 --- a/dashboards/smart-vehicle.yaml +++ b/dashboards/smart-vehicle.yaml @@ -419,20 +419,25 @@ views: --primary-text-color: #FFFFFF; --secondary-text-color: #B3B3B3; } - - type: custom:mushroom-entity-card - entity: binary_sensor.smart_234118_sunroof_open - name: Sunroof - icon: mdi:car-select - secondary_info: >- - Position: {{ states('sensor.smart_234118_sunroof_position') }}% - card_mod: - style: | - ha-card { - background: #1E1E1E; - border: 1px solid #333; - --primary-text-color: #FFFFFF; - --secondary-text-color: #B3B3B3; - } + - type: conditional + conditions: + - entity: binary_sensor.smart_234118_sunroof_open + state_not: unavailable + card: + type: custom:mushroom-entity-card + entity: binary_sensor.smart_234118_sunroof_open + name: Sunroof + icon: mdi:car-select + secondary_info: >- + Position: {{ states('sensor.smart_234118_sunroof_position') }}% + card_mod: + style: | + ha-card { + background: #1E1E1E; + border: 1px solid #333; + --primary-text-color: #FFFFFF; + --secondary-text-color: #B3B3B3; + } - type: custom:mushroom-entity-card entity: button.smart_234118_smart_close_windows name: Close All Windows diff --git a/docker/ha-config/dashboards/smart-vehicle-basic.yaml b/docker/ha-config/dashboards/smart-vehicle-basic.yaml index 552456a..c4bf967 100644 --- a/docker/ha-config/dashboards/smart-vehicle-basic.yaml +++ b/docker/ha-config/dashboards/smart-vehicle-basic.yaml @@ -246,12 +246,22 @@ views: - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_rear_right_window_position name: Rear Right Position icon: mdi:arrow-up-down - - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof - name: Sunroof - icon: mdi:car-select - - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof_position - name: Sunroof Position - icon: mdi:arrow-up-down + - type: conditional + conditions: + - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof + state_not: unavailable + row: + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof + name: Sunroof + icon: mdi:car-select + - type: conditional + conditions: + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof_position + state_not: unavailable + row: + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof_position + name: Sunroof Position + icon: mdi:arrow-up-down - entity: button.cm590_hc11_performance_4wd_rhd_apac_close_windows name: Close All Windows icon: mdi:window-closed @@ -277,7 +287,7 @@ views: - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_service_warning name: Service Warning icon: mdi:alert-circle - - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_washer_fluid + - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_washer_fluid name: Washer Fluid icon: mdi:wiper-wash - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_brake_fluid diff --git a/docker/ha-config/dashboards/smart-vehicle.yaml b/docker/ha-config/dashboards/smart-vehicle.yaml index bebf5e4..7dd7389 100644 --- a/docker/ha-config/dashboards/smart-vehicle.yaml +++ b/docker/ha-config/dashboards/smart-vehicle.yaml @@ -17,10 +17,10 @@ views: - type: custom:mushroom-template-card primary: Smart #3 secondary: >- - {{ states('sensor.smart_234118_power_mode') | title }} - · {{ 'Online' if is_state('binary_sensor.smart_234118_telematics_connected', 'on') else 'Offline' }} + {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_power_mode') | title }} + · {{ 'Online' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_vehicle_online', 'on') else 'Offline' }} icon: mdi:car - entity: binary_sensor.smart_234118_telematics_connected + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_vehicle_online picture: /local/hello_smart/HESCA2C42RS234118_side.png layout: horizontal card_mod: @@ -37,22 +37,22 @@ views: - type: custom:mushroom-chips-card chips: - type: entity - entity: lock.smart_234118_smart_door_lock + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_driver_door_lock icon: >- - {{ 'mdi:lock' if is_state('lock.smart_234118_smart_door_lock', 'locked') else 'mdi:lock-open' }} + {{ 'mdi:lock' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_driver_door_lock', 'locked') else 'mdi:lock-open' }} content_info: name - type: entity - entity: sensor.smart_234118_charging_status + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_charging_status icon: mdi:ev-station content_info: state - type: entity - entity: sensor.smart_234118_power_mode + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_power_mode icon: mdi:power content_info: state - type: entity - entity: binary_sensor.smart_234118_telematics_connected + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_vehicle_online icon: >- - {{ 'mdi:wifi' if is_state('binary_sensor.smart_234118_telematics_connected', 'on') else 'mdi:wifi-off' }} + {{ 'mdi:wifi' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_vehicle_online', 'on') else 'mdi:wifi-off' }} content_info: name card_mod: style: | @@ -77,11 +77,11 @@ views: - type: custom:mushroom-template-card primary: Battery Level secondary: >- - {{ states('sensor.smart_234118_battery_level') }}% + {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_battery_level') }}% icon: mdi:battery - entity: sensor.smart_234118_battery_level + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_battery_level icon_color: >- - {% set level = states('sensor.smart_234118_battery_level') | int(0) %} + {% set level = states('sensor.cm590_hc11_performance_4wd_rhd_apac_battery_level') | int(0) %} {% if level > 60 %}green {% elif level > 20 %}orange {% else %}red{% endif %} @@ -95,10 +95,10 @@ views: } - type: entities entities: - - entity: sensor.smart_234118_range_remaining + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_estimated_range name: Range Remaining icon: mdi:map-marker-distance - - entity: sensor.smart_234118_range_at_full_charge + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_range_at_full_charge name: Range at Full Charge icon: mdi:map-marker-check card_mod: @@ -126,7 +126,7 @@ views: - type: vertical-stack cards: - type: custom:mushroom-entity-card - entity: sensor.smart_234118_charging_status + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_charging_status name: Charging Status icon: mdi:ev-station card_mod: @@ -140,15 +140,15 @@ views: - type: custom:mushroom-chips-card chips: - type: entity - entity: binary_sensor.smart_234118_charger_connected + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_charger icon: mdi:power-plug content_info: name - type: entity - entity: binary_sensor.smart_234118_charge_lid_ac + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_ac_charge_lid icon: mdi:ev-plug-type2 content_info: name - type: entity - entity: binary_sensor.smart_234118_charge_lid_dc + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_dc_charge_lid icon: mdi:ev-plug-ccs2 content_info: name card_mod: @@ -159,19 +159,19 @@ views: } - type: entities entities: - - entity: sensor.smart_234118_charge_voltage + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_charging_voltage name: Voltage icon: mdi:flash - - entity: sensor.smart_234118_charge_current + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_charging_current name: AC Current icon: mdi:current-ac - - entity: sensor.smart_234118_dc_charge_current + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_dc_charging_current name: DC Current icon: mdi:current-dc - - entity: sensor.smart_234118_charging_power + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_charging_power name: Power icon: mdi:lightning-bolt - - entity: sensor.smart_234118_time_to_full + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_time_to_full_charge name: Time to Full icon: mdi:timer-sand card_mod: @@ -183,7 +183,7 @@ views: --secondary-text-color: #B3B3B3; } - type: custom:mushroom-entity-card - entity: number.smart_234118_smart_target_soc + entity: number.cm590_hc11_performance_4wd_rhd_apac_charge_target name: Target SOC icon: mdi:battery-charging-high card_mod: @@ -196,16 +196,16 @@ views: } - type: entities entities: - - entity: sensor.smart_234118_charging_schedule_status + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_scheduled_charging name: Schedule Status icon: mdi:calendar-clock - - entity: sensor.smart_234118_charging_target_soc + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_charge_target name: Schedule Target SOC icon: mdi:battery-heart - - entity: time.smart_234118_smart_charging_start + - entity: time.cm590_hc11_performance_4wd_rhd_apac_charging_start_time name: Charging Start Time icon: mdi:clock-start - - entity: time.smart_234118_smart_charging_end + - entity: time.cm590_hc11_performance_4wd_rhd_apac_charging_end_time name: Charging End Time icon: mdi:clock-end card_mod: @@ -217,7 +217,7 @@ views: --secondary-text-color: #B3B3B3; } - type: custom:mushroom-entity-card - entity: switch.smart_234118_smart_charging + entity: switch.cm590_hc11_performance_4wd_rhd_apac_charging name: Charging icon: mdi:ev-station card_mod: @@ -244,8 +244,8 @@ views: } - type: vertical-stack cards: - - type: custom:mushroom-lock-card - entity: lock.smart_234118_smart_door_lock + - type: custom:mushroom-entity-card + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_driver_door_lock name: Door Lock card_mod: style: | @@ -256,7 +256,7 @@ views: --secondary-text-color: #B3B3B3; } - type: custom:hello-smart-vehicle-card - entity: binary_sensor.smart_234118_driver_door + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_driver_door show_windows: true show_locks: true @@ -280,34 +280,34 @@ views: - type: custom:mushroom-template-card primary: Front Left secondary: >- - {{ states('sensor.smart_234118_tyre_pressure_fl') }} kPa - · {{ states('sensor.smart_234118_tyre_temp_fl') }}°C + {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_pressure_front_rightont_left') }} kPa + · {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_temperature_front_left') }}°C icon: mdi:tire - entity: binary_sensor.smart_234118_tyre_warning_fl + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_front_rightont_left icon_color: >- - {{ 'red' if is_state('binary_sensor.smart_234118_tyre_warning_fl', 'on') else 'green' }} + {{ 'red' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_front_rightont_left', 'on') else 'green' }} card_mod: style: | ha-card { background: #1E1E1E; - border: 1px solid {{ '#F44336' if is_state('binary_sensor.smart_234118_tyre_warning_fl', 'on') else '#333' }}; + border: 1px solid {{ '#F44336' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_front_rightont_left', 'on') else '#333' }}; --primary-text-color: #FFFFFF; --secondary-text-color: #B3B3B3; } - type: custom:mushroom-template-card primary: Front Right secondary: >- - {{ states('sensor.smart_234118_tyre_pressure_fr') }} kPa - · {{ states('sensor.smart_234118_tyre_temp_fr') }}°C + {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_pressure_front_right') }} kPa + · {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_temperature_front_right') }}°C icon: mdi:tire - entity: binary_sensor.smart_234118_tyre_warning_fr + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_front_right icon_color: >- - {{ 'red' if is_state('binary_sensor.smart_234118_tyre_warning_fr', 'on') else 'green' }} + {{ 'red' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_front_right', 'on') else 'green' }} card_mod: style: | ha-card { background: #1E1E1E; - border: 1px solid {{ '#F44336' if is_state('binary_sensor.smart_234118_tyre_warning_fr', 'on') else '#333' }}; + border: 1px solid {{ '#F44336' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_front_right', 'on') else '#333' }}; --primary-text-color: #FFFFFF; --secondary-text-color: #B3B3B3; } @@ -316,34 +316,34 @@ views: - type: custom:mushroom-template-card primary: Rear Left secondary: >- - {{ states('sensor.smart_234118_tyre_pressure_rl') }} kPa - · {{ states('sensor.smart_234118_tyre_temp_rl') }}°C + {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_pressure_rear_left') }} kPa + · {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_temperature_rear_left') }}°C icon: mdi:tire - entity: binary_sensor.smart_234118_tyre_warning_rl + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_rear_left icon_color: >- - {{ 'red' if is_state('binary_sensor.smart_234118_tyre_warning_rl', 'on') else 'green' }} + {{ 'red' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_rear_left', 'on') else 'green' }} card_mod: style: | ha-card { background: #1E1E1E; - border: 1px solid {{ '#F44336' if is_state('binary_sensor.smart_234118_tyre_warning_rl', 'on') else '#333' }}; + border: 1px solid {{ '#F44336' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_rear_left', 'on') else '#333' }}; --primary-text-color: #FFFFFF; --secondary-text-color: #B3B3B3; } - type: custom:mushroom-template-card primary: Rear Right secondary: >- - {{ states('sensor.smart_234118_tyre_pressure_rr') }} kPa - · {{ states('sensor.smart_234118_tyre_temp_rr') }}°C + {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_pressure_rear_right') }} kPa + · {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_temperature_rear_right') }}°C icon: mdi:tire - entity: binary_sensor.smart_234118_tyre_warning_rr + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_rear_right icon_color: >- - {{ 'red' if is_state('binary_sensor.smart_234118_tyre_warning_rr', 'on') else 'green' }} + {{ 'red' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_rear_right', 'on') else 'green' }} card_mod: style: | ha-card { background: #1E1E1E; - border: 1px solid {{ '#F44336' if is_state('binary_sensor.smart_234118_tyre_warning_rr', 'on') else '#333' }}; + border: 1px solid {{ '#F44336' if is_state('binary_sensor.cm590_hc11_performance_4wd_rhd_apac_tyre_warning_rear_right', 'on') else '#333' }}; --primary-text-color: #FFFFFF; --secondary-text-color: #B3B3B3; } @@ -364,11 +364,11 @@ views: - type: vertical-stack cards: - type: custom:mushroom-entity-card - entity: binary_sensor.smart_234118_driver_window + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_driver_window name: Driver Window icon: mdi:car-door secondary_info: >- - Position: {{ states('sensor.smart_234118_window_position_driver') }}% + Position: {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_driver_window_position') }}% card_mod: style: | ha-card { @@ -378,11 +378,11 @@ views: --secondary-text-color: #B3B3B3; } - type: custom:mushroom-entity-card - entity: binary_sensor.smart_234118_passenger_window + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_passenger_window name: Passenger Window icon: mdi:car-door secondary_info: >- - Position: {{ states('sensor.smart_234118_window_position_passenger') }}% + Position: {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_passenger_window_position') }}% card_mod: style: | ha-card { @@ -392,11 +392,11 @@ views: --secondary-text-color: #B3B3B3; } - type: custom:mushroom-entity-card - entity: binary_sensor.smart_234118_rear_left_window + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_rear_left_window name: Rear Left Window icon: mdi:car-door secondary_info: >- - Position: {{ states('sensor.smart_234118_window_position_driver_rear') }}% + Position: {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_rear_left_window_position') }}% card_mod: style: | ha-card { @@ -406,25 +406,11 @@ views: --secondary-text-color: #B3B3B3; } - type: custom:mushroom-entity-card - entity: binary_sensor.smart_234118_rear_right_window + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_rear_right_window name: Rear Right Window icon: mdi:car-door secondary_info: >- - Position: {{ states('sensor.smart_234118_window_position_passenger_rear') }}% - card_mod: - style: | - ha-card { - background: #1E1E1E; - border: 1px solid #333; - --primary-text-color: #FFFFFF; - --secondary-text-color: #B3B3B3; - } - - type: custom:mushroom-entity-card - entity: binary_sensor.smart_234118_sunroof_open - name: Sunroof - icon: mdi:car-select - secondary_info: >- - Position: {{ states('sensor.smart_234118_sunroof_position') }}% + Position: {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_rear_right_window_position') }}% card_mod: style: | ha-card { @@ -433,8 +419,27 @@ views: --primary-text-color: #FFFFFF; --secondary-text-color: #B3B3B3; } + - type: conditional + conditions: + - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof + state_not: unavailable + card: + type: custom:mushroom-entity-card + entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof + name: Sunroof + icon: mdi:car-select + secondary_info: >- + Position: {{ states('sensor.cm590_hc11_performance_4wd_rhd_apac_sunroof_position') }}% + card_mod: + style: | + ha-card { + background: #1E1E1E; + border: 1px solid #333; + --primary-text-color: #FFFFFF; + --secondary-text-color: #B3B3B3; + } - type: custom:mushroom-entity-card - entity: button.smart_234118_smart_close_windows + entity: button.cm590_hc11_performance_4wd_rhd_apac_close_windows name: Close All Windows icon: mdi:window-closed tap_action: @@ -464,7 +469,7 @@ views: - type: vertical-stack cards: - type: custom:mushroom-entity-card - entity: sensor.smart_234118_odometer + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_odometer name: Odometer icon: mdi:counter card_mod: @@ -478,15 +483,15 @@ views: - type: custom:mushroom-chips-card chips: - type: entity - entity: sensor.smart_234118_days_to_service + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_service_due_in icon: mdi:calendar-clock content_info: state - type: entity - entity: sensor.smart_234118_distance_to_service + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_service_due_distance icon: mdi:road-variant content_info: state - type: entity - entity: sensor.smart_234118_engine_hours_to_service + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_engine_hours_to_service icon: mdi:engine content_info: state card_mod: @@ -496,7 +501,7 @@ views: --chip-border-color: #444; } - type: custom:mushroom-entity-card - entity: sensor.smart_234118_service_warning + entity: sensor.cm590_hc11_performance_4wd_rhd_apac_service_warning name: Service Warning icon: mdi:alert-circle card_mod: @@ -510,13 +515,13 @@ views: - type: entities title: Fluid Levels entities: - - entity: binary_sensor.smart_234118_washer_fluid_low + - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_washer_fluid name: Washer Fluid icon: mdi:wiper-wash - - entity: binary_sensor.smart_234118_brake_fluid_ok + - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_brake_fluid name: Brake Fluid icon: mdi:car-brake-fluid-level - - entity: sensor.smart_234118_engine_coolant_level + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_coolant_level name: Engine Coolant icon: mdi:coolant-temperature card_mod: @@ -530,13 +535,13 @@ views: - type: entities title: Firmware entities: - - entity: sensor.smart_234118_current_firmware_version + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_firmware_version name: Current Firmware icon: mdi:cellphone-arrow-down - - entity: sensor.smart_234118_target_firmware_version + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_available_firmware_version name: Target Firmware icon: mdi:cellphone-arrow-up - - entity: binary_sensor.smart_234118_update_available + - entity: binary_sensor.cm590_hc11_performance_4wd_rhd_apac_firmware_update_available name: Update Available icon: mdi:update card_mod: @@ -550,10 +555,10 @@ views: - type: entities title: Diagnostics entities: - - entity: sensor.smart_234118_diagnostic_status + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_diagnostic_status name: Diagnostic Status icon: mdi:stethoscope - - entity: sensor.smart_234118_diagnostic_code + - entity: sensor.cm590_hc11_performance_4wd_rhd_apac_diagnostic_trouble_code name: Diagnostic Code icon: mdi:barcode card_mod: diff --git a/specs/006-capability-entity-filtering/checklists/requirements.md b/specs/006-capability-entity-filtering/checklists/requirements.md new file mode 100644 index 0000000..53e58f6 --- /dev/null +++ b/specs/006-capability-entity-filtering/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Capability-Based Entity Filtering + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-05-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- The spec references specific API endpoint paths and APK class names in the Assumptions section — these are domain context, not implementation details, and are appropriate for a vehicle integration specification. +- FR-009 references specific function ID strings (e.g., `remote_control_lock`) — these are domain identifiers from the vehicle API, not implementation choices. diff --git a/specs/006-capability-entity-filtering/contracts/capability-api-response.md b/specs/006-capability-entity-filtering/contracts/capability-api-response.md new file mode 100644 index 0000000..fa6ef41 --- /dev/null +++ b/specs/006-capability-entity-filtering/contracts/capability-api-response.md @@ -0,0 +1,113 @@ +# Contract: Capability API Response Schema + +**Type**: External API (inbound data) +**Endpoint**: `GET /geelyTCAccess/tcservices/capability/{vin}` +**Owner**: Smart/Geely cloud platform (external, read-only) + +## Expected Response Structure + +Based on APK `TscVehicleCapability.java` and `Capability.java` (authoritative model). + +> **NOTE**: This schema is derived from APK decompilation. A live API response +> capture is required during implementation to verify field names and nesting. + +```json +{ + "code": 1000, + "data": { + "list": [ + { + "functionId": "remote_control_lock", + "valueEnable": true, + "functionCategory": "remote_control", + "name": "Remote Lock", + "showType": "switch", + "tips": "", + "valueEnum": "", + "valueRange": "", + "paramsJson": [], + "configCode": "", + "platform": "tsp", + "priority": 1, + "vin": "WMEXXXXXXXXXXXXXXX", + "modelCode": "HX11", + "id": 1 + }, + { + "functionId": "remote_air_condition_switch", + "valueEnable": true, + "functionCategory": "remote_control", + "name": "Remote AC", + "showType": "switch", + "tips": "", + "valueEnum": "", + "valueRange": "16|28", + "paramsJson": [], + "configCode": "", + "platform": "tsp", + "priority": 2, + "vin": "WMEXXXXXXXXXXXXXXX", + "modelCode": "HX11", + "id": 2 + } + ], + "serviceResult": { + "code": "200", + "message": "success" + } + }, + "success": true, + "message": "operation succeed" +} +``` + +## Fallback Response Structure + +If the actual API uses the previously-assumed format (unverified), the parser +must also handle: + +```json +{ + "code": 1000, + "data": { + "capabilities": [ + { + "serviceId": "RCE_2", + "enabled": true, + "version": "1.0" + } + ] + } +} +``` + +## Parsing Contract + +The integration MUST: + +1. Try `data.list[]` first (APK model format) +2. Fall back to `data.capabilities[]` (legacy format) +3. Extract `functionId`/`valueEnable` from the primary format +4. Extract `serviceId`/`enabled` from the fallback format +5. Return `VehicleCapabilities` with both `capability_flags` and `service_ids` +6. On any parsing failure, return empty `VehicleCapabilities` (permissive) + +## Field Reference + +| JSON Field | Java Type | Required | Description | +|-----------|-----------|----------|-------------| +| `functionId` | String | Yes | Feature identifier (e.g., `"remote_control_lock"`) | +| `valueEnable` | boolean | Yes | Whether the feature is enabled | +| `functionCategory` | String | No | Grouping category | +| `name` | String | No | Human-readable name | +| `showType` | String | No | UI display type | +| `tips` | String | No | Help text | +| `valueEnum` | String | No | Comma-separated valid values | +| `valueRange` | String | No | Value range (e.g., `"16\|28"` for temp) | +| `paramsJson` | Array | No | Additional parameters | +| `configCode` | String | No | Configuration code | +| `platform` | String | No | Platform identifier | +| `priority` | Integer | No | Display priority | +| `vin` | String | No | Vehicle VIN | +| `modelCode` | String | No | Vehicle model code | +| `id` | Integer | No | Capability entry ID | diff --git a/specs/006-capability-entity-filtering/contracts/entity-filtering.md b/specs/006-capability-entity-filtering/contracts/entity-filtering.md new file mode 100644 index 0000000..58e09ed --- /dev/null +++ b/specs/006-capability-entity-filtering/contracts/entity-filtering.md @@ -0,0 +1,54 @@ +# Contract: Entity Capability Filtering + +**Type**: Internal integration pattern +**Scope**: All entity platforms (`sensor.py`, `binary_sensor.py`, `switch.py`, `lock.py`, `button.py`, `select.py`, `climate.py`, `number.py`, `time.py`) + +## Pattern + +Each entity description dataclass gains an optional `required_capability` field. +Entity filtering occurs **once** during `async_setup_entry()`, not per-poll. + +## Entity Description Contract + +Every platform's entity description dataclass MUST follow this pattern: + +```python +@dataclass(frozen=True, kw_only=True) +class SmartEntityDescription(EntityDescription): + # ... existing fields ... + required_capability: str | None = None # NEW: opt-in capability gating +``` + +- `required_capability = None` → entity is always created (default, backward-compatible) +- `required_capability = "some_function_id"` → entity is only created if `capability_flags.get("some_function_id", True)` is `True` + +## Filtering Contract + +Every platform's `async_setup_entry()` MUST apply this filtering logic: + +``` +for description in DESCRIPTIONS: + 1. Check required_capability: + - If None → proceed to create + - If set AND caps.capability_flags is non-empty: + - If capability_flags.get(required_capability, True) is False → SKIP + - Otherwise → proceed to create + 2. Check existing available_fn (if platform has it): + - If returns False → SKIP + 3. Create entity +``` + +## Logging Contract + +| Level | When | Format | +|-------|------|--------| +| DEBUG | Entity skipped due to disabled capability | `"Skipping %s for %s: capability '%s' disabled"` (entity_key, vin_prefix, function_id) | +| INFO | After all entities processed per platform | `"%s: created %d entities, filtered %d by capability for %s"` (platform, created, filtered, vin_prefix) | + +## Invariants + +1. A `None` `required_capability` MUST NEVER cause an entity to be filtered +2. Empty `capability_flags` dict MUST NEVER cause any entity to be filtered +3. A function ID not present in `capability_flags` MUST NOT cause filtering +4. Only an explicitly `False` value for the matching function ID causes filtering +5. The total entities created when capabilities are unavailable MUST equal the total entities created in the current integration version (zero regression) diff --git a/specs/006-capability-entity-filtering/data-model.md b/specs/006-capability-entity-filtering/data-model.md new file mode 100644 index 0000000..30ad2ab --- /dev/null +++ b/specs/006-capability-entity-filtering/data-model.md @@ -0,0 +1,205 @@ +# Data Model: Capability-Based Entity Filtering + +**Branch**: `006-capability-entity-filtering` | **Date**: 2026-03-28 + +--- + +## Entity Relationship Diagram + +``` +┌─────────────────────────────────┐ +│ VehicleData │ +│ (existing, per-VIN) │ +├─────────────────────────────────┤ +│ vehicle: Vehicle │ +│ status: VehicleStatus │ +│ capabilities: VehicleCapabili… │──┐ +│ ability: VehicleAbility │ │ +│ ... │ │ +└─────────────────────────────────┘ │ + │ + ┌────────────────────────────────┘ + ▼ +┌─────────────────────────────────┐ +│ VehicleCapabilities │ +│ (MODIFIED) │ +├─────────────────────────────────┤ +│ service_ids: list[str] │ ← existing (backward compat) +│ capability_flags: dict[str, │ ← NEW: functionId → valueEnable +│ bool] │ +└─────────────────────────────────┘ + ▲ + │ populated from API response + │ +┌─────────────────────────────────┐ +│ Capability API Response │ +│ (per TscVehicleCapability) │ +├─────────────────────────────────┤ +│ data.list[]: │ +│ functionId: str │ +│ valueEnable: bool │ +│ functionCategory: str │ +│ name: str │ +│ valueEnum: str │ +│ valueRange: str │ +│ paramsJson: list[Params] │ +│ configCode: str │ +│ platform: str │ +│ priority: int │ +│ showType: str │ +│ tips: str │ +└─────────────────────────────────┘ + +┌─────────────────────────────────┐ ┌──────────────────────────────┐ +│ StaticVehicleData │ │ SmartDataCoordinator │ +│ (NEW, cached per-VIN) │ │ (MODIFIED) │ +├─────────────────────────────────┤ ├──────────────────────────────┤ +│ capabilities: VehicleCapabili… │ │ _static_cache: │ +│ ability: VehicleAbility | None │ │ dict[str, StaticVehicle…] │ +│ plant_no: str │ │ │ +└─────────────────────────────────┘ └──────────────────────────────┘ + +┌─────────────────────────────────┐ +│ Entity Description Dataclass │ +│ (MODIFIED — all platforms) │ +├─────────────────────────────────┤ +│ key: str │ ← existing +│ value_fn / is_on_fn / etc. │ ← existing (platform-specific) +│ required_capability: str|None │ ← NEW: function ID that must be enabled +└─────────────────────────────────┘ + +┌─────────────────────────────────┐ +│ CAPABILITY_MAP (const.py) │ +│ (NEW, static lookup) │ +├─────────────────────────────────┤ +│ Maps entity description keys │ +│ to function ID strings. │ +│ Used to populate │ +│ required_capability on entity │ +│ descriptions at definition │ +│ time. │ +└─────────────────────────────────┘ +``` + +--- + +## Modified Models + +### VehicleCapabilities (models.py) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `service_ids` | `list[str]` | `[]` | Existing. List of enabled service IDs (backward compat) | +| `capability_flags` | `dict[str, bool]` | `{}` | NEW. Maps `functionId` → `valueEnable`. Populated from API response `data.list[]` | + +### StaticVehicleData (models.py — NEW) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `capabilities` | `VehicleCapabilities \| None` | `None` | Cached capability flags | +| `ability` | `VehicleAbility \| None` | `None` | Cached vehicle visual config | +| `plant_no` | `str` | `""` | Cached factory plant number | + +### Entity Description Extension (all platforms) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `required_capability` | `str \| None` | `None` | Function ID from `FunctionId.java` that must be enabled for this entity to be created. `None` = always create. | + +--- + +## Capability-to-Entity Mapping (const.py) + +``` +FUNCTION_ID_REMOTE_LOCK = "remote_control_lock" +FUNCTION_ID_REMOTE_UNLOCK = "remote_control_unlock" +FUNCTION_ID_CLIMATE = "remote_air_condition_switch" +FUNCTION_ID_WINDOW_CLOSE = "remote_window_close" +FUNCTION_ID_WINDOW_OPEN = "remote_window_open" +FUNCTION_ID_TRUNK_OPEN = "remote_trunk_open" +FUNCTION_ID_HONK_FLASH = "honk_flash" +FUNCTION_ID_SEAT_HEAT = "remote_seat_preheat_switch" +FUNCTION_ID_SEAT_VENT = "seat_ventilation_status" +FUNCTION_ID_FRAGRANCE = "remote_control_fragrance" +FUNCTION_ID_CHARGING = "charging_status" +FUNCTION_ID_DOOR_STATUS = "door_lock_switch_status" +FUNCTION_ID_TRUNK_STATUS = "trunk_status" +FUNCTION_ID_WINDOW_STATUS = "windows_rolling_status" +FUNCTION_ID_SKYLIGHT_STATUS = "skylight_rolling_status" +FUNCTION_ID_TYRE_PRESSURE = "tyre_pressure" +FUNCTION_ID_VEHICLE_POSITION = "vehicle_position" +FUNCTION_ID_TOTAL_MILEAGE = "total_mileage" +FUNCTION_ID_HOOD_STATUS = "engine_compartment_cover_status" +FUNCTION_ID_CHARGE_PORT_STATUS = "recharge_lid_status" +FUNCTION_ID_CURTAIN_STATUS = "curtain_status" +FUNCTION_ID_DOORS_STATUS = "vehiecle_doors_status" +FUNCTION_ID_CLIMATE_STATUS = "climate_status" +FUNCTION_ID_CHARGING_RESERVATION = "remote_appointment_charging" +``` + +--- + +## Filtering Logic Flow + +``` +async_setup_entry(): + for each VIN in coordinator.data: + vehicle_data = coordinator.data[VIN] + caps = vehicle_data.capabilities + + for each entity_description in DESCRIPTIONS: + ┌─ Has required_capability? ─── No ──→ CREATE entity (always) + │ + Yes + │ + ├─ caps is None or caps.capability_flags empty? ── Yes ──→ CREATE entity (permissive fallback) + │ + No + │ + ├─ required_capability in caps.capability_flags? ── No ──→ CREATE entity (unknown cap = permissive) + │ + Yes + │ + ├─ caps.capability_flags[required_capability] is True? ── Yes ──→ CREATE entity + │ + No + │ + └─ SKIP entity (capability explicitly disabled) + Log: "Skipping {key}: capability {func_id} disabled" +``` + +--- + +## State Transitions + +### Static Data Cache Lifecycle + +``` +HA Start → Coordinator.__init__() + _static_cache = {} (empty) + +First poll → _async_update_data() → _async_fetch_all_vehicles() + Per VIN: fetch capabilities, ability, plant_no from API + Store in _static_cache[vin] = StaticVehicleData(...) + Build VehicleData using cached values + +Subsequent polls → _async_update_data() → _async_fetch_all_vehicles() + Per VIN: _static_cache[vin] exists → skip API calls + Build VehicleData using cached values + +HA Restart → Coordinator.__init__() + _static_cache = {} (cleared, re-fetched on first poll) +``` + +--- + +## Validation Rules + +| Rule | Applies To | Behavior | +|------|-----------|----------| +| Missing `functionId` in capability object | API parsing | Skip entry, log debug warning | +| Empty capability list from API | Entity filtering | Permissive fallback (create all) | +| API call failure | Entity filtering | Permissive fallback (create all) | +| `required_capability` is `None` | Entity creation | Always create (opt-in filtering) | +| Function ID not in capability flags dict | Entity creation | Create entity (unknown = permitted) | +| Function ID in flags and `False` | Entity creation | Skip entity | diff --git a/specs/006-capability-entity-filtering/plan.md b/specs/006-capability-entity-filtering/plan.md new file mode 100644 index 0000000..9d1db3e --- /dev/null +++ b/specs/006-capability-entity-filtering/plan.md @@ -0,0 +1,88 @@ +# Implementation Plan: Capability-Based Entity Filtering + +**Branch**: `006-capability-entity-filtering` | **Date**: 2026-03-28 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/006-capability-entity-filtering/spec.md` + +## Summary + +Implement capability-based entity filtering so the Hello Smart integration only creates HA entities for features the vehicle actually supports, based on `functionId`/`valueEnable` flags from the capability API. Additionally, cache static data (capabilities, vehicle ability, plant number) to eliminate redundant API calls on every 60-second poll cycle. Update API documentation to reflect the full capability response schema. + +## Technical Context + +**Language/Version**: Python 3.13+ (Home Assistant 2025.x minimum) +**Primary Dependencies**: `aiohttp` (HA-bundled HTTP client), `homeassistant` core APIs (`DataUpdateCoordinator`, `Entity`, `ConfigEntry`) +**Storage**: In-memory caching on `SmartDataCoordinator` instance; no persistent storage needed +**Testing**: `pytest` (test framework exists at `tests/` but no tests are written yet) +**Target Platform**: Home Assistant (Linux/Docker/HassOS), HACS custom component +**Project Type**: Home Assistant custom integration (cloud-polling IoT) +**Performance Goals**: No increase in per-poll latency; reduce API calls by 3 per poll cycle per vehicle +**Constraints**: Must run in HA's single-threaded asyncio event loop; no blocking I/O; aiohttp only +**Scale/Scope**: Single integration, ~10 entity platform files, ~15 files modified total + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| **I. HA Compatibility** | PASS | Python 3.13+, asyncio patterns, no new dependencies, `manifest.json` requirements unchanged | +| **II. Security-First** | PASS | No new external endpoints; capability data is read-only from existing signed API; no credentials affected; no new URL construction beyond existing allowlisted base URLs | +| **III. Minimal Production Footprint** | PASS | Adds a `required_capability` field to entity description dataclasses and a capability-check helper; removes redundant API calls per poll cycle; no new files in `custom_components/` | +| **IV. Organized Testing & Reuse** | PASS | New tests will be under `tests/` mirroring production structure; no new debug scripts needed | +| **V. Simplicity & Code Quality** | PASS | Uses HA's built-in `DataUpdateCoordinator` caching pattern; capability map is a simple dict constant in `const.py`; no wrapper layers or factory patterns | + +**Gate result: PASS** — no violations. Proceeding to Phase 0. + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-capability-entity-filtering/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +custom_components/hello_smart/ +├── __init__.py # Integration setup (unchanged) +├── api.py # MODIFIED: async_get_capabilities() enhanced to parse functionId/valueEnable +├── const.py # MODIFIED: Add CAPABILITY_MAP dict and function ID constants +├── coordinator.py # MODIFIED: Cache static data, fetch once on setup +├── models.py # MODIFIED: VehicleCapabilities gets capability_flags dict +├── sensor.py # MODIFIED: Add required_capability to descriptions, filter in async_setup_entry +├── binary_sensor.py # MODIFIED: Add required_capability to descriptions, filter in async_setup_entry +├── switch.py # MODIFIED: Add required_capability to descriptions (already has available_fn) +├── lock.py # MODIFIED: Add required_capability to descriptions (already has available_fn) +├── button.py # MODIFIED: Add required_capability to descriptions, filter in async_setup_entry +├── select.py # MODIFIED: Add required_capability to descriptions, filter in async_setup_entry +├── climate.py # MODIFIED: Add capability check before entity creation +├── device_tracker.py # UNCHANGED (always created - vehicle position is universal) +├── diagnostics.py # UNCHANGED +├── number.py # MODIFIED: Add required_capability if applicable +└── time.py # MODIFIED: Add required_capability if applicable + +API/ +├── endpoints/ +│ └── capabilities.md # MODIFIED: Full response schema with functionId/valueEnable +├── entities.md # MODIFIED: Add capability function ID column +└── models.md # MODIFIED: Updated VehicleCapabilities model + +tests/ +├── unit/ +│ └── test_capability_filtering.py # NEW: Unit tests for capability map and filtering +└── helpers/ + └── (existing) +``` + +**Structure Decision**: Existing single-project structure. All changes are modifications to existing files in `custom_components/hello_smart/`. One new test file. Three API doc files updated. No new production files created. + +## Complexity Tracking + +No constitution violations — this section is not applicable. diff --git a/specs/006-capability-entity-filtering/quickstart.md b/specs/006-capability-entity-filtering/quickstart.md new file mode 100644 index 0000000..1ca63f7 --- /dev/null +++ b/specs/006-capability-entity-filtering/quickstart.md @@ -0,0 +1,63 @@ +# Quickstart: Capability-Based Entity Filtering + +**Branch**: `006-capability-entity-filtering` + +## What This Feature Does + +Filters Home Assistant entity creation based on vehicle capability flags from the Smart API. Vehicles only get entities for features they actually support — no more phantom "unavailable" entities for missing features (fridge, sunroof, etc.). Also caches static vehicle data to reduce API calls. + +## Key Files to Modify + +| File | Change Summary | +|------|---------------| +| `custom_components/hello_smart/models.py` | Add `capability_flags` to `VehicleCapabilities`; add `StaticVehicleData` dataclass | +| `custom_components/hello_smart/api.py` | Update `async_get_capabilities()` to parse `functionId`/`valueEnable` | +| `custom_components/hello_smart/const.py` | Add `FUNCTION_ID_*` constants | +| `custom_components/hello_smart/coordinator.py` | Add `_static_cache`, fetch static data once | +| `custom_components/hello_smart/sensor.py` | Add `required_capability` to `SmartSensorEntityDescription`, filter in setup | +| `custom_components/hello_smart/binary_sensor.py` | Same pattern as sensor.py | +| `custom_components/hello_smart/switch.py` | Add `required_capability` (already has `available_fn`) | +| `custom_components/hello_smart/lock.py` | Add `required_capability` (already has `available_fn`) | +| `custom_components/hello_smart/button.py` | Add `required_capability` to description, filter in setup | +| `custom_components/hello_smart/select.py` | Add `required_capability` to description, filter in setup | +| `custom_components/hello_smart/climate.py` | Add capability check before entity creation | +| `API/endpoints/capabilities.md` | Update response schema | +| `API/entities.md` | Add capability column | +| `API/models.md` | Update VehicleCapabilities model | + +## Development Workflow + +```bash +# 1. Switch to feature branch +git checkout 006-capability-entity-filtering + +# 2. Make changes to custom_components/hello_smart/ + +# 3. Deploy to dev container +docker cp custom_components/hello_smart/ ha-dev:/config/custom_components/hello_smart/ + +# 4. Restart HA in container +docker exec ha-dev python -m homeassistant restart + +# 5. Check logs for capability parsing +docker logs ha-dev 2>&1 | grep -i "capability\|Skipping\|filtered" +``` + +## Critical Implementation Note + +**FIRST TASK**: Capture a live API response from `/geelyTCAccess/tcservices/capability/{vin}` and log the raw JSON structure. The response schema is APK-modeled (uses `data.list[]` with `functionId`/`valueEnable`) but has never been verified against a live response. The parser should handle both the expected format and the legacy format (`data.capabilities[]` with `serviceId`/`enabled`). + +## Testing Strategy + +1. **Live API verification**: Capture and log actual capability response +2. **Entity count comparison**: Compare entity counts before/after with a vehicle that has all features +3. **Filtered entity verification**: Confirm entities for disabled capabilities are not created +4. **Fallback verification**: Simulate API failure and confirm all entities still created +5. **Cache verification**: Monitor API call logs across poll cycles + +## Architecture Decisions + +- **Permissive default**: Unknown/missing capabilities → create entity (safe fallback) +- **Opt-in filtering**: Only entities with `required_capability` set are filtered +- **In-memory cache**: Static data cached on coordinator instance, cleared on restart +- **No new files**: All changes are modifications to existing production files diff --git a/specs/006-capability-entity-filtering/research.md b/specs/006-capability-entity-filtering/research.md new file mode 100644 index 0000000..46a2cae --- /dev/null +++ b/specs/006-capability-entity-filtering/research.md @@ -0,0 +1,236 @@ +# Research: Capability-Based Entity Filtering + +**Branch**: `006-capability-entity-filtering` | **Date**: 2026-03-28 + +## Sources + +| Source | Confidence | +|--------|-----------| +| APK decompiled `Capability.java` (intl + eu) | HIGH (authoritative Java model for API deserialization) | +| APK decompiled `FunctionId.java` (intl + eu) | HIGH (canonical function ID constants) | +| APK decompiled `TscVehicleCapability.java` | HIGH (Retrofit response wrapper) | +| APK Retrofit annotation `bllnew.java` | HIGH (confirms endpoint path and response type) | +| Existing integration code (`api.py`, `coordinator.py`, `models.py`) | HIGH (current behavior) | +| Spec-002 research (capability schema) | LOW — marked **SPECULATIVE** in original research | +| HA debug logs | LOW (only token-expired errors captured, no successful responses) | + +--- + +## Research Topic 1: Capability API Response Structure + +**Decision**: The actual capability API response structure uses `functionId`/`valueEnable` fields (matching the APK's `Capability.java` model), NOT the `serviceId`/`enabled`/`version` format currently assumed by the integration. + +**Evidence**: + +1. **APK Retrofit annotation** (`bllnew.java` line 48): + ```java + @GET("/geelyTCAccess/tcservices/capability/{vin}") + Observable> blldo(...) + ``` + The return type `BaseResult` means Gson deserializes the response into `TscVehicleCapability`. + +2. **`TscVehicleCapability.java`** has two fields: + - `List list` — the capability entries + - `ServiceResult serviceResult` — service metadata + +3. **`Capability.java`** fields (the class Gson maps JSON to): + - `functionId` (String) — feature identifier, e.g., `"remote_control_lock"` + - `valueEnable` (boolean) — whether the feature is enabled + - `functionCategory` (String) — grouping + - `name` (String) — display name + - `showType` (String) — UI display type + - `tips` (String) — tooltip text + - `valueEnum` (String) — comma-separated enum options + - `valueRange` (String) — value range specification + - `paramsJson` (ArrayList\) — parameters + - `configCode`, `platform`, `priority`, `vin`, `modelCode`, `id`, `_id` + +4. **`Capability.java` does NOT have** `serviceId`, `enabled`, or `version` fields. + +5. **Spec-002 research** listed the `/capability/{vin}` response schema under the **SPECULATIVE** table — inferred from Geely platform patterns, not verified against live data. + +**Conclusion**: The integration's current parsing in `async_get_capabilities()` likely yields empty results: +```python +# Current (likely broken): +caps = data.get("data", {}).get("capabilities", []) # key "capabilities" doesn't exist +service_ids = [c.get("serviceId", "") for c in caps if c.get("enabled")] # fields don't exist +``` + +The correct parsing should be: +```python +# Corrected: +caps = data.get("data", {}).get("list", []) # key "list" per TscVehicleCapability +capability_flags = { + c.get("functionId"): c.get("valueEnable", False) for c in caps if c.get("functionId") +} +``` + +**Verification step required**: First implementation task MUST capture a live API response and log its structure before finalizing the parser. If the response differs from the APK model, the parser should handle both formats. + +**Alternatives considered**: Trust the spec-002 speculative schema — rejected because the APK's Retrofit return type is definitive about what structure Gson expects. + +--- + +## Research Topic 2: Function ID to Entity Mapping + +**Decision**: Map APK `FunctionId.java` constants to integration entity descriptions. Only map entities that have clear functional equivalents. + +**Rationale**: The APK has 114 unique function IDs. Most are status indicators or app-only features (navigation, car sharing, QR scan). The integration only needs mappings for entities it actually creates. + +**Complete mapping** (function ID → integration entity): + +### Remote Control Commands (buttons, locks, switches) + +| Function ID | Entity Type | Platform | Integration Entity | +|------------|-------------|----------|-------------------| +| `remote_control_lock` | lock | lock.py | Door lock (lock action) | +| `remote_control_unlock` | lock | lock.py | Door lock (unlock action) | +| `remote_air_condition_switch` | climate | climate.py | Climate control | +| `remote_window_close` | button | button.py | Close windows | +| `remote_window_open` | button | button.py | Open windows | +| `remote_trunk_open` | button | button.py | Open trunk | +| `honk_flash` | button | button.py | Horn & flash | +| `remote_seat_preheat_switch` | select | select.py | Seat heating level | +| `remote_control_fragrance` | switch | switch.py | Fragrance on/off | +| `remote_appointment_charging` | time/switch | time.py, switch.py | Charging schedule | + +### Status Sensors (read-only) + +| Function ID | Entity Type | Platform | Integration Entity | +|------------|-------------|----------|-------------------| +| `charging_status` | sensor | sensor.py | Charging state, voltage, current, time-to-full | +| `climate_status` | sensor/binary_sensor | sensor.py, binary_sensor.py | Climate active, target temp | +| `door_lock_switch_status` | binary_sensor | binary_sensor.py | Door lock status | +| `trunk_status` | binary_sensor | binary_sensor.py | Trunk open/closed | +| `windows_rolling_status` | binary_sensor | binary_sensor.py | Window positions | +| `skylight_rolling_status` | binary_sensor | binary_sensor.py | Sunroof status | +| `tyre_pressure` | sensor | sensor.py | Tyre pressure (4 wheels) | +| `seat_heat_status` | sensor | sensor.py | Seat heating active | +| `seat_ventilation_status` | sensor | sensor.py | Seat ventilation active | +| `vehicle_position` | device_tracker | device_tracker.py | GPS location | +| `total_mileage` | sensor | sensor.py | Odometer | +| `engine_compartment_cover_status` | binary_sensor | binary_sensor.py | Hood status | +| `recharge_lid_status` | binary_sensor | binary_sensor.py | Charge port status | +| `curtain_status` | binary_sensor | binary_sensor.py | Curtain/blind status | +| `vehiecle_doors_status` | binary_sensor | binary_sensor.py | Individual door statuses | + +### Unmapped (no integration entity, or always-available) + +Remaining ~80 function IDs are either app-only features (navigation, car sharing, Bluetooth key, stolen car tracking) or are not represented by entities in the current integration. + +**Alternatives considered**: Map every function ID including app-only features — rejected per YAGNI; only entities the integration creates need mappings. + +--- + +## Research Topic 3: Caching Strategy for Static Data + +**Decision**: Use instance variables on `SmartDataCoordinator` to cache static data after first successful fetch. Skip re-fetching on subsequent `_async_update_data` calls. + +**Rationale**: Three data sources are static per vehicle session: +1. **Capabilities** — vehicle feature flags, fixed per firmware version +2. **Vehicle Ability** — visual config (images, colors, model info), fixed per vehicle spec +3. **Plant Number** — factory code, never changes + +Currently all three are fetched every 60-second poll cycle (coordinator `_async_fetch_all_vehicles`). Each is an independent API call × number of vehicles. + +**Implementation pattern**: +```python +class SmartDataCoordinator(DataUpdateCoordinator): + def __init__(self, ...): + self._static_cache: dict[str, StaticVehicleData] = {} # keyed by VIN + + async def _async_fetch_all_vehicles(self, account): + for vin in vehicle_vins: + # Only fetch static data if not yet cached + if vin not in self._static_cache: + capabilities = await self._api.async_get_capabilities(...) + ability = await self._api.async_get_vehicle_ability(...) + plant_no = await self._api.async_get_plant_no(...) + self._static_cache[vin] = StaticVehicleData(capabilities, ability, plant_no) + + # Use cached values for VehicleData construction + cached = self._static_cache[vin] + # ... build VehicleData with cached.capabilities, cached.ability, cached.plant_no +``` + +**Cache invalidation**: Cache is naturally cleared when HA restarts (coordinator re-instantiated). No manual invalidation needed — firmware updates that change capabilities require an HA restart to take effect. + +**Alternatives considered**: +- HA `Store` for persistent caching — rejected; adds complexity and stale data risk on firmware updates. In-memory is sufficient since capabilities are always available from the API. +- TTL-based cache with expiry — rejected; unnecessary complexity. Capabilities don't change during a session. +- Separate one-shot setup method — rejected; the coordinator's existing `_async_update_data` flow is the right place, just with a conditional check. + +--- + +## Research Topic 4: Entity Description Extension Pattern + +**Decision**: Add an optional `required_capability: str | None` field to each entity description dataclass. When `None`, the entity is always created (opt-in filtering). + +**Rationale**: Two entity platforms already have `available_fn` (switch, lock), but this is a runtime availability check (whether data exists), not a setup-time capability filter. The new `required_capability` field serves a different purpose: it gates entity *creation* during `async_setup_entry`, before the entity ever exists. + +**Implementation pattern for platforms WITHOUT `available_fn`** (sensor, binary_sensor, button, select): +```python +@dataclass(frozen=True, kw_only=True) +class SmartSensorEntityDescription(SensorEntityDescription): + value_fn: Callable[[VehicleData], Any] + required_capability: str | None = None # New field, opt-in +``` + +**Filtering logic in `async_setup_entry`** (shared across all platforms): +```python +async def async_setup_entry(hass, entry, async_add_entities): + coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + for vin, vehicle_data in coordinator.data.items(): + caps = vehicle_data.capabilities + for description in DESCRIPTIONS: + # Capability check (FR-010 through FR-014) + if description.required_capability and caps and caps.capability_flags: + if not caps.capability_flags.get(description.required_capability, True): + _LOGGER.debug("Skipping %s: capability %s disabled", + description.key, description.required_capability) + continue + # Existing available_fn check (for switch/lock platforms) + if hasattr(description, 'available_fn') and not description.available_fn(vehicle_data): + continue + entities.append(SmartSensorEntity(coordinator, vin, description)) + async_add_entities(entities) +``` + +**Key design choice**: `caps.capability_flags.get(description.required_capability, True)` — defaults to `True` (create entity) when the function ID is not in the capability flags dict. This ensures: +- Unmapped capabilities → entity created (permissive) +- Empty capabilities (API failure) → all entities created (permissive fallback) +- Explicitly disabled capability → entity skipped + +**Alternatives considered**: +- Reuse `available_fn` for capability checks — rejected; `available_fn` runs per-poll for runtime state, capability check is one-time at setup. +- Separate capability filter function — rejected; violates Simplicity principle. A single field + shared logic is simpler. +- Decorator pattern on entity descriptions — rejected; over-engineering for a simple flag check. + +--- + +## Research Topic 5: Fridge Feature — Function ID Discovery + +**Decision**: The fridge feature does NOT have a corresponding `FunctionId.java` constant. The fridge is gated by service ID `"UFR"` (already defined as `SERVICE_ID_FRIDGE` in `const.py`), not by a function ID. + +**Evidence**: Searched both EU and INTL decompiled FunctionId.java for "fridge" — no matches. The integration currently uses `SERVICE_ID_FRIDGE = "UFR"` for command service identification. + +**Implication**: For fridge entities, the capability check should use the **service ID** path (check if `"UFR"` is in `service_ids`) rather than the function ID path. This means the `required_capability` field needs to support both function IDs and service IDs, OR the fridge uses the existing `available_fn` pattern (data is `None` when unsupported). + +**Decision**: Use the existing `available_fn` pattern for fridge — `available_fn=lambda data: data.fridge is not None`. This already works because the fridge status endpoint returns an error/empty for vehicles without a fridge, resulting in `fridge=None` in VehicleData. No additional capability check needed. + +**Alternatives considered**: Add a dual-mode `required_capability` supporting both function IDs and service IDs — rejected; increases complexity for a single edge case that's already handled. + +--- + +## Research Topic 6: API Documentation Gap Analysis + +**Decision**: Three documentation files need updates to reflect the actual capability response structure. + +| File | Current State | Required Update | +|------|--------------|-----------------| +| `API/endpoints/capabilities.md` | Shows speculative `serviceId`/`enabled`/`version` schema | Replace with `functionId`/`valueEnable` schema from Capability.java; note that schema is APK-modeled and requires live verification | +| `API/entities.md` | Entity table with no capability column | Add `Required Capability` column with function IDs | +| `API/models.md` | `VehicleCapabilities` has only `service_ids` | Add `capability_flags: dict[str, bool]` field documentation | + +**Alternatives considered**: Create a separate capability mapping document — rejected; the information fits naturally in the existing entity mapping table. diff --git a/specs/006-capability-entity-filtering/spec.md b/specs/006-capability-entity-filtering/spec.md new file mode 100644 index 0000000..9d8f249 --- /dev/null +++ b/specs/006-capability-entity-filtering/spec.md @@ -0,0 +1,158 @@ +# Feature Specification: Capability-Based Entity Filtering + +**Feature Branch**: `006-capability-entity-filtering` +**Created**: 2025-05-25 +**Status**: Draft +**Input**: User description: "Implement capability-based entity filtering using the vehicle capability API to only register entities for features the vehicle actually supports, cache static data, and update API documentation." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Only See Entities My Vehicle Supports (Priority: P1) + +As a Home Assistant user with a Smart vehicle, I want the integration to only create entities for features my vehicle actually supports so that my dashboard is clean and free of phantom entities that show "unavailable" or "unknown" states. + +**Why this priority**: This is the core value of the feature. Users currently see dozens of entities for features their vehicle does not have (e.g., fridge controls on a vehicle without a fridge, sunroof controls on a vehicle without a sunroof). These phantom entities create confusion, clutter dashboards, and generate spurious "unavailable" log warnings. + +**Independent Test**: Can be fully tested by configuring the integration with a vehicle that lacks certain features (e.g., no fridge, no sunroof) and verifying that only entities corresponding to enabled capability flags are created. Delivers immediate value by eliminating phantom entities. + +**Acceptance Scenarios**: + +1. **Given** a vehicle that does NOT support the fridge feature (capability flag `vehicle_fridge` is `false` or absent), **When** the integration loads, **Then** no fridge-related entities (fridge switch, fridge temperature sensor) are created. +2. **Given** a vehicle that DOES support remote door lock/unlock (capability flag `remote_control_lock` is `true`), **When** the integration loads, **Then** the door lock entity is created and functional. +3. **Given** a vehicle where the capability API returns a subset of enabled features, **When** the user views the Home Assistant entity list, **Then** only entities matching enabled capabilities appear — no "unavailable" or "unknown" phantom entities exist. +4. **Given** a vehicle with all common features enabled (climate, locks, windows, charging), **When** the integration loads, **Then** all corresponding entities are created and behave identically to the current integration (no regression). + +--- + +### User Story 2 - Command Controls Respect Capabilities (Priority: P2) + +As a Home Assistant user, I want command-based entities (lock/unlock, climate start/stop, window open/close, horn/flash) to only appear if my vehicle's capability flags indicate the vehicle supports those remote control operations, so I don't accidentally send unsupported commands. + +**Why this priority**: Command entities that target unsupported operations produce confusing error responses from the API. Filtering commands by capability prevents user frustration and reduces unnecessary API calls. + +**Independent Test**: Can be tested by checking that a vehicle without remote window control capability does not expose window open/close button entities, and that sending commands through enabled entities succeeds as before. + +**Acceptance Scenarios**: + +1. **Given** a vehicle whose capability flags include `remote_window_close` as enabled, **When** the integration loads, **Then** the window close button entity is created. +2. **Given** a vehicle whose capability flags do NOT include `remote_trunk_open`, **When** the integration loads, **Then** no trunk open button entity is created. +3. **Given** a vehicle with climate control capabilities enabled, **When** the user activates the climate entity, **Then** the command executes successfully (no regression from current behavior). + +--- + +### User Story 3 - Reduced API Calls for Static Data (Priority: P3) + +As an integration maintainer, I want static vehicle data (capabilities, vehicle ability/visual config, plant number) to be fetched only once during setup and cached, rather than re-fetched every polling cycle, so that the integration makes fewer API calls and initializes faster on subsequent polls. + +**Why this priority**: Currently, capabilities, vehicle ability, and plant number are fetched on every 60-second poll cycle despite being static data that doesn't change during a vehicle's lifetime. Caching eliminates redundant API calls, reduces load on the Smart cloud API, and speeds up each poll cycle. + +**Independent Test**: Can be tested by monitoring API call logs during multiple poll cycles and confirming that capability, vehicle ability, and plant number endpoints are called only once during initial setup and not repeated on subsequent polls. + +**Acceptance Scenarios**: + +1. **Given** the integration has completed its initial setup and fetched capabilities, **When** the next polling cycle runs, **Then** the capability endpoint is NOT called again. +2. **Given** the integration has cached vehicle ability data, **When** the coordinator performs a data update, **Then** vehicle ability data is served from cache without an API call. +3. **Given** the integration is restarted (Home Assistant restart), **When** it initializes, **Then** capabilities, vehicle ability, and plant number are fetched fresh and cached again. + +--- + +### User Story 4 - Accurate API Documentation (Priority: P4) + +As a contributor or developer working on the integration, I want the API documentation to accurately describe the capability endpoint response structure (including `functionId`, `valueEnable`, and related fields) and the mapping between capability flags and entities, so that future development is well-informed. + +**Why this priority**: The current API documentation does not reflect the full capability response structure. Updated documentation reduces onboarding time for contributors and prevents repeated reverse-engineering of the APK. + +**Independent Test**: Can be tested by reviewing the documentation files and confirming they contain the full capability response schema, function ID mappings, and entity-to-capability relationships. + +**Acceptance Scenarios**: + +1. **Given** a contributor reads the capabilities endpoint documentation, **When** they look for the response schema, **Then** they find the complete structure including `functionId`, `valueEnable`, `functionCategory`, `paramsJson`, and other fields. +2. **Given** a contributor wants to add a new entity, **When** they consult the entity documentation, **Then** they find guidance on how to associate a new entity with a capability flag. + +--- + +### Edge Cases + +- What happens when the capability API is unreachable or returns an error during initial setup? The integration should fall back to creating all entities (permissive default) and log a warning, so users are not left with zero entities. +- What happens when the capability API returns an empty capabilities list? Same permissive fallback — create all entities and log a warning indicating capabilities could not be determined. +- What happens when a capability flag's `functionId` does not match any known entity mapping? The unmapped capability is ignored (no entity is created for it), and a debug log entry records the unknown function ID for future investigation. +- What happens when a new entity description is added to the code but no capability mapping is defined for it? The entity should be created unconditionally (opt-in filtering), so that new entities work immediately and capability mapping can be added later. +- What happens if the capability response structure changes (e.g., field names differ between API versions)? The parser should handle missing fields gracefully and fall back to permissive entity creation with a logged warning. +- What happens when a vehicle's capabilities change (e.g., after a firmware update that enables a new feature)? Since capabilities are cached per session, the new capability will be picked up on the next Home Assistant restart. + +## Assumptions + +- The capability endpoint (`/geelyTCAccess/tcservices/capability/{vin}`) returns a list of capability objects, each containing at minimum `functionId` (string) and `valueEnable` (boolean), matching the structure observed in the decompiled APK (`Capability.java`). +- The APK's `FunctionId.java` constants (126 function IDs) represent the canonical list of capability identifiers used by the Smart cloud API. +- Capability flags are static for a vehicle's current firmware version and do not change between polling intervals. They may change after OTA firmware updates, but this is handled by restarting Home Assistant. +- The existing `serviceId` field in capability objects is unrelated to `functionId` — service IDs identify API service permissions, while function IDs identify specific vehicle features. +- A permissive default (create all entities) is safer than a restrictive default (create no entities) when capability data is unavailable, ensuring backward compatibility. +- Vehicle ability and plant number data are similarly static and safe to cache for the lifetime of a Home Assistant session. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Capability Data Parsing + +- **FR-001**: System MUST parse the full capability response from the vehicle capability API, extracting `functionId` and `valueEnable` for each capability object. +- **FR-002**: System MUST store capability flags as a mapping of function ID strings to boolean enabled/disabled values. +- **FR-003**: System MUST continue to extract `serviceId` values from the capability response for backward compatibility with existing service-ID-based checks. +- **FR-004**: System MUST handle malformed or incomplete capability objects gracefully, skipping individual entries that lack required fields and logging a debug-level warning. + +#### Capability-to-Entity Mapping + +- **FR-005**: System MUST define a mapping between capability function IDs and entity descriptions, specifying which capability flag(s) gate each entity. +- **FR-006**: System MUST support entities that require a single capability flag to be enabled. +- **FR-007**: System MUST support entities that have no capability requirement (always created regardless of capability flags). +- **FR-008**: The capability mapping MUST cover at minimum these feature categories: door lock/unlock, climate control, window control, trunk open, horn/flash, seat heating, seat ventilation, fridge, and charging. +- **FR-009**: System MUST map the following APK function IDs to their corresponding entity types: + - `remote_control_lock` / `remote_control_unlock` → door lock entity + - `remote_air_condition_switch` → climate entity + - `remote_window_close` / `remote_window_open` → window control entities + - `remote_trunk_open` → trunk button entity + - `honk_flash` → horn/flash button entity + - `remote_seat_preheat_switch` → seat heating entities + - `vehicle_fridge` → fridge entities + - `charging_status` → charging-related sensor entities + +#### Entity Filtering + +- **FR-010**: System MUST check capability flags before creating each entity during platform setup. +- **FR-011**: System MUST skip entity creation when the associated capability flag exists and is set to disabled (`valueEnable` is `false`). +- **FR-012**: System MUST create the entity when the associated capability flag is set to enabled (`valueEnable` is `true`). +- **FR-013**: System MUST create the entity when no capability mapping is defined for that entity type (opt-in filtering — unmapped entities default to always created). +- **FR-014**: System MUST create all entities (permissive fallback) when the capability API call fails or returns no data, to maintain backward compatibility. +- **FR-015**: System MUST log at info level the total count of entities filtered out by capability checks during setup, per entity platform. +- **FR-016**: System MUST log at debug level each individual entity that is skipped due to a disabled capability flag, including the entity key and function ID. + +#### Static Data Caching + +- **FR-017**: System MUST fetch capability data only once during initial coordinator setup, not on every polling cycle. +- **FR-018**: System MUST fetch vehicle ability (visual configuration) data only once during initial coordinator setup. +- **FR-019**: System MUST fetch plant number data only once during initial coordinator setup. +- **FR-020**: System MUST serve cached capability, vehicle ability, and plant number data for all subsequent data updates within the same Home Assistant session. +- **FR-021**: System MUST re-fetch all cached static data when Home Assistant restarts or the integration is reloaded. +- **FR-022**: System MUST NOT block or delay coordinator polling if static data fetching fails — failures should be logged and the coordinator should continue with available data. + +#### API Documentation Updates + +- **FR-023**: The capabilities endpoint documentation MUST include the full response schema showing all fields of a capability object (`functionId`, `valueEnable`, `functionCategory`, `name`, `showType`, `tips`, `valueEnum`, `valueRange`, `paramsJson`, `configCode`, `platform`, `priority`). +- **FR-024**: The entity documentation MUST include a reference table mapping entity types to their required capability function IDs. +- **FR-025**: The models documentation MUST reflect the updated capability data model with function-ID-based capability flags. + +### Key Entities + +- **Capability Flag**: Represents a single vehicle feature capability. Key attributes: function ID (unique string identifier, e.g., `remote_control_lock`), enabled status (boolean), function category (grouping), and optional parameters. Sourced from the vehicle capability API. +- **Capability Map Entry**: Represents the association between a capability function ID and one or more entity descriptions. Defines which capability flag(s) must be enabled for a given entity to be created. Maintained as a static configuration within the integration. +- **Vehicle Capabilities (enhanced)**: The collection of all capability flags for a specific vehicle. Key attributes: function ID to enabled-status mapping (dictionary), plus the existing service ID list for backward compatibility. Cached after initial fetch. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Vehicles missing specific features (e.g., fridge, sunroof) show zero phantom entities for those features — 100% of unsupported feature entities are filtered out. +- **SC-002**: Vehicles with all features enabled show identical entity counts and behavior compared to the current integration (zero regression in entity creation or functionality). +- **SC-003**: After initial setup, the number of API calls per polling cycle is reduced by eliminating repeated calls to the capability, vehicle ability, and plant number endpoints (3 fewer API calls per poll cycle per vehicle). +- **SC-004**: New entities added to the integration without a capability mapping are created unconditionally, ensuring the filtering system does not block future development. +- **SC-005**: Contributors can determine the required capability flag for any entity by consulting the API documentation within 2 minutes, without needing to inspect APK source code. diff --git a/specs/006-capability-entity-filtering/tasks.md b/specs/006-capability-entity-filtering/tasks.md new file mode 100644 index 0000000..e4ac41e --- /dev/null +++ b/specs/006-capability-entity-filtering/tasks.md @@ -0,0 +1,158 @@ +# Tasks: Capability-Based Entity Filtering + +**Input**: Design documents from `/specs/006-capability-entity-filtering/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Tests**: Not explicitly requested in the feature specification. Omitted per SpecKit convention. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Phase 1: Setup + +**Purpose**: Foundation data model changes and function ID constants needed by all user stories + +- [x] T001 Add `capability_flags: dict[str, bool]` field to `VehicleCapabilities` dataclass in `custom_components/hello_smart/models.py` +- [x] T002 Add `StaticVehicleData` dataclass with `capabilities`, `ability`, and `plant_no` fields in `custom_components/hello_smart/models.py` +- [x] T003 [P] Add `FUNCTION_ID_*` constants to `custom_components/hello_smart/const.py` (24 constants from data-model.md mapping: `FUNCTION_ID_REMOTE_LOCK`, `FUNCTION_ID_REMOTE_UNLOCK`, `FUNCTION_ID_CLIMATE`, `FUNCTION_ID_WINDOW_CLOSE`, `FUNCTION_ID_WINDOW_OPEN`, `FUNCTION_ID_TRUNK_OPEN`, `FUNCTION_ID_HONK_FLASH`, `FUNCTION_ID_SEAT_HEAT`, `FUNCTION_ID_SEAT_VENT`, `FUNCTION_ID_FRAGRANCE`, `FUNCTION_ID_CHARGING`, `FUNCTION_ID_DOOR_STATUS`, `FUNCTION_ID_TRUNK_STATUS`, `FUNCTION_ID_WINDOW_STATUS`, `FUNCTION_ID_SKYLIGHT_STATUS`, `FUNCTION_ID_TYRE_PRESSURE`, `FUNCTION_ID_VEHICLE_POSITION`, `FUNCTION_ID_TOTAL_MILEAGE`, `FUNCTION_ID_HOOD_STATUS`, `FUNCTION_ID_CHARGE_PORT_STATUS`, `FUNCTION_ID_CURTAIN_STATUS`, `FUNCTION_ID_DOORS_STATUS`, `FUNCTION_ID_CLIMATE_STATUS`, `FUNCTION_ID_CHARGING_RESERVATION`) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Update the capability API parser and coordinator caching — MUST complete before entity platform work begins + +**⚠️ CRITICAL**: All user story phases depend on these tasks + +- [x] T004 Update `async_get_capabilities()` in `custom_components/hello_smart/api.py` to parse `functionId`/`valueEnable` from `data.list[]` (APK model format), falling back to `data.capabilities[]` with `serviceId`/`enabled` (legacy format). Populate both `capability_flags` dict and `service_ids` list on the returned `VehicleCapabilities`. Log raw response structure at debug level on first call to verify schema (see research.md Topic 1 and contracts/capability-api-response.md) +- [x] T005 Add `_static_cache: dict[str, StaticVehicleData]` instance variable to `SmartDataCoordinator.__init__()` in `custom_components/hello_smart/coordinator.py`. Modify `_async_fetch_all_vehicles()` to check `_static_cache[vin]` before calling `async_get_capabilities()`, `async_get_vehicle_ability()`, and `async_get_plant_no()`. On first fetch, store results in cache. On subsequent polls, use cached values. Build `VehicleData` using cached `capabilities`, `ability`, and `plant_no` instead of re-fetched values + +**Checkpoint**: Capability flags are parsed and cached. Entity platforms can now be updated. + +--- + +## Phase 3: User Story 1 — Only See Entities My Vehicle Supports (Priority: P1) 🎯 MVP + +**Goal**: Filter read-only sensor and binary sensor entities based on vehicle capability flags so phantom entities are eliminated + +**Independent Test**: Configure integration with a vehicle lacking certain features and verify only supported entities are created. Compare entity counts with a fully-capable vehicle to confirm zero regression. + +### Implementation for User Story 1 + +- [x] T006 [P] [US1] Add `required_capability: str | None = None` field to `SmartSensorEntityDescription` dataclass in `custom_components/hello_smart/sensor.py`. Add capability filtering logic to `async_setup_entry()`: for each description with a non-None `required_capability`, check `vehicle_data.capabilities.capability_flags.get(required_capability, True)` — skip entity if `False`. Log skipped entities at debug level and total filtered count at info level per contracts/entity-filtering.md +- [x] T007 [P] [US1] Add `required_capability: str | None = None` field to `SmartBinarySensorEntityDescription` dataclass in `custom_components/hello_smart/binary_sensor.py`. Add the same capability filtering logic to `async_setup_entry()` as in T006 +- [x] T008 [US1] Assign `required_capability` values to sensor entity descriptions in `custom_components/hello_smart/sensor.py` using `FUNCTION_ID_*` constants from const.py. Map: tyre pressure sensors → `FUNCTION_ID_TYRE_PRESSURE`, charging sensors (voltage, current, time_to_full, charging_status) → `FUNCTION_ID_CHARGING`, seat heat sensors → `FUNCTION_ID_SEAT_HEAT`, seat ventilation sensors → `FUNCTION_ID_SEAT_VENT`, odometer → `FUNCTION_ID_TOTAL_MILEAGE`. Leave all other sensors as `required_capability=None` (always created) +- [x] T009 [US1] Assign `required_capability` values to binary sensor entity descriptions in `custom_components/hello_smart/binary_sensor.py` using constants from const.py. Map: door lock status → `FUNCTION_ID_DOOR_STATUS`, trunk status → `FUNCTION_ID_TRUNK_STATUS`, window statuses → `FUNCTION_ID_WINDOW_STATUS`, sunroof/skylight → `FUNCTION_ID_SKYLIGHT_STATUS`, hood → `FUNCTION_ID_HOOD_STATUS`, charge port → `FUNCTION_ID_CHARGE_PORT_STATUS`, curtain → `FUNCTION_ID_CURTAIN_STATUS`, individual doors → `FUNCTION_ID_DOORS_STATUS`. Leave all other binary sensors as `required_capability=None` + +**Checkpoint**: Sensor and binary_sensor entities are filtered by capability. A vehicle missing features shows no phantom read-only entities. Vehicles with all features show identical entity counts (zero regression). + +--- + +## Phase 4: User Story 2 — Command Controls Respect Capabilities (Priority: P2) + +**Goal**: Filter command-based entities (lock, climate, buttons, switches, selects) based on capability flags so unsupported commands are never exposed + +**Independent Test**: Verify that a vehicle missing window control capability has no window button entities, while lock/climate still work for vehicles with those capabilities. + +### Implementation for User Story 2 + +- [x] T010 [P] [US2] Add `required_capability: str | None = None` field to `SmartLockEntityDescription` dataclass in `custom_components/hello_smart/lock.py`. Add capability filtering logic to `async_setup_entry()` (before the existing `available_fn` check). Assign `required_capability=FUNCTION_ID_REMOTE_LOCK` to the lock entity description +- [x] T011 [P] [US2] Add `required_capability: str | None = None` field to `SmartSwitchEntityDescription` dataclass in `custom_components/hello_smart/switch.py`. Add capability filtering logic to `async_setup_entry()` (before the existing `available_fn` check). Assign `required_capability` to switch descriptions: fragrance → `FUNCTION_ID_FRAGRANCE`, charging schedule switch → `FUNCTION_ID_CHARGING_RESERVATION`. Leave fridge switch as `required_capability=None` (fridge uses existing `available_fn` pattern per research.md Topic 5) +- [x] T012 [P] [US2] Add `required_capability: str | None = None` field to `SmartButtonEntityDescription` dataclass in `custom_components/hello_smart/button.py`. Add capability filtering logic to `async_setup_entry()`. Assign: window close → `FUNCTION_ID_WINDOW_CLOSE`, window open → `FUNCTION_ID_WINDOW_OPEN`, trunk open → `FUNCTION_ID_TRUNK_OPEN`, horn/flash → `FUNCTION_ID_HONK_FLASH` +- [x] T013 [P] [US2] Add `required_capability: str | None = None` field to `SmartSelectEntityDescription` dataclass in `custom_components/hello_smart/select.py`. Add capability filtering logic to `async_setup_entry()`. Assign: seat heating level → `FUNCTION_ID_SEAT_HEAT`, seat ventilation → `FUNCTION_ID_SEAT_VENT` +- [x] T014 [US2] Add capability check to `async_setup_entry()` in `custom_components/hello_smart/climate.py` before creating the climate entity. Check `vehicle_data.capabilities.capability_flags.get(FUNCTION_ID_CLIMATE, True)` — skip if `False`. Import `FUNCTION_ID_CLIMATE` from const +- [x] T015 [US2] Add `required_capability: str | None = None` field to entity descriptions in `custom_components/hello_smart/number.py` and `custom_components/hello_smart/time.py`. Add capability filtering logic to their `async_setup_entry()` functions. Assign `required_capability` values where applicable (charging schedule time → `FUNCTION_ID_CHARGING_RESERVATION`). Leave others as `None` + +**Checkpoint**: All command-based entities are filtered by capability. Unsupported remote operations no longer appear as HA entities. + +--- + +## Phase 5: User Story 3 — Reduced API Calls for Static Data (Priority: P3) + +**Goal**: Verify caching is working correctly by deploying to dev container and confirming static data endpoints are only called once + +**Independent Test**: Monitor API call logs across multiple poll cycles — capability, vehicle ability, and plant number endpoints should appear only on first poll. + +### Implementation for User Story 3 + +- [x] T016 [US3] Deploy to dev container and capture debug logs across 3+ poll cycles. Verify: (1) capability endpoint called exactly once per VIN on first poll, (2) vehicle ability endpoint called exactly once per VIN on first poll, (3) plant number endpoint called exactly once per VIN on first poll, (4) none of the three endpoints called on subsequent polls. Log the raw capability API response to verify the response schema matches `data.list[]` format from contracts/capability-api-response.md. If the response uses a different format, update `async_get_capabilities()` in `custom_components/hello_smart/api.py` accordingly + +**Checkpoint**: API call reduction confirmed. 3 fewer API calls per poll cycle per vehicle. + +--- + +## Phase 6: User Story 4 — Accurate API Documentation (Priority: P4) + +**Goal**: Update API documentation to reflect actual capability response structure and entity-to-capability mappings + +**Independent Test**: Review documentation files and confirm they contain full capability response schema, function ID mappings, and entity-capability relationships. + +### Implementation for User Story 4 + +- [x] T017 [P] [US4] Update `API/endpoints/capabilities.md`: replace the speculative `serviceId`/`enabled`/`version` response schema with the actual `functionId`/`valueEnable` schema from `Capability.java`. Include full field reference table (functionId, valueEnable, functionCategory, name, showType, tips, valueEnum, valueRange, paramsJson, configCode, platform, priority). Add note about dual-format parsing (primary `data.list[]`, fallback `data.capabilities[]`). Update the Data Model section to reference the enhanced `VehicleCapabilities` with `capability_flags` +- [x] T018 [P] [US4] Update `API/entities.md`: add a `Required Capability` column to all entity tables (Sensors, Binary Sensors, Switches, Locks, Buttons, Selects, Climate, Number, Time). Populate with the corresponding `FUNCTION_ID_*` value or "—" for entities with no capability requirement. Add a note explaining that entities without a required capability are always created +- [x] T019 [P] [US4] Update `API/models.md`: add `capability_flags: dict[str, bool]` field to the `VehicleCapabilities` model documentation. Add the new `StaticVehicleData` model with its three fields. Update any references to the old `VehicleCapabilities` model to note both `service_ids` (backward compat) and `capability_flags` (new) + +**Checkpoint**: Documentation accurately reflects the implementation. Contributors can look up capability requirements for any entity. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and cleanup + +- [x] T020 Verify zero regression by comparing full entity list for a vehicle with all features enabled against the entity list from the previous integration version. Confirm identical entity count and identical entity keys +- [x] T021 Run quickstart.md validation steps: deploy to dev container, check logs for capability parsing output, verify filtered entity counts match expectations + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 completion (needs models and constants) — BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Phase 2 — sensor/binary_sensor filtering +- **US2 (Phase 4)**: Depends on Phase 2 — command entity filtering (can run in parallel with US1) +- **US3 (Phase 5)**: Depends on Phases 2 + 3 or 4 — deployment verification +- **US4 (Phase 6)**: Depends on Phase 2 — documentation (can run in parallel with US1/US2) +- **Polish (Phase 7)**: Depends on all prior phases + +### Within Each User Story + +- Entity description dataclass change before capability assignment +- T006/T007 (dataclass + filtering logic) must complete before T008/T009 (assigning values) +- T010-T013 can all run in parallel (different files) +- T017-T019 can all run in parallel (different doc files) + +### Parallel Opportunities + +``` +Phase 1: T001, T002 (sequential in same file) + T003 (parallel, different file) +Phase 2: T004, T005 (sequential — T005 depends on T004's return type) +Phase 3: T006 ∥ T007 (parallel — different files) + T008 T009 (after T006, T007 respectively — same files) +Phase 4: T010 ∥ T011 ∥ T012 ∥ T013 (all parallel — different files) + T014, T015 (after any of T010-T013, different files, can parallel) +Phase 5: T016 (sequential — deployment verification) +Phase 6: T017 ∥ T018 ∥ T019 (all parallel — different doc files) +Phase 7: T020, T021 (sequential — final validation) +``` + +--- + +## Implementation Strategy + +**MVP**: Phase 1 + Phase 2 + Phase 3 (User Story 1) — delivers the core value of eliminating phantom entities for read-only sensors and binary sensors. + +**Incremental delivery**: +1. MVP (Phases 1-3): Sensor/binary sensor filtering — immediate user-visible improvement +2. +Phase 4 (US2): Command entity filtering — prevents sending unsupported commands +3. +Phase 5 (US3): Cache verification — confirms API call reduction +4. +Phase 6 (US4): Documentation — contributor onboarding improvement +5. +Phase 7: Final validation and cleanup