diff --git a/Sources/BBCLI/main.swift b/Sources/BBCLI/main.swift index bc1cc9e..a658d5f 100644 --- a/Sources/BBCLI/main.swift +++ b/Sources/BBCLI/main.swift @@ -456,6 +456,36 @@ func fetchEVTripDetails(state: CLIState) async throws { } } +@MainActor +func fetchEVTripInfo(state: CLIState) async throws { + guard let vehicle = selectVehicle(state: state) else { return } + guard let token = state.authToken else { + throw APIError(message: "Not logged in") + } + + guard let client = state.client else { + throw APIError(message: "No API client initialized") + } + + let dateString = prompt("Enter date (yyyyMMdd): ") + guard !dateString.isEmpty else { return } + + printSubheader("Fetching EV Trip Info for \(vehicle.model) on \(dateString)") + + let trips = try await client.fetchEVTripInfo(for: vehicle, authToken: token, dateString: dateString) ?? [] + + printSuccess("Found \(trips.count) individual trip(s) for the day") + + for (index, info) in trips.enumerated() { + print("\n[\(index + 1)] Time: \(info.hhmmss)") + print(" Distance: \(info.distance)") + print(" Drive Time: \(info.driveTimeMinutes) min") + print(" Idle Time: \(info.idleTimeMinutes) min") + print(" Avg Speed: \(info.avgSpeed)") + print(" Max Speed: \(info.maxSpeed)") + } +} + // MARK: - Interactive Menu func showMenu() { @@ -474,6 +504,7 @@ func showMenu() { 8. Stop Charge 9. Set Charge Limits 10. Fetch EV Trip Details + 11. Fetch EV Trip Info 0. Exit """) @@ -520,6 +551,8 @@ func runInteractiveLoop(state: CLIState) async { ) case "10": try await fetchEVTripDetails(state: state) + case "11": + try await fetchEVTripInfo(state: state) case "0", "q", "quit", "exit": print("\nGoodbye!") return diff --git a/Sources/BetterBlueKit/API/APIClient.swift b/Sources/BetterBlueKit/API/APIClient.swift index 52c187e..3fd8c64 100644 --- a/Sources/BetterBlueKit/API/APIClient.swift +++ b/Sources/BetterBlueKit/API/APIClient.swift @@ -135,6 +135,9 @@ public protocol APIClientProtocol { /// Optional: Fetch EV trip details for a vehicle (not all brands/APIs support this) func fetchEVTripDetails(for vehicle: Vehicle, authToken: AuthToken) async throws -> [EVTripDetail]? + /// Optional: Fetch specific EV trip info summary for a given date (not all brands/APIs support this) + func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> [EVTripInfo]? + /// Returns true if this API client implements `fetchEVTripDetails` func supportsEVTripDetails() -> Bool @@ -170,6 +173,10 @@ extension APIClientProtocol { nil } + public func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> [EVTripInfo]? { + nil + } + public func supportsEVTripDetails() -> Bool { false } diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift index 4449de7..8f7fa6c 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift @@ -424,10 +424,51 @@ extension HyundaiEuropeAPIClient { drivetrainEnergy: motorPwrCsp, batteryCareEnergy: batteryMgPwrCsp, startDate: startDate, - durationSeconds: 0, // Not provided - avgSpeed: 0, // Not provided - maxSpeed: 0 // Not provided + durationSeconds: 0, // Populated later from /tripinfo + avgSpeed: 0, // Populated later from /tripinfo + maxSpeed: 0 // Populated later from /tripinfo ) } } + + package func parseIndividualTripsResponse(_ data: Data) throws -> [EVTripInfo] { + guard + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let resMsg = json["resMsg"] as? [String: Any], + let dayTripList = resMsg["dayTripList"] as? [[String: Any]] + else { + throw APIError(message: "Failed to parse EU individual trips", apiName: apiName) + } + + var allTrips: [EVTripInfo] = [] + + for dayTrip in dayTripList { + let date = dayTrip["tripDay"] as? String ?? dayTrip["date"] as? String ?? "" + + guard let tripList = dayTrip["tripList"] as? [[String: Any]] else { + continue + } + + for tripData in tripList { + let hhmmss = tripData["tripTime"] as? String ?? tripData["hhmmss"] as? String ?? "" + let driveTime = tripData["tripDrvTime"] as? Int ?? tripData["drive_time"] as? Int ?? 0 + let idleTime = tripData["tripIdleTime"] as? Int ?? tripData["idle_time"] as? Int ?? 0 + let distance = getDoubleFromJson(from: tripData, key: "tripDist") + let avgSpeed = getDoubleFromJson(from: tripData, key: "tripAvgSpeed") + let maxSpeed = getDoubleFromJson(from: tripData, key: "tripMaxSpeed") + + allTrips.append(EVTripInfo( + date: date, + hhmmss: hhmmss, + driveTimeMinutes: driveTime, + idleTimeMinutes: idleTime, + distance: distance, + avgSpeed: avgSpeed, + maxSpeed: maxSpeed + )) + } + } + + return allTrips + } } diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift index 50d4315..4c06325 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift @@ -328,4 +328,23 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol { return try parseEVTripDetailsResponse(data, vehicle: vehicle) } + + public func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> [EVTripInfo]? { + let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false + let url = "\(baseURL)/api/v1/spa/vehicles/\(vehicle.regId)/tripinfo" + + let (data, _, _) = try await performJSONRequest( + url: url, + method: .POST, + headers: authorizedHeaders(authToken: authToken, ccs2: ccs2), + body: [ + "tripPeriodType": 1, + "setTripDay": dateString + ], + requestType: .fetchEVTripDetails, + vin: vehicle.vin + ) + + return try parseIndividualTripsResponse(data) + } } diff --git a/Sources/BetterBlueKit/Models/EVTripDetails.swift b/Sources/BetterBlueKit/Models/EVTripDetails.swift index e35dc23..effd192 100644 --- a/Sources/BetterBlueKit/Models/EVTripDetails.swift +++ b/Sources/BetterBlueKit/Models/EVTripDetails.swift @@ -103,3 +103,26 @@ public struct EVTripDetailsResponse: Codable, Sendable { self.trips = trips } } + +// MARK: - EV Trip Info + +/// Represents summary of a specific trip from the tripinfo endpoint +public struct EVTripInfo: Codable, Hashable, Sendable { + public let date: String + public let hhmmss: String + public let driveTimeMinutes: Int + public let idleTimeMinutes: Int + public let distance: Double + public let avgSpeed: Double + public let maxSpeed: Double + + public init(date: String, hhmmss: String, driveTimeMinutes: Int, idleTimeMinutes: Int, distance: Double, avgSpeed: Double, maxSpeed: Double) { + self.date = date + self.hhmmss = hhmmss + self.driveTimeMinutes = driveTimeMinutes + self.idleTimeMinutes = idleTimeMinutes + self.distance = distance + self.avgSpeed = avgSpeed + self.maxSpeed = maxSpeed + } +}