From 9014054d25a15ef3fbc7c3a04cd60975badba566 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 26 May 2026 11:13:03 +0200 Subject: [PATCH] Fix Hyundai Europe commands on legacy (non-CCS2) vehicles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EU commands authenticate differently by generation. CCS2 (Gen5W) cars use a PIN-derived control token and the /api/v2/.../ccs2/control/* endpoints; legacy cars use the normal access token and the /api/v1/.../control/* endpoints with no PIN step at all (per hyundai_kia_connect_api's ApiImplType1). sendCommand previously called setCommandToken() unconditionally, so legacy vehicles failed with "Failed to get command token" — the PIN endpoint isn't part of their flow. It also built a malformed path because commandPathAndBody() defaulted to ccs2: true while the URL used v1. This matches the "wrong token usage on legacy command path" issue noted in schmidtwmark/BetterBlueKit#8. - sendCommand: branch on ccs2 — control token + commandHeaders for CCS2, plain authorizedHeaders (no PIN) for legacy; pass the real ccs2 flag into commandPathAndBody. - commandPathAndBody: drop stray leading slash in the legacy door path ("/control/door" -> "control/door") that produced a double slash. - setCommandToken: route through performJSONRequest so the request is logged and status-validated, and surface a clearer PIN-failure error. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HyundaiEuropeAPIClient+Commands.swift | 4 +- .../HyundaiEuropeAPIClient.swift | 53 +++++++++++-------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift index 025d94c..328755c 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift @@ -17,10 +17,10 @@ extension HyundaiEuropeAPIClient { switch command { case .lock: return ccs2 ? ("ccs2/control/door", ["command": "close"]) - : ("/control/door", ["action": "close", "deviceId": deviceId]) + : ("control/door", ["action": "close", "deviceId": deviceId]) case .unlock: return ccs2 ? ("ccs2/control/door", ["command": "open"]) - : ("/control/door", ["action": "open", "deviceId": deviceId]) + : ("control/door", ["action": "open", "deviceId": deviceId]) case .startClimate(let options): // EU CCS2 vehicles only accept temperatures on the // 0.5°C grid (15.0–30.0). The car silently no-ops when diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift index d37f935..8c33dbe 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift @@ -199,30 +199,30 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol { return } - let body = [ - "deviceId": configuration.deviceId, + let body: [String: Any] = [ + "deviceId": configuration.deviceId ?? "", "pin": pin ] - let headers = authorizedHeaders(authToken: authToken) - - let bodyData = try? JSONSerialization.data( - withJSONObject: body, options: [] + // Route through performJSONRequest so the PIN/control-token + // request is captured in the HTTP logs and its status is + // validated. Previously this used a raw URLSession call, so a + // failure here was invisible to diagnostics and surfaced only + // as a generic "Failed to get command token". + let (_, json, _) = try await performJSONRequest( + url: "\(baseURL)/api/v1/user/pin?token=", + method: .PUT, + headers: authorizedHeaders(authToken: authToken), + body: body, + requestType: .sendCommand ) - var request = URLRequest(url: URL(string: "\(baseURL)/api/v1/user/pin?token=")!) - request.httpMethod = "PUT" - request.httpBody = bodyData - - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - - let (data, _) = try await urlSession.data(for: request) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let token = json["controlToken"] as? String, + guard let token = json["controlToken"] as? String, let expires = json["expiresTime"] as? Int else { - throw APIError(message: "Failed to get command token", apiName: apiName) + throw APIError( + message: "PIN verification failed — check that the account PIN is correct.", + apiName: apiName + ) } commandToken = token @@ -277,13 +277,24 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol { // MARK: - Commands public func sendCommand(for vehicle: Vehicle, command: VehicleCommand, authToken: AuthToken) async throws { - let (path, body) = commandPathAndBody(for: command) let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false + let (path, body) = commandPathAndBody(for: command, ccs2: ccs2) let url = "\(baseURL)/api/\(ccs2 ? "v2" : "v1")" + "/spa/vehicles/\(vehicle.regId)/\(path)" - try await setCommandToken(authToken: authToken) - let header = commandHeaders(authToken: authToken, ccs2: ccs2) + + // CCS2 (Gen5W) cars authenticate commands with a PIN-derived + // control token; legacy cars use the normal access token and + // have no PIN step at all. Fetching a control token for a + // legacy car is what produced the "Failed to get command token" + // error — the PIN endpoint isn't part of the legacy flow. + let header: [String: String] + if ccs2 { + try await setCommandToken(authToken: authToken) + header = commandHeaders(authToken: authToken, ccs2: ccs2) + } else { + header = authorizedHeaders(authToken: authToken, ccs2: ccs2) + } _ = try await performJSONRequest( url: url,