diff --git a/README.md b/README.md
index 80655f8..7e79688 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,22 @@ Please let me know if your car works with this addon so I can expand the list!v1.9.0, this addon uses the same OTP authentication as the Volvo app.
+The following steps are required for authentication in exactly this order:
+
+1. Setup volvo2Mqtt, either via Docker, or via HA addon (take a look at the "Setup" section below)
+2. Fill in your settings and start volvo2Mqtt
+3. Your log will show the following lines
+```Waiting for otp code... Please check your mailbox and post your otp code to the following mqtt topic "volvoAAOS2mqtt/otp_code". Retry 0/15```
+```Waiting for otp code... Please check your mailbox and post your otp code to the following mqtt topic "volvoAAOS2mqtt/otp_code". Retry 1/15```
+```etc ...```
+4. Now, open your mailbox and copy your OTP Code
+5. Open HomeAssistant and search for the entity ID ```text.volvo_otp```
+6. Paste your OTP into the text entity and press Enter
+7. If everything has worked, your addon is now authenticated. In the future, OTP authentication only needs to be done when updating, not when restarting the container.
+
## Setup
Docker:
@@ -106,7 +122,6 @@ Here is what every option means:
| `TZ` | `string` | | **required** | Container timezone eg "Europe/Berlin" from [here](https://docs.diladele.com/docker/timezones.html)|
-
[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg
[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg
[armhf-shield]: https://img.shields.io/badge/armhf-yes-green.svg
diff --git a/requirements.txt b/requirements.txt
index fc8e167..ab85721 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
-requests~=2.29.0
+requests~=2.31.0
dynaconf~=3.1.12
paho-mqtt~=1.6.1
Babel~=2.12.1
-pytz~=2023.3
\ No newline at end of file
+pytz~=2023.3
+google_play_scraper~=1.2.6
\ No newline at end of file
diff --git a/src/config.yaml b/src/config.yaml
index 2e3abc7..73645ba 100644
--- a/src/config.yaml
+++ b/src/config.yaml
@@ -1,6 +1,6 @@
name: "Volvo2Mqtt"
description: "Volvo AAOS MQTT bridge"
-version: "1.8.27"
+version: "1.9.0"
slug: "volvo2mqtt"
init: false
url: "https://github.com/Dielee/volvo2mqtt"
diff --git a/src/const.py b/src/const.py
index 6543473..0c8de31 100644
--- a/src/const.py
+++ b/src/const.py
@@ -1,8 +1,9 @@
from config import settings
-VERSION = "v1.8.27"
+VERSION = "v1.9.0"
-OAUTH_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"
VEHICLES_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles"
VEHICLE_DETAILS_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}"
WINDOWS_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/windows"
@@ -137,3 +138,5 @@ supported_entities = [
]
old_entity_ids = ["months_to_service", "service_warning_trigger", "distance_to_empty"]
+otp_max_loops = 15
+otp_mqtt_topic = "volvoAAOS2mqtt/otp_code"
\ No newline at end of file
diff --git a/src/mqtt.py b/src/mqtt.py
index 4115195..043a657 100644
--- a/src/mqtt.py
+++ b/src/mqtt.py
@@ -10,7 +10,7 @@ from datetime import datetime
from babel.dates import format_datetime
from config import settings
from const import CLIMATE_START_URL, CLIMATE_STOP_URL, CAR_LOCK_URL, \
- CAR_UNLOCK_URL, availability_topic, icon_states, old_entity_ids
+ CAR_UNLOCK_URL, availability_topic, icon_states, old_entity_ids, otp_mqtt_topic
mqtt_client: mqtt.Client
subscribed_topics = []
@@ -20,7 +20,7 @@ climate_timer = {}
engine_status = {}
devices = {}
active_schedules = {}
-
+otp_code = None
def connect():
client = mqtt.Client("volvoAAOS2mqtt") if os.environ.get("IS_HA_ADDON") \
@@ -37,6 +37,7 @@ def connect():
port = settings["mqtt"]["port"]
client.connect(settings["mqtt"]["broker"], port)
client.loop_start()
+ client.subscribe("volvoAAOS2mqtt/otp_code")
client.on_message = on_message
client.on_disconnect = on_disconnect
client.on_connect = on_connect
@@ -45,6 +46,28 @@ def connect():
mqtt_client = client
+def create_otp_input():
+ config = {
+ "name": "Volvo OTP",
+ "object_id": f"volvo_otp",
+ "schema": "state",
+ "command_topic": otp_mqtt_topic,
+ "unique_id": f"volvoAAOS2mqtt_otp",
+ "pattern": "\d{6}",
+ "icon": "mdi:two-factor-authentication"
+ }
+
+ mqtt_client.publish(
+ f"homeassistant/text/volvoAAOS2mqtt/volvo_otp/config",
+ json.dumps(config),
+ retain=True
+ )
+
+def delete_otp_input():
+ topic = "homeassistant/text/volvoAAOS2mqtt/volvo_otp/config"
+ mqtt_client.publish(topic, payload="", retain=True)
+
+
def send_car_images(vin, data, device):
if util.keys_exists(data, "images"):
for entity in [{"name": "Exterior Image", "id": "exterior_image"},
@@ -94,13 +117,18 @@ def on_disconnect(client, userdata, rc):
def on_message(client, userdata, msg):
- try:
- vin = msg.topic.split('/')[2].split('_')[0]
- except IndexError:
- logging.error("Error - Cannot get vin from MQTT topic!")
- return None
-
payload = msg.payload.decode("UTF-8")
+ if msg.topic == otp_mqtt_topic:
+ global otp_code
+ otp_code = payload
+ return None
+ else:
+ try:
+ vin = msg.topic.split('/')[2].split('_')[0]
+ except IndexError:
+ logging.error("Error - Cannot get vin from MQTT topic!")
+ return None
+
if "climate_status" in msg.topic:
if payload == "ON":
start_climate(vin)
diff --git a/src/util.py b/src/util.py
index f20eff6..2a40f26 100644
--- a/src/util.py
+++ b/src/util.py
@@ -4,6 +4,8 @@ import os
import re
import sys
import config
+import json
+from google_play_scraper import app
from logging import handlers
from datetime import datetime
from const import units
@@ -28,6 +30,21 @@ class SensitiveDataFilter(logging.Filter):
record.msg = re.sub(pattern, "", record.msg)
return True
+def get_volvo_app_version():
+ result = app(
+ 'se.volvo.vcc',
+ lang='en',
+ country='us'
+ )
+
+ if "version" in result:
+ return result["version"]
+ else:
+ return "5.37.0"
+
+def save_to_json(data):
+ with open('.token', 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=4)
def get_icon_between(icon_list, state):
icon = None
diff --git a/src/volvo.py b/src/volvo.py
index 15bbdb3..ed02db4 100644
--- a/src/volvo.py
+++ b/src/volvo.py
@@ -1,18 +1,21 @@
+import json
import logging
import requests
import mqtt
import util
import time
import re
+import os.path
from threading import current_thread, Thread
from datetime import datetime, timedelta
from config import settings
from babel.dates import format_datetime
from json import JSONDecodeError
from const import charging_system_states, charging_connection_states, door_states, window_states, \
- OAUTH_URL, VEHICLES_URL, VEHICLE_DETAILS_URL, RECHARGE_STATE_URL, CLIMATE_START_URL, \
+ OAUTH_AUTH_URL, OAUTH_TOKEN_URL, VEHICLES_URL, VEHICLE_DETAILS_URL, RECHARGE_STATE_URL, CLIMATE_START_URL, \
WINDOWS_STATE_URL, LOCK_STATE_URL, TYRE_STATE_URL, supported_entities, FUEL_BATTERY_STATE_URL, \
- STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, VEHICLE_DIAGNOSTICS_URL, API_BACKEND_STATUS, WARNINGS_URL, engine_states
+ STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, VEHICLE_DIAGNOSTICS_URL, API_BACKEND_STATUS, WARNINGS_URL, engine_states, \
+ otp_max_loops
session = requests.Session()
session.headers = {
@@ -30,37 +33,136 @@ vcc_api_keys = []
backend_status = ""
-def authorize():
- headers = {
- "authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=",
- "content-type": "application/x-www-form-urlencoded",
- "accept": "application/json"
- }
-
- body = {
- "username": settings.volvoData["username"],
- "password": settings.volvoData["password"],
- "grant_type": "password",
- "scope": "openid email profile care_by_volvo:financial_information:invoice:read care_by_volvo:financial_information:payment_method care_by_volvo:subscription:read customer:attributes customer:attributes:write order:attributes vehicle:attributes tsp_customer_api:all conve:brake_status conve:climatization_start_stop conve:command_accessibility conve:commands conve:diagnostics_engine_status conve:diagnostics_workshop conve:doors_status conve:engine_status conve:environment conve:fuel_status conve:honk_flash conve:lock conve:lock_status conve:navigation conve:odometer_status conve:trip_statistics conve:tyre_status conve:unlock conve:vehicle_relation conve:warnings conve:windows_status energy:battery_charge_level energy:charging_connection_status energy:charging_system_status energy:electric_range energy:estimated_charging_time energy:recharge_status vehicle:attributes"
- }
- auth = requests.post(OAUTH_URL, data=body, headers=headers)
- if auth.status_code == 200:
- data = auth.json()
- session.headers.update({"authorization": "Bearer " + data["access_token"]})
-
- global token_expires_at, refresh_token
- token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(data["expires_in"] - 30))
+def authorize(renew_tokenfile=False):
+ global refresh_token
+ if os.path.isfile(".token") and not renew_tokenfile:
+ logging.info("Using login from token file")
+ f = open('.token')
+ data = json.load(f)
refresh_token = data["refresh_token"]
+ refresh_auth()
+ else:
+ logging.info("Starting login with OTP")
+ auth_session = requests.session()
+ auth_session.headers = {
+ "authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=",
+ "User-Agent": "vca-android/" + util.get_volvo_app_version(),
+ "Accept-Encoding": "gzip",
+ "Content-Type": "application/json; charset=utf-8"
+ }
- get_vcc_api_keys()
- get_vehicles()
- check_supported_endpoints()
- Thread(target=backend_status_loop).start()
+ url_params = ("?client_id=h4Yf0b"
+ "&response_type=code"
+ "&acr_values=urn:volvoid:aal:bronze:2sv"
+ "&response_mode=pi.flow"
+ "&scope=openid email profile care_by_volvo:financial_information:invoice:read care_by_volvo:financial_information:payment_method care_by_volvo:subscription:read customer:attributes customer:attributes:write order:attributes vehicle:attributes tsp_customer_api:all conve:brake_status conve:climatization_start_stop conve:command_accessibility conve:commands conve:diagnostics_engine_status conve:diagnostics_workshop conve:doors_status conve:engine_status conve:environment conve:fuel_status conve:honk_flash conve:lock conve:lock_status conve:navigation conve:odometer_status conve:trip_statistics conve:tyre_status conve:unlock conve:vehicle_relation conve:warnings conve:windows_status energy:battery_charge_level energy:charging_connection_status energy:charging_system_status energy:electric_range energy:estimated_charging_time energy:recharge_status vehicle:attributes")
+
+ auth = auth_session.get(OAUTH_AUTH_URL + url_params)
+ if auth.status_code == 200:
+ response = auth.json()
+ auth_state = response["status"]
+
+ if auth_state == "USERNAME_PASSWORD_REQUIRED":
+ auth_session.headers.update({"x-xsrf-header": "PingFederate"})
+ response = check_username_password(auth_session, response)
+ auth_state = response["status"]
+
+ if auth_state == "OTP_REQUIRED":
+ response = send_otp(auth_session, response)
+ response = continue_auth(auth_session, response)
+ token_data = get_token(auth_session, response)
+ else:
+ raise Exception("Unkown auth state " + auth_state)
+ elif auth_state == "OTP_REQUIRED":
+ response = send_otp(auth_session, response)
+ response = continue_auth(auth_session, response)
+ token_data = get_token(auth_session, response)
+ elif auth_state == "OTP_VERIFIED":
+ response = continue_auth(auth_session, response)
+ token_data = get_token(auth_session, response)
+ elif auth_state == "COMPLETED":
+ token_data = get_token(auth_session, response)
+ else:
+ raise Exception("Unkown auth state " + auth_state)
+
+ session.headers.update({"authorization": "Bearer " + token_data["access_token"]})
+
+ global token_expires_at
+ token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(token_data["expires_in"] - 30))
+ refresh_token = token_data["refresh_token"]
+
+ util.save_to_json(token_data)
+ else:
+ message = auth.json()
+ raise Exception(message["details"][0]["userMessage"])
+
+ get_vcc_api_keys()
+ get_vehicles()
+ check_supported_endpoints()
+ Thread(target=backend_status_loop).start()
+
+def continue_auth(auth_session, data):
+ next_url = data["_links"]["continueAuthentication"]["href"] + "?action=continueAuthentication"
+ auth = auth_session.get(next_url)
+
+ if auth.status_code == 200:
+ return auth.json()
else:
message = auth.json()
+ raise Exception(message["details"][0]["userMessage"])
+
+
+def get_token(auth_session, data):
+ auth_session.headers.update({"content-type": "application/x-www-form-urlencoded"})
+ body = {"code": data["authorizeResponse"]["code"], "grant_type": "authorization_code"}
+ token_auth = auth_session.post(OAUTH_TOKEN_URL, data=body)
+
+ if token_auth.status_code == 200:
+ return token_auth.json()
+ else:
+ message = token_auth.json()
raise Exception(message["error_description"])
+def check_username_password(auth_session, data):
+ next_url = data["_links"]["checkUsernamePassword"]["href"] + "?action=checkUsernamePassword"
+ body = {"username": settings.volvoData["username"],
+ "password": settings.volvoData["password"]}
+ auth = auth_session.post(next_url, data=json.dumps(body))
+
+ if auth.status_code == 200:
+ return auth.json()
+ else:
+ message = auth.json()
+ raise Exception(message["details"][0]["userMessage"])
+
+
+def send_otp(auth_session, data):
+ mqtt.create_otp_input()
+ next_url = data["_links"]["checkOtp"]["href"] + "?action=checkOtp"
+ body = {"otp": ""}
+
+ for i in range(otp_max_loops):
+ if mqtt.otp_code:
+ body["otp"] = mqtt.otp_code
+ break
+
+ logging.info("Waiting for otp code... Please check your mailbox and post your otp code to the following "
+ "mqtt topic \"volvoAAOS2mqtt/otp_code\". Retry " + str(i) + "/" + str(otp_max_loops))
+ time.sleep(5)
+
+ if not mqtt.otp_code:
+ raise Exception ("No OTP found, exting...")
+
+ mqtt.delete_otp_input()
+ auth = auth_session.post(next_url, data=json.dumps(body))
+ if auth.status_code == 200:
+ return auth.json()
+ else:
+ message = auth.json()
+ raise Exception(message["details"][0]["userMessage"])
+
+
def refresh_auth():
logging.info("Refreshing credentials")
global refresh_token
@@ -76,18 +178,21 @@ def refresh_auth():
}
try:
- auth = requests.post(OAUTH_URL, data=body, headers=headers)
+ auth = requests.post(OAUTH_TOKEN_URL, data=body, headers=headers)
except requests.exceptions.RequestException as e:
logging.error("Error refreshing credentials data: " + str(e))
return None
if auth.status_code == 200:
data = auth.json()
+ util.save_to_json(data)
session.headers.update({"authorization": "Bearer " + data["access_token"]})
global token_expires_at
token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(data["expires_in"] - 30))
refresh_token = data["refresh_token"]
+ else:
+ authorize(renew_tokenfile=True)
def get_vehicles():