Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Sources/BBCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,10 @@ func fetchVehicleStatus(state: CLIState) async throws {

printSubheader("Fetching Status for \(vehicle.model)")

let status = try await client.fetchVehicleStatus(for: vehicle, authToken: token)
// cached: false → wake the car and read fresh state. On EU CCS2 vehicles
// this adds a ~20s wait but is the only way to see a just-sent command
// (e.g. climate) actually reflected, instead of a stale cached snapshot.
let status = try await client.fetchVehicleStatus(for: vehicle, authToken: token, cached: false)

printSuccess("Status fetched successfully")

Expand Down
68 changes: 68 additions & 0 deletions Sources/BetterBlueKit/API/APIClientBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ open class APIClientBase {
// MARK: - Error Handling

func validateHTTPResponse(_ httpResponse: HTTPURLResponse, data: Data, responseBody: String?) throws {
// CCSP (the EU/AU/IN "Connected Car Service Platform") reports
// application-level failures inside the body — `retCode: "F"` plus a
// numeric `resCode` — and usually pairs them with an unhelpful HTTP
// 400. Decode those first so a duplicate/timeout/rate-limit surfaces
// as a typed, user-facing error instead of "HTTP 400: bad request".
try checkCCSPResponseForErrors(data: data)

if httpResponse.statusCode == 401 {
throw APIError.invalidCredentials(
"Authentication expired: \(responseBody ?? "Unknown error")",
Expand All @@ -191,6 +198,67 @@ open class APIClientBase {
}
}

/// Translate a CCSP `retCode: "F"` error envelope into a typed `APIError`.
///
/// A no-op for any response that isn't a CCSP envelope (no `retCode`/
/// `resCode`), so the US/Canada/China clients are unaffected. The codes
/// and their meanings track Home Assistant's `hyundai_kia_connect_api`
/// `_check_response_for_errors`, which is the reference implementation for
/// the European Hyundai/Kia API.
func checkCCSPResponseForErrors(data: Data) throws {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["retCode"] as? String == "F",
let resCode = json["resCode"] as? String else {
return
}
let resMsg = (json["resMsg"] as? String) ?? "Unknown error"

switch resCode {
case "7501": // "Key not authorized" / token expired
throw APIError.invalidCredentials(
"Authentication expired — please sign in again.", apiName: apiName
)
case "4002": // Invalid deviceId — re-registering the device fixes it
throw APIError.invalidVehicleSession(
"Invalid device ID — please sign out and back in.", apiName: apiName
)
case "4004": // A previous command is still queued server-side
throw APIError.concurrentRequest(
"A previous command is still being processed. Please wait a moment and try again.",
apiName: apiName
)
case "4005": // Control action not supported for this vehicle
throw APIError(
message: "This action isn't supported for this vehicle.",
code: 400, apiName: apiName
)
case "4081", "9999": // Request/response timeout
throw APIError.serverError(
"The request timed out. Please try again.", apiName: apiName
)
case "5031": // Remote control temporarily unavailable
throw APIError.serverError(
"Remote control is temporarily unavailable. Please try again later.",
apiName: apiName
)
case "5091": // Exceeds number of requests
throw APIError.serverError(
"Too many requests — please wait a while before trying again.",
apiName: apiName
)
case "5921": // No data found yet
throw APIError(
message: "No data available from the vehicle yet. Try refreshing in a moment.",
code: 400, apiName: apiName
)
default:
throw APIError(
message: "Server returned \(resCode): \(resMsg)",
code: 400, apiName: apiName
)
}
}

func handleNetworkError(_ error: Error, context: RequestContext) -> APIError {
logHTTPRequest(createErrorLogData(context: context, error: error.localizedDescription))
return APIError(message: "Network error: \(error.localizedDescription)", apiName: apiName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation

extension HyundaiEuropeAPIClient {

func commandPathAndBody(for command: VehicleCommand, ccs2: Bool = true)
func commandPathAndBody(for command: VehicleCommand, ccs2: Bool = true, drvSeatLoc: String = "L")
-> (String, [String: Any]) {
let deviceId = configuration.deviceId ?? ""
switch command {
Expand All @@ -35,7 +35,10 @@ extension HyundaiEuropeAPIClient {
table: .european
)
if ccs2 {
return ("ccs2/control/temperature", startClimateCCS2Body(options: options, tempCelsius: tempCelsius))
return (
"ccs2/control/temperature",
startClimateCCS2Body(options: options, tempCelsius: tempCelsius, drvSeatLoc: drvSeatLoc)
)
}
return ("control/temperature", [
"action": "start",
Expand Down Expand Up @@ -82,18 +85,30 @@ extension HyundaiEuropeAPIClient {
/// CCS2 climate-start body. Identical shape to Kia EU's — both
/// brands share ApiImplType1.start_climate (CCS2 branch).
/// `tempCelsius` is already snapped to the 0.5°C EU grid.
private func startClimateCCS2Body(options: ClimateOptions, tempCelsius: Double) -> [String: Any] {
[
private func startClimateCCS2Body(
options: ClimateOptions,
tempCelsius: Double,
drvSeatLoc: String
) -> [String: Any] {
// On a right-hand-drive car the driver sits on the right, so the
// front-left/right seat controls map to passenger/driver. Matches
// hyundai_kia_connect_api's `start_climate` seat handling.
let (drvSeat, psgSeat) = drvSeatLoc == "R"
? (options.frontRightSeat, options.frontLeftSeat)
: (options.frontLeftSeat, options.frontRightSeat)
return [
"command": "start",
"ignitionDuration": options.duration,
"strgWhlHeating": options.steeringWheel,
"hvacTempType": 1,
"hvacTemp": tempCelsius,
"sideRearMirrorHeating": 1,
"drvSeatLoc": "R",
// Rear-window + side-mirror heaters ride along with the heating
// levels that engage them (1/2/4); off for 0 and steering-only (3).
"sideRearMirrorHeating": [1, 2, 4].contains(options.heatValue) ? 1 : 0,
"drvSeatLoc": drvSeatLoc,
"seatClimateInfo": [
"drvSeatClimateState": options.frontLeftSeat,
"psgSeatClimateState": options.frontRightSeat,
"drvSeatClimateState": drvSeat,
"psgSeatClimateState": psgSeat,
"rrSeatClimateState": options.rearRightSeat,
"rlSeatClimateState": options.rearLeftSeat
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
// the main file.
//

import CryptoKit
import Foundation

extension HyundaiEuropeAPIClient {
Expand All @@ -28,8 +27,8 @@ extension HyundaiEuropeAPIClient {
"Host": apiHost,
"Connection": "Keep-Alive",
"Accept-Encoding": "gzip",
// Fresh HMAC per request — Stamp is an `<appId>:<ISO8601>` signature
// and the server appears to validate the timestamp window.
// Fresh stamp per request — the server validates the embedded
// timestamp window (see `generateStamp()`).
"Stamp": generateStamp()
]
}
Expand All @@ -48,16 +47,29 @@ extension HyundaiEuropeAPIClient {
return result
}

/// HMAC-SHA256 of `<appId>:<ISO8601 timestamp>` keyed by the
/// first 32 bytes of the base64-decoded `authCfb` shared secret,
/// base64-encoded. Sent as the `Stamp` header on every request
/// and as the `pushRegId` field during device registration.
/// CCSP `Stamp`: base64 of `authCfb ⊕ "<appId>:<unixSeconds>"`, where the
/// XOR runs over the shorter of the two byte strings (the message here).
///
/// This ports the scheme used by Home Assistant's
/// `hyundai_kia_connect_api` (`_get_stamp`) / bluelinky, which is the
/// canonical stamp the EU CCSP servers expect. The previous
/// HMAC-SHA256-over-ISO8601 form happened to be accepted on read
/// endpoints but was rejected with HTTP 403 on the control endpoints,
/// so remote actions failed across Europe (e.g. NL). Sent as the `Stamp`
/// header on every request and as `pushRegId` during device registration.
func generateStamp() -> String {
let timestamp = ISO8601DateFormatter().string(from: Date())
let message = "\(Self.appId):\(timestamp)"
guard let cfbData = Data(base64Encoded: Self.authCfb) else { return message }
let key = SymmetricKey(data: cfbData.prefix(32))
let signature = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
return Data(signature).base64EncodedString()
let timestamp = Int(Date().timeIntervalSince1970)
let message = Array("\(Self.appId):\(timestamp)".utf8)
guard let cfbData = Data(base64Encoded: Self.authCfb) else {
return Data(message).base64EncodedString()
}
let cfb = Array(cfbData)
let count = min(cfb.count, message.count)
var xored = [UInt8]()
xored.reserveCapacity(count)
for index in 0 ..< count {
xored.append(cfb[index] ^ message[index])
}
return Data(xored).base64EncodedString()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
// Based on: https://github.com/andyfase/egmp-bluelink-scriptable
//

import CryptoKit
import Foundation

// MARK: - Hyundai Europe API Client
Expand All @@ -20,6 +19,10 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol {
static let clientSecret = "KUy49XxPzLpLuoK0xhBC77W6VXhmtQR9iQhmIFjjoY4IpxsV"
static let appId = "014d2225-8495-4735-812d-2616334fd15d"
static let authCfb = "RFtoRq/vDXJmRndoZaZQyfOot7OrIqGVFj96iY2WL3yyH5Z/pUvlUhqmCxD2t+D65SQ="
/// How long to wait after waking a CCS2 car before reading `/latest`, in
/// nanoseconds. ~20s matches the live-measured report latency in
/// hyundai_kia_connect_api.
static let ccs2ForceRefreshDelay: UInt64 = 20 * 1_000_000_000
var commandToken: String = ""
var commandTokenExpiration: Date = Date()

Expand Down Expand Up @@ -247,14 +250,22 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol {
public func fetchVehicleStatus(
for vehicle: Vehicle,
authToken: AuthToken,
cached _: Bool
cached: Bool
) async throws -> VehicleStatus {

// CCS2 or Gen5W endpoint?
let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false

// The `/latest` endpoint is a passive cache — it won't reflect a
// just-sent command (e.g. climate that just started) until the car
// next reports in on its own. A manual / post-command refresh
// (`cached == false`) therefore has to wake the car first. Mirrors
// hyundai_kia_connect_api's force_refresh_vehicle_state for CCS2.
if !cached, ccs2 {
try await forceRefreshCCS2(for: vehicle, authToken: authToken)
}

let endpoint: String = ccs2 ? "/ccs2/carstatus/latest" : "/status/latest"
// Europe uses a single "latest" endpoint; no force-refresh knob is
// currently wired up here, so the cached flag is a no-op.
let (statusData, _, _) = try await performJSONRequest(
url: "\(baseURL)/api/v1/spa/vehicles/\(vehicle.regId)\(endpoint)",
method: .GET,
Expand All @@ -274,14 +285,36 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol {
return try parseVehicleStatusResponse(statusData, parkData, for: vehicle)
}

/// Wake a CCS2 vehicle so the subsequent `/latest` read returns current
/// state. `GET /ccs2/carstatus` (no `/latest`) triggers the wake and
/// returns an async ack envelope — its body is discarded, but errors
/// propagate so we never fall through to applying a stale snapshot.
/// Ports hyundai_kia_connect_api's `_force_refresh_vehicle_state_ccs2`.
func forceRefreshCCS2(for vehicle: Vehicle, authToken: AuthToken) async throws {
_ = try await performJSONRequest(
url: "\(baseURL)/api/v1/spa/vehicles/\(vehicle.regId)/ccs2/carstatus",
method: .GET,
headers: authorizedHeaders(authToken: authToken, ccs2: true),
requestType: .fetchVehicleStatus,
vin: vehicle.vin
)
// The car reports back asynchronously; give it time before reading
// `/latest` (~20s live-measured on a reachable EU CCS2 car).
try await Task.sleep(nanoseconds: Self.ccs2ForceRefreshDelay)
}

// MARK: - Commands

public func sendCommand(for vehicle: Vehicle, command: VehicleCommand, authToken: AuthToken) async throws {
let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false
// Pass the vehicle's actual protocol into the body builder.
// Previously this used the default (ccs2: true), so a legacy
// Hyundai EU vehicle got a v1 URL with CCS2-shaped bodies.
let (path, body) = commandPathAndBody(for: command, ccs2: ccs2)
// "R" only for the mile-based RHD markets (UK/Ireland); everything
// else — including km-based continental EU (e.g. NL) — is "L".
// Mirrors hyundai_kia_connect_api's `_get_drv_seat_loc`.
let drvSeatLoc = vehicle.odometer.units == .miles ? "R" : "L"
let (path, body) = commandPathAndBody(for: command, ccs2: ccs2, drvSeatLoc: drvSeatLoc)
let url =
"\(baseURL)/api/\(ccs2 ? "v2" : "v1")"
+ "/spa/vehicles/\(vehicle.regId)/\(path)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation

extension KiaEuropeAPIClient {

func commandPathAndBody(for command: VehicleCommand, ccs2: Bool = true)
func commandPathAndBody(for command: VehicleCommand, ccs2: Bool = true, drvSeatLoc: String = "L")
-> (String, [String: Any]) {
let deviceId = configuration.deviceId ?? ""
switch command {
Expand All @@ -23,7 +23,7 @@ extension KiaEuropeAPIClient {
? ("ccs2/control/door", ["command": "open"])
: ("control/door", ["action": "open", "deviceId": deviceId])
case .startClimate(let options):
return ("ccs2/control/temperature", startClimateBody(options: options))
return ("ccs2/control/temperature", startClimateBody(options: options, drvSeatLoc: drvSeatLoc))
case .stopClimate:
return ccs2
? ("ccs2/control/temperature", ["command": "stop"])
Expand Down Expand Up @@ -60,7 +60,7 @@ extension KiaEuropeAPIClient {
/// and the payload shape is easier to find. Shape mirrors the
/// Python reference in hyundai-kia-connect/hyundai_kia_connect_api
/// (ApiImplType1.start_climate, CCS2 branch).
private func startClimateBody(options: ClimateOptions) -> [String: Any] {
private func startClimateBody(options: ClimateOptions, drvSeatLoc: String) -> [String: Any] {
// Kia EU only accepts temperatures on the 0.5°C grid
// (15.0–30.0). Sending 22.22 (linear F→C of 72°F)
// silently no-ops on the car — `hvacConvert` snaps to the
Expand All @@ -71,17 +71,25 @@ extension KiaEuropeAPIClient {
to: .celsius,
table: .european
)
// On a right-hand-drive car the driver sits on the right, so the
// front-left/right seat controls map to passenger/driver. Matches
// hyundai_kia_connect_api's `start_climate` seat handling.
let (drvSeat, psgSeat) = drvSeatLoc == "R"
? (options.frontRightSeat, options.frontLeftSeat)
: (options.frontLeftSeat, options.frontRightSeat)
return [
"command": "start",
"ignitionDuration": options.duration,
"strgWhlHeating": options.steeringWheel,
"hvacTempType": 1,
"hvacTemp": tempCelsius,
"sideRearMirrorHeating": 1,
"drvSeatLoc": "R",
// Rear-window + side-mirror heaters ride along with the heating
// levels that engage them (1/2/4); off for 0 and steering-only (3).
"sideRearMirrorHeating": [1, 2, 4].contains(options.heatValue) ? 1 : 0,
"drvSeatLoc": drvSeatLoc,
"seatClimateInfo": [
"drvSeatClimateState": options.frontLeftSeat,
"psgSeatClimateState": options.frontRightSeat,
"drvSeatClimateState": drvSeat,
"psgSeatClimateState": psgSeat,
"rrSeatClimateState": options.rearRightSeat,
"rlSeatClimateState": options.rearLeftSeat
],
Expand Down
Loading