Compare commits

...
13 Commits
Author SHA1 Message Date
linus 9d11dff24b Fix #318 2026-03-23 07:47:15 +01:00
bazza2000andGitHub 3a4160451c Fix: use POST for continueAuthentication endpoint (#317)
Volvo's PingFederate auth API now requires a POST request for the
continueAuthentication step after OTP verification. Using GET returns
the same OTP_VERIFIED state in a loop, never progressing to COMPLETED,
which causes a KeyError on 'authorizeResponse'.

Changing auth_session.get() to auth_session.post() with an empty JSON
body causes the API to return COMPLETED with the expected authorizeResponse.
2026-03-23 07:43:53 +01:00
linus 515e1a8d27 Bump addon version 2026-01-02 18:17:31 +01:00
linus 4961f7bfad Move readme to 2026 maintenance 2026-01-02 18:16:30 +01:00
linus a68216bdd5 Move object_id to default_entity_id for mqtt entities according to #312 2026-01-02 18:12:47 +01:00
linus 98d8dd7e65 Fix missing charging system state 2026-01-02 18:06:30 +01:00
linus 7acfdea1d8 Move energy api to v2 2025-11-16 08:36:30 +01:00
linus f95348769b Fix missing local refresh file while loading from filesystem 2025-11-12 14:04:52 +01:00
linus 4673f14a1c Add more logging for refresh process // renew refresh token if returned from api 2025-08-23 11:25:38 +02:00
linus 1a2f35f1b5 Add more logging for refresh process // renew refresh token if returned from api 2025-08-23 11:22:37 +02:00
Gert-Andry KäärameesandGitHub 33ee258f3f handle missing refresh_token key in token file (#306) 2025-07-28 07:39:44 +02:00
Russell StephensandGitHub 54db72df94 Update README.md (#304)
Improve readability and copy-paste functionality of docker command
2025-07-16 10:58:03 +02:00
linus 70b027fb70 Fix and unit for and 2025-07-13 20:03:27 +02:00
6 changed files with 291 additions and 213 deletions
+12 -2
View File
@@ -129,7 +129,17 @@ The following steps are required for authentication in exactly this order:
Just install this addon with the following command. Just install this addon with the following command.
Please note to fill in your settings inside the environment variables. Please note to fill in your settings inside the environment variables.
`docker run -d --pull=always -e CONF_updateInterval=300 -e CONF_babelLocale='de' -e CONF_mqtt='@json {"broker": "", "username": "", "password": "", "port": 1883}' -e CONF_volvoData='@json {"username": "", "password": "", "vin": "", "vccapikey": ["key1", "key2"]}' -e TZ='Europe/Berlin' --name volvo2mqtt ghcr.io/dielee/volvo2mqtt:latest` <pre>
<code>docker run -d --pull=always \
-e CONF_updateInterval=300 \
-e CONF_babelLocale='de' \
-e CONF_mqtt='@json {"broker": "", "username": "", "password": "", "port": 1883}' \
-e CONF_volvoData='@json {"username": "", "password": "", "vin": "", "vccapikey": ["key1", "key2"]}' \
-e TZ='Europe/Berlin' \
--name volvo2mqtt \
ghcr.io/dielee/volvo2mqtt:latest
</code>
</pre>
<b>HA Add-On:</b><br> <b>HA Add-On:</b><br>
@@ -161,6 +171,6 @@ Here is what every option means:
[i386-shield]: https://img.shields.io/badge/i386-yes-green.svg [i386-shield]: https://img.shields.io/badge/i386-yes-green.svg
[releases]: https://github.com/Dielee/volvo2mqtt/releases [releases]: https://github.com/Dielee/volvo2mqtt/releases
[releases-shield]: https://img.shields.io/github/release/Dielee/volvo2mqtt.svg [releases-shield]: https://img.shields.io/github/release/Dielee/volvo2mqtt.svg
[maintenance-shield]: https://img.shields.io/maintenance/yes/2025.svg [maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
[commits-shield]: https://img.shields.io/github/commit-activity/y/Dielee/volvo2mqtt.svg [commits-shield]: https://img.shields.io/github/commit-activity/y/Dielee/volvo2mqtt.svg
[commits]: https://github.com/Dielee/volvo2mqtt/commits/main [commits]: https://github.com/Dielee/volvo2mqtt/commits/main
+32
View File
@@ -1,3 +1,35 @@
## v1.13.2
### 🐛 Bug Fixes:
- Fix KeyError: 'authorizeResponse' in auth flow #318
## v1.13.1
### 🐛 Bug Fixes:
- Fix missing charging system state `error` #314
- Move `object_id` to `default_entity_id` for mqtt entities according to #312
## v1.13.0
### 🚀 Features:
- Move energy api to v2 (https://developer.volvocars.com/apis/energy/v2/release-notes/)
## v1.12.2
### 🚀 Features:
- Add more logging for refresh process
- Update refresh token, if volvo API returns a new one
## v1.12.1
### 🐛 Bug Fixes:
- Fix `trip_speed` and `trip_distance` unit for `en_US` and `en_GB` #301
## v1.12.0 ## v1.12.0
### 🚀 Features: ### 🚀 Features:
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Volvo2Mqtt" name: "Volvo2Mqtt"
description: "Volvo AAOS MQTT bridge" description: "Volvo AAOS MQTT bridge"
version: "1.12.0" version: "1.13.2"
slug: "volvo2mqtt" slug: "volvo2mqtt"
init: false init: false
url: "https://github.com/Dielee/volvo2mqtt" url: "https://github.com/Dielee/volvo2mqtt"
+14 -10
View File
@@ -1,6 +1,6 @@
from config import settings from config import settings
VERSION = "v1.12.0" VERSION = "v1.13.2"
OAUTH_TOKEN_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2" OAUTH_TOKEN_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2"
OAUTH_AUTH_URL = "https://volvoid.eu.volvocars.com/as/authorization.oauth2" OAUTH_AUTH_URL = "https://volvoid.eu.volvocars.com/as/authorization.oauth2"
@@ -12,7 +12,7 @@ CLIMATE_STOP_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/
LOCK_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/doors" LOCK_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/doors"
CAR_LOCK_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/commands/lock" CAR_LOCK_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/commands/lock"
CAR_UNLOCK_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/commands/unlock" CAR_UNLOCK_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/commands/unlock"
RECHARGE_STATE_URL = "https://api.volvocars.com/energy/v1/vehicles/{0}/recharge-status" RECHARGE_STATE_URL = "https://api.volvocars.com/energy/v2/vehicles/{0}/state"
ODOMETER_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/odometer" ODOMETER_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/odometer"
LOCATION_STATE_URL = "https://api.volvocars.com/location/v1/vehicles/{0}/location" LOCATION_STATE_URL = "https://api.volvocars.com/location/v1/vehicles/{0}/location"
TYRE_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/tyres" TYRE_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/tyres"
@@ -41,7 +41,9 @@ units = {
"odometer": {"unit": LENGTH_MILES}, "odometer": {"unit": LENGTH_MILES},
"average_speed": {"unit": SPEED_MILES_PER_HOUR}, "average_speed": {"unit": SPEED_MILES_PER_HOUR},
"distance_to_empty": {"unit": LENGTH_MILES}, "distance_to_empty": {"unit": LENGTH_MILES},
"distance_to_service": {"unit": LENGTH_MILES} "distance_to_service": {"unit": LENGTH_MILES},
"trip_distance": {"unit": LENGTH_MILES},
"trip_speed": {"unit": SPEED_MILES_PER_HOUR}
}, },
"en_US": { "en_US": {
"divider": 1.60934, "divider": 1.60934,
@@ -49,19 +51,21 @@ units = {
"odometer": {"unit": LENGTH_MILES}, "odometer": {"unit": LENGTH_MILES},
"average_speed": {"unit": SPEED_MILES_PER_HOUR}, "average_speed": {"unit": SPEED_MILES_PER_HOUR},
"distance_to_empty": {"unit": LENGTH_MILES}, "distance_to_empty": {"unit": LENGTH_MILES},
"distance_to_service": {"unit": LENGTH_MILES} "distance_to_service": {"unit": LENGTH_MILES},
"trip_distance": {"unit": LENGTH_MILES},
"trip_speed": {"unit": SPEED_MILES_PER_HOUR}
} }
} }
availability_topic = "volvoAAOS2mqtt/availability" availability_topic = "volvoAAOS2mqtt/availability"
charging_system_states = {"CHARGING_SYSTEM_CHARGING": "Charging", "CHARGING_SYSTEM_IDLE": "Idle", charging_system_states = {"CHARGING": "Charging", "IDLE": "Idle",
"CHARGING_SYSTEM_FAULT": "Fault", "CHARGING_SYSTEM_UNSPECIFIED": "Unspecified", "FAULT": "Fault", "UNSPECIFIED": "Unspecified",
"CHARGING_SYSTEM_DONE": "Done", "CHARGING_SYSTEM_SCHEDULED": "Scheduled"} "DONE": "Done", "SCHEDULED": "Scheduled", "ERROR": "Error",
"DISCHARGING": "Discharging"}
charging_connection_states = {"CONNECTION_STATUS_DISCONNECTED": "Disconnected", "CONNECTION_STATUS_UNSPECIFIED": "Unspecified", charging_connection_states = {"DISCONNECTED": "Disconnected", "UNSPECIFIED": "Unspecified",
"CONNECTION_STATUS_CONNECTED_DC": "Connected DC", "CONNECTION_STATUS_CONNECTED_AC": "Connected AC", "FAULT": "Fault", "CONNECTED": "Connected"}
"CONNECTION_STATUS_FAULT": "Fault"}
window_states = {"CLOSED": "OFF", "OPEN": "ON", "UNSPECIFIED": "UNKNOWN", "AJAR": "ON"} window_states = {"CLOSED": "OFF", "OPEN": "ON", "UNSPECIFIED": "UNKNOWN", "AJAR": "ON"}
door_states = {"CLOSED": "OFF", "OPEN": "ON", "UNSPECIFIED": "UNKNOWN", "AJAR": "ON"} door_states = {"CLOSED": "OFF", "OPEN": "ON", "UNSPECIFIED": "UNKNOWN", "AJAR": "ON"}
+17 -4
View File
@@ -58,7 +58,7 @@ def create_otp_input():
state_topic = otp_mqtt_topic + "/state" state_topic = otp_mqtt_topic + "/state"
config = { config = {
"name": "Volvo OTP", "name": "Volvo OTP",
"object_id": "volvo_otp", "default_entity_id": "volvo_otp",
"schema": "state", "schema": "state",
"command_topic": otp_mqtt_topic, "command_topic": otp_mqtt_topic,
"state_topic": state_topic, "state_topic": state_topic,
@@ -95,7 +95,7 @@ def send_car_images(vin, data, device):
image_topic = f"homeassistant/image/{vin}_{entity['id']}/image_topic" image_topic = f"homeassistant/image/{vin}_{entity['id']}/image_topic"
config = { config = {
"name": entity["name"], "name": entity["name"],
"object_id": f"volvo_{vin}_{entity['id']}", "default_entity_id": f"volvo_{vin}_{entity['id']}",
"schema": "state", "schema": "state",
"icon": "mdi:image-area", "icon": "mdi:image-area",
"content_type": "image/png", "content_type": "image/png",
@@ -176,16 +176,24 @@ def on_message(client, userdata, msg):
if "climate_status" in msg.topic: if "climate_status" in msg.topic:
if payload == "ON": if payload == "ON":
start_climate(vin) start_climate(vin)
return None
elif payload == "OFF": elif payload == "OFF":
stop_climate(vin) stop_climate(vin)
return None
return None
elif "lock_status" in msg.topic: elif "lock_status" in msg.topic:
if payload == "LOCK": if payload == "LOCK":
lock_car(vin) lock_car(vin)
return None
elif payload == "UNLOCK": elif payload == "UNLOCK":
unlock_car(vin) unlock_car(vin)
return None
return None
elif "update_data" in msg.topic: elif "update_data" in msg.topic:
if payload == "PRESS": if payload == "PRESS":
update_car_data(True) update_car_data(True)
return None
return None
elif "update_interval" in msg.topic: elif "update_interval" in msg.topic:
try: try:
update_interval = int(payload) update_interval = int(payload)
@@ -194,8 +202,10 @@ def on_message(client, userdata, msg):
else: else:
logging.warning(f"Interval {update_interval} seconds is to low. Doing nothing!") logging.warning(f"Interval {update_interval} seconds is to low. Doing nothing!")
update_car_data() update_car_data()
return None
except ValueError as error: except ValueError as error:
logging.error(f"Unable to change update_interval {error} payload={payload}") logging.error(f"Unable to change update_interval {error} payload={payload}")
return None
elif "schedule" in msg.topic: elif "schedule" in msg.topic:
try: try:
d = json.loads(payload) d = json.loads(payload)
@@ -205,8 +215,11 @@ def on_message(client, userdata, msg):
if d["mode"] == "timer": if d["mode"] == "timer":
start_climate_timer(d, vin) start_climate_timer(d, vin)
return None
else: else:
logging.warning("No schedule mode found, doing nothing") logging.warning("No schedule mode found, doing nothing")
return None
return None
def start_climate_timer(d, vin): def start_climate_timer(d, vin):
@@ -392,7 +405,7 @@ def update_ha_device(entity, vin, state):
logging.debug("Updating icon to " + icon + " for " + entity["id"]) logging.debug("Updating icon to " + icon + " for " + entity["id"])
config = { config = {
"name": entity['name'], "name": entity['name'],
"object_id": f"volvo_{vin}_{entity['id']}", "default_entity_id": f"volvo_{vin}_{entity['id']}",
"schema": "state", "schema": "state",
"icon": f"mdi:{icon}" if icon else f"mdi:{entity['icon']}", "icon": f"mdi:{icon}" if icon else f"mdi:{entity['icon']}",
"state_topic": f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state", "state_topic": f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state",
@@ -433,7 +446,7 @@ def create_ha_devices():
for entity in volvo.supported_endpoints[vin]: for entity in volvo.supported_endpoints[vin]:
config = { config = {
"name": entity['name'], "name": entity['name'],
"object_id": f"volvo_{vin}_{entity['id']}", "default_entity_id": f"volvo_{vin}_{entity['id']}",
"schema": "state", "schema": "state",
"icon": f"mdi:{entity['icon']}", "icon": f"mdi:{entity['icon']}",
"state_topic": f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state", "state_topic": f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state",
+39 -20
View File
@@ -48,6 +48,10 @@ def authorize(renew_tokenfile=False):
logging.warning("Detected corrupted token file, restarting auth process") logging.warning("Detected corrupted token file, restarting auth process")
authorize(True) authorize(True)
return return
except KeyError:
logging.warning("Token file missing refresh_token, restarting auth process")
authorize(True)
return
else: else:
logging.info("Starting login with OTP") logging.info("Starting login with OTP")
auth_session = requests.session() auth_session = requests.session()
@@ -110,7 +114,7 @@ def authorize(renew_tokenfile=False):
def continue_auth(auth_session, data): def continue_auth(auth_session, data):
next_url = data["_links"]["continueAuthentication"]["href"].replace("http://", "https://") + "?action=continueAuthentication" next_url = data["_links"]["continueAuthentication"]["href"].replace("http://", "https://") + "?action=continueAuthentication"
auth = auth_session.get(next_url) auth = auth_session.post(next_url, data="{}")
if auth.status_code == 200: if auth.status_code == 200:
return auth.json() return auth.json()
@@ -171,8 +175,9 @@ def send_otp(auth_session, data):
def refresh_auth(): def refresh_auth():
logging.info("Refreshing credentials")
global refresh_token global refresh_token
logging.info("Refreshing credentials with token " + refresh_token)
headers = { headers = {
"authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=", "authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=",
"content-type": "application/x-www-form-urlencoded", "content-type": "application/x-www-form-urlencoded",
@@ -192,16 +197,22 @@ def refresh_auth():
if auth.status_code == 200: if auth.status_code == 200:
token_path = util.get_token_path() token_path = util.get_token_path()
if os.path.exists(token_path):
with open(token_path) as f:
data = json.load(f)
refresh_data = auth.json() refresh_data = auth.json()
if not "refresh_token" in refresh_data:
f = open(token_path) f = open(token_path)
try: try:
data = json.load(f) data = json.load(f)
refresh_token = data["refresh_token"] refresh_data["refresh_token"] = data["refresh_token"]
except ValueError: except ValueError:
raise Exception("Cannot refresh token!") raise Exception("Cannot refresh token!")
else:
logging.info("New refresh token found, updating!")
refresh_data["refresh_token"] = refresh_token
util.save_to_json(refresh_data, token_path) util.save_to_json(refresh_data, token_path)
session.headers.update({"authorization": "Bearer " + refresh_data["access_token"]}) session.headers.update({"authorization": "Bearer " + refresh_data["access_token"]})
@@ -451,7 +462,9 @@ def check_engine_status(vin):
mqtt.assumed_climate_state[vin] = "OFF" mqtt.assumed_climate_state[vin] = "OFF"
mqtt.update_car_data() mqtt.update_car_data()
break break
time.sleep(5) time.sleep(5)
return None
def backend_status_loop(): def backend_status_loop():
@@ -587,43 +600,50 @@ def cached_request(url, method, vin, force_update=False, key_change=False):
def parse_api_data(data, sensor_id=None): def parse_api_data(data, sensor_id=None):
if sensor_id != "api_backend_status": if sensor_id in ["battery_charge_level", "electric_range", "charging_system_status",
data = data["data"] "charging_connection_status", "estimated_charging_time", "estimated_charging_finish_time"]:
if sensor_id == "battery_charge_level": if sensor_id == "battery_charge_level":
return data["batteryChargeLevel"]["value"] if util.keys_exists(data, "batteryChargeLevel") else None return data["batteryChargeLevel"]["value"] if util.keys_exists(data, "batteryChargeLevel") else None
elif sensor_id == "battery_capacity":
return data["batteryCapacityKWH"] if util.keys_exists(data, "batteryCapacityKWH") else None
elif sensor_id == "electric_range": elif sensor_id == "electric_range":
return util.convert_metric_values(data["electricRange"]["value"]) \ return util.convert_metric_values(data["electricRange"]["value"]) \
if util.keys_exists(data, "electricRange") else None if util.keys_exists(data, "electricRange") else None
elif sensor_id == "charging_system_status": elif sensor_id == "charging_system_status":
return charging_system_states[data["chargingSystemStatus"]["value"]] \ return charging_system_states[data["chargingStatus"]["value"]] \
if util.keys_exists(data, "chargingSystemStatus") else None if util.keys_exists(data, "chargingStatus") else None
elif sensor_id == "charging_connection_status": elif sensor_id == "charging_connection_status":
return charging_connection_states[data["chargingConnectionStatus"]["value"]] \ return charging_connection_states[data["chargerConnectionStatus"]["value"]] \
if util.keys_exists(data, "chargingConnectionStatus") else None if util.keys_exists(data, "chargerConnectionStatus") else None
elif sensor_id == "estimated_charging_time": elif sensor_id == "estimated_charging_time":
if util.keys_exists(data, "chargingSystemStatus"): if util.keys_exists(data, "chargingStatus"):
charging_system_state = charging_system_states[data["chargingSystemStatus"]["value"]] charging_system_state = charging_system_states[data["chargingStatus"]["value"]]
if charging_system_state == "Charging": if charging_system_state == "Charging":
return data["estimatedChargingTime"]["value"] if util.keys_exists(data, "estimatedChargingTime") else "" return data["estimatedChargingTimeToTargetBatteryChargeLevel"]["value"] if util.keys_exists(data,
"estimatedChargingTimeToTargetBatteryChargeLevel") else ""
else: else:
return 0 return 0
return None return None
elif sensor_id == "estimated_charging_finish_time": elif sensor_id == "estimated_charging_finish_time":
if util.keys_exists(data, "chargingSystemStatus"): if util.keys_exists(data, "estimatedChargingTimeToTargetBatteryChargeLevel"):
charging_system_state = charging_system_states[data["chargingSystemStatus"]["value"]] charging_system_state = charging_system_states[data["chargingStatus"]["value"]]
if charging_system_state == "Charging": if charging_system_state == "Charging":
charging_time = int( charging_time = int(
data["estimatedChargingTime"]["value"] if util.keys_exists(data, "estimatedChargingTime") data["estimatedChargingTimeToTargetBatteryChargeLevel"]["value"] if util.keys_exists(data, "estimatedChargingTimeToTargetBatteryChargeLevel")
else 0) else 0)
charging_finished = datetime.now(util.TZ) + timedelta(minutes=charging_time) charging_finished = datetime.now(util.TZ) + timedelta(minutes=charging_time)
return format_datetime(charging_finished, format="medium", locale=settings["babelLocale"]) return format_datetime(charging_finished, format="medium", locale=settings["babelLocale"])
else: else:
return "" return ""
return None return None
elif sensor_id == "lock_status": return None
else:
if sensor_id != "api_backend_status":
data = data["data"]
if sensor_id == "lock_status":
return data["centralLock"]["value"] if util.keys_exists(data, "centralLock") else None return data["centralLock"]["value"] if util.keys_exists(data, "centralLock") else None
elif sensor_id == "battery_capacity":
return data["batteryCapacityKWH"] if util.keys_exists(data, "batteryCapacityKWH") else None
elif sensor_id == "odometer": elif sensor_id == "odometer":
return util.convert_metric_values(int(data["odometer"]["value"])) \ return util.convert_metric_values(int(data["odometer"]["value"])) \
if util.keys_exists(data, "odometer") else None if util.keys_exists(data, "odometer") else None
@@ -775,7 +795,6 @@ def parse_api_data(data, sensor_id=None):
if trip_speed != 0: if trip_speed != 0:
return util.convert_metric_values(trip_speed) return util.convert_metric_values(trip_speed)
return None return None
else: else:
return None return None