The HACS validation + hassfest CI workflow is at .github/workflows/validate.yml. It runs on push, PR, and daily schedule.
uv sync # Install deps (uv.lock is source of truth)
pytest # All tests
pytest -m integration # No HA runtime needed
pytest -m system # Requires HA runtime (pytest-homeassistant-custom-component)
pytest --cov # Coverage
make lint # ruff check custom_components/ora tests
make format # ruff format custom_components/ora tests
make test-cov # pytest --cov=custom_components.ora with HTML report
make test-integration # pytest -m integration
make test-system # pytest -m system
make clean # Remove __pycache__, .coverage, htmlcovStyle: Python >=3.14 (pyproject.toml), ruff double quotes, line-length 100 (ruff.toml), pytest asyncio_mode=auto.
custom_components/ora/— HA custom component (entry point:async_setup_entryin__init__.py). Manifestiot_class: cloud_polling, depends onmqtt.custom_components/ora/api/— API subpackage;GwmApifacade composesGwmHttpClient+GwmAuthClient+GwmVehicleClientcustom_components/ora/cert/— mTLS certificates. The bundled private key (gwm_general.key) is a transformed PKCS#1 DER;cert.pyreconstructs it via inverse chunk encoding +rsa.rsa_recover_prime_factors.tests/integration/— Unit/integration tests (no HA runtime)tests/system/— Full HA integration tests (needspytest-homeassistant-custom-component)
Two gateway URLs, selected via use_app_gateway flag on GwmHttpClient:
- h5_gateway (
eu-h5-gateway.gwmcloud.com): auth/login/email-code (no mTLS) - app_gateway (
eu-app-gateway.gwmcloud.com): vehicles/status (requires mTLS)
Setting country on the client clears the access token (client.py:68-70).
Three steps in ConfigFlow class (NOT in OptionsFlowHandler):
async_step_user— email/password loginasync_step_email_code— email verification (API code110641triggers this)async_step_reauth— triggered byConfigEntryAuthFailed, retries 3× (2s→4s→8s), only retries308024(not550002)- Options flow (
OptionsFlowHandler.async_step_init) handles poll interval (10-300s) and manual re-auth trigger
Critical patterns:
if not user_input:notif user_input is None:— HA may pass{}.- Fresh API instances: config flow creates new
GwmApi()for each step afterclose()on previous. - Stable device_id from
_get_stable_device_id(hass)via uuid5 ofhass.config.path(). - Update listener (
_async_entry_updated_listenerin__init__.py) updates coordinator in-place — no full reload. Avoids re-entrant loops whenasync_update_entrytriggers the listener during setup.
- Proactive refresh: coordinator checks
token_issued_at+expires_inbefore each poll cycle; refreshes if ≤5 min from expiry - On-demand refresh: if vehicle API returns
570062or550004, calls_do_token_refresh(). Non-matching API errors propagate asGwmApiException(not retried). HTTP errors (502, timeouts) retried 3× with backoff in_fetch_vehicle_data_with_retry. - Both fail: raises
ConfigEntryAuthFailed→ triggers HA re-auth flow - No
get_user_info()call on poll (removed v0.2.35). Token validity determined reactively via vehicle API responses.
- Default interval: 60s (configurable 10-300s)
- Transient errors (
308024rate limit): retry 10s→20s→40s (max 120s, 3 attempts) DataUpdateCoordinatorbuilt-in polling (no manual start/stop)550002("System busy") treated asUpdateFailed, NOT retried
Platforms: [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER]
Data points in const.py (DATA_POINTS keyed by API code). Sensors added via listener pattern — async_add_listener in sensor.py adds entities when coordinator first has data, not at setup time.
- Markers required:
@pytest.mark.integrationand@pytest.mark.system - Autouse fixtures (
conftest.py):mock_ora_integration(patches HA loader),patch_certificates,prevent_entry_setup(avoids HA setup post-config-flow) - Fixtures:
tests/fixtures/api_responses.py(mock API responses),tests/fixtures/certificates.py(mock cert PEMs) - Mocking:
AsyncMockonGwmHttpClient._request— aioresponses not used
- Config flow methods in
ConfigFlow:async_step_user,async_step_email_code,async_step_reauth,_create_entrymust be inConfigFlow, NOTOptionsFlowHandler. HA shows "not_implemented" otherwise. - Check user_input with
if not user_input:— HA may pass empty dict{}notNone. - Private key is non-standard:
gwm_general.keyis a 9-integer PKCS#1 DER with a transformeddvia 5-bit chunk encoding.CertificateHandler._reconstruct_private_keyincert.pyhandles the full decode. - Country setter clears token:
GwmHttpClient.countrysetter resets_access_tokentoNone. - Email code endpoint:
get_email_code()andsend_email_code()both hituserAuth/getSMSCodewith differentscenariovalues. - SSL context caching: Two separate caches —
_ssl_context(plain, h5-gateway) and_ssl_context_client(mTLS, app-gateway). Always checkrequire_client_certbefore returning cached context. Single-cache design broke app-gateway requests. - mTLS intermediate CA chain:
gwm_root.pemintermediates must be concatenated with the client cert inload_cert_chain(exclude the self-signed root). Without them, server returns HTTP 400. - SHA-1 intermediate + OpenSSL 3:
IOV APP General SubCAuses SHA-1; OpenSSL 3.x rejects at default seclevel 2. Workaround: set@SECLEVEL=0for the mTLS context, usessl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)(notcreate_default_context). - cryptography >=46 chain parsing: GWM's
gwm_root.pemuses old PrintableString encoding that the Rust ASN.1 parser rejects. Usechain_intermediate_pem()(pyOpenSSL) instead ofchainproperty.