diff --git a/requirements.txt b/requirements.txt index bfbe4fa3..b3891bb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiohttp beautifulsoup4 +cryptography lxml pyjwt diff --git a/tests/fixtures/resources/responses/README.md b/tests/fixtures/resources/responses/README.md new file mode 100644 index 00000000..e49993d3 --- /dev/null +++ b/tests/fixtures/resources/responses/README.md @@ -0,0 +1,26 @@ +# Example Responses + +The sub-directories contain some examples for responses of VW's API for certain car types: + +* [Arteon Diesel](arteon_2023_diesel) +* [eUP! Electric](eup_electric) +* [Golf GTE Hybrid](golf_gte_hybrid) + +## Files + +`capabilities.json` + +Response to the GET request to https://emea.bff.cariad.digital/vehicle/v1/vehicles/{vin}/capabilities + +`last_trip.json` + +Response to the GET request to https://emea.bff.cariad.digital/vehicle/v1/trips/{vin}/shortterm/last + +`parkingposition.json` + +Response to the GET request to https://emea.bff.cariad.digital/vehicle/v1/vehicles/{vin}/parkingposition + +`selectivestatus_by_app.json` + +Response to the GET request to https://emea.bff.cariad.digital/vehicle/v1/vehicles/{vin}/selectivestatus?jobs=XXX. +The exact URL is the one the Volkswagen app fires for the respective car type. \ No newline at end of file diff --git a/tests/fixtures/resources/responses/arteon_2023_diesel/capabilities.json b/tests/fixtures/resources/responses/arteon_2023_diesel/capabilities.json new file mode 100644 index 00000000..22449fcb --- /dev/null +++ b/tests/fixtures/resources/responses/arteon_2023_diesel/capabilities.json @@ -0,0 +1,426 @@ +{ + "vin": "WVWZZZ0XXXX000000", + "capabilities": { + "destinations": { + "id": "destinations", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getDestinations": { + "id": "getDestinations", + "scopes": [ + "navigation" + ] + }, + "postDestinations": { + "id": "postDestinations", + "scopes": [ + "navigation" + ] + }, + "putDestinations": { + "id": "putDestinations", + "scopes": [ + "navigation" + ] + }, + "deleteDestinationsByID": { + "id": "deleteDestinationsByID", + "scopes": [ + "navigation" + ] + } + }, + "parameters": [ + { + "key": "chargingStationsForEVTourImport", + "value": "" + } + ] + }, + "roadsideAssistant": { + "id": "roadsideAssistant", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": {}, + "parameters": [] + }, + "fuelStatus": { + "id": "fuelStatus", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getFuelStatus": { + "id": "getFuelStatus", + "scopes": [ + "fuelLevels" + ] + } + }, + "parameters": [] + }, + "honkAndFlash": { + "id": "honkAndFlash", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postHonkandflash": { + "id": "postHonkandflash", + "scopes": [ + "honk" + ] + }, + "getHonkandflashRequestsByID": { + "id": "getHonkandflashRequestsByID", + "scopes": [ + "honk" + ] + } + }, + "parameters": [] + }, + "measurements": { + "id": "measurements", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMeasurements": { + "id": "getMeasurements", + "scopes": [ + "range", + "mileage" + ] + } + }, + "parameters": [] + }, + "parkingPosition": { + "id": "parkingPosition", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getParkingposition": { + "id": "getParkingposition", + "scopes": [ + "parking_position" + ] + } + }, + "parameters": [] + }, + "state": { + "id": "state", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getAccessStatus": { + "id": "getAccessStatus", + "scopes": [ + "doors_windows" + ] + } + }, + "parameters": [] + }, + "vehicleHealth": { + "id": "vehicleHealth", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": {}, + "parameters": [] + }, + "vehicleHealthInspection": { + "id": "vehicleHealthInspection", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMaintenanceStatus": { + "id": "getMaintenanceStatus", + "scopes": [ + "serviceInterval" + ] + } + }, + "parameters": [] + }, + "vehicleHealthWarnings": { + "id": "vehicleHealthWarnings", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getWarninglightsLast": { + "id": "getWarninglightsLast", + "scopes": [ + "warning_lights" + ] + } + }, + "parameters": [] + }, + "vehicleWakeUpTrigger": { + "id": "vehicleWakeUpTrigger", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postVehiclewakeupUpdate": { + "id": "postVehiclewakeupUpdate", + "scopes": [] + } + }, + "parameters": [] + }, + "vehicleWakeUp": { + "id": "vehicleWakeUp", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postVehiclewakeup": { + "id": "postVehiclewakeup", + "scopes": [] + }, + "getVehiclewakeupRequestsByID": { + "id": "getVehiclewakeupRequestsByID", + "scopes": [] + } + }, + "parameters": [] + }, + "personalizationOnline": { + "id": "personalizationOnline", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": {}, + "parameters": [] + }, + "vehicleLights": { + "id": "vehicleLights", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getLightsStatus": { + "id": "getLightsStatus", + "scopes": [ + "vehicleLights" + ] + } + }, + "parameters": [] + }, + "tripStatistics": { + "id": "tripStatistics", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "deleteTripdataCyclicByID": { + "id": "deleteTripdataCyclicByID", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataLongtermLast": { + "id": "getTripdataLongtermLast", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataLongtermByID": { + "id": "deleteTripdataLongtermByID", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataShorttermLast": { + "id": "getTripdataShorttermLast", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataCyclic": { + "id": "getTripdataCyclic", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataCyclicLast": { + "id": "getTripdataCyclicLast", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataLongterm": { + "id": "getTripdataLongterm", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataShortterm": { + "id": "deleteTripdataShortterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataShortterm": { + "id": "getTripdataShortterm", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataShorttermByID": { + "id": "deleteTripdataShorttermByID", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataCyclic": { + "id": "deleteTripdataCyclic", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataLongterm": { + "id": "deleteTripdataLongterm", + "scopes": [ + "tripStatistics" + ] + } + }, + "parameters": [] + }, + "access": { + "id": "access", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getAccessRequestsByID": { + "id": "getAccessRequestsByID", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessUnlock": { + "id": "postAccessUnlock", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessUnlockwithoutspin": { + "id": "postAccessUnlockwithoutspin", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessLock": { + "id": "postAccessLock", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessLockwithoutspin": { + "id": "postAccessLockwithoutspin", + "scopes": [ + "lock_unlock" + ] + } + }, + "parameters": [] + }, + "dealerAppointment": { + "id": "dealerAppointment", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": true, + "endpoint": "vs", + "isEnabled": false, + "status": [ + "DisabledByUser" + ], + "operations": { + "getMaintenanceStatus": { + "id": "getMaintenanceStatus", + "scopes": [ + "serviceInterval" + ] + }, + "getPredictivemaintenanceDealerappointment": { + "id": "getPredictivemaintenanceDealerappointment", + "scopes": [ + "serviceInterval" + ] + }, + "getWarninglightsLast": { + "id": "getWarninglightsLast", + "scopes": [ + "warning_lights" + ] + } + }, + "parameters": [] + }, + "oilLevelStatus": { + "id": "oilLevelStatus", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getStates": { + "id": "getStates", + "scopes": [ + "parkingBrakeStatus", + "ignitionStatus", + "oilLevels" + ] + } + }, + "parameters": [] + } + }, + "parameters": {} +} diff --git a/tests/fixtures/resources/responses/arteon_2023_diesel/last_trip.json b/tests/fixtures/resources/responses/arteon_2023_diesel/last_trip.json new file mode 100644 index 00000000..a570958e --- /dev/null +++ b/tests/fixtures/resources/responses/arteon_2023_diesel/last_trip.json @@ -0,0 +1,14 @@ +{ + "data": { + "id": "2000000000", + "tripEndTimestamp": "2023-11-30T08:53:20Z", + "tripType": "shortTerm", + "vehicleType": "fuel", + "mileage_km": 18, + "startMileage_km": 38933, + "overallMileage_km": 38952, + "travelTime": 27, + "averageFuelConsumption": 7.1, + "averageSpeed_kmph": 41 + } +} \ No newline at end of file diff --git a/tests/fixtures/resources/responses/arteon_2023_diesel/parkingposition.json b/tests/fixtures/resources/responses/arteon_2023_diesel/parkingposition.json new file mode 100644 index 00000000..f3db821f --- /dev/null +++ b/tests/fixtures/resources/responses/arteon_2023_diesel/parkingposition.json @@ -0,0 +1 @@ +{"data":{"lon":8.000000,"lat":52.000000,"carCapturedTimestamp":"2023-11-30T08:54:37Z"}} \ No newline at end of file diff --git a/tests/fixtures/resources/responses/arteon_2023_diesel/selectivestatus_by_app.json b/tests/fixtures/resources/responses/arteon_2023_diesel/selectivestatus_by_app.json new file mode 100644 index 00000000..400f83a2 --- /dev/null +++ b/tests/fixtures/resources/responses/arteon_2023_diesel/selectivestatus_by_app.json @@ -0,0 +1,284 @@ +{ + "access": { + "accessStatus": { + "value": { + "overallStatus": "unsafe", + "carCapturedTimestamp": "2023-11-30T09:44:47Z", + "doors": [ + { + "name": "bonnet", + "status": [ + "open" + ] + }, + { + "name": "frontLeft", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "frontRight", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "rearLeft", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "rearRight", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "trunk", + "status": [ + "unlocked", + "closed" + ] + } + ], + "windows": [ + { + "name": "frontLeft", + "status": [ + "open" + ] + }, + { + "name": "frontRight", + "status": [ + "closed" + ] + }, + { + "name": "rearLeft", + "status": [ + "closed" + ] + }, + { + "name": "rearRight", + "status": [ + "closed" + ] + }, + { + "name": "roofCover", + "status": [ + "unsupported" + ] + }, + { + "name": "sunRoof", + "status": [ + "unsupported" + ] + } + ], + "doorLockStatus": "unlocked" + } + } + }, + "userCapabilities": { + "capabilitiesStatus": { + "value": [ + { + "id": "access", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "cubicNetwork", + "userDisablingAllowed": false + }, + { + "id": "cubicNetworkConsumption", + "userDisablingAllowed": false + }, + { + "id": "dealerAppointment", + "status": [ + 1004 + ], + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": true + }, + { + "id": "destinations", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "digitalKey", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "emergencyCalling", + "expirationDate": "2032-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "fuelStatus", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "honkAndFlash", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "measurements", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "oilLevelStatus", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "parkingPosition", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "personalizationOnline", + "userDisablingAllowed": false + }, + { + "id": "roadsideAssistant", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "state", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryDigitalKey", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryHonkFlash", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryLockUnlock", + "expirationDate": "2025-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "tripStatistics", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealth", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthArchive", + "status": [ + 1007 + ], + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthInspection", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthSettings", + "status": [ + 1007 + ], + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthTrigger", + "status": [ + 1007 + ], + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthWakeUp", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthWarnings", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleLights", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleWakeUp", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleWakeUpTrigger", + "expirationDate": "2052-11-25T10:37:00Z", + "userDisablingAllowed": false + } + ] + } + }, + "fuelStatus": { + "rangeStatus": { + "value": { + "carCapturedTimestamp": "2023-11-30T09:44:47Z", + "carType": "diesel", + "primaryEngine": { + "type": "diesel", + "currentSOC_pct": 19, + "remainingRange_km": 140, + "currentFuelLevel_pct": 19 + }, + "totalRange_km": 140 + } + } + }, + "vehicleLights": { + "lightsStatus": { + "value": { + "carCapturedTimestamp": "2023-11-30T09:44:47Z", + "lights": [ + { + "name": "right", + "status": "off" + }, + { + "name": "left", + "status": "off" + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/resources/responses/eup_electric/capabilities.json b/tests/fixtures/resources/responses/eup_electric/capabilities.json new file mode 100644 index 00000000..4147a20a --- /dev/null +++ b/tests/fixtures/resources/responses/eup_electric/capabilities.json @@ -0,0 +1,607 @@ +{ + "vin": "WVWZZZXXXXX000000", + "capabilities": { + "automation": { + "id": "automation", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getChargingTimers": { + "id": "getChargingTimers", + "scopes": [ + "charging" + ] + }, + "getClimatisationTimers": { + "id": "getClimatisationTimers", + "scopes": [ + "climatisation" + ] + }, + "putClimatisationTimers": { + "id": "putClimatisationTimers", + "scopes": [ + "manageClimatisation" + ] + }, + "getDepartureTimers": { + "id": "getDepartureTimers", + "scopes": [ + "climatisation", + "charging" + ] + }, + "postChargingProfiles": { + "id": "postChargingProfiles", + "scopes": [ + "manageCharging" + ] + }, + "putChargingProfiles": { + "id": "putChargingProfiles", + "scopes": [ + "manageCharging" + ] + }, + "putChargingTimers": { + "id": "putChargingTimers", + "scopes": [ + "manageCharging" + ] + }, + "getChargingProfiles": { + "id": "getChargingProfiles", + "scopes": [ + "charging" + ] + }, + "deleteChargingProfilesByID": { + "id": "deleteChargingProfilesByID", + "scopes": [ + "charging" + ] + } + }, + "parameters": [] + }, + "state": { + "id": "state", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getAccessStatus": { + "id": "getAccessStatus", + "scopes": [ + "doors_windows" + ] + } + }, + "parameters": [] + }, + "vehicleWakeUp": { + "id": "vehicleWakeUp", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postVehiclewakeup": { + "id": "postVehiclewakeup", + "scopes": [] + }, + "getVehiclewakeupRequestsByID": { + "id": "getVehiclewakeupRequestsByID", + "scopes": [] + } + }, + "parameters": [] + }, + "fuelStatus": { + "id": "fuelStatus", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getFuelStatus": { + "id": "getFuelStatus", + "scopes": [ + "fuelLevels" + ] + } + }, + "parameters": [] + }, + "oilLevelStatus": { + "id": "oilLevelStatus", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getStates": { + "id": "getStates", + "scopes": [ + "parkingBrakeStatus", + "ignitionStatus", + "oilLevels" + ] + } + }, + "parameters": [] + }, + "parkingPosition": { + "id": "parkingPosition", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getParkingposition": { + "id": "getParkingposition", + "scopes": [ + "parking_position" + ] + } + }, + "parameters": [] + }, + "vehicleHealthInspection": { + "id": "vehicleHealthInspection", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMaintenanceStatus": { + "id": "getMaintenanceStatus", + "scopes": [ + "serviceInterval" + ] + } + }, + "parameters": [] + }, + "charging": { + "id": "charging", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getChargingCareStatus": { + "id": "getChargingCareStatus", + "scopes": [ + "manageCharging" + ] + }, + "putChargingMode": { + "id": "putChargingMode", + "scopes": [ + "manageCharging" + ] + }, + "putChargingSettings": { + "id": "putChargingSettings", + "scopes": [ + "manageCharging" + ] + }, + "getChargingStatus": { + "id": "getChargingStatus", + "scopes": [ + "charging" + ] + }, + "postChargingStop": { + "id": "postChargingStop", + "scopes": [ + "manageCharging" + ] + }, + "getChargingMode": { + "id": "getChargingMode", + "scopes": [ + "charging" + ] + }, + "getChargingRequestsByID": { + "id": "getChargingRequestsByID", + "scopes": [ + "manageCharging" + ] + }, + "getChargingSettings": { + "id": "getChargingSettings", + "scopes": [ + "charging" + ] + }, + "postChargingStart": { + "id": "postChargingStart", + "scopes": [ + "manageCharging" + ] + } + }, + "parameters": [ + { + "key": "allowPlugUnlockACPermanent", + "value": "false" + }, + { + "key": "allowPlugUnlockACOnce", + "value": "false" + }, + { + "key": "allowPlugUnlockDCPermanent", + "value": "false" + }, + { + "key": "allowPlugUnlockDCOnce", + "value": "false" + }, + { + "key": "supportsTargetStateOfCharge", + "value": "" + }, + { + "key": "targetStateOfChargeMinimumAllowedValue", + "value": "" + }, + { + "key": "targetStateOfChargeStepSize", + "value": "" + } + ] + }, + "climatisation": { + "id": "climatisation", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getClimatisationSettings": { + "id": "getClimatisationSettings", + "scopes": [ + "climatisation" + ] + }, + "putClimatisationSettings": { + "id": "putClimatisationSettings", + "scopes": [ + "manageClimatisation" + ] + }, + "getClimatisationStatus": { + "id": "getClimatisationStatus", + "scopes": [ + "climatisation" + ] + }, + "postClimatisationStop": { + "id": "postClimatisationStop", + "scopes": [ + "manageClimatisation" + ] + }, + "postWindowheatingStart": { + "id": "postWindowheatingStart", + "scopes": [ + "manageClimatisation" + ] + }, + "getClimatisationRequestsByID": { + "id": "getClimatisationRequestsByID", + "scopes": [ + "manageClimatisation" + ] + }, + "postClimatisationStart": { + "id": "postClimatisationStart", + "scopes": [ + "manageClimatisation" + ] + }, + "getWindowheatingRequestsByID": { + "id": "getWindowheatingRequestsByID", + "scopes": [ + "manageClimatisation" + ] + }, + "postWindowheatingStop": { + "id": "postWindowheatingStop", + "scopes": [ + "manageClimatisation" + ] + } + }, + "parameters": [ + { + "key": "supportsTargetTemperatureInStartClimatisation", + "value": "" + }, + { + "key": "supportsTargetTemperatureInSettings", + "value": "" + }, + { + "key": "supportsClimatisationAtUnlock", + "value": "false" + }, + { + "key": "supportsWindowHeatingEnabled", + "value": "false" + }, + { + "key": "supportsZoneFrontLeftEnabled", + "value": "false" + }, + { + "key": "supportsZoneFrontRightEnabled", + "value": "false" + }, + { + "key": "supportsZoneRearLeftEnabled", + "value": "false" + }, + { + "key": "supportsZoneRearRightEnabled", + "value": "false" + }, + { + "key": "supportsClimatisationMode", + "value": "" + }, + { + "key": "supportsOffGridClimatisation", + "value": "true" + }, + { + "key": "supportsStartParallelClimatisationWindowHeating", + "value": "true" + }, + { + "key": "supportsStartWindowHeating", + "value": "false" + } + ] + }, + "departureProfiles": { + "id": "departureProfiles", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "deleteDepartureProfiles": { + "id": "deleteDepartureProfiles", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + }, + "getDepartureProfiles": { + "id": "getDepartureProfiles", + "scopes": [ + "climatisation", + "charging" + ] + }, + "postDepartureProfiles": { + "id": "postDepartureProfiles", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + }, + "putDepartureProfiles": { + "id": "putDepartureProfiles", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + }, + "deleteDepartureProfilesByID": { + "id": "deleteDepartureProfilesByID", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + } + }, + "parameters": [] + }, + "measurements": { + "id": "measurements", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMeasurements": { + "id": "getMeasurements", + "scopes": [ + "range", + "mileage" + ] + } + }, + "parameters": [] + }, + "hybridCarAuxiliaryHeating": { + "id": "hybridCarAuxiliaryHeating", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [ + "MissingOperation" + ], + "operations": { + "getAuxiliaryheatingRequestsByID": { + "id": "getAuxiliaryheatingRequestsByID", + "scopes": [ + "manageClimatisation" + ] + }, + "postAuxiliaryheatingStart": { + "id": "postAuxiliaryheatingStart", + "scopes": [ + "manageClimatisation" + ] + }, + "postAuxiliaryheatingStop": { + "id": "postAuxiliaryheatingStop", + "scopes": [ + "manageClimatisation" + ] + }, + "putAuxiliaryheatingTimers": { + "id": "putAuxiliaryheatingTimers", + "scopes": [ + "manageClimatisation" + ] + } + }, + "parameters": [ + { + "key": "supportsAutomaticMode", + "value": "false" + } + ] + }, + "tripStatistics": { + "id": "tripStatistics", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "deleteTripdataCyclicByID": { + "id": "deleteTripdataCyclicByID", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataLongterm": { + "id": "deleteTripdataLongterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataLongterm": { + "id": "getTripdataLongterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataLongtermLast": { + "id": "getTripdataLongtermLast", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataShortterm": { + "id": "deleteTripdataShortterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataShortterm": { + "id": "getTripdataShortterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataCyclic": { + "id": "getTripdataCyclic", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataCyclicLast": { + "id": "getTripdataCyclicLast", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataShorttermLast": { + "id": "getTripdataShorttermLast", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataShorttermByID": { + "id": "deleteTripdataShorttermByID", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataCyclic": { + "id": "deleteTripdataCyclic", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataLongtermByID": { + "id": "deleteTripdataLongtermByID", + "scopes": [ + "tripStatistics" + ] + } + }, + "parameters": [] + }, + "vehicleLights": { + "id": "vehicleLights", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getLightsStatus": { + "id": "getLightsStatus", + "scopes": [ + "vehicleLights" + ] + } + }, + "parameters": [] + }, + "vehicleWakeUpTrigger": { + "id": "vehicleWakeUpTrigger", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postVehiclewakeupUpdate": { + "id": "postVehiclewakeupUpdate", + "scopes": [] + } + }, + "parameters": [] + } + }, + "parameters": {} +} \ No newline at end of file diff --git a/tests/fixtures/resources/responses/eup_electric/last_trip.json b/tests/fixtures/resources/responses/eup_electric/last_trip.json new file mode 100644 index 00000000..7a56acc8 --- /dev/null +++ b/tests/fixtures/resources/responses/eup_electric/last_trip.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": "2000000000", + "tripEndTimestamp": "2023-12-05T08:27:02Z", + "tripType": "shortTerm", + "vehicleType": "electric", + "mileage_km": 14, + "startMileage_km": 15906, + "overallMileage_km": 15921, + "travelTime": 32, + "averageElectricConsumption": 16.9, + "averageAuxConsumption": 7.6, + "averageRecuperation": 8.1, + "averageSpeed_kmph": 28 + } +} diff --git a/tests/fixtures/resources/responses/eup_electric/parkingposition.json b/tests/fixtures/resources/responses/eup_electric/parkingposition.json new file mode 100644 index 00000000..bdddda9d --- /dev/null +++ b/tests/fixtures/resources/responses/eup_electric/parkingposition.json @@ -0,0 +1 @@ +{"data":{"lon":21.000000,"lat":47.00000,"carCapturedTimestamp":"2023-12-05T07:29:14Z"}} diff --git a/tests/fixtures/resources/responses/eup_electric/selectivestatus_by_app.json b/tests/fixtures/resources/responses/eup_electric/selectivestatus_by_app.json new file mode 100644 index 00000000..d1657e50 --- /dev/null +++ b/tests/fixtures/resources/responses/eup_electric/selectivestatus_by_app.json @@ -0,0 +1,395 @@ +{ + "access": { + "accessStatus": { + "value": { + "overallStatus": "safe", + "carCapturedTimestamp": "2023-12-05T08:29:18Z", + "doors": [ + { + "name": "bonnet", + "status": [ + "closed" + ] + }, + { + "name": "frontLeft", + "status": [ + "locked", + "closed" + ] + }, + { + "name": "frontRight", + "status": [ + "locked", + "closed" + ] + }, + { + "name": "rearLeft", + "status": [ + "locked", + "closed" + ] + }, + { + "name": "rearRight", + "status": [ + "locked", + "closed" + ] + }, + { + "name": "trunk", + "status": [ + "locked", + "closed" + ] + } + ], + "windows": [ + { + "name": "frontLeft", + "status": [ + "unsupported" + ] + }, + { + "name": "frontRight", + "status": [ + "unsupported" + ] + }, + { + "name": "rearLeft", + "status": [ + "unsupported" + ] + }, + { + "name": "rearRight", + "status": [ + "unsupported" + ] + }, + { + "name": "roofCover", + "status": [ + "unsupported" + ] + }, + { + "name": "sunRoof", + "status": [ + "unsupported" + ] + } + ], + "doorLockStatus": "locked" + } + } + }, + "userCapabilities": { + "capabilitiesStatus": { + "value": [ + { + "id": "automation", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "charging", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "climatisation", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "departureProfiles", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "fuelStatus", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "hybridCarAuxiliaryHeating", + "status": [ + 1007 + ], + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "hybridCarAuxiliaryHeatingTimers", + "status": [ + 1007 + ], + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "measurements", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "oilLevelStatus", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "parkingPosition", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "state", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "tripStatistics", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthInspection", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthWakeUp", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleLights", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleWakeUp", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleWakeUpTrigger", + "expirationDate": "2024-10-25T14:11:00Z", + "userDisablingAllowed": false + } + ] + } + }, + "charging": { + "batteryStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:27:15Z", + "currentSOC_pct": 82, + "cruisingRangeElectric_km": 137 + } + }, + "chargingStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:27:15Z", + "remainingChargingTimeToComplete_min": 80, + "chargingState": "notReadyForCharging", + "chargeMode": "", + "chargeType": "" + } + }, + "chargingSettings": { + "value": { + "carCapturedTimestamp": "2023-12-04T07:32:11Z", + "maxChargeCurrentAC": "maximum" + } + }, + "plugStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:27:42Z", + "plugConnectionState": "disconnected", + "plugLockState": "locked", + "externalPower": "unavailable", + "ledColor": "none" + } + }, + "chargeMode": { + "error": { + "message": "Bad Gateway", + "errorTimeStamp": "2023-12-05T08:30:40Z", + "info": "Upstream service responded with an unexpected status. If the problem persists, please contact our support.", + "code": 4111, + "group": 2, + "retry": true + } + } + }, + "climatisation": { + "climatisationSettings": { + "value": { + "carCapturedTimestamp": "2023-11-24T13:10:27Z", + "targetTemperature_C": 30, + "targetTemperature_F": 88, + "climatisationWithoutExternalPower": true + } + }, + "climatisationStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:27:10Z", + "climatisationState": "off" + } + }, + "windowHeatingStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:27:11Z", + "windowHeatingStatus": [ + { + "windowLocation": "front", + "windowHeatingState": "off" + }, + { + "windowLocation": "rear", + "windowHeatingState": "off" + } + ] + } + } + }, + "fuelStatus": { + "rangeStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:27:15Z", + "carType": "electric", + "primaryEngine": { + "type": "electric", + "currentSOC_pct": 82, + "remainingRange_km": 137 + }, + "totalRange_km": 137 + } + } + }, + "vehicleLights": { + "lightsStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T08:29:18Z", + "lights": [ + { + "name": "right", + "status": "off" + }, + { + "name": "left", + "status": "off" + } + ] + } + } + }, + "departureProfiles": { + "departureProfilesStatus": { + "value": { + "carCapturedTimestamp": "2023-11-27T23:42:03Z", + "minSOC_pct": 30, + "timers": [ + { + "id": 2, + "enabled": false, + "recurringTimer": { + "startTime": "06:00", + "recurringOn": { + "mondays": true, + "tuesdays": true, + "wednesdays": true, + "thursdays": true, + "fridays": true, + "saturdays": true, + "sundays": true + } + }, + "profileIDs": [ + 1 + ] + }, + { + "id": 1, + "enabled": false, + "recurringTimer": { + "startTime": "16:00", + "recurringOn": { + "mondays": true, + "tuesdays": true, + "wednesdays": true, + "thursdays": true, + "fridays": true, + "saturdays": true, + "sundays": true + } + }, + "profileIDs": [ + 2 + ] + }, + { + "id": 3, + "enabled": false, + "recurringTimer": { + "startTime": "13:00", + "recurringOn": { + "mondays": true, + "tuesdays": true, + "wednesdays": true, + "thursdays": true, + "fridays": true, + "saturdays": true, + "sundays": true + } + }, + "profileIDs": [ + 2 + ] + } + ], + "profiles": [ + { + "id": 1, + "name": "Profile 1", + "charging": true, + "climatisation": false, + "targetSOC_pct": 90, + "maxChargeCurrentAC": 5, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "00:00", + "endTime": "00:00" + } + ] + }, + { + "id": 2, + "name": "Profile 2", + "charging": true, + "climatisation": true, + "targetSOC_pct": 90, + "maxChargeCurrentAC": 32, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "00:00", + "endTime": "00:00" + } + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/resources/responses/golf_gte_hybrid/capabilities.json b/tests/fixtures/resources/responses/golf_gte_hybrid/capabilities.json new file mode 100644 index 00000000..4f97b415 --- /dev/null +++ b/tests/fixtures/resources/responses/golf_gte_hybrid/capabilities.json @@ -0,0 +1,791 @@ +{ + "vin": "WVWZZZXXXXX000000", + "capabilities": { + "climatisation": { + "id": "climatisation", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getClimatisationSettings": { + "id": "getClimatisationSettings", + "scopes": [ + "climatisation" + ] + }, + "getClimatisationStatus": { + "id": "getClimatisationStatus", + "scopes": [ + "climatisation" + ] + }, + "postWindowheatingStart": { + "id": "postWindowheatingStart", + "scopes": [ + "manageClimatisation" + ] + }, + "postWindowheatingStop": { + "id": "postWindowheatingStop", + "scopes": [ + "manageClimatisation" + ] + }, + "getClimatisationRequestsByID": { + "id": "getClimatisationRequestsByID", + "scopes": [ + "manageClimatisation" + ] + }, + "putClimatisationSettings": { + "id": "putClimatisationSettings", + "scopes": [ + "manageClimatisation" + ] + }, + "postClimatisationStart": { + "id": "postClimatisationStart", + "scopes": [ + "manageClimatisation" + ] + }, + "postClimatisationStop": { + "id": "postClimatisationStop", + "scopes": [ + "manageClimatisation" + ] + }, + "getWindowheatingRequestsByID": { + "id": "getWindowheatingRequestsByID", + "scopes": [ + "manageClimatisation" + ] + } + }, + "parameters": [ + { + "key": "supportsTargetTemperatureInStartClimatisation", + "value": "" + }, + { + "key": "supportsTargetTemperatureInSettings", + "value": "" + }, + { + "key": "supportsClimatisationAtUnlock", + "value": "false" + }, + { + "key": "supportsWindowHeatingEnabled", + "value": "false" + }, + { + "key": "supportsZoneFrontLeftEnabled", + "value": "false" + }, + { + "key": "supportsZoneFrontRightEnabled", + "value": "false" + }, + { + "key": "supportsZoneRearLeftEnabled", + "value": "false" + }, + { + "key": "supportsZoneRearRightEnabled", + "value": "false" + }, + { + "key": "supportsClimatisationMode", + "value": "" + }, + { + "key": "supportsOffGridClimatisation", + "value": "true" + }, + { + "key": "supportsStartParallelClimatisationWindowHeating", + "value": "true" + }, + { + "key": "supportsStartWindowHeating", + "value": "true" + } + ] + }, + "access": { + "id": "access", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postAccessLock": { + "id": "postAccessLock", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessLockwithoutspin": { + "id": "postAccessLockwithoutspin", + "scopes": [ + "lock_unlock" + ] + }, + "getAccessRequestsByID": { + "id": "getAccessRequestsByID", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessUnlock": { + "id": "postAccessUnlock", + "scopes": [ + "lock_unlock" + ] + }, + "postAccessUnlockwithoutspin": { + "id": "postAccessUnlockwithoutspin", + "scopes": [ + "lock_unlock" + ] + } + }, + "parameters": [] + }, + "departureProfiles": { + "id": "departureProfiles", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "deleteDepartureProfiles": { + "id": "deleteDepartureProfiles", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + }, + "getDepartureProfiles": { + "id": "getDepartureProfiles", + "scopes": [ + "climatisation", + "charging" + ] + }, + "postDepartureProfiles": { + "id": "postDepartureProfiles", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + }, + "putDepartureProfiles": { + "id": "putDepartureProfiles", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + }, + "deleteDepartureProfilesByID": { + "id": "deleteDepartureProfilesByID", + "scopes": [ + "manageClimatisation", + "manageCharging" + ] + } + }, + "parameters": [] + }, + "parkingInformation": { + "id": "parkingInformation", + "expirationDate": "2023-03-13T10:16:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": false, + "status": [ + "MissingLicense", + "LicenseExpired" + ], + "operations": {}, + "parameters": [] + }, + "parkingPosition": { + "id": "parkingPosition", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getParkingposition": { + "id": "getParkingposition", + "scopes": [ + "parking_position" + ] + } + }, + "parameters": [] + }, + "honkAndFlash": { + "id": "honkAndFlash", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postHonkandflash": { + "id": "postHonkandflash", + "scopes": [ + "honk" + ] + }, + "getHonkandflashRequestsByID": { + "id": "getHonkandflashRequestsByID", + "scopes": [ + "honk" + ] + } + }, + "parameters": [] + }, + "measurements": { + "id": "measurements", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMeasurements": { + "id": "getMeasurements", + "scopes": [ + "range", + "mileage" + ] + } + }, + "parameters": [] + }, + "automation": { + "id": "automation", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getChargingProfiles": { + "id": "getChargingProfiles", + "scopes": [ + "charging" + ] + }, + "putChargingProfiles": { + "id": "putChargingProfiles", + "scopes": [ + "manageCharging" + ] + }, + "deleteChargingProfilesByID": { + "id": "deleteChargingProfilesByID", + "scopes": [ + "charging" + ] + }, + "getChargingTimers": { + "id": "getChargingTimers", + "scopes": [ + "charging" + ] + }, + "getClimatisationTimers": { + "id": "getClimatisationTimers", + "scopes": [ + "climatisation" + ] + }, + "getDepartureTimers": { + "id": "getDepartureTimers", + "scopes": [ + "climatisation", + "charging" + ] + }, + "postChargingProfiles": { + "id": "postChargingProfiles", + "scopes": [ + "manageCharging" + ] + }, + "putChargingTimers": { + "id": "putChargingTimers", + "scopes": [ + "manageCharging" + ] + }, + "putClimatisationTimers": { + "id": "putClimatisationTimers", + "scopes": [ + "manageClimatisation" + ] + } + }, + "parameters": [] + }, + "charging": { + "id": "charging", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "putChargingMode": { + "id": "putChargingMode", + "scopes": [ + "manageCharging" + ] + }, + "getChargingSettings": { + "id": "getChargingSettings", + "scopes": [ + "charging" + ] + }, + "postChargingStop": { + "id": "postChargingStop", + "scopes": [ + "manageCharging" + ] + }, + "getChargingCareStatus": { + "id": "getChargingCareStatus", + "scopes": [ + "manageCharging" + ] + }, + "getChargingMode": { + "id": "getChargingMode", + "scopes": [ + "charging" + ] + }, + "getChargingRequestsByID": { + "id": "getChargingRequestsByID", + "scopes": [ + "manageCharging" + ] + }, + "putChargingSettings": { + "id": "putChargingSettings", + "scopes": [ + "manageCharging" + ] + }, + "postChargingStart": { + "id": "postChargingStart", + "scopes": [ + "manageCharging" + ] + }, + "getChargingStatus": { + "id": "getChargingStatus", + "scopes": [ + "charging" + ] + } + }, + "parameters": [ + { + "key": "allowPlugUnlockACPermanent", + "value": "false" + }, + { + "key": "allowPlugUnlockACOnce", + "value": "false" + }, + { + "key": "allowPlugUnlockDCPermanent", + "value": "false" + }, + { + "key": "allowPlugUnlockDCOnce", + "value": "false" + }, + { + "key": "supportsTargetStateOfCharge", + "value": "" + }, + { + "key": "targetStateOfChargeMinimumAllowedValue", + "value": "" + }, + { + "key": "targetStateOfChargeStepSize", + "value": "" + } + ] + }, + "dealerAppointment": { + "id": "dealerAppointment", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": true, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMaintenanceStatus": { + "id": "getMaintenanceStatus", + "scopes": [ + "serviceInterval" + ] + }, + "getPredictivemaintenanceDealerappointment": { + "id": "getPredictivemaintenanceDealerappointment", + "scopes": [ + "serviceInterval" + ] + }, + "getWarninglightsLast": { + "id": "getWarninglightsLast", + "scopes": [ + "warning_lights" + ] + } + }, + "parameters": [] + }, + "destinations": { + "id": "destinations", + "expirationDate": "2023-03-13T10:16:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": false, + "status": [ + "MissingLicense", + "LicenseExpired" + ], + "operations": { + "deleteDestinationsByID": { + "id": "deleteDestinationsByID", + "scopes": [ + "navigation" + ] + }, + "getDestinations": { + "id": "getDestinations", + "scopes": [ + "navigation" + ] + }, + "postDestinations": { + "id": "postDestinations", + "scopes": [ + "navigation" + ] + }, + "putDestinations": { + "id": "putDestinations", + "scopes": [ + "navigation" + ] + } + }, + "parameters": [ + { + "key": "chargingStationsForEVTourImport", + "value": "" + } + ] + }, + "fuelStatus": { + "id": "fuelStatus", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getFuelStatus": { + "id": "getFuelStatus", + "scopes": [ + "fuelLevels" + ] + } + }, + "parameters": [] + }, + "hybridCarAuxiliaryHeating": { + "id": "hybridCarAuxiliaryHeating", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getAuxiliaryheatingRequestsByID": { + "id": "getAuxiliaryheatingRequestsByID", + "scopes": [ + "manageClimatisation" + ] + }, + "postAuxiliaryheatingStart": { + "id": "postAuxiliaryheatingStart", + "scopes": [ + "manageClimatisation" + ] + }, + "postAuxiliaryheatingStop": { + "id": "postAuxiliaryheatingStop", + "scopes": [ + "manageClimatisation" + ] + }, + "putAuxiliaryheatingTimers": { + "id": "putAuxiliaryheatingTimers", + "scopes": [ + "manageClimatisation" + ] + } + }, + "parameters": [ + { + "key": "supportsAutomaticMode", + "value": "false" + } + ] + }, + "oilLevelStatus": { + "id": "oilLevelStatus", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getStates": { + "id": "getStates", + "scopes": [ + "parkingBrakeStatus", + "ignitionStatus", + "oilLevels" + ] + } + }, + "parameters": [] + }, + "roadsideAssistant": { + "id": "roadsideAssistant", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": {}, + "parameters": [] + }, + "vehicleHealthInspection": { + "id": "vehicleHealthInspection", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getMaintenanceStatus": { + "id": "getMaintenanceStatus", + "scopes": [ + "serviceInterval" + ] + } + }, + "parameters": [] + }, + "vehicleWakeUp": { + "id": "vehicleWakeUp", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postVehiclewakeup": { + "id": "postVehiclewakeup", + "scopes": [] + }, + "getVehiclewakeupRequestsByID": { + "id": "getVehiclewakeupRequestsByID", + "scopes": [] + } + }, + "parameters": [] + }, + "vehicleWakeUpTrigger": { + "id": "vehicleWakeUpTrigger", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "postVehiclewakeupUpdate": { + "id": "postVehiclewakeupUpdate", + "scopes": [] + } + }, + "parameters": [] + }, + "state": { + "id": "state", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getAccessStatus": { + "id": "getAccessStatus", + "scopes": [ + "doors_windows" + ] + } + }, + "parameters": [] + }, + "vehicleHealthWarnings": { + "id": "vehicleHealthWarnings", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getWarninglightsLast": { + "id": "getWarninglightsLast", + "scopes": [ + "warning_lights" + ] + } + }, + "parameters": [] + }, + "vehicleHealth": { + "id": "vehicleHealth", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": {}, + "parameters": [] + }, + "vehicleLights": { + "id": "vehicleLights", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getLightsStatus": { + "id": "getLightsStatus", + "scopes": [ + "vehicleLights" + ] + } + }, + "parameters": [] + }, + "tripStatistics": { + "id": "tripStatistics", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false, + "endpoint": "vs", + "isEnabled": true, + "status": [], + "operations": { + "getTripdataCyclic": { + "id": "getTripdataCyclic", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataLongtermLast": { + "id": "getTripdataLongtermLast", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataLongtermByID": { + "id": "deleteTripdataLongtermByID", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataShortterm": { + "id": "getTripdataShortterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataShorttermLast": { + "id": "getTripdataShorttermLast", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataShorttermByID": { + "id": "deleteTripdataShorttermByID", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataCyclic": { + "id": "deleteTripdataCyclic", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataCyclicLast": { + "id": "getTripdataCyclicLast", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataCyclicByID": { + "id": "deleteTripdataCyclicByID", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataLongterm": { + "id": "deleteTripdataLongterm", + "scopes": [ + "tripStatistics" + ] + }, + "getTripdataLongterm": { + "id": "getTripdataLongterm", + "scopes": [ + "tripStatistics" + ] + }, + "deleteTripdataShortterm": { + "id": "deleteTripdataShortterm", + "scopes": [ + "tripStatistics" + ] + } + }, + "parameters": [] + } + }, + "parameters": {} +} diff --git a/tests/fixtures/resources/responses/golf_gte_hybrid/last_trip.json b/tests/fixtures/resources/responses/golf_gte_hybrid/last_trip.json new file mode 100644 index 00000000..2023791a --- /dev/null +++ b/tests/fixtures/resources/responses/golf_gte_hybrid/last_trip.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "2000000000", + "tripEndTimestamp": "2023-12-04T16:10:01Z", + "tripType": "shortTerm", + "vehicleType": "hybrid", + "mileage_km": 7, + "startMileage_km": 32199, + "overallMileage_km": 32205, + "travelTime": 10, + "averageFuelConsumption": 0, + "averageElectricConsumption": 28.9, + "averageSpeed_kmph": 40 + } +} \ No newline at end of file diff --git a/tests/fixtures/resources/responses/golf_gte_hybrid/selectivestatus_by_app.json b/tests/fixtures/resources/responses/golf_gte_hybrid/selectivestatus_by_app.json new file mode 100644 index 00000000..639f14cf --- /dev/null +++ b/tests/fixtures/resources/responses/golf_gte_hybrid/selectivestatus_by_app.json @@ -0,0 +1,557 @@ +{ + "access": { + "accessStatus": { + "value": { + "overallStatus": "unsafe", + "carCapturedTimestamp": "2023-12-05T06:41:03Z", + "doors": [ + { + "name": "bonnet", + "status": [ + "closed" + ] + }, + { + "name": "frontLeft", + "status": [ + "unlocked", + "open" + ] + }, + { + "name": "frontRight", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "rearLeft", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "rearRight", + "status": [ + "unlocked", + "closed" + ] + }, + { + "name": "trunk", + "status": [ + "unlocked", + "closed" + ] + } + ], + "windows": [ + { + "name": "frontLeft", + "status": [ + "closed" + ] + }, + { + "name": "frontRight", + "status": [ + "closed" + ] + }, + { + "name": "rearLeft", + "status": [ + "closed" + ] + }, + { + "name": "rearRight", + "status": [ + "closed" + ] + }, + { + "name": "roofCover", + "status": [ + "unsupported" + ] + }, + { + "name": "sunRoof", + "status": [ + "unsupported" + ] + } + ], + "doorLockStatus": "unlocked" + } + } + }, + "userCapabilities": { + "capabilitiesStatus": { + "value": [ + { + "id": "access", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "automation", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "charging", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "climatisation", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "dealerAppointment", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": true + }, + { + "id": "departureProfiles", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "destinations", + "status": [ + 2003, + 2002 + ], + "expirationDate": "2023-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "destinationsTours", + "status": [ + 2003, + 2002 + ], + "expirationDate": "2023-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "emergencyCalling", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "fuelStatus", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "honkAndFlash", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "hybridCarAuxiliaryHeating", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "hybridCarAuxiliaryHeatingTimers", + "status": [ + 1007 + ], + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "measurements", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "news", + "status": [ + 2003, + 2002 + ], + "expirationDate": "2023-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "oilLevelStatus", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "parkingInformation", + "status": [ + 2003, + 2002 + ], + "expirationDate": "2023-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "parkingPosition", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "roadsideAssistant", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "state", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "theftWarning", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryAntiTheftAlert", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryAntiTheftAlertDelete", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryHonkFlash", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "transactionHistoryLockUnlock", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "tripStatistics", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealth", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthArchive", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthInspection", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthSettings", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthTrigger", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthWakeUp", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleHealthWarnings", + "expirationDate": "2030-03-13T10:16:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleLights", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleWakeUp", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + }, + { + "id": "vehicleWakeUpTrigger", + "expirationDate": "2025-03-14T12:50:00Z", + "userDisablingAllowed": false + } + ] + } + }, + "charging": { + "batteryStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:41:15Z", + "currentSOC_pct": 65, + "cruisingRangeElectric_km": 14 + } + }, + "chargingStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:41:15Z", + "chargingState": "notReadyForCharging", + "chargeMode": "", + "chargeType": "" + } + }, + "chargingSettings": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:40:51Z", + "maxChargeCurrentAC": "reduced" + } + }, + "plugStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:41:15Z", + "plugConnectionState": "disconnected", + "plugLockState": "unlocked", + "externalPower": "unavailable", + "ledColor": "none" + } + }, + "chargeMode": { + "error": { + "message": "Bad Gateway", + "errorTimeStamp": "2023-12-05T08:25:12Z", + "info": "Upstream service responded with an unexpected status. If the problem persists, please contact our support.", + "code": 4111, + "group": 2, + "retry": true + } + } + }, + "climatisation": { + "climatisationSettings": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:40:48Z", + "targetTemperature_C": 22, + "targetTemperature_F": 72, + "climatisationWithoutExternalPower": true, + "heaterSource": "electric" + } + }, + "climatisationStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:41:12Z", + "climatisationState": "off" + } + }, + "windowHeatingStatus": { + "value": { + "carCapturedTimestamp": "2023-12-03T10:30:14Z", + "windowHeatingStatus": [ + { + "windowLocation": "front", + "windowHeatingState": "off" + }, + { + "windowLocation": "rear", + "windowHeatingState": "off" + } + ] + } + } + }, + "fuelStatus": { + "rangeStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:41:15Z", + "carType": "hybrid", + "primaryEngine": { + "type": "gasoline", + "currentSOC_pct": 37, + "remainingRange_km": 180, + "currentFuelLevel_pct": 37 + }, + "secondaryEngine": { + "type": "electric", + "currentSOC_pct": 65, + "remainingRange_km": 14 + }, + "totalRange_km": 194 + } + } + }, + "vehicleLights": { + "lightsStatus": { + "value": { + "carCapturedTimestamp": "2023-12-05T06:41:03Z", + "lights": [ + { + "name": "right", + "status": "on" + }, + { + "name": "left", + "status": "on" + } + ] + } + } + }, + "departureProfiles": { + "departureProfilesStatus": { + "value": { + "carCapturedTimestamp": "2023-12-04T16:01:44Z", + "minSOC_pct": 0, + "timers": [ + { + "id": 3, + "enabled": true, + "recurringTimer": { + "startTime": "15:30", + "recurringOn": { + "mondays": true, + "tuesdays": true, + "wednesdays": true, + "thursdays": true, + "fridays": false, + "saturdays": false, + "sundays": false + } + }, + "profileIDs": [ + 3 + ] + }, + { + "id": 2, + "enabled": true, + "recurringTimer": { + "startTime": "11:00", + "recurringOn": { + "mondays": true, + "tuesdays": true, + "wednesdays": true, + "thursdays": true, + "fridays": false, + "saturdays": false, + "sundays": false + } + }, + "profileIDs": [ + 4 + ] + }, + { + "id": 1, + "enabled": true, + "recurringTimer": { + "startTime": "11:00", + "recurringOn": { + "mondays": false, + "tuesdays": false, + "wednesdays": false, + "thursdays": false, + "fridays": true, + "saturdays": false, + "sundays": false + } + }, + "profileIDs": [ + 5 + ] + } + ], + "profiles": [ + { + "id": 1, + "name": "Profile 1", + "charging": true, + "climatisation": false, + "targetSOC_pct": 100, + "maxChargeCurrentAC": 16, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "22:00", + "endTime": "22:00" + } + ] + }, + { + "id": 2, + "name": "Profile 2", + "charging": true, + "climatisation": true, + "targetSOC_pct": 100, + "maxChargeCurrentAC": 10, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "01:00", + "endTime": "01:00" + } + ] + }, + { + "id": 3, + "name": "Profile 3", + "charging": true, + "climatisation": false, + "targetSOC_pct": 100, + "maxChargeCurrentAC": 5, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "00:00", + "endTime": "00:00" + } + ] + }, + { + "id": 4, + "name": "Profile 4", + "charging": true, + "climatisation": false, + "targetSOC_pct": 90, + "maxChargeCurrentAC": 5, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "00:00", + "endTime": "00:00" + } + ] + }, + { + "id": 5, + "name": "Profile 5", + "charging": true, + "climatisation": false, + "targetSOC_pct": 100, + "maxChargeCurrentAC": 32, + "preferredChargingTimes": [ + { + "id": 1, + "enabled": false, + "startTime": "22:00", + "endTime": "22:00" + } + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/volkswagencarnet/vw_connection.py b/volkswagencarnet/vw_connection.py index 671649d3..0a4ea066 100644 --- a/volkswagencarnet/vw_connection.py +++ b/volkswagencarnet/vw_connection.py @@ -29,11 +29,9 @@ HEADERS_SESSION, HEADERS_AUTH, BASE_SESSION, + BASE_API, BASE_AUTH, CLIENT, - XCLIENT_ID, - XAPPVERSION, - XAPPNAME, USER_AGENT, APP_URI, ) @@ -103,32 +101,23 @@ async def doLogin(self, tries: int = 1): _LOGGER.info("Successfully logged in") self._session_tokens["identity"] = self._session_tokens["Legacy"].copy() - self._session_logged_in = True - - # Get VW-Group API tokens - if not await self._getAPITokens(): - self._session_logged_in = False - return False # Get list of vehicles from account _LOGGER.debug("Fetching vehicles associated with account") - await self.set_token("vwg") self._session_headers.pop("Content-Type", None) - loaded_vehicles = await self.get( - url=f"https://msg.volkswagen.de/fs-car/usermanagement/users/v1/{BRAND}/{self._session_country}/vehicles" - ) + loaded_vehicles = await self.get(url=f"{BASE_API}/vehicle/v2/vehicles") # Add Vehicle class object for all VIN-numbers from account - if loaded_vehicles.get("userVehicles") is not None: + if loaded_vehicles.get("data") is not None: _LOGGER.debug("Found vehicle(s) associated with account.") - for vehicle in loaded_vehicles.get("userVehicles").get("vehicle"): - self._vehicles.append(Vehicle(self, vehicle)) + self._vehicles = [] + for vehicle in loaded_vehicles.get("data"): + self._vehicles.append(Vehicle(self, vehicle.get("vin"))) else: _LOGGER.warning("Failed to login to We Connect API.") self._session_logged_in = False return False # Update all vehicles data before returning - await self.set_token("vwg") await self.update() return True @@ -165,16 +154,18 @@ def base64URLEncode(s): self._session_auth_headers = HEADERS_AUTH.copy() if self._session_fulldebug: _LOGGER.debug("Requesting openid config") - req = await self._session.get(url="https://identity.vwgroup.io/.well-known/openid-configuration") + req = await self._session.get(url=f"{BASE_API}/login/v1/idk/openid-configuration") if req.status != 200: _LOGGER.debug("OpenId config error") return False response_data = await req.json() authorization_endpoint = response_data["authorization_endpoint"] + token_endpoint = response_data["token_endpoint"] auth_issuer = response_data["issuer"] # Get authorization page (login page) # https://identity.vwgroup.io/oidc/v1/authorize?nonce={NONCE}&state={STATE}&response_type={TOKEN_TYPES}&scope={SCOPE}&redirect_uri={APP_URI}&client_id={CLIENT_ID} + # https://identity.vwgroup.io/oidc/v1/authorize?client_id={CLIENT_ID}&scope={SCOPE}&response_type={TOKEN_TYPES}&redirect_uri={APP_URI} if self._session_fulldebug: _LOGGER.debug(f'Get authorization page from "{authorization_endpoint}"') self._session_auth_headers.pop("Referer", None) @@ -186,7 +177,6 @@ def base64URLEncode(s): raise ValueError("Verifier too short. n_bytes must be > 30.") elif len(code_verifier) > 128: raise ValueError("Verifier too long. n_bytes must be < 97.") - challenge = base64URLEncode(hashlib.sha256(code_verifier).digest()) req = await self._session.get( url=authorization_endpoint, @@ -194,11 +184,6 @@ def base64URLEncode(s): allow_redirects=False, params={ "redirect_uri": APP_URI, - "prompt": "login", - "nonce": getNonce(), - "state": getNonce(), - "code_challenge_method": "s256", - "code_challenge": challenge.decode(), "response_type": CLIENT[client].get("TOKEN_TYPES"), "client_id": CLIENT[client].get("CLIENT_ID"), "scope": CLIENT[client].get("SCOPE"), @@ -222,7 +207,7 @@ def base64URLEncode(s): ) else: _LOGGER.warning("Unable to fetch authorization endpoint.") - raise Exception('Missing "location" header') + raise Exception(f'Missing "location" header, payload returned: {await req.content.read()}') except Exception as error: _LOGGER.warning("Failed to get authorization endpoint") raise error @@ -329,24 +314,24 @@ def base64URLEncode(s): _LOGGER.debug("Login successful, received authorization code.") # Extract code and tokens - parsed_qs = parse_qs(urlparse(ref).fragment) + parsed_qs = parse_qs(urlparse(ref).query) jwt_auth_code = parsed_qs["code"][0] - jwt_id_token = parsed_qs["id_token"][0] + # jwt_id_token = parsed_qs["id_token"][0] # Exchange Auth code and id_token for new tokens with refresh_token (so we can easier fetch new ones later) token_body = { - "auth_code": jwt_auth_code, - "id_token": jwt_id_token, - "code_verifier": code_verifier.decode(), - "brand": BRAND, + "client_id": CLIENT[client].get("CLIENT_ID"), + "grant_type": "authorization_code", + "code": jwt_auth_code, + "redirect_uri": APP_URI + # "brand": BRAND, } _LOGGER.debug("Trying to fetch user identity tokens.") - token_url = "https://tokenrefreshservice.apps.emea.vwapps.io/exchangeAuthCode" + token_url = token_endpoint req = await self._session.post( url=token_url, headers=self._session_auth_headers, data=token_body, allow_redirects=False ) if req.status != 200: - raise Exception("Token exchange failed") - # Save tokens as "identity", these are tokens representing the user + raise Exception(f"Token exchange failed. Received message: {await req.content.read()}") self._session_tokens[client] = await req.json() if "error" in self._session_tokens[client]: error_msg = self._session_tokens[client].get("error", "") @@ -362,61 +347,13 @@ def base64URLEncode(s): _LOGGER.warning("User identity token could not be verified!") else: _LOGGER.debug("User identity token verified OK.") + self._session_logged_in = True except Exception as error: _LOGGER.error(f"Login failed for {BRAND} account, {error}") _LOGGER.exception(error) self._session_logged_in = False return False - return True - - async def _getAPITokens(self): - try: - # Get VW Group API tokens - # https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token - tokenBody2 = { - "grant_type": "id_token", - "token": self._session_tokens["identity"]["id_token"], - "scope": "sc2:fal", - } - _LOGGER.debug("Trying to fetch api tokens.") - req = await self._session.post( - url="https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token", - headers={ - "User-Agent": USER_AGENT, - "X-App-Version": XAPPVERSION, - "X-App-Name": XAPPNAME, - "X-Client-Id": XCLIENT_ID, - }, - data=tokenBody2, - allow_redirects=False, - ) - if req.status > 400: - _LOGGER.debug("API token request failed.") - raise Exception(f"API token request returned with status code {req.status}") - else: - # Save tokens as "vwg", use these for get/posts to VW Group API - self._session_tokens["vwg"] = await req.json() - if "error" in self._session_tokens["vwg"]: - error = self._session_tokens["vwg"].get("error", "") - if "error_description" in self._session_tokens["vwg"]: - error_description = self._session_tokens["vwg"].get("error_description", "") - raise Exception(f"{error} - {error_description}") - else: - raise Exception(error) - if self._session_fulldebug: - for token in self._session_tokens.get("vwg", {}): - _LOGGER.debug(f"Got token {token}") - if not await self.verify_tokens(self._session_tokens["vwg"].get("access_token", ""), "vwg"): - _LOGGER.warning("VW-Group API token could not be verified!") - else: - _LOGGER.debug("VW-Group API token verified OK.") - - # Update headers for requests, defaults to using VWG token - self._session_headers["Authorization"] = "Bearer " + self._session_tokens["vwg"]["access_token"] - except Exception as error: - _LOGGER.error(f"Failed to fetch VW-Group API tokens, {error}") - self._session_logged_in = False - return False + self._session_headers["Authorization"] = "Bearer " + self._session_tokens[client]["access_token"] return True async def terminate(self): @@ -426,35 +363,21 @@ async def terminate(self): async def logout(self): """Logout, revoke tokens.""" + # TODO: not tested yet self._session_headers.pop("Authorization", None) if self._session_logged_in: - if self._session_headers.get("vwg", {}).get("access_token"): - _LOGGER.info("Revoking API Access Token...") - self._session_headers["token_type_hint"] = "access_token" - params = {"token": self._session_tokens["vwg"]["access_token"]} - await self.post( - "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/revoke", data=params - ) - if self._session_headers.get("vwg", {}).get("refresh_token"): - _LOGGER.info("Revoking API Refresh Token...") - self._session_headers["token_type_hint"] = "refresh_token" - params = {"token": self._session_tokens["vwg"]["refresh_token"]} - await self.post( - "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/revoke", data=params - ) - self._session_headers.pop("token_type_hint", None) if self._session_headers.get("identity", {}).get("identity_token"): _LOGGER.info("Revoking Identity Access Token...") # params = { # "token": self._session_tokens['identity']['access_token'], # "brand": BRAND # } - # revoke_at = await self.post('https://tokenrefreshservice.apps.emea.vwapps.io/revokeToken', data = params) + # revoke_at = await self.post('https://emea.bff.cariad.digital/login/v1/idk/revoke', data = params) if self._session_headers.get("identity", {}).get("refresh_token"): _LOGGER.info("Revoking Identity Refresh Token...") - params = {"token": self._session_tokens["identity"]["refresh_token"], "brand": BRAND} - await self.post("https://tokenrefreshservice.apps.emea.vwapps.io/revokeToken", data=params) + params = {"token": self._session_tokens["identity"]["refresh_token"]} + await self.post("https://emea.bff.cariad.digital/login/v1/idk/revoke", data=params) # HTTP methods to API async def _request(self, method, url, **kwargs): @@ -469,6 +392,8 @@ async def _request(self, method, url, **kwargs): raise_for_status=False, **kwargs, ) as response: + if not response.ok: + _LOGGER.debug(f"Request failed with status {response.status}, body: {response.text}") response.raise_for_status() # Update cookie jar @@ -480,16 +405,16 @@ async def _request(self, method, url, **kwargs): try: if response.status == 204: res = {"status_code": response.status} - elif response.status >= 200 or response.status <= 300: + elif response.status >= 200 and response.status <= 300: res = await response.json(loads=json_loads) else: res = {} - _LOGGER.debug(f"Not success status code [{response.status}] response: {response}") + _LOGGER.debug(f"Not success status code [{response.status}] response: {response.text}") if "X-RateLimit-Remaining" in response.headers: res["rate_limit_remaining"] = response.headers.get("X-RateLimit-Remaining", "") except Exception: res = {} - _LOGGER.debug(f"Something went wrong [{response.status}] response: {response}") + _LOGGER.debug(f"Something went wrong [{response.status}] response: {response.text}") return res if self._session_fulldebug: @@ -541,6 +466,8 @@ async def post(self, url, vin="", tries=0, **data): # Construct URL from request, home region and variables def _make_url(self, ref, vin=""): + # TODO after verifying that we don't need home region handling anymore, this method should be completely removed + return ref replacedUrl = re.sub("\\$vin", vin, ref) if "://" in replacedUrl: # already server contained in URL @@ -583,7 +510,8 @@ async def getHomeRegion(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") + # TODO: handle multiple home regions! (no examples available currently) + return True response = await self.get( "https://mal-1a.prd.ece.vwg-connect.com/api/cs/vds/v1/vehicles/$vin/homeRegion", vin ) @@ -604,10 +532,9 @@ async def getOperationList(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") - response = await self.get("/api/rolesrights/operationlist/v3/vehicles/$vin", vin) - if response.get("operationList", False): - data = response.get("operationList", {}) + response = await self.get(f"{BASE_API}/vehicle/v1/vehicles/{vin}/capabilities", "") + if response.get("capabilities", False): + data = response.get("capabilities", {}) elif response.get("status_code", {}): _LOGGER.warning(f'Could not fetch operation list, HTTP status code: {response.get("status_code")}') data = response @@ -619,6 +546,76 @@ async def getOperationList(self, vin): data = {"error": "unknown"} return data + async def getSelectiveStatus(self, vin, services): + """Get status information for specified services.""" + if not await self.validate_tokens: + return False + try: + response = await self.get( + f"{BASE_API}/vehicle/v1/vehicles/{vin}/selectivestatus?jobs={','.join(services)}", "" + ) + + for service in services: + if not response.get(service): + _LOGGER.debug( + f"Did not receive return data for requested service {service}. (This is expected for several service/car combinations)" + ) + + return response + + except Exception as error: + _LOGGER.warning(f"Could not fetch selectivestatus, error: {error}") + return False + + async def getVehicleData(self, vin): + """Get car information like VIN, nickname, etc.""" + if not await self.validate_tokens: + return False + try: + response = await self.get(f"{BASE_API}/vehicle/v2/vehicles", "") + + for vehicle in response.get("data"): + if vehicle.get("vin") == vin: + data = {"vehicle": vehicle} + return data + + _LOGGER.warning(f"Could not fetch vehicle data for vin {vin}") + + except Exception as error: + _LOGGER.warning(f"Could not fetch vehicle data, error: {error}") + return False + + async def getParkingPosition(self, vin): + """Get information about the parking position.""" + if not await self.validate_tokens: + return False + try: + response = await self.get(f"{BASE_API}/vehicle/v1/vehicles/{vin}/parkingposition", "") + + if "data" in response: + return {"parkingposition": response["data"]} + else: + return {"parkingposition": {}} + + except Exception as error: + _LOGGER.warning(f"Could not fetch parkingposition, error: {error}") + return False + + async def getTripLast(self, vin): + """Get car information like VIN, nickname, etc.""" + if not await self.validate_tokens: + return False + try: + response = await self.get(f"{BASE_API}/vehicle/v1/trips/{vin}/shortterm/last", "") + if "data" in response: + return {"trip_last": response["data"]} + else: + _LOGGER.warning(f"Could not fetch last trip data, server response: {response}") + + except Exception as error: + _LOGGER.warning(f"Could not fetch last trip data, error: {error}") + return False + async def getRealCarData(self, vin): """Get car information from customer profile, VIN, nickname, etc.""" if not await self.validate_tokens: @@ -629,7 +626,6 @@ async def getRealCarData(self, vin): subject = jwt.decode(atoken, options={"verify_signature": False}, algorithms=JWT_ALGORITHMS).get( "sub", None ) - await self.set_token("identity") self._session_headers["Accept"] = "application/json" response = await self.get(f"https://customer-profile.vwgroup.io/v1/customers/{subject}/realCarData") if response.get("realCars", {}): @@ -647,36 +643,9 @@ async def getRealCarData(self, vin): _LOGGER.warning(f"Could not fetch realCarData, error: {error}") return False - async def getCarportData(self, vin): - """Get carport data for vehicle, model, model year etc.""" - if not await self.validate_tokens: - return False - try: - await self.set_token("vwg") - self._session_headers["Accept"] = ( - "application/vnd.vwg.mbb.vehicleDataDetail_v2_1_0+json," - " application/vnd.vwg.mbb.genericError_v1_0_2+json" - ) - response = await self.get( - f"fs-car/vehicleMgmt/vehicledata/v2/{BRAND}/{self._session_country}/vehicles/$vin", vin=vin - ) - self._session_headers["Accept"] = "application/json" - - if response.get("vehicleDataDetail", {}).get("carportData", {}): - data = {"carportData": response.get("vehicleDataDetail", {}).get("carportData", {})} - return data - elif response.get("status_code", {}): - _LOGGER.warning(f'Could not fetch carportdata, HTTP status code: {response.get("status_code")}') - else: - _LOGGER.info("Unhandled error while trying to fetch carport data") - except Exception as error: - _LOGGER.warning(f"Could not fetch carportData, error: {error}") - return False - async def getVehicleStatusData(self, vin): """Get stored vehicle data response.""" try: - await self.set_token("vwg") response = await self.get(f"fs-car/bs/vsr/v1/{BRAND}/{self._session_country}/vehicles/$vin/status", vin=vin) if ( response.get("StoredVehicleDataResponse", {}) @@ -708,7 +677,6 @@ async def getTripStatistics(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") response = await self.get( f"fs-car/bs/tripstatistics/v1/{BRAND}/{self._session_country}/vehicles/$vin/tripdata/shortTerm?newest", vin=vin, @@ -729,7 +697,6 @@ async def getPosition(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") response = await self.get( f"fs-car/bs/cf/v1/{BRAND}/{self._session_country}/vehicles/$vin/position", vin=vin ) @@ -754,7 +721,6 @@ async def getTimers(self, vin) -> TimerData | None: if not await self.validate_tokens: return None try: - await self.set_token("vwg") response = await self.get( f"fs-car/bs/departuretimer/v1/{BRAND}/{self._session_country}/vehicles/$vin/timer", vin=vin ) @@ -774,7 +740,6 @@ async def getClimater(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") response = await self.get( f"fs-car/bs/climatisation/v1/{BRAND}/{self._session_country}/vehicles/$vin/climater", vin=vin ) @@ -794,7 +759,6 @@ async def getCharger(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") response = await self.get( f"fs-car/bs/batterycharge/v1/{BRAND}/{self._session_country}/vehicles/$vin/charger", vin=vin ) @@ -814,7 +778,6 @@ async def getPreHeater(self, vin): if not await self.validate_tokens: return False try: - await self.set_token("vwg") response = await self.get(f"fs-car/bs/rs/v1/{BRAND}/{self._session_country}/vehicles/$vin/status", vin=vin) if response.get("statusResponse", {}): data = {"heating": response.get("statusResponse", {})} @@ -839,7 +802,6 @@ async def get_request_status(self, vin, sectionId, requestId): if not await self.doLogin(): _LOGGER.warning(f"Login for {BRAND} account failed!") raise Exception(f"Login for {BRAND} account failed") - await self.set_token("vwg") if sectionId == "climatisation": url = ( f"fs-car/bs/$sectionId/v1/{BRAND}/{self._session_country}/vehicles/$vin/climater/actions/$requestId" @@ -964,9 +926,8 @@ async def dataCall(self, query, vin="", **data): async def setRefresh(self, vin): """Force vehicle data update.""" try: - await self.set_token("vwg") - response = await self.dataCall( - f"fs-car/bs/vsr/v1/{BRAND}/{self._session_country}/vehicles/$vin/requests", vin, data=None + response = await self.post( + f"{BASE_API}/vehicle/v1/vehicles/{vin}/vehiclewakeuptrigger", data=None ) if not response: raise Exception("Invalid or no response") @@ -987,7 +948,6 @@ async def setRefresh(self, vin): async def setCharger(self, vin, data) -> dict[str, str | int | None]: """Start/Stop charger.""" try: - await self.set_token("vwg") response = await self.dataCall( f"fs-car/bs/batterycharge/v1/{BRAND}/{self._session_country}/vehicles/$vin/charger/actions", vin, @@ -1012,7 +972,6 @@ async def setCharger(self, vin, data) -> dict[str, str | int | None]: async def setClimater(self, vin, data, spin): """Execute climatisation actions.""" try: - await self.set_token("vwg") # Only get security token if auxiliary heater is to be started if data.get("action", {}).get("settings", {}).get("heaterSource", None) == "auxiliary": self._session_headers["X-securityToken"] = await self.get_sec_token(vin=vin, spin=spin, action="rclima") @@ -1043,7 +1002,6 @@ async def setPreHeater(self, vin, data, spin): """Petrol/diesel parking heater actions.""" content_type = None try: - await self.set_token("vwg") if "Content-Type" in self._session_headers: content_type = self._session_headers["Content-Type"] else: @@ -1103,7 +1061,6 @@ async def setChargeMinLevel(self, vin: str, limit: int): async def _setDepartureTimer(self, vin, data: TimersAndProfiles, action: str): """Set schedules.""" try: - await self.set_token("vwg") response = await self.dataCall( f"fs-car/bs/departuretimer/v1/{BRAND}/{self._session_country}/vehicles/$vin/timer/actions", vin=vin, @@ -1137,7 +1094,6 @@ async def setLock(self, vin, data, spin): """Remote lock and unlock actions.""" content_type = None try: - await self.set_token("vwg") # Prepare data, headers and fetch security token if "Content-Type" in self._session_headers: content_type = self._session_headers["Content-Type"] @@ -1181,7 +1137,7 @@ async def setLock(self, vin, data, spin): async def validate_tokens(self): """Validate expiry of tokens.""" idtoken = self._session_tokens["identity"]["id_token"] - atoken = self._session_tokens["vwg"]["access_token"] + atoken = self._session_tokens["identity"]["access_token"] id_exp = jwt.decode( idtoken, options={"verify_signature": False, "verify_aud": False}, algorithms=JWT_ALGORITHMS ).get("exp", None) @@ -1212,7 +1168,7 @@ async def validate_tokens(self): async def verify_tokens(self, token, type, client="Legacy"): """Verify JWT against JWK(s).""" if type == "identity": - req = await self._session.get(url="https://identity.vwgroup.io/oidc/v1/keys") + req = await self._session.get(url="https://identity.vwgroup.io/v1/jwks") keys = await req.json() audience = [ CLIENT[client].get("CLIENT_ID"), @@ -1220,10 +1176,6 @@ async def verify_tokens(self, token, type, client="Legacy"): "https://api.vas.eu.dp15.vwg-connect.com", "https://api.vas.eu.wcardp.io", ] - elif type == "vwg": - req = await self._session.get(url="https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/public/jwk/v1") - keys = await req.json() - audience = "mal.prd.ece.vwg-connect.com" else: _LOGGER.debug("Not implemented") return False @@ -1235,8 +1187,6 @@ async def verify_tokens(self, token, type, client="Legacy"): pubkeys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(to_json(jwk)) token_kid = jwt.get_unverified_header(token)["kid"] - if type == "vwg": - token_kid = "VWGMBB01DELIV1." + token_kid pubkey = pubkeys[token_kid] jwt.decode(token, key=pubkey, algorithms=JWT_ALGORITHMS, audience=audience) @@ -1253,18 +1203,15 @@ async def refresh_tokens(self): "Connection": "keep-alive", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT, - "X-App-Version": XAPPVERSION, - "X-App-Name": XAPPNAME, - "X-Client-Id": XCLIENT_ID, } body = { "grant_type": "refresh_token", - "brand": BRAND, "refresh_token": self._session_tokens["identity"]["refresh_token"], + "client_id": CLIENT["Legacy"]["CLIENT_ID"] } response = await self._session.post( - url="https://tokenrefreshservice.apps.emea.vwapps.io/refreshTokens", headers=tHeaders, data=body + url="https://emea.bff.cariad.digital/login/v1/idk/token", headers=tHeaders, data=body ) if response.status == 200: tokens = await response.json() @@ -1277,34 +1224,11 @@ async def refresh_tokens(self): _LOGGER.warning(f"Something went wrong when refreshing {BRAND} account tokens.") return False - body = {"grant_type": "id_token", "scope": "sc2:fal", "token": self._session_tokens["identity"]["id_token"]} - - response = await self._session.post( - url="https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token", - headers=tHeaders, - data=body, - allow_redirects=True, - ) - if response.status == 200: - tokens = await response.json() - if not await self.verify_tokens(tokens["access_token"], "vwg"): - _LOGGER.warning("Token could not be verified!") - for token in tokens: - self._session_tokens["vwg"][token] = tokens[token] - else: - resp = await response.text() - _LOGGER.warning("Something went wrong when refreshing API tokens. %s" % resp) - return False return True except Exception as error: _LOGGER.warning(f"Could not refresh tokens: {error}") return False - async def set_token(self, type): - """Switch between tokens.""" - self._session_headers["Authorization"] = "Bearer " + self._session_tokens[type]["access_token"] - return - # Class helpers # @property def vehicles(self): diff --git a/volkswagencarnet/vw_const.py b/volkswagencarnet/vw_const.py index 1a8212e3..861bc962 100644 --- a/volkswagencarnet/vw_const.py +++ b/volkswagencarnet/vw_const.py @@ -2,37 +2,21 @@ BASE_SESSION = "https://msg.volkswagen.de" BASE_AUTH = "https://identity.vwgroup.io" +BASE_API = "https://emea.bff.cariad.digital" BRAND = "VW" COUNTRY = "DE" # Data used in communication CLIENT = { "Legacy": { - "CLIENT_ID": "9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com", - # client id for VWG API, legacy Skoda Connect/MySkoda - "SCOPE": "openid mbb profile cars address email birthdate nickname phone", - # 'SCOPE': 'openid mbb profile cars address email birthdate badge phone driversLicense dealers profession vin', - "TOKEN_TYPES": "code id_token token", - }, - "New": { - "CLIENT_ID": "f9a2359a-b776-46d9-bd0c-db1904343117@apps_vw-dilab_com", - # Provides access to new API? tokentype=IDK_TECHNICAL.. - "SCOPE": "openid mbb profile", - "TOKEN_TYPES": "code id_token", - }, - "Unknown": { - "CLIENT_ID": "72f9d29d-aa2b-40c1-bebe-4c7683681d4c@apps_vw-dilab_com", # gives tokentype=IDK_SMARTLINK ? - "SCOPE": "openid dealers profile email cars address", - "TOKEN_TYPES": "code id_token", - }, + "CLIENT_ID": "a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com", + "SCOPE": "openid profile badge cars dealers vin", + "TOKEN_TYPES": "code" + } } - -XCLIENT_ID = "c8fcb3bf-22d3-44b0-b6ce-30eae0a4986f" -XAPPVERSION = "5.3.2" -XAPPNAME = "We Connect" -USER_AGENT = "OneConnect/000000148 CFNetwork/1485 Darwin/23.1.0" -APP_URI = "carnet://identity-kit/login" +USER_AGENT = "Volkswagen/2.20.0 iOS/17.1.1" +APP_URI = "weconnect://authenticated" # Used when fetching data HEADERS_SESSION = { @@ -40,9 +24,6 @@ "Content-Type": "application/json", "Accept-charset": "UTF-8", "Accept": "application/json", - "X-Client-Id": XCLIENT_ID, - "X-App-Version": XAPPVERSION, - "X-App-Name": XAPPNAME, "User-Agent": USER_AGENT, "tokentype": "IDK_TECHNICAL", } @@ -53,9 +34,6 @@ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Content-Type": "application/x-www-form-urlencoded", - "x-requested-with": XAPPNAME, - "User-Agent": USER_AGENT, - "X-App-Name": XAPPNAME, } TEMP_CELSIUS: str = "°C" diff --git a/volkswagencarnet/vw_dashboard.py b/volkswagencarnet/vw_dashboard.py index 4c15ae15..6896c219 100644 --- a/volkswagencarnet/vw_dashboard.py +++ b/volkswagencarnet/vw_dashboard.py @@ -1052,6 +1052,9 @@ def create_instruments(): BinarySensor( attr="sunroof_closed", name="Sunroof closed", device_class=VWDeviceClass.WINDOW, reverse_state=True ), + BinarySensor( + attr="roof_cover_closed", name="Roof cover closed", device_class=VWDeviceClass.WINDOW, reverse_state=True + ), BinarySensor( attr="windows_closed", name="Windows closed", device_class=VWDeviceClass.WINDOW, reverse_state=True ), @@ -1079,7 +1082,9 @@ def create_instruments(): device_class=VWDeviceClass.WINDOW, reverse_state=True, ), - BinarySensor(attr="vehicle_moving", name="Vehicle Moving", device_class=VWDeviceClass.MOVING), + ## not used currently, because we only update parkingposition when the last trip's data has changed, because + ## of its rate limiting, so this is not reliable at all... + # BinarySensor(attr="vehicle_moving", name="Vehicle Moving", device_class=VWDeviceClass.MOVING), BinarySensor(attr="request_in_progress", name="Request in progress", device_class=VWDeviceClass.CONNECTIVITY), ] diff --git a/volkswagencarnet/vw_vehicle.py b/volkswagencarnet/vw_vehicle.py index 8a6b4d80..a4afbb96 100644 --- a/volkswagencarnet/vw_vehicle.py +++ b/volkswagencarnet/vw_vehicle.py @@ -13,6 +13,13 @@ from .vw_const import VehicleStatusParameter as P from .vw_utilities import find_path, is_valid_path +# TODO +# Images (https://emea.bff.cariad.digital/media/v2/vehicle-images/WVWZZZ3HZPK002581?resolution=3x) +# {"data":[{"id":"door_right_front_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_right_front_overlay.png","fileName":"image_door_right_front_overlay.png"},{"id":"light_right","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_light_right.png","fileName":"image_light_right.png"},{"id":"sunroof_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_sunroof_overlay.png","fileName":"image_sunroof_overlay.png"},{"id":"trunk_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_trunk_overlay.png","fileName":"image_trunk_overlay.png"},{"id":"car_birdview","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_car_birdview.png","fileName":"image_car_birdview.png"},{"id":"door_left_front","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_left_front.png","fileName":"image_door_left_front.png"},{"id":"door_right_front","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_right_front.png","fileName":"image_door_right_front.png"},{"id":"sunroof","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_sunroof.png","fileName":"image_sunroof.png"},{"id":"window_right_front_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_window_right_front_overlay.png","fileName":"image_window_right_front_overlay.png"},{"id":"car_34view","url":"https://media.volkswagen.com/Vilma/V/3H9/2023/Front_Right/c8ca31fcf999b04d42940620653c494215e0d49756615f3524499261d96ccdce.png?width=1163","fileName":""},{"id":"door_left_back","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_left_back.png","fileName":"image_door_left_back.png"},{"id":"door_right_back","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_right_back.png","fileName":"image_door_right_back.png"},{"id":"window_left_back_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_window_left_back_overlay.png","fileName":"image_window_left_back_overlay.png"},{"id":"window_right_back_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_window_right_back_overlay.png","fileName":"image_window_right_back_overlay.png"},{"id":"bonnet_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_bonnet_overlay.png","fileName":"image_bonnet_overlay.png"},{"id":"door_left_back_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_left_back_overlay.png","fileName":"image_door_left_back_overlay.png"},{"id":"door_left_front_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_left_front_overlay.png","fileName":"image_door_left_front_overlay.png"},{"id":"door_right_back_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_door_right_back_overlay.png","fileName":"image_door_right_back_overlay.png"},{"id":"light_left","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_light_left.png","fileName":"image_light_left.png"},{"id":"window_left_front_overlay","url":"https://emea.bff.cariad.digital/media/v2/image/arteon_shooting_brake/3x/image_window_left_front_overlay.png","fileName":"image_window_left_front_overlay.png"}]} +# +# Model Year (unclear, seems to only be available via the web API, language dependent and with separate authentication) + + BACKEND_RECEIVED_TIMESTAMP = "BACKEND_RECEIVED_TIMESTAMP" LOCKED_STATE = 2 @@ -21,9 +28,9 @@ _LOGGER = logging.getLogger(__name__) -ENGINE_TYPE_ELECTRIC = "3" -ENGINE_TYPE_DIESEL = "5" -ENGINE_TYPE_GASOLINE = "6" +ENGINE_TYPE_ELECTRIC = "electric" +ENGINE_TYPE_DIESEL = "diesel" +ENGINE_TYPE_GASOLINE = "gasoline" ENGINE_TYPE_COMBUSTION = [ENGINE_TYPE_DIESEL, ENGINE_TYPE_GASOLINE] UNSUPPORTED = 0 @@ -53,18 +60,25 @@ def __init__(self, conn, url): } self._climate_duration: int = 30 self._climatisation_target_temperature: float | None = None + self._last_last_trip_id: int = 0 # API Endpoints that might be enabled for car (that we support) self._services: dict[str, dict[str, Any]] = { - "rheating_v1": {"active": False}, - "rclima_v1": {"active": False}, - "rlu_v1": {"active": False}, - "trip_statistic_v1": {"active": False}, - "statusreport_v1": {"active": False}, - "rbatterycharge_v1": {"active": False}, - "rhonk_v1": {"active": False}, - "carfinder_v1": {"active": False}, - "timerprogramming_v1": {"active": False}, + # TODO needs a complete rework... + "access": {"active": False}, + "charging": {"active": False}, + "climatisation": {"active": False}, + "tripStatistics": {"active": False}, + "measurements": {"active": False}, + "honkAndFlash": {"active": False}, + "parkingPosition": {"active" : False}, + "vehicleWakeUpTrigger": {"active": False} + # "rheating_v1": {"active": False}, + # "rclima_v1": {"active": False}, + # "statusreport_v1": {"active": False}, + # "rbatterycharge_v1": {"active": False}, + # "carfinder_v1": {"active": False}, + # "timerprogramming_v1": {"active": False}, # "jobs_v1": {"active": False}, # "owner_v1": {"active": False}, # vehicles_v1_cai, services_v1, vehicletelemetry_v1 @@ -111,42 +125,30 @@ async def _handle_response(self, response, topic: str, error_msg: str | None = N # Init and update vehicle data async def discover(self): """Discover vehicle and initial data.""" - homeregion = await self._connection.getHomeRegion(self.vin) - _LOGGER.debug(f"Get homeregion for VIN {self.vin}") - if homeregion: - self._homeregion = homeregion - - await asyncio.gather(self.get_carportdata(), self.get_realcardata(), return_exceptions=True) - _LOGGER.info(f'Vehicle {self.vin} added. Homeregion is "{self._homeregion}"') _LOGGER.debug("Attempting discovery of supported API endpoints for vehicle.") operation_list = await self._connection.getOperationList(self.vin) if operation_list: - service_info = operation_list["serviceInfo"] - # Iterate over all endpoints in ServiceInfo list - for service in service_info: + for service_id in operation_list.keys(): try: - if service.get("serviceId", "Invalid") in self._services.keys(): + if service_id in self._services.keys(): + service = operation_list[service_id] data = {} - service_name = service.get("serviceId", None) - if service.get("serviceStatus", {}).get("status", "Disabled") == "Enabled": - _LOGGER.debug(f'Discovered enabled service: {service["serviceId"]}') + service_name = service.get("id", None) + if service.get("isEnabled", False): + _LOGGER.debug(f"Discovered enabled service: {service_name}") data["active"] = True - if service.get("cumulatedLicense", {}).get("expirationDate", False): - data["expiration"] = ( - service.get("cumulatedLicense", {}).get("expirationDate", None).get("content", None) - ) - if service.get("operation", False): + if service.get("expirationDate", False): + data["expiration"] = service.get("expirationDate", None) + if service.get("operations", False): data.update({"operations": []}) - for operation in service.get("operation", []): + for operation_id in service.get("operations", []).keys(): + operation = service.get("operations").get(operation_id) data["operations"].append(operation.get("id", None)) - elif service.get("serviceStatus", {}).get("status", None) == "Disabled": - reason = service.get("serviceStatus", {}).get("reason", "Unknown") + else: + reason = service.get("status", "Unknown") _LOGGER.debug(f"Service: {service_name} is disabled because of reason: {reason}") data["active"] = False - else: - _LOGGER.warning(f"Could not determine status of service: {service_name}, assuming enabled") - data["active"] = True self._services[service_name].update(data) except Exception as error: _LOGGER.warning(f'Encountered exception: "{error}" while parsing service item: {service}') @@ -161,28 +163,69 @@ async def update(self): await self.discover() if not self.deactivated: await asyncio.gather( - self.get_preheater(), - self.get_climater(), - self.get_trip_statistic(), - self.get_position(), - self.get_statusreport(), - self.get_charger(), - self.get_timerprogramming(), - return_exceptions=True, + self.get_selectivestatus( + [ + "access", + "fuelStatus", + "vehicleLights", + "vehicleHealthInspection", + "measurements", + "charging", + "climatisation", + "automation", + ] + ), + self.get_vehicle(), + self.get_trip_last() + # Parking Position is _not_ requested here, because it is rate limited! + # Parking Position will be implicitly updated when `trip_last` has new data + # self.get_parkingposition(), + # TODO: these legacy calls still have to be evaluated to find their new counterparts + # self.get_preheater(), + # self.get_statusreport(), + # self.get_timerprogramming(), + # return_exceptions=True, ) else: _LOGGER.info(f"Vehicle with VIN {self.vin} is deactivated.") # Data collection functions - async def get_realcardata(self): - """Fetch realcardata.""" - data = await self._connection.getRealCarData(self.vin) + async def get_selectivestatus(self, services): + """Fetch selective status for specified services.""" + + # only keep active services for the update + services = [service for service in services if self._services.get(service, {}).get("active", False)] + + data = await self._connection.getSelectiveStatus(self.vin, services) if data: self._states.update(data) - async def get_carportdata(self): - """Fetch carport data.""" - data = await self._connection.getCarportData(self.vin) + async def get_vehicle(self): + """Fetch car masterdata.""" + data = await self._connection.getVehicleData(self.vin) + if data: + self._states.update(data) + + async def get_parkingposition(self): + """Fetch parking position if supported. Be aware: THIS REQUEST IS RATE LIMITED.""" + if self._services.get("parkingPosition", {}).get("active", False): + data = await self._connection.getParkingPosition(self.vin) + if data: + self._states.update(data) + + async def get_trip_last(self): + """Fetch last trip statistics if supported.""" + if self._services.get("tripStatistics", {}).get("active", False): + data = await self._connection.getTripLast(self.vin) + if data: + self._states.update(data) + if self._last_last_trip_id != data.get("id", self._last_last_trip_id): + # trip id has changed, update parking position + self.get_parkingposition() + + async def get_realcardata(self): + """Fetch realcardata.""" + data = await self._connection.getRealCarData(self.vin) if data: self._states.update(data) @@ -351,7 +394,7 @@ async def set_charge_min_level(self, level: int): async def set_charger(self, action) -> bool: """Charging actions.""" - if not self._services.get("rbatterycharge_v1", False): + if not self._services.get("charging", False): _LOGGER.info("Remote start/stop of charger is not supported.") raise Exception("Remote start/stop of charger is not supported.") if self._in_progress("batterycharge"): @@ -445,7 +488,7 @@ async def set_climatisation(self, mode="off", spin=False): async def set_climater(self, data, spin=False): """Climater actions.""" - if not self._services.get("rclima_v1", False): + if not self._services.get("climatisation", False): _LOGGER.info("Remote control of climatisation functions is not supported.") raise Exception("Remote control of climatisation functions is not supported.") if self._in_progress("climatisation"): @@ -516,7 +559,7 @@ async def set_lock(self, action, spin): # Refresh vehicle data (VSR) async def set_refresh(self): """Wake up vehicle and update status data.""" - if not self._services.get("statusreport_v1", {}).get("active", False): + if not self._services.get("vehicleWakeUpTrigger", {}).get("active", False): _LOGGER.info("Data refresh is not supported.") raise Exception("Data refresh is not supported.") if self._in_progress("refresh", unknown_offset=-5): @@ -640,7 +683,7 @@ def nickname(self) -> str | None: :return: """ - return self.attrs.get("carData", {}).get("nickname", None) + return self.attrs.get("vehicle", {}).get("nickname", None) @property def is_nickname_supported(self) -> bool: @@ -649,7 +692,7 @@ def is_nickname_supported(self) -> bool: :return: """ - return self.attrs.get("carData", {}).get("nickname", False) is not False + return self.attrs.get("vehicle", {}).get("nickname", False) is not False @property def deactivated(self) -> bool | None: @@ -672,22 +715,22 @@ def is_deactivated_supported(self) -> bool: @property def model(self) -> str | None: """Return model.""" - return self.attrs.get("carportData", {}).get("modelName", None) + return self.attrs.get("vehicle", {}).get("model", None) @property def is_model_supported(self) -> bool: """Return true if model is supported.""" - return self.attrs.get("carportData", {}).get("modelName", False) is not False + return self.attrs.get("vehicle", {}).get("modelName", False) is not False @property def model_year(self) -> bool | None: """Return model year.""" - return self.attrs.get("carportData", {}).get("modelYear", None) + return self.attrs.get("vehicle", {}).get("modelYear", None) @property def is_model_year_supported(self) -> bool: """Return true if model year is supported.""" - return self.attrs.get("carportData", {}).get("modelYear", False) is not False + return self.attrs.get("vehicle", {}).get("modelYear", False) is not False @property def model_image(self) -> str: @@ -709,32 +752,32 @@ def is_model_image_supported(self) -> bool: @property def parking_light(self) -> bool: """Return true if parking light is on.""" - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.PARKING_LIGHT].get("value", 0)) - return response != 2 + lights = self.attrs.get("vehicleLights").get("lightsStatus").get("value").get("lights") + lights_on_count = 0 + for light in lights: + if light["status"] == "on": + lights_on_count = lights_on_count + 1 + return lights_on_count == 1 @property def parking_light_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.PARKING_LIGHT].get(BACKEND_RECEIVED_TIMESTAMP) + return self.attrs.get("vehicleLights").get("lightsStatus").get("value").get("carCapturedTimestamp") @property def is_parking_light_supported(self) -> bool: """Return true if parking light is supported.""" - return self.attrs.get("StoredVehicleDataResponseParsed", False) and P.PARKING_LIGHT in self.attrs.get( - "StoredVehicleDataResponseParsed" - ) + return self.attrs.get("vehicleLights", False) and "lights" in self.attrs.get("vehicleLights").get( + "lightsStatus" + ).get("value") # Connection status @property def last_connected(self) -> str: """Return when vehicle was last connected to connect servers in local time.""" - last_connected_utc = ( - self.attrs.get("StoredVehicleDataResponse") - .get("vehicleData") - .get("data")[0] - .get("field")[0] - .get("tsCarSentUtc") - ) + # this field is only a dirty hack, because there is no overarching information for the car anymore, + # only information per service, so we just use the one for odometer + last_connected_utc = self.attrs.get("measurements").get("odometerStatus").get("carCapturedTimestamp")[0] last_connected = last_connected_utc.replace(tzinfo=timezone.utc).astimezone(tz=None) return last_connected.strftime("%Y-%m-%d %H:%M:%S") @@ -758,34 +801,27 @@ def is_last_connected_supported(self) -> bool: @property def distance(self) -> int | None: """Return vehicle odometer.""" - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.ODOMETER].get("value", 0) - if value: - return int(value) - return None + return find_path(self.attrs, "measurements.odometerStatus.value.odometer") @property def distance_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.ODOMETER].get("BACKEND_RECEIVED_TIMESTAMP") + return find_path(self.attrs, "measurements.odometerStatus.value.carCapturedTimestamp") @property def is_distance_supported(self) -> bool: """Return true if odometer is supported.""" - return self.attrs.get("StoredVehicleDataResponseParsed", False) and P.ODOMETER in self.attrs.get( - "StoredVehicleDataResponseParsed" - ) + return is_valid_path(self.attrs, "measurements.odometerStatus.value.odometer") @property def service_inspection(self): """Return time left for service inspection.""" - return -int(self.attrs.get("StoredVehicleDataResponseParsed")[P.DAYS_TO_SERVICE_INSPECTION].get("value")) + return int(find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.inspectionDue_days")) @property def service_inspection_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.DAYS_TO_SERVICE_INSPECTION].get( - BACKEND_RECEIVED_TIMESTAMP - ) + return find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.carCapturedTimestamp") @property def is_service_inspection_supported(self) -> bool: @@ -794,21 +830,17 @@ def is_service_inspection_supported(self) -> bool: :return: """ - return self.attrs.get( - "StoredVehicleDataResponseParsed", False - ) and P.DAYS_TO_SERVICE_INSPECTION in self.attrs.get("StoredVehicleDataResponseParsed") + return is_valid_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.inspectionDue_days") @property def service_inspection_distance(self): """Return distance left for service inspection.""" - return -int(self.attrs.get("StoredVehicleDataResponseParsed")[P.DISTANCE_TO_SERVICE_INSPECTION].get("value", 0)) + return int(find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.inspectionDue_km")) @property def service_inspection_distance_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.DISTANCE_TO_SERVICE_INSPECTION].get( - BACKEND_RECEIVED_TIMESTAMP - ) + return find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.carCapturedTimestamp") @property def is_service_inspection_distance_supported(self) -> bool: @@ -817,24 +849,17 @@ def is_service_inspection_distance_supported(self) -> bool: :return: """ - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.DISTANCE_TO_SERVICE_INSPECTION in self.attrs.get("StoredVehicleDataResponseParsed"): - return True - return False + return is_valid_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.carCapturedTimestamp") @property def oil_inspection(self): """Return time left for oil inspection.""" - return -int( - self.attrs.get("StoredVehicleDataResponseParsed", {}).get(P.DAYS_TO_OIL_INSPECTION, {}).get("value", 0) - ) + return int(find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.oilServiceDue_days")) @property def oil_inspection_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.DAYS_TO_OIL_INSPECTION].get( - BACKEND_RECEIVED_TIMESTAMP - ) + return find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.carCapturedTimestamp") @property def is_oil_inspection_supported(self) -> bool: @@ -845,28 +870,17 @@ def is_oil_inspection_supported(self) -> bool: """ if not self.has_combustion_engine(): return False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.DAYS_TO_OIL_INSPECTION in self.attrs.get("StoredVehicleDataResponseParsed"): - if ( - self.attrs.get("StoredVehicleDataResponseParsed").get(P.DAYS_TO_OIL_INSPECTION).get("value", None) - is not None - ): - return True - return False + return is_valid_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.carCapturedTimestamp") @property def oil_inspection_distance(self): """Return distance left for oil inspection.""" - return -int( - self.attrs.get("StoredVehicleDataResponseParsed", {}).get(P.DISTANCE_TO_OIL_INSPECTION, {}).get("value", 0) - ) + return int(find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.oilServiceDue_km")) @property def oil_inspection_distance_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.DISTANCE_TO_OIL_INSPECTION].get( - BACKEND_RECEIVED_TIMESTAMP - ) + return find_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.carCapturedTimestamp") @property def is_oil_inspection_distance_supported(self) -> bool: @@ -877,235 +891,137 @@ def is_oil_inspection_distance_supported(self) -> bool: """ if not self.has_combustion_engine(): return False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.DISTANCE_TO_OIL_INSPECTION in self.attrs.get("StoredVehicleDataResponseParsed"): - if ( - self.attrs.get("StoredVehicleDataResponseParsed") - .get(P.DISTANCE_TO_OIL_INSPECTION) - .get("value", None) - is not None - ): - return True - return False + return is_valid_path(self.attrs, "vehicleHealthInspection.maintenanceStatus.value.oilServiceDue_km") @property def adblue_level(self) -> int: """Return adblue level.""" - return int(self.attrs.get("StoredVehicleDataResponseParsed", {}).get(P.ADBLUE_LEVEL, {}).get("value", 0)) + return int(find_path(self.attrs, "measurements.rangeStatus.value.adBlueRange")) @property def adblue_level_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.ADBLUE_LEVEL].get(BACKEND_RECEIVED_TIMESTAMP) + return find_path(self.attrs, "measurements.rangeStatus.value.carCapturedTimestamp") @property def is_adblue_level_supported(self) -> bool: """Return true if adblue level is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.ADBLUE_LEVEL in self.attrs.get("StoredVehicleDataResponseParsed"): - if "value" in self.attrs.get("StoredVehicleDataResponseParsed")[P.ADBLUE_LEVEL]: - if self.attrs.get("StoredVehicleDataResponseParsed")[P.ADBLUE_LEVEL].get("value", 0) is not None: - return True - return False + return is_valid_path(self.attrs, "measurements.rangeStatus.value.adBlueRange") # Charger related states for EV and PHEV @property def charging(self) -> bool: """Return charging state.""" - cstate = ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("chargingStatusData", {}) - .get("chargingState", {}) - .get("content", "") - ) + cstate = find_path(self.attrs, "charging.chargingStatus.value.chargingState") return cstate == "charging" @property def charging_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("chargingStatusData", {}) - .get("chargingState", {}) - .get("timstamp") - ) + return find_path(self.attrs, "charging.chargingStatus.value.carCapturedTimestamp") @property def is_charging_supported(self) -> bool: """Return true if charging is supported.""" - if self.attrs.get("charger", False): - if "status" in self.attrs.get("charger", {}): - if "chargingStatusData" in self.attrs.get("charger")["status"]: - if "chargingState" in self.attrs.get("charger")["status"]["chargingStatusData"]: - return True - return False + return is_valid_path(self.attrs, "charging.chargingStatus.value.chargingState") @property def battery_level(self) -> int: """Return battery level.""" - return int( - self.attrs.get("charger").get("status").get("batteryStatusData").get("stateOfCharge").get("content", 0) - ) + return int(find_path(self.attrs, "charging.batteryStatus.value.currentSOC_pct")) @property def battery_level_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("charger").get("status").get("batteryStatusData").get("stateOfCharge").get("timestamp") + return find_path(self.attrs, "charging.batteryStatus.value.carCapturedTimestamp") @property def is_battery_level_supported(self) -> bool: """Return true if battery level is supported.""" - if self.attrs.get("charger", False): - if "status" in self.attrs.get("charger"): - if "batteryStatusData" in self.attrs.get("charger")["status"]: - if "stateOfCharge" in self.attrs.get("charger")["status"]["batteryStatusData"]: - return True - return False + return is_valid_path(self.attrs, "charging.batteryStatus.value.currentSOC_pct") @property def charge_max_ampere(self) -> str | int: """Return charger max ampere setting.""" - value = int(self.attrs.get("charger").get("settings").get("maxChargeCurrent").get("content")) - if value == 254: - return "Maximum" - if value == 252: - return "Reduced" - if value == 0: - return "Unknown" - else: - return value + value = find_path(self.attrs, "charging.chargingSettings.value.maxChargeCurrentAC") + return value @property def charge_max_ampere_last_updated(self) -> datetime: """Return charger max ampere last updated.""" - return self.attrs.get("charger").get("settings").get("maxChargeCurrent").get("timestamp") + return find_path(self.attrs, "charging.chargingSettings.value.carCapturedTimestamp") @property def is_charge_max_ampere_supported(self) -> bool: """Return true if Charger Max Ampere is supported.""" - if self.attrs.get("charger", False): - if "settings" in self.attrs.get("charger", {}): - if "maxChargeCurrent" in self.attrs.get("charger", {})["settings"]: - return True - return False + return is_valid_path(self.attrs, "charging.chargingSettings.value.maxChargeCurrentAC") @property def charging_cable_locked(self) -> bool: """Return plug locked state.""" - response = self.attrs.get("charger")["status"]["plugStatusData"]["lockState"].get("content", 0) + response = find_path(self.attrs, "charging.plugStatus.value.plugLockState") return response == "locked" @property def charging_cable_locked_last_updated(self) -> datetime: """Return plug locked state.""" - return self.attrs.get("charger")["status"]["plugStatusData"]["lockState"].get("timestamp") + return find_path(self.attrs, "charging.plugStatus.value.carCapturedTimestamp") @property def is_charging_cable_locked_supported(self) -> bool: """Return true if plug locked state is supported.""" - if self.attrs.get("charger", False): - if "status" in self.attrs.get("charger", {}): - if "plugStatusData" in self.attrs.get("charger").get("status", {}): - if "lockState" in self.attrs.get("charger")["status"].get("plugStatusData", {}): - return True - return False + return is_valid_path(self.attrs, "charging.plugStatus.value.plugLockState") @property def charging_cable_connected(self) -> bool: """Return plug connected state.""" - response = self.attrs.get("charger")["status"]["plugStatusData"]["plugState"].get("content", 0) + response = find_path(self.attrs, "charging.plugStatus.value.plugConnectionState") return response == "connected" @property def charging_cable_connected_last_updated(self) -> datetime: """Return plug connected state last updated.""" - return self.attrs.get("charger")["status"]["plugStatusData"]["plugState"].get("timestamp") + return find_path(self.attrs, "charging.plugStatus.value.carCapturedTimestamp") @property def is_charging_cable_connected_supported(self) -> bool: - """Return true if charging cable connected is supported.""" - if self.attrs.get("charger", False): - if "status" in self.attrs.get("charger", {}): - if "plugStatusData" in self.attrs.get("charger").get("status", {}): - if "plugState" in self.attrs.get("charger")["status"].get("plugStatusData", {}): - return True - return False + """Return true if supported.""" + return is_valid_path(self.attrs, "charging.plugStatus.value.plugConnectionState") @property def charging_time_left(self) -> int: """Return minutes to charging complete.""" - if self.external_power: - minutes = ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("batteryStatusData", {}) - .get("remainingChargingTime", {}) - .get("content", 0) - ) - if minutes: - try: - if minutes == -1: - return 0 - if minutes == 65535: - return 0 - return minutes - except Exception: - pass - return 0 + return int(find_path(self.attrs, "charging.chargingStatus.value.remainingChargingTimeToComplete_min")) @property def charging_time_left_last_updated(self) -> datetime: """Return minutes to charging complete last updated.""" - return ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("batteryStatusData", {}) - .get("remainingChargingTime", {}) - .get("timestamp") - ) + return find_path(self.attrs, "charging.chargingStatus.value.carCapturedTimestamp") @property def is_charging_time_left_supported(self) -> bool: """Return true if charging is supported.""" - return self.is_charging_supported + return is_valid_path(self.attrs, "charging.chargingStatus.value.remainingChargingTimeToComplete_min") @property def external_power(self) -> bool: """Return true if external power is connected.""" - check = ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("chargingStatusData", {}) - .get("externalPowerSupplyState", {}) - .get("content", "") - ) - return check in ["stationConnected", "available"] + check = find_path(self.attrs, "charging.plugStatus.value.externalPower") + return check in ["stationConnected", "available", "ready"] @property def external_power_last_updated(self) -> datetime: """Return external power last updated.""" - return ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("chargingStatusData", {}) - .get("externalPowerSupplyState", {}) - .get("timestamp") - ) + return find_path(self.attrs, "charging.plugStatus.value.carCapturedTimestamp") @property def is_external_power_supported(self) -> bool: """External power supported.""" - return ( - self.attrs.get("charger", {}) - .get("status", {}) - .get("chargingStatusData", {}) - .get("externalPowerSupplyState", False) - ) + return is_valid_path(self.attrs, "charging.plugStatus.value.externalPower") @property def energy_flow(self): + # TODO untouched """Return true if energy is flowing through charging port.""" check = ( self.attrs.get("charger", {}) @@ -1118,6 +1034,7 @@ def energy_flow(self): @property def energy_flow_last_updated(self) -> datetime: + # TODO untouched """Return energy flow last updated.""" return ( self.attrs.get("charger", {}) @@ -1129,6 +1046,7 @@ def energy_flow_last_updated(self) -> datetime: @property def is_energy_flow_supported(self) -> bool: + # TODO untouched """Energy flow supported.""" return self.attrs.get("charger", {}).get("status", {}).get("chargingStatusData", {}).get("energyFlow", False) @@ -1141,10 +1059,9 @@ def position(self) -> dict[str, str | float | None]: if self.vehicle_moving: output = {"lat": None, "lng": None, "timestamp": None} else: - pos_obj = self.attrs.get("findCarResponse", {}) - lat = int(pos_obj.get("Position").get("carCoordinate").get("latitude")) / 1000000 - lng = int(pos_obj.get("Position").get("carCoordinate").get("longitude")) / 1000000 - parking_time = pos_obj.get("parkingTimeUTC") + lat = float(find_path(self.attrs, "parkingposition.lat")) + lng = float(find_path(self.attrs, "parkingposition.lon")) + parking_time = find_path(self.attrs, "parkingposition.carCapturedTimestamp") output = {"lat": lat, "lng": lng, "timestamp": parking_time} except Exception: output = { @@ -1156,22 +1073,25 @@ def position(self) -> dict[str, str | float | None]: @property def position_last_updated(self) -> datetime: """Return position last updated.""" - return self.attrs.get("findCarResponse", {}).get("Position", {}).get("timestampTssReceived") + return find_path(self.attrs, "parkingposition.carCapturedTimestamp") @property def is_position_supported(self) -> bool: - """Return true if carfinder_v1 service is active.""" - return self._services.get("carfinder_v1", {}).get("active", False) or self.attrs.get("isMoving", False) + """Return true if position is available.""" + return is_valid_path(self.attrs, "parkingposition.carCapturedTimestamp") @property def vehicle_moving(self) -> bool: """Return true if vehicle is moving.""" - return self.attrs.get("isMoving", False) + # there is not "isMoving" property anymore in VW's API, so we just take the absence of position data as the indicator + ## not used currently, because we only update parkingposition when the last trip's data has changed, because + ## of its rate limiting, so this is not reliable at all... + return not is_valid_path(self.attrs, "parkingposition.lat") @property def vehicle_moving_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("findCarResponse", {}).get("Position", {}).get("timestampTssReceived") + return find_path(self.attrs, "parkingposition.carCapturedTimestamp") @property def is_vehicle_moving_supported(self) -> bool: @@ -1181,19 +1101,23 @@ def is_vehicle_moving_supported(self) -> bool: @property def parking_time(self) -> str: """Return timestamp of last parking time.""" - park_time_utc: datetime = self.attrs.get("findCarResponse", {}).get("parkingTimeUTC", "Unknown") - park_time = park_time_utc.replace(tzinfo=timezone.utc).astimezone(tz=None) - return park_time.strftime("%Y-%m-%d %H:%M:%S") + parking_time_path = "parkingposition.carCapturedTimestamp" + if is_valid_path(self.attrs, parking_time_path): + park_time_utc: datetime = find_path(self.attrs, parking_time_path) + park_time = park_time_utc.replace(tzinfo=timezone.utc).astimezone(tz=None) + return park_time.strftime("%Y-%m-%d %H:%M:%S") + + return None @property def parking_time_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("findCarResponse", {}).get("Position", {}).get(BACKEND_RECEIVED_TIMESTAMP) + return find_path(self.attrs, "parkingposition.carCapturedTimestamp") @property def is_parking_time_supported(self) -> bool: """Return true if vehicle parking timestamp is supported.""" - return "parkingTimeUTC" in self.attrs.get("findCarResponse", {}) + return is_valid_path(self.attrs, "parkingposition.carCapturedTimestamp") # Vehicle fuel level and range @property @@ -1203,23 +1127,12 @@ def electric_range(self) -> int: :return: """ - value = NO_VALUE - if self.is_primary_drive_electric(): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.PRIMARY_RANGE].get("value", UNSUPPORTED) - - elif self.is_secondary_drive_electric(): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.SECONDARY_RANGE].get("value", UNSUPPORTED) - return int(value) + return int(find_path(self.attrs, "measurements.rangeStatus.value.electricRange")) @property def electric_range_last_updated(self) -> datetime: """Return electric range last updated.""" - if self.is_primary_drive_electric(): - return self.attrs.get("StoredVehicleDataResponseParsed")[P.PRIMARY_RANGE].get(BACKEND_RECEIVED_TIMESTAMP) - - elif self.is_secondary_drive_electric(): - return self.attrs.get("StoredVehicleDataResponseParsed")[P.SECONDARY_RANGE].get(BACKEND_RECEIVED_TIMESTAMP) - raise ValueError() + return find_path(self.attrs, "measurements.rangeStatus.value.carCapturedTimestamp") @property def is_electric_range_supported(self) -> bool: @@ -1228,14 +1141,7 @@ def is_electric_range_supported(self) -> bool: :return: """ - supported = False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if self.is_primary_drive_electric(): - supported = True - - elif self.is_secondary_drive_electric(): - supported = True - return supported + return is_valid_path(self.attrs, "measurements.rangeStatus.value.electricRange") @property def combustion_range(self) -> int: @@ -1244,23 +1150,18 @@ def combustion_range(self) -> int: :return: """ - value = NO_VALUE - if self.is_primary_drive_combustion(): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.PRIMARY_RANGE].get("value", NO_VALUE) - - elif self.is_secondary_drive_combustion(): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.SECONDARY_RANGE].get("value", NO_VALUE) - return int(value) + DIESEL_RANGE = "measurements.rangeStatus.value.dieselRange" + GASOLINE_RANGE = "measurements.rangeStatus.value.gasolineRange" + if is_valid_path(self.attrs, DIESEL_RANGE): + return int(find_path(self.attrs, DIESEL_RANGE)) + if is_valid_path(self.attrs, GASOLINE_RANGE): + return int(find_path(self.attrs, GASOLINE_RANGE)) + return -1 @property def combustion_range_last_updated(self) -> datetime | None: """Return combustion engine range last updated.""" - value = None - if self.is_primary_drive_combustion(): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.PRIMARY_RANGE].get(BACKEND_RECEIVED_TIMESTAMP) - elif self.is_secondary_drive_combustion(): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.SECONDARY_RANGE].get(BACKEND_RECEIVED_TIMESTAMP) - return value + return find_path(self.attrs, "measurements.rangeStatus.value.carCapturedTimestamp") @property def is_combustion_range_supported(self) -> bool: @@ -1269,13 +1170,9 @@ def is_combustion_range_supported(self) -> bool: :return: """ - supported = False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if self.is_primary_drive_combustion(): - supported = True - elif self.is_secondary_drive_combustion(): - supported = True - return supported + return is_valid_path(self.attrs, "measurements.rangeStatus.value.dieselRange") or is_valid_path( + self.attrs, "measurements.rangeStatus.value.gasolineRange" + ) @property def combined_range(self) -> int: @@ -1284,18 +1181,12 @@ def combined_range(self) -> int: :return: """ - value = -1 - if P.COMBINED_RANGE in self.attrs.get("StoredVehicleDataResponseParsed"): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.COMBINED_RANGE].get("value", NO_VALUE) - return int(value) + return int(find_path(self.attrs, "measurements.rangeStatus.value.totalRange_km")) @property def combined_range_last_updated(self) -> datetime | None: """Return combined range last updated.""" - value = None - if P.COMBINED_RANGE in self.attrs.get("StoredVehicleDataResponseParsed"): - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.COMBINED_RANGE].get(BACKEND_RECEIVED_TIMESTAMP) - return value + return find_path(self.attrs, "measurements.rangeStatus.value.carCapturedTimestamp") @property def is_combined_range_supported(self) -> bool: @@ -1304,10 +1195,7 @@ def is_combined_range_supported(self) -> bool: :return: """ - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.COMBINED_RANGE in self.attrs.get("StoredVehicleDataResponseParsed"): - return self.is_electric_range_supported and self.is_combustion_range_supported - return False + return is_valid_path(self.attrs, "measurements.rangeStatus.value.totalRange_km") @property def fuel_level(self) -> int: @@ -1316,20 +1204,24 @@ def fuel_level(self) -> int: :return: """ - value = -1 - if P.FUEL_LEVEL in self.attrs.get("StoredVehicleDataResponseParsed"): - if "value" in self.attrs.get("StoredVehicleDataResponseParsed")[P.FUEL_LEVEL]: - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.FUEL_LEVEL].get("value", 0) - return int(value) + fuel_level_pct = "" + if is_valid_path(self.attrs, "fuelStatus.rangeStatus.value.primaryEngine.currentFuelLevel_pct"): + fuel_level_pct = find_path(self.attrs, "fuelStatus.rangeStatus.value.primaryEngine.currentFuelLevel_pct") + + if is_valid_path(self.attrs, "measurements.fuelLevelStatus.value.currentFuelLevel_pct"): + fuel_level_pct = find_path(self.attrs, "measurements.fuelLevelStatus.value.currentFuelLevel_pct") + return int(fuel_level_pct) @property def fuel_level_last_updated(self) -> datetime: """Return fuel level last updated.""" - value = datetime.now() - if P.FUEL_LEVEL in self.attrs.get("StoredVehicleDataResponseParsed"): - if "value" in self.attrs.get("StoredVehicleDataResponseParsed")[P.FUEL_LEVEL]: - value = self.attrs.get("StoredVehicleDataResponseParsed")[P.FUEL_LEVEL].get(BACKEND_RECEIVED_TIMESTAMP) - return value + fuel_level_lastupdated = "" + if is_valid_path(self.attrs, "fuelStatus.rangeStatus.value.carCapturedTimestamp"): + fuel_level_lastupdated = find_path(self.attrs, "fuelStatus.rangeStatus.value.carCapturedTimestamp") + + if is_valid_path(self.attrs, "measurements.fuelLevelStatus.value.carCapturedTimestamp"): + fuel_level_lastupdated = find_path(self.attrs, "measurements.fuelLevelStatus.value.carCapturedTimestamp") + return fuel_level_lastupdated @property def is_fuel_level_supported(self) -> bool: @@ -1338,59 +1230,45 @@ def is_fuel_level_supported(self) -> bool: :return: """ - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FUEL_LEVEL in self.attrs.get("StoredVehicleDataResponseParsed"): - return self.is_combustion_range_supported - return False + return (is_valid_path(self.attrs, "measurements.fuelLevelStatus.value.currentFuelLevel_pct") + or is_valid_path(self.attrs, "fuelStatus.rangeStatus.value.primaryEngine.currentFuelLevel_pct")) # Climatisation settings @property def climatisation_target_temperature(self) -> float | None: """Return the target temperature from climater.""" - value = self.attrs.get("climater").get("settings").get("targetTemperature").get("content") - if value: - reply = float((value / 10) - 273) - self._climatisation_target_temperature = reply - return reply - else: - return None + # TODO should we handle Fahrenheit?? + return int(find_path(self.attrs, "climatisation.climatisationSettings.value.targetTemperature_C")) @property def climatisation_target_temperature_last_updated(self) -> datetime: """Return the target temperature from climater last updated.""" - return self.attrs.get("climater").get("settings").get("targetTemperature").get(BACKEND_RECEIVED_TIMESTAMP) + return find_path(self.attrs, "climatisation.climatisationSettings.value.carCapturedTimestamp") @property def is_climatisation_target_temperature_supported(self) -> bool: """Return true if climatisation target temperature is supported.""" - if self.attrs.get("climater", False): - if "settings" in self.attrs.get("climater", {}): - if "targetTemperature" in self.attrs.get("climater", {})["settings"]: - return True - return False + return is_valid_path(self.attrs, "climatisation.climatisationSettings.value.targetTemperature_C") @property def climatisation_without_external_power(self): """Return state of climatisation from battery power.""" - return self.attrs.get("climater").get("settings").get("climatisationWithoutHVpower").get("content", False) + return find_path(self.attrs, "climatisation.climatisationSettings.value.climatisationWithoutExternalPower") @property def climatisation_without_external_power_last_updated(self) -> datetime: """Return state of climatisation from battery power last updated.""" - return self.attrs.get("climater").get("settings").get("climatisationWithoutHVpower").get("timestamp") + return find_path(self.attrs, "climatisation.climatisationSettings.value.carCapturedTimestamp") @property def is_climatisation_without_external_power_supported(self) -> bool: """Return true if climatisation on battery power is supported.""" - if self.attrs.get("climater", False): - if "settings" in self.attrs.get("climater", {}): - if "climatisationWithoutHVpower" in self.attrs.get("climater", {})["settings"]: - return True - return False + return is_valid_path(self.attrs, "climatisation.climatisationSettings.value.climatisationWithoutExternalPower") @property def outside_temperature(self) -> float | bool: # FIXME should probably be Optional[float] instead """Return outside temperature.""" + # TODO not found yet response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.OUTSIDE_TEMPERATURE].get("value", None)) if response is not None: return round(float((response / 10) - 273.15), 1) @@ -1400,11 +1278,13 @@ def outside_temperature(self) -> float | bool: # FIXME should probably be Optio @property def outside_temperature_last_updated(self) -> datetime: """Return outside temperature last updated.""" + # TODO not found yet return self.attrs.get("StoredVehicleDataResponseParsed")[P.OUTSIDE_TEMPERATURE].get(BACKEND_RECEIVED_TIMESTAMP) @property def is_outside_temperature_supported(self) -> bool: """Return true if outside temp is supported.""" + # TODO not found yet if self.attrs.get("StoredVehicleDataResponseParsed", False): if P.OUTSIDE_TEMPERATURE in self.attrs.get("StoredVehicleDataResponseParsed"): if "value" in self.attrs.get("StoredVehicleDataResponseParsed")[P.OUTSIDE_TEMPERATURE]: @@ -1415,6 +1295,7 @@ def is_outside_temperature_supported(self) -> bool: @property def electric_climatisation(self) -> bool: """Return status of climatisation.""" + # TODO not found yet climatisation_type = ( self.attrs.get("climater", {}).get("settings", {}).get("heaterSource", {}).get("content", "") ) @@ -1430,6 +1311,7 @@ def electric_climatisation(self) -> bool: @property def electric_climatisation_last_updated(self) -> datetime: """Return status of climatisation last updated.""" + # TODO not found yet return ( self.attrs.get("climater", {}) .get("status", {}) @@ -1441,130 +1323,90 @@ def electric_climatisation_last_updated(self) -> datetime: @property def is_electric_climatisation_supported(self) -> bool: """Return true if vehicle has climater.""" + # TODO not found yet return self.is_climatisation_supported @property def auxiliary_climatisation(self) -> bool: """Return status of auxiliary climatisation.""" - climatisation_type = ( - self.attrs.get("climater", {}).get("settings", {}).get("heaterSource", {}).get("content", "") - ) - status = ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("climatisationStatusData", {}) - .get("climatisationState", {}) - .get("content", "") - ) - if status in ["heating", "heatingAuxiliary", "on"] and climatisation_type == "auxiliary": - return True - elif status in ["heatingAuxiliary"] and climatisation_type == "electric": + climatisation_state = find_path(self.attrs, "climatisation.climatisationStatus.value.climatisationState") + if climatisation_state in ["heating", "heatingAuxiliary", "on"]: return True - else: - return False @property def auxiliary_climatisation_last_updated(self) -> datetime: """Return status of auxiliary climatisation last updated.""" - return ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("climatisationStatusData", {}) - .get("climatisationState", {}) - .get(BACKEND_RECEIVED_TIMESTAMP) - ) + return find_path(self.attrs, "climatisation.climatisationStatus.value.carCapturedTimestamp") @property def is_auxiliary_climatisation_supported(self) -> bool: """Return true if vehicle has auxiliary climatisation.""" - if self._services.get("rclima_v1", False): - functions = self._services.get("rclima_v1", {}).get("operations", []) - if "P_START_CLIMA_AU" in functions: - return True - return False + return is_valid_path(self.attrs, "climatisation.climatisationStatus.value.climatisationState") @property def is_climatisation_supported(self) -> bool: """Return true if climatisation has State.""" - response = ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("climatisationStatusData", {}) - .get("climatisationState", {}) - .get("content", "") - ) - return response != "" + return is_valid_path(self.attrs, "climatisation.climatisationStatus.value.climatisationState") @property def is_climatisation_supported_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("climatisationStatusData", {}) - .get("climatisationState", {}) - .get(BACKEND_RECEIVED_TIMESTAMP) - ) + return find_path(self.attrs, "climatisation.climatisationStatus.value.carCapturedTimestamp") + + @property + def window_heater_front(self) -> bool: + """Return status of front window heater.""" + window_heating_status = find_path(self.attrs, "climatisation.windowHeatingStatus.value.windowHeatingStatus") + for window_heating_state in window_heating_status: + if window_heating_state["windowLocation"] == "front": + return window_heating_state["windowHeatingState"] == "on" + + return False + + @property + def window_heater_front_last_updated(self) -> datetime: + """Return front window heater last updated.""" + return find_path(self.attrs, "climatisation.windowHeatingStatus.value.carCapturedTimestamp") + + @property + def is_window_heater_front_supported(self) -> bool: + """Return true if vehicle has heater.""" + return is_valid_path(self.attrs, "climatisation.windowHeatingStatus.value.windowHeatingStatus") + + @property + def window_heater_back(self) -> bool: + """Return status of rear window heater.""" + window_heating_status = find_path(self.attrs, "climatisation.windowHeatingStatus.value.windowHeatingStatus") + for window_heating_state in window_heating_status: + if window_heating_state["windowLocation"] == "rear": + return window_heating_state["windowHeatingState"] == "on" + + return False + + @property + def window_heater_back_last_updated(self) -> datetime: + """Return front window heater last updated.""" + return find_path(self.attrs, "climatisation.windowHeatingStatus.value.carCapturedTimestamp") + + @property + def is_window_heater_back_supported(self) -> bool: + """Return true if vehicle has heater.""" + return is_valid_path(self.attrs, "climatisation.windowHeatingStatus.value.windowHeatingStatus") @property def window_heater(self) -> bool: """Return status of window heater.""" - ret = False - status_front = ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("windowHeatingStatusData", {}) - .get("windowHeatingStateFront", {}) - .get("content", "") - ) - if status_front == "on": - ret = True - - status_rear = ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("windowHeatingStatusData", {}) - .get("windowHeatingStateRear", {}) - .get("content", "") - ) - if status_rear == "on": - ret = True - return ret + return self.window_heater_front or self.window_heater_back @property def window_heater_last_updated(self) -> datetime: - """Return window heater last updated.""" - front = ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("windowHeatingStatusData", {}) - .get("windowHeatingStateFront", {}) - .get(BACKEND_RECEIVED_TIMESTAMP) - ) - if front is not None: - return front - - return ( - self.attrs.get("climater", {}) - .get("status", {}) - .get("windowHeatingStatusData", {}) - .get("windowHeatingStateRear", {}) - .get(BACKEND_RECEIVED_TIMESTAMP) - ) + """Return front window heater last updated.""" + return self.window_heater_front_last_updated @property def is_window_heater_supported(self) -> bool: """Return true if vehicle has heater.""" - if self.is_electric_climatisation_supported: - if self.attrs.get("climater", {}).get("status", {}).get("windowHeatingStatusData", {}).get( - "windowHeatingStateFront", {} - ).get("content", "") in ["on", "off"]: - return True - if self.attrs.get("climater", {}).get("status", {}).get("windowHeatingStatusData", {}).get( - "windowHeatingStateRear", {} - ).get("content", "") in ["on", "off"]: - return True - return False + return self.is_window_heater_front_supported # Parking heater, "legacy" auxiliary climatisation @property @@ -1664,12 +1506,7 @@ def windows_closed(self) -> bool: @property def windows_closed_last_updated(self) -> datetime: """Return timestamp for windows state last updated.""" - return ( - self.attrs.get("StoredVehicleDataResponseParsed", {}) - .get("StoredVehicleDataResponseParsed", {}) - .get(P.FRONT_LEFT_WINDOW_CLOSED, {}) - .get("BACKEND_RECEIVED_TIMESTAMP") - ) + return self.window_closed_left_front_last_updated @property def is_windows_closed_supported(self) -> bool: @@ -1688,28 +1525,25 @@ def window_closed_left_front(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_WINDOW_CLOSED].get("value", 0)) - return response == CLOSED_STATE + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "frontLeft": + return "closed" in window["status"] + return False @property def window_closed_left_front_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("StoredVehicleDataResponseParsed", {}) - .get("StoredVehicleDataResponseParsed", {}) - .get(P.FRONT_LEFT_WINDOW_CLOSED, {}) - .get("BACKEND_RECEIVED_TIMESTAMP") - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_window_closed_left_front_supported(self) -> bool: - """Return true if window state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FRONT_LEFT_WINDOW_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return ( - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_WINDOW_CLOSED].get("value", 0)) - != 0 - ) + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.windows"): + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "frontLeft" and "unsupported" not in window["status"]: + return True return False @property @@ -1719,28 +1553,25 @@ def window_closed_right_front(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_RIGHT_WINDOW_CLOSED].get("value", 0)) - return response == CLOSED_STATE + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "frontRight": + return "closed" in window["status"] + return False @property def window_closed_right_front_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("StoredVehicleDataResponseParsed", {}) - .get("StoredVehicleDataResponseParsed", {}) - .get(P.FRONT_RIGHT_WINDOW_CLOSED, {}) - .get("BACKEND_RECEIVED_TIMESTAMP") - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_window_closed_right_front_supported(self) -> bool: - """Return true if window state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FRONT_RIGHT_WINDOW_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return ( - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_RIGHT_WINDOW_CLOSED].get("value", 0)) - != 0 - ) + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.windows"): + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "frontRight" and "unsupported" not in window["status"]: + return True return False @property @@ -1750,28 +1581,25 @@ def window_closed_left_back(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_LEFT_WINDOW_CLOSED].get("value", 0)) - return response == CLOSED_STATE + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "rearLeft": + return "closed" in window["status"] + return False @property def window_closed_left_back_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("StoredVehicleDataResponseParsed", {}) - .get("StoredVehicleDataResponseParsed", {}) - .get(P.REAR_LEFT_WINDOW_CLOSED, {}) - .get("BACKEND_RECEIVED_TIMESTAMP") - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_window_closed_left_back_supported(self) -> bool: - """Return true if window state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.REAR_LEFT_WINDOW_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return ( - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_LEFT_WINDOW_CLOSED].get("value", 0)) - != 0 - ) + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.windows"): + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "rearLeft" and "unsupported" not in window["status"]: + return True return False @property @@ -1781,28 +1609,25 @@ def window_closed_right_back(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_RIGHT_WINDOW_CLOSED].get("value", 0)) - return response == CLOSED_STATE + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "rearRight": + return "closed" in window["status"] + return False @property def window_closed_right_back_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("StoredVehicleDataResponseParsed", {}) - .get("StoredVehicleDataResponseParsed", {}) - .get(P.REAR_RIGHT_WINDOW_CLOSED, {}) - .get("BACKEND_RECEIVED_TIMESTAMP") - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_window_closed_right_back_supported(self) -> bool: - """Return true if window state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.REAR_RIGHT_WINDOW_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return ( - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_RIGHT_WINDOW_CLOSED].get("value", 0)) - != 0 - ) + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.windows"): + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "rearRight" and "unsupported" not in window["status"]: + return True return False @property @@ -1812,25 +1637,53 @@ def sunroof_closed(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.SUNROOF_CLOSED].get("value", 0)) - return response == CLOSED_STATE + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "sunRoof": + return "closed" in window["status"] + return False @property def sunroof_closed_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return ( - self.attrs.get("StoredVehicleDataResponseParsed", {}) - .get("StoredVehicleDataResponseParsed", {}) - .get(P.SUNROOF_CLOSED, {}) - .get("BACKEND_RECEIVED_TIMESTAMP") - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_sunroof_closed_supported(self) -> bool: - """Return true if sunroof state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.SUNROOF_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return int(self.attrs.get("StoredVehicleDataResponseParsed")[P.SUNROOF_CLOSED].get("value", 0)) != 0 + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.windows"): + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "sunRoof" and "unsupported" not in window["status"]: + return True + return False + + @property + def roof_cover_closed(self) -> bool: + """ + Return roof cover closed state. + + :return: + """ + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "roofCover": + return "closed" in window["status"] + return False + + @property + def roof_cover_closed_last_updated(self) -> datetime: + """Return attribute last updated timestamp.""" + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") + + @property + def is_roof_cover_closed_supported(self) -> bool: + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + windows = find_path(self.attrs, "access.accessStatus.value.windows") + for window in windows: + if window["name"] == "roofCover" and "unsupported" not in window["status"]: + return True return False # Locks @@ -1846,29 +1699,17 @@ def door_locked(self) -> bool: :return: """ - return all( - s == LOCKED_STATE - for s in [ - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_DOOR_LOCK].get("value", 0)), - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_LEFT_DOOR_LOCK].get("value", 0)), - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_RIGHT_DOOR_LOCK].get("value", 0)), - int(self.attrs.get("StoredVehicleDataResponseParsed")[P.READ_RIGHT_DOOR_LOCK].get("value", 0)), - ] - ) + return find_path(self.attrs, "access.accessStatus.value.doorLockStatus") == "locked" @property def door_locked_last_updated(self) -> datetime: """Return door lock last updated.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_DOOR_LOCK].get( - "BACKEND_RECEIVED_TIMESTAMP" - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def door_locked_sensor_last_updated(self) -> datetime: """Return door lock last updated.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_DOOR_LOCK].get( - "BACKEND_RECEIVED_TIMESTAMP" - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_door_locked_supported(self) -> bool: @@ -1877,13 +1718,7 @@ def is_door_locked_supported(self) -> bool: :return: """ - # First check that the service is actually enabled - if not self._services.get("rlu_v1", {}).get("active", False): - return False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FRONT_LEFT_DOOR_LOCK in self.attrs.get("StoredVehicleDataResponseParsed"): - return True - return False + return is_valid_path(self.attrs, "access.accessStatus.value.doorLockStatus") @property def is_door_locked_sensor_supported(self) -> bool: @@ -1892,13 +1727,7 @@ def is_door_locked_sensor_supported(self) -> bool: :return: """ - # Use real lock if the service is actually enabled - if self._services.get("rlu_v1", {}).get("active", False): - return False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FRONT_LEFT_DOOR_LOCK in self.attrs.get("StoredVehicleDataResponseParsed"): - return True - return False + return is_valid_path(self.attrs, "access.accessStatus.value.doorLockStatus") @property def trunk_locked(self) -> bool: @@ -1907,13 +1736,16 @@ def trunk_locked(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.TRUNK_LOCK].get("value", 0)) - return response == LOCKED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "trunk": + return "locked" in door["status"] + return False @property def trunk_locked_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.TRUNK_LOCK].get("BACKEND_RECEIVED_TIMESTAMP") + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_trunk_locked_supported(self) -> bool: @@ -1922,11 +1754,11 @@ def is_trunk_locked_supported(self) -> bool: :return: """ - if not self._services.get("rlu_v1", {}).get("active", False): - return False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.TRUNK_LOCK in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "trunk" and "unsupported" not in door["status"]: + return True return False @property @@ -1936,13 +1768,16 @@ def trunk_locked_sensor(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.TRUNK_LOCK].get("value", 0)) - return response == LOCKED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "trunk": + return "locked" in door["status"] + return False @property def trunk_locked_sensor_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.TRUNK_LOCK].get("BACKEND_RECEIVED_TIMESTAMP") + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_trunk_locked_sensor_supported(self) -> bool: @@ -1951,31 +1786,40 @@ def is_trunk_locked_sensor_supported(self) -> bool: :return: """ - if self._services.get("rlu_v1", {}).get("active", False): - return False - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.TRUNK_LOCK in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "trunk" and "unsupported" not in door["status"]: + return True return False # Doors, hood and trunk @property def hood_closed(self) -> bool: - """Return true if hood is closed.""" - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.HOOD_CLOSED].get("value", 0)) - return response == CLOSED_STATE + """ + Return hood closed state. + + :return: + """ + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "bonnet": + return "closed" in door["status"] + return False @property def hood_closed_last_updated(self) -> datetime: - """Return hood closed last updated.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.HOOD_CLOSED].get("BACKEND_RECEIVED_TIMESTAMP") + """Return attribute last updated timestamp.""" + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_hood_closed_supported(self) -> bool: - """Return true if hood state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.HOOD_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed", {}): - return int(self.attrs.get("StoredVehicleDataResponseParsed")[P.HOOD_CLOSED].get("value", 0)) != 0 + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "bonnet" and "unsupported" not in door["status"]: + return True return False @property @@ -1985,22 +1829,25 @@ def door_closed_left_front(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_DOOR_CLOSED].get("value", 0)) - return response == CLOSED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "frontLeft": + return "closed" in door["status"] + return False @property def door_closed_left_front_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_LEFT_DOOR_CLOSED].get( - "BACKEND_RECEIVED_TIMESTAMP" - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_door_closed_left_front_supported(self) -> bool: """Return true if supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FRONT_LEFT_DOOR_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "frontLeft" and "unsupported" not in door["status"]: + return True return False @property @@ -2010,22 +1857,25 @@ def door_closed_right_front(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_RIGHT_DOOR_CLOSED].get("value", 0)) - return response == CLOSED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "frontRight": + return "closed" in door["status"] + return False @property def door_closed_right_front_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.FRONT_RIGHT_DOOR_CLOSED].get( - "BACKEND_RECEIVED_TIMESTAMP" - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_door_closed_right_front_supported(self) -> bool: """Return true if supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.FRONT_RIGHT_DOOR_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "frontRight" and "unsupported" not in door["status"]: + return True return False @property @@ -2035,22 +1885,25 @@ def door_closed_left_back(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_LEFT_DOOR_CLOSED].get("value", 0)) - return response == CLOSED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "rearLeft": + return "closed" in door["status"] + return False @property def door_closed_left_back_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_LEFT_DOOR_CLOSED].get( - "BACKEND_RECEIVED_TIMESTAMP" - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_door_closed_left_back_supported(self) -> bool: """Return true if supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.REAR_LEFT_DOOR_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "rearLeft" and "unsupported" not in door["status"]: + return True return False @property @@ -2060,45 +1913,53 @@ def door_closed_right_back(self) -> bool: :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_RIGHT_DOOR_CLOSED].get("value", 0)) - return response == CLOSED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "rearRight": + return "closed" in door["status"] + return False @property def door_closed_right_back_last_updated(self) -> datetime: """Return attribute last updated timestamp.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.REAR_RIGHT_DOOR_CLOSED].get( - "BACKEND_RECEIVED_TIMESTAMP" - ) + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_door_closed_right_back_supported(self) -> bool: """Return true if supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.REAR_RIGHT_DOOR_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "rearRight" and "unsupported" not in door["status"]: + return True return False @property def trunk_closed(self) -> bool: """ - Return state of trunk closed. + Return trunk closed state. :return: """ - response = int(self.attrs.get("StoredVehicleDataResponseParsed")[P.TRUNK_CLOSED].get("value", 0)) - return response == CLOSED_STATE + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "trunk": + return "closed" in door["status"] + return False @property def trunk_closed_last_updated(self) -> datetime: - """Return trunk closed last updated.""" - return self.attrs.get("StoredVehicleDataResponseParsed")[P.TRUNK_CLOSED].get("BACKEND_RECEIVED_TIMESTAMP") + """Return attribute last updated timestamp.""" + return find_path(self.attrs, "access.accessStatus.value.carCapturedTimestamp") @property def is_trunk_closed_supported(self) -> bool: - """Return true if trunk closed state is supported.""" - if self.attrs.get("StoredVehicleDataResponseParsed", False): - if P.TRUNK_CLOSED in self.attrs.get("StoredVehicleDataResponseParsed"): - return True + """Return true if supported.""" + if is_valid_path(self.attrs, "access.accessStatus.value.doors"): + doors = find_path(self.attrs, "access.accessStatus.value.doors") + for door in doors: + if door["name"] == "trunk" and "unsupported" not in door["status"]: + return True return False # Departure timers @@ -2256,7 +2117,7 @@ def trip_last_entry(self): :return: """ - return self.attrs.get("tripstatistics", {}) + return self.attrs.get("trip_last", {}) @property def trip_last_average_speed(self): @@ -2265,12 +2126,12 @@ def trip_last_average_speed(self): :return: """ - return self.trip_last_entry.get("averageSpeed") + return find_path(self.attrs, "trip_last.averageSpeed_kmph") @property def trip_last_average_speed_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_average_speed_supported(self) -> bool: @@ -2279,8 +2140,10 @@ def is_trip_last_average_speed_supported(self) -> bool: :return: """ - response = self.trip_last_entry - return response and type(response.get("averageSpeed", None)) in (float, int) + return ( + is_valid_path(self.attrs, "trip_last.averageSpeed_kmph") and + type(find_path(self.attrs, "trip_last.averageSpeed_kmph")) in (float, int) + ) @property def trip_last_average_electric_engine_consumption(self): @@ -2289,13 +2152,12 @@ def trip_last_average_electric_engine_consumption(self): :return: """ - value = self.trip_last_entry.get("averageElectricEngineConsumption") - return float(value / 10) + return float(find_path(self.attrs, "trip_last.averageElectricConsumption")) @property def trip_last_average_electric_engine_consumption_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_average_electric_engine_consumption_supported(self) -> bool: @@ -2304,8 +2166,10 @@ def is_trip_last_average_electric_engine_consumption_supported(self) -> bool: :return: """ - response = self.trip_last_entry - return response and type(response.get("averageElectricEngineConsumption", None)) in (float, int) + return ( + is_valid_path(self.attrs, "trip_last.averageElectricConsumption") and + type(find_path(self.attrs, "trip_last.averageElectricConsumption")) in (float, int) + ) @property def trip_last_average_fuel_consumption(self): @@ -2314,12 +2178,12 @@ def trip_last_average_fuel_consumption(self): :return: """ - return int(self.trip_last_entry.get("averageFuelConsumption")) / 10 + return float(find_path(self.attrs, "trip_last.averageFuelConsumption")) @property def trip_last_average_fuel_consumption_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_average_fuel_consumption_supported(self) -> bool: @@ -2328,11 +2192,9 @@ def is_trip_last_average_fuel_consumption_supported(self) -> bool: :return: """ - response = self.trip_last_entry return ( - self.has_combustion_engine() - and response - and type(response.get("averageFuelConsumption", None)) in (float, int) + is_valid_path(self.attrs, "trip_last.averageFuelConsumption") and + type(find_path(self.attrs, "trip_last.averageFuelConsumption")) in (float, int) ) @property @@ -2342,12 +2204,14 @@ def trip_last_average_auxillary_consumption(self): :return: """ + # no example verified yet return self.trip_last_entry.get("averageAuxiliaryConsumption") @property def trip_last_average_auxillary_consumption_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") + @property def is_trip_last_average_auxillary_consumption_supported(self) -> bool: @@ -2356,8 +2220,11 @@ def is_trip_last_average_auxillary_consumption_supported(self) -> bool: :return: """ - response = self.trip_last_entry - return response and type(response.get("averageAuxiliaryConsumption", None)) in (float, int) + return ( + is_valid_path(self.attrs, "trip_last.averageAuxiliaryConsumption") and + type(find_path(self.attrs, "trip_last.averageAuxiliaryConsumption")) in (float, int) + ) + @property def trip_last_average_aux_consumer_consumption(self): @@ -2366,15 +2233,13 @@ def trip_last_average_aux_consumer_consumption(self): :return: """ - value = self.trip_last_entry.get("averageAuxConsumerConsumption") - if value == 65535: - return None - return float(value / 10) + # no example verified yet + return self.trip_last_entry.get("averageAuxConsumerConsumption") @property def trip_last_average_aux_consumer_consumption_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_average_aux_consumer_consumption_supported(self) -> bool: @@ -2383,10 +2248,10 @@ def is_trip_last_average_aux_consumer_consumption_supported(self) -> bool: :return: """ - response = self.trip_last_entry - if response.get("averageAuxConsumerConsumption", 65535) == 65535: - return False - return response and type(response.get("averageAuxConsumerConsumption", None)) in (float, int) + return ( + is_valid_path(self.attrs, "trip_last.averageAuxConsumerConsumption") and + type(find_path(self.attrs, "trip_last.averageAuxConsumerConsumption")) in (float, int) + ) @property def trip_last_duration(self): @@ -2395,12 +2260,13 @@ def trip_last_duration(self): :return: """ - return self.trip_last_entry.get("traveltime") + return find_path(self.attrs, "trip_last.travelTime") + @property def trip_last_duration_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_duration_supported(self) -> bool: @@ -2409,8 +2275,11 @@ def is_trip_last_duration_supported(self) -> bool: :return: """ - response = self.trip_last_entry - return response and type(response.get("traveltime", None)) in (float, int) + return ( + is_valid_path(self.attrs, "trip_last.travelTime") and + type(find_path(self.attrs, "trip_last.travelTime")) in (float, int) + ) + @property def trip_last_length(self): @@ -2419,12 +2288,13 @@ def trip_last_length(self): :return: """ - return self.trip_last_entry.get("mileage") + return find_path(self.attrs, "trip_last.mileage_km") @property def trip_last_length_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") + @property def is_trip_last_length_supported(self) -> bool: @@ -2433,8 +2303,11 @@ def is_trip_last_length_supported(self) -> bool: :return: """ - response = self.trip_last_entry - return response and type(response.get("mileage", None)) in (float, int) + return ( + is_valid_path(self.attrs, "trip_last.mileage_km") and + type(find_path(self.attrs, "trip_last.mileage_km")) in (float, int) + ) + @property def trip_last_recuperation(self): @@ -2449,7 +2322,7 @@ def trip_last_recuperation(self): @property def trip_last_recuperation_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_recuperation_supported(self) -> bool: @@ -2475,7 +2348,7 @@ def trip_last_average_recuperation(self): @property def trip_last_average_recuperation_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_average_recuperation_supported(self) -> bool: @@ -2500,7 +2373,7 @@ def trip_last_total_electric_consumption(self): @property def trip_last_total_electric_consumption_last_updated(self) -> datetime: """Return last updated timestamp.""" - return self.trip_last_entry.get("timestamp") + return find_path(self.attrs, "trip_last.tripEndTimestamp") @property def is_trip_last_total_electric_consumption_supported(self) -> bool: @@ -2668,35 +2541,37 @@ def serialize(obj): def is_primary_drive_electric(self): """Check if primary engine is electric.""" - return ( - P.PRIMARY_DRIVE in self.attrs.get("StoredVehicleDataResponseParsed", {}) - and self.attrs.get("StoredVehicleDataResponseParsed")[P.PRIMARY_DRIVE].get("value", UNSUPPORTED) - == ENGINE_TYPE_ELECTRIC - ) + return find_path(self.attrs, "measurements.fuelLevelStatus.value.primaryEngineType") == ENGINE_TYPE_ELECTRIC + def is_secondary_drive_electric(self): """Check if secondary engine is electric.""" return ( - P.SECONDARY_DRIVE in self.attrs.get("StoredVehicleDataResponseParsed", {}) - and self.attrs.get("StoredVehicleDataResponseParsed")[P.SECONDARY_DRIVE].get("value", UNSUPPORTED) - == ENGINE_TYPE_ELECTRIC + is_valid_path(self.attrs, "measurements.fuelLevelStatus.value.primaryEngineType") and + find_path(self.attrs, "measurements.fuelLevelStatus.value.primaryEngineType") == ENGINE_TYPE_ELECTRIC ) def is_primary_drive_combustion(self): """Check if primary engine is combustion.""" - return ( - P.PRIMARY_DRIVE in self.attrs.get("StoredVehicleDataResponseParsed", {}) - and self.attrs.get("StoredVehicleDataResponseParsed")[P.PRIMARY_DRIVE].get("value", UNSUPPORTED) - in ENGINE_TYPE_COMBUSTION - ) + engine_type = "" + if is_valid_path(self.attrs, "fuelStatus.rangeStatus.value.primaryEngine.type"): + engine_type = find_path(self.attrs, "fuelStatus.rangeStatus.value.primaryEngine.type") + + if is_valid_path(self.attrs, "measurements.fuelLevelStatus.value.primaryEngineType"): + engine_type = find_path(self.attrs, "measurements.fuelLevelStatus.value.primaryEngineType") + + return engine_type in ENGINE_TYPE_COMBUSTION def is_secondary_drive_combustion(self): """Check if secondary engine is combustion.""" - return ( - P.SECONDARY_DRIVE in self.attrs.get("StoredVehicleDataResponseParsed", {}) - and self.attrs.get("StoredVehicleDataResponseParsed")[P.SECONDARY_DRIVE].get("value", UNSUPPORTED) - in ENGINE_TYPE_COMBUSTION - ) + engine_type = "" + if is_valid_path(self.attrs, "fuelStatus.rangeStatus.value.secondaryEngine.type"): + engine_type = find_path(self.attrs, "fuelStatus.rangeStatus.value.secondaryEngine.type") + + if is_valid_path(self.attrs, "measurements.fuelLevelStatus.value.secondaryEngineType"): + engine_type = find_path(self.attrs, "measurements.fuelLevelStatus.value.secondaryEngineType") + + return engine_type in ENGINE_TYPE_COMBUSTION def has_combustion_engine(self): """Return true if car has a combustion engine."""