diff --git a/Sources/BBCLI/main.swift b/Sources/BBCLI/main.swift index dc4ada8..b2af31e 100644 --- a/Sources/BBCLI/main.swift +++ b/Sources/BBCLI/main.swift @@ -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") diff --git a/Sources/BetterBlueKit/API/APIClientBase.swift b/Sources/BetterBlueKit/API/APIClientBase.swift index 973465c..a9e4cb8 100644 --- a/Sources/BetterBlueKit/API/APIClientBase.swift +++ b/Sources/BetterBlueKit/API/APIClientBase.swift @@ -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")", @@ -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) diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift index d5bac1a..2a49507 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Commands.swift @@ -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 { @@ -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", @@ -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 ], diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Headers.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Headers.swift index 94dc702..a744f32 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Headers.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Headers.swift @@ -10,7 +10,6 @@ // the main file. // -import CryptoKit import Foundation extension HyundaiEuropeAPIClient { @@ -28,8 +27,8 @@ extension HyundaiEuropeAPIClient { "Host": apiHost, "Connection": "Keep-Alive", "Accept-Encoding": "gzip", - // Fresh HMAC per request — Stamp is an `:` 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() ] } @@ -48,16 +47,29 @@ extension HyundaiEuropeAPIClient { return result } - /// HMAC-SHA256 of `:` 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 ⊕ ":"`, 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.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() } } diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift index 64d7d9d..3013141 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift @@ -6,7 +6,6 @@ // Based on: https://github.com/andyfase/egmp-bluelink-scriptable // -import CryptoKit import Foundation // MARK: - Hyundai Europe API Client @@ -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() @@ -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, @@ -274,6 +285,24 @@ 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 { @@ -281,7 +310,11 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol { // 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)" diff --git a/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Commands.swift b/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Commands.swift index 58523db..9e866f5 100644 --- a/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Commands.swift +++ b/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Commands.swift @@ -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 { @@ -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"]) @@ -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 @@ -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 ], diff --git a/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Headers.swift b/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Headers.swift index a43a76c..829150b 100644 --- a/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Headers.swift +++ b/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient+Headers.swift @@ -8,7 +8,6 @@ // 250-line type-body cap. // -import CryptoKit import Foundation import Security @@ -29,8 +28,8 @@ extension KiaEuropeAPIClient { "Host": apiHost, "Connection": "Keep-Alive", "Accept-Encoding": "gzip", - // Fresh HMAC per request — Stamp is an `:` 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() ] } @@ -42,15 +41,28 @@ extension KiaEuropeAPIClient { return result } - /// HMAC-SHA256 of `:` keyed by the first - /// 32 bytes of the base64-decoded `authCfb`, base64-encoded. + /// CCSP `Stamp`: base64 of `authCfb ⊕ ":"`, where the + /// XOR runs over the shorter of the two byte strings (the message here). + /// + /// Ports the canonical scheme from Home Assistant's + /// `hyundai_kia_connect_api` (`_get_stamp`) / bluelinky. The previous + /// HMAC-SHA256-over-ISO8601 form was accepted on read endpoints but + /// rejected with HTTP 403 on the control endpoints, so remote actions + /// failed across Europe. 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.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() } // MARK: - RSA / encoding helpers (used by signin) diff --git a/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient.swift b/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient.swift index c78bd49..d36b0e8 100644 --- a/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient.swift +++ b/Sources/BetterBlueKit/API/KiaEurope/KiaEuropeAPIClient.swift @@ -8,7 +8,6 @@ // by appending `_CCS_APP_AOS` to the User-Agent. // -import CryptoKit import Foundation // MARK: - Kia Europe API Client @@ -24,6 +23,10 @@ public final class KiaEuropeAPIClient: APIClientBase, APIClientProtocol { static let basicAuthorization = "Basic ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==" static let authCfb = "wLTVxwidmH8CfJYBWSnHD6E0huk0ozdiuygB4hLkM5XCgzAL1Dk5sE36d/bx5PFMbZs=" static let pushType = "APNS" + /// 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 /// The `_CCS_APP_AOS` suffix is what gets past Cloudflare on /// `idpconnect-eu.kia.com` — without it, the authorize endpoint @@ -136,9 +139,18 @@ public final class KiaEuropeAPIClient: APIClientBase, APIClientProtocol { public func fetchVehicleStatus( for vehicle: Vehicle, authToken: AuthToken, - cached _: Bool + cached: Bool ) async throws -> VehicleStatus { let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false + + // A manual / post-command refresh (`cached == false`) must wake the + // car — the `/latest` snapshot is a passive cache and won't reflect a + // just-sent command until the vehicle reports in. 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" let (statusData, _, _) = try await performJSONRequest( @@ -190,11 +202,33 @@ public final class KiaEuropeAPIClient: APIClientBase, APIClientProtocol { commandTokenExpiration = Date().addingTimeInterval(TimeInterval(expires)) } + /// 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 - 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 — 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)" let headers: [String: String] diff --git a/Tests/BetterBlueKitTests/CCSPResponseErrorTests.swift b/Tests/BetterBlueKitTests/CCSPResponseErrorTests.swift new file mode 100644 index 0000000..1e2e133 --- /dev/null +++ b/Tests/BetterBlueKitTests/CCSPResponseErrorTests.swift @@ -0,0 +1,91 @@ +// +// CCSPResponseErrorTests.swift +// BetterBlueKit +// +// Tests for CCSP (`retCode`/`resCode`) application-level error decoding. +// The European Hyundai/Kia API answers control commands with an HTTP 400 +// whose body carries the real reason (e.g. `resCode: "4004"` for a +// duplicate request). These verify we translate that envelope into a typed +// `APIError` instead of surfacing a bare "HTTP 400", mirroring Home +// Assistant's `hyundai_kia_connect_api` `_check_response_for_errors`. +// + +import Foundation +import Testing +@testable import BetterBlueKit + +@MainActor +@Suite("CCSP Response Error Decoding") +struct CCSPResponseErrorTests { + private func makeClient() -> HyundaiEuropeAPIClient { + HyundaiEuropeAPIClient( + configuration: APIClientConfiguration( + region: .europe, + brand: .hyundai, + username: "test@example.com", + password: "password123", + pin: "0000", + accountId: UUID() + ) + ) + } + + @Test("Duplicate request (4004) maps to concurrentRequest") + func testDuplicateRequest() { + let body = Data(#""" + {"msgId":"abc","resCode":"4004","resMsg":"Duplicate request","retCode":"F"} + """#.utf8) + + #expect(throws: APIError.self) { + try self.makeClient().checkCCSPResponseForErrors(data: body) + } + do { + try makeClient().checkCCSPResponseForErrors(data: body) + } catch let error as APIError { + #expect(error.errorType == .concurrentRequest) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Rate limiting (5091) maps to serverError") + func testRateLimited() { + let body = Data(#"{"resCode":"5091","resMsg":"Exceeds number of requests","retCode":"F"}"#.utf8) + do { + try makeClient().checkCCSPResponseForErrors(data: body) + Issue.record("Expected an error to be thrown") + } catch let error as APIError { + #expect(error.errorType == .serverError) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Unknown failure code surfaces resCode and resMsg") + func testUnknownFailureCode() { + let body = Data(#"{"resCode":"1234","resMsg":"Some new error","retCode":"F"}"#.utf8) + do { + try makeClient().checkCCSPResponseForErrors(data: body) + Issue.record("Expected an error to be thrown") + } catch let error as APIError { + #expect(error.errorType == .general) + #expect(error.message.contains("1234")) + #expect(error.message.contains("Some new error")) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("Success envelope (retCode S) does not throw") + func testSuccessEnvelope() throws { + let body = Data(#"{"resMsg":{"vehicles":[]},"resCode":"0000","retCode":"S"}"#.utf8) + try makeClient().checkCCSPResponseForErrors(data: body) + } + + @Test("Non-CCSP body (no retCode) does not throw") + func testNonCCSPBody() throws { + // US/Canada-style error payload — must be ignored here. + let body = Data(#"{"status":{"errorCode":1003,"errorMessage":"Session expired"}}"#.utf8) + try makeClient().checkCCSPResponseForErrors(data: body) + } +}