feat(eu-cci): HyundaiCciApiEU — new EU CCI/GSPA region (pure-python cipher) - #1284
feat(eu-cci): HyundaiCciApiEU — new EU CCI/GSPA region (pure-python cipher)#1284blka wants to merge 16 commits into
Conversation
Port the pure-python GSPA cipher (create_tsid, GspaCipher encrypt_block/ encrypt_cfb) from the private branch. No native .so — pure-python only. Stamp parity tests pin the cipher output for fixed inputs.
Additive: new region constant REGION_EUROPE_CCI (key 9) + ccs_user_id Token field (uid claim from CCS JWT, used for GSPA X-Stamp computation). No existing region affected.
New class HyundaiCciApiEU(ApiImpl) for Hyundai EU CCI/GSPA region: - CCI password login (OneApp client_id 4f4953b5, bypasses IDPConnect WAF) - CCI token exchange (auth code -> CCI tokens -> CCS token) - Device registration (v3/notifications/bases/devices) - CCS user-id extraction (uid claim from CCS token JWT, fallback id_token sub) - Token refresh via v1/auth/token-refresh (D5 fix, not v2) - GSPA X-Stamp computation (pure-Python gspa cipher, region 1=EU) - Full-login fallback (refresh fail -> headless login) - Hyundai-only (Kia/Genesis raise NotImplementedError) No MQTT, no GSPA vehicle data, no control, no OTA (Tasks 7, PR2-4). No X-Fingerprint (works without — see Hyundai-Kia-Connect#1277). No R2 fallbacks (Keycloak, IDPConnect-refresh middle, CCSP/CCAPI). 23 tests: 8 CCI login error/success paths (duplicated from test_headless_login), brand constraint, login routing, CCI refresh + fallback, v1 endpoint check, GSPA stamp, JWT uid extraction, test_token.
Additive: import HyundaiCciApiEU + elif REGIONS[region]==REGION_EUROPE_CCI → HyundaiCciApiEU. Existing regions unaffected.
…ice-id Fixes GSPA 403-007 Invalid stamp: - X-Request-Id (tsid): server validates X-Stamp against tsid in X-Request-Id. - ttables.py: corrected (Task 3 corrupted data during RE-scrub). - ccsp-service-id: 6d477c38 (CCSP service ID, not OneApp 4f4953b5). - cipher_pure.py: RE-scrub (APK references removed). Live-verified: SANTA FE via GSPA stored-status, drive_mode=Eco, supports_valet_mode=True, 4 R1 field gaps confirmed.
…ers + clean pre-commit _get_stamp returns (stamp, tsid) — both needed as X-Stamp + X-Request-Id headers. _get_authenticated_headers calls _get_stamp (DRY, was inline). _get_stamp will be reused by control headers in PR3. Removed: dead _compute_x_stamp wrapper (delegated to _get_stamp). Cleaned: pre-commit exclude (removed dead ttables.py ref). Cleaned: cipher_keys comment (removed ttables ref).
…d assign, bool_or_none, regex, AuthError) F1: air_temperature + departure climates read Unit from response, default °C F2: car_battery_percentage uses normalize_battery_soc (sentinel 255 guard) F3: DTE.Total float() guarded against None (crash fix) F4: removed dead ev_battery_is_plugged_in first assignment F5: sleep_mode_check uses bool_or_none (None when absent) F6: set-cookie regex anchored (no false match on other cookies) F7: get_stored_status re-raises AuthenticationError (refresh cascade) F8: ev_first/second_departure_enabled guarded against None
…n guard, AuthError, null guards) F9: SEAT_STATUS[key] → .get(key) — KeyError crash on unknown seat status F10: removed total_driving_range from drivingInfo.totalDistance (odometer, not DTE) F11: windows check OpenLevel for vent position (Hyundai-Kia-Connect#1215 regression fix) F12: _gspa_get checks status_code before response.json() (JSONDecodeError on 401 HTML) F13: prewakeup re-raises AuthenticationError (refresh cascade) F14: drivingInfo null guard (AttributeError on None.get) F15: air_temp None guard (None != 'OFF' → (None, °C) bug) F16: jwk['n'] bracket → .get() with guard (KeyError on empty retValue)
583efd3 to
8fd1b69
Compare
cdnninja
left a comment
There was a problem hiding this comment.
Few minor comments at my first run though.
| non_ccs_refresh_token: str | None = None | ||
| id_token: str | None = None | ||
| # CCS user ID (uid claim from ccs_token JWT) — used for GSPA X-Stamp. | ||
| ccs_user_id: str | None = None |
There was a problem hiding this comment.
Could we just name this User ID to make it so it can be used by other regions if they call it something different?
| self.brand: int = brand | ||
|
|
||
| if BRANDS[self.brand] == BRAND_HYUNDAI: | ||
| # Confirmed production endpoints. |
There was a problem hiding this comment.
Not sure this comment brings value.
| self.CCI_API_URL: str = "https://cci-api-eu.hyundai.com" | ||
| self.LOGIN_FORM_HOST: str = "https://idpconnect-eu.hyundai.com" | ||
| self._cci_package_id: str = "com.hyundai.oneapp.eu" | ||
| self._cci_client_name: str = "hyundai" |
There was a problem hiding this comment.
This feels like duplicate of brand?
| "Genesis CCI EU not yet implemented — use KiaUvoApiEU for Genesis EU." | ||
| ) | ||
| else: | ||
| raise APIError(f"Unknown brand {BRANDS[self.brand]} for CCI EU API") |
There was a problem hiding this comment.
This isn't an API error.
| self.session = ApiImplSession() | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Login |
There was a problem hiding this comment.
This comment doesn't seem to follow the rest of the file.
|
|
||
| def _login_with_password( | ||
| self, username: str, password: str, device_id: str | ||
| ) -> dict: |
There was a problem hiding this comment.
Would it be easier to just return the token here instead of creating a dict then mapping it a second time? I am good either way.
There was a problem hiding this comment.
Kept the dict return — _login_with_password doesn't have pin (the user's PIN), which login() adds to the Token. Also, _register_device and _fetch_user_id need the Token object, so the steps are: 1) get CCI/CCS tokens (dict), 2) create Token with pin, 3) register device, 4) fetch user-id. I can refactor to return Token directly if preferred — pin could be passed to _login_with_password as well.
| then re-exchange the CCS token. Falls back to full login if | ||
| the refresh token is missing or the exchange fails. | ||
| """ | ||
| if getattr(token, "cci_access_token", None) or getattr( |
There was a problem hiding this comment.
Does this need getattr? I would think it always exists as it is defined in the dataclass token. If token.cci_access_token or token.non_ccs_token.
| except NotImplementedError: | ||
| raise | ||
| except Exception as e: | ||
| _LOGGER.debug(f"{DOMAIN} - X-Stamp computation error: {e}") |
There was a problem hiding this comment.
Wouldn't this cause it to fail so you want the exception to be created / pass on?
| headers["X-Request-Id"] = tsid | ||
| return headers | ||
|
|
||
| def _ensure_ccs_token(self, token: Token) -> None: |
There was a problem hiding this comment.
The method name isn't very clear.
| return | ||
|
|
||
| for item in driving_info_list: | ||
| if not isinstance(item, dict): |
There was a problem hiding this comment.
I am not sure I understand this. What does this do? If not matter the value it continues.
There was a problem hiding this comment.
This filters the drivingInfo response for the summary period (drivingPeriod == 0). The API returns a list of driving periods — period 0 is the total/summary that contains totalDistance (odometer), totalPwrCsp (total power consumption), and calculativeOdo (calculated odometer). Other periods (1, 2, ...) are individual trips. The continue skips non-summary entries so only the aggregate is parsed into total_driving_range and power_consumption_30d.
Summary
New EU CCI/GSPA region class
HyundaiCciApiEU(ApiImpl)for Hyundai EU vehicles. Uses the CCI login flow (OneApp4f4953b5) which bypasses the IDPConnect WAF that blocks the legacy6d477c38authorize (#1273). The CCS token is accepted by GSPA stored-status endpoints for vehicle data.What's new
HyundaiCciApiEU(new region class, 1577 lines)gspa/cipher_keys.pygspa/(new package, pure-python)cipher_keys.py(217 lines): 14-round whitebox AES reimplementation from recovered affine parameterscipher_params.json(106KB): recovered round keys + affine paramsShared files (additive only)
const.py:REGION_EUROPE_CCI(key 9)Token.py:ccs_user_idfield (uid claim from CCS JWT)VehicleManager.py: import + elif mapping forREGION_EUROPE_CCIVerification
drive_mode= Eco ✅supports_valet_mode= True ✅car_battery_percentage= 70% (12V) ✅ev_battery_percentage= 53% (traction battery) ✅odometer= 3548 km ✅total_driving_range= 385 km ✅fuel_level= 53% ✅Tests
tests/test_hyundai_cci_api_eu.py: 26 tests (login + GSPA + X-Stamp + user-id)tests/gspa/test_cipher_keys.py: 18 tests (cipher parity)Code review (16 findings, all fixed)
Follow-up (separate PRs)
Fixes #1273. Follow-up to #1277/#1278/#1279.