Skip to content

feat(eu-cci): HyundaiCciApiEU — new EU CCI/GSPA region (pure-python cipher) - #1284

Open
blka wants to merge 16 commits into
Hyundai-Kia-Connect:masterfrom
blka:feat/eu-cci-region
Open

feat(eu-cci): HyundaiCciApiEU — new EU CCI/GSPA region (pure-python cipher)#1284
blka wants to merge 16 commits into
Hyundai-Kia-Connect:masterfrom
blka:feat/eu-cci-region

Conversation

@blka

@blka blka commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

New EU CCI/GSPA region class HyundaiCciApiEU(ApiImpl) for Hyundai EU vehicles. Uses the CCI login flow (OneApp 4f4953b5) which bypasses the IDPConnect WAF that blocks the legacy 6d477c38 authorize (#1273). The CCS token is accepted by GSPA stored-status endpoints for vehicle data.

What's new

HyundaiCciApiEU (new region class, 1577 lines)

  • Login: CCI password login → CCI token → CCS token exchange
  • GSPA X-Stamp: Pure-python cipher via gspa/cipher_keys.py
  • GSPA vehicle data: stored-status fetch + CCS2 status parser with all fields
  • Token refresh: CCI token-refresh (v1 per D5) + full-login fallback
  • Device registration: CCI device registration
  • CCS user-id extraction: uid claim from CCS JWT for X-Stamp computation
  • Hyundai-only: Kia/Genesis raise NotImplementedError (follow-up PR)

gspa/ (new package, pure-python)

  • cipher_keys.py (217 lines): 14-round whitebox AES reimplementation from recovered affine parameters
  • cipher_params.json (106KB): recovered round keys + affine params
  • Verified bit-identical to known-good test vectors (P1→C1, P2→C2)

Shared files (additive only)

  • const.py: REGION_EUROPE_CCI (key 9)
  • Token.py: ccs_user_id field (uid claim from CCS JWT)
  • VehicleManager.py: import + elif mapping for REGION_EUROPE_CCI

Verification

  • Live-verified: Santa Fe HEV via GSPA stored-status
    • 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% ✅
    • Token refresh + full-login fallback ✅
    • X-Request-Id (tsid) required for GSPA 403-007 ✅

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)
  • Full suite: 553 passed, 15 skipped
  • ruff: All checks passed (84 files)

Code review (16 findings, all fixed)

Follow-up (separate PRs)

  • PR2: MQTT push (gspa-ccs-eu MQTT service hub)
  • PR3: GSPA control (lock/climate/charge/windows)
  • PR4: OTA firmware
  • kia_uvo companion: config_flow new region + manifest bump

Fixes #1273. Follow-up to #1277/#1278/#1279.

blka added 14 commits August 17, 2026 16:36
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)
@blka
blka force-pushed the feat/eu-cci-region branch from 583efd3 to 8fd1b69 Compare August 18, 2026 13:48

@cdnninja cdnninja left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few minor comments at my first run though.

Comment thread hyundai_kia_connect_api/Token.py Outdated
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't an API error.

self.session = ApiImplSession()

# ------------------------------------------------------------------
# Login

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method name isn't very clear.

return

for item in driving_info_list:
if not isinstance(item, dict):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I understand this. What does this do? If not matter the value it continues.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

And new log in issues again today (at some point after 2026-08-10,23:08:05 UTC)

2 participants