Compare commits

..
10 Commits
Author SHA1 Message Date
Linus Dietz e6861ab872 Rebuild app OTP authentication 2024-03-14 09:06:17 +01:00
Linus DietzandGitHub 03755ca034 add C40 2024 2024-03-05 18:32:10 +01:00
Linus Dietz 907440b81e Fix changelog typo 2024-01-26 08:29:26 +01:00
Linus Dietz a68faf8267 Merge remote-tracking branch 'origin/main' into main 2024-01-26 08:25:50 +01:00
Linus Dietz 290a441f2b Add state cacheing for "diagnostics" endpoint 2024-01-26 08:24:51 +01:00
mkrzychandGitHub 63f333a186 Add XC60 B5 2023 2024-01-24 10:18:36 +01:00
Linus Dietz 3d2285f59e Add option to disable updates #160 2024-01-23 09:25:25 +01:00
Linus Dietz e444b6a838 Bump version 2024-01-23 08:44:57 +01:00
Linus Dietz ce856d91c4 Fix #165 2024-01-23 08:42:37 +01:00
9c749001c9 V90 B5 Mildhybrid (2023) added (#164)
Co-authored-by: Anthony <anthony@betts.sh>
2024-01-23 06:38:36 +01:00
7 changed files with 218 additions and 47 deletions
+3
View File
@@ -28,15 +28,18 @@ If you like my work:<br>
- V60 T8 PHEV (2023)
- C40 BEV (2023)
- C40 BEV (2022)
- C40 BEV (2024)
- XC90 T8 PHEV (2023)
- XC60 PHEV (2024)
- XC60 PHEV (2023)
- XC60 PHEV (2022)
- XC60 B5 Mildhybrid (2023)
- XC90 PHEV T8 (2023)
- XC90 PHEV T8 (2024)
- XC90 B5 Mildhybrid (2024)
- V90 PHEV T8 (2019)*
- V90 PHEV T6 (2024)
- V90 B5 Mildhybrid (2023)
*only partly working
+15
View File
@@ -1,3 +1,18 @@
## v1.8.27
### 🚀 Features:
- Add state caching for "diagnostics" endpoint
## v1.8.26
### 🚀 Features:
- Add option to disable updates #160 (Set the update interval to -1)
## v1.8.25
### 🚀 Features:
- Fix INTERNAL_SERVER_ERROR #165
## v1.8.24
### 🚀 Features:
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Volvo2Mqtt"
description: "Volvo AAOS MQTT bridge"
version: "1.8.24"
version: "1.8.27"
slug: "volvo2mqtt"
init: false
url: "https://github.com/Dielee/volvo2mqtt"
+6 -3
View File
@@ -1,8 +1,9 @@
from config import settings
VERSION = "v1.8.24"
VERSION = "v1.9.0b1"
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"
@@ -132,8 +133,10 @@ supported_entities = [
{"name": "Service warning status", "domain": "sensor", "id": "service_warning_status", "icon": "alert-outline", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "Washer Fluid Level warning", "domain": "sensor", "id": "washer_fluid_warning", "icon": "alert-outline", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "API Backend status", "domain": "sensor", "id": "api_backend_status", "icon": "alert"},
{"name": "Update Interval", "domain": "number", "id": "update_interval", "unit": "seconds", "icon": "timer", "min": 60, "max": 600, "mode": "box"},
{"name": "Update Interval", "domain": "number", "id": "update_interval", "unit": "seconds", "icon": "timer", "min": -1, "max": 600, "mode": "box"},
{"name": "Warnings", "domain": "sensor", "id": "warnings", "icon": "alert", "url": WARNINGS_URL}
]
old_entity_ids = ["months_to_service", "service_warning_trigger", "distance_to_empty"]
otp_max_loops = 15
otp_mqtt_topic = "volvoAAOS2mqtt/otp_code"
+56 -15
View File
@@ -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)
@@ -115,7 +143,12 @@ def on_message(client, userdata, msg):
if payload == "PRESS":
update_car_data(True)
elif "update_interval" in msg.topic:
settings.update({"updateInterval": int(payload)})
update_interval = int(payload)
if update_interval >= 60 or update_interval == -1:
settings.update({"updateInterval": int(update_interval)})
else:
logging.warning("Interval " + str(update_interval) + " seconds is to low. Doing nothing!")
update_car_data()
elif "schedule" in msg.topic:
try:
d = json.loads(payload)
@@ -216,11 +249,15 @@ def start_climate(vin):
def update_loop():
create_ha_devices()
while True:
logging.info("Sending mqtt update...")
send_heartbeat()
update_car_data()
logging.info("Mqtt update done. Next run in " + str(settings["updateInterval"]) + " seconds.")
time.sleep(settings["updateInterval"])
if settings["updateInterval"] > 0:
logging.info("Sending mqtt update...")
send_heartbeat()
update_car_data()
logging.info("Mqtt update done. Next run in " + str(settings["updateInterval"]) + " seconds.")
time.sleep(settings["updateInterval"])
else:
logging.info("Data update is disabled, doing nothing for 30 seconds")
time.sleep(30)
def update_car_data(force_update=False, overwrite={}):
@@ -265,7 +302,11 @@ def update_car_data(force_update=False, overwrite={}):
f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/attributes",
json.dumps(state)
)
state = sum(value == "FAILURE" for value in state.values())
if state:
state = sum(value == "FAILURE" for value in state.values())
else:
state = 0
topic = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state"
else:
topic = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state"
+5
View File
@@ -4,6 +4,7 @@ import os
import re
import sys
import config
import json
from logging import handlers
from datetime import datetime
from const import units
@@ -29,6 +30,10 @@ class SensitiveDataFilter(logging.Filter):
return True
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
for s in icon_list:
+132 -28
View File
@@ -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, 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,135 @@ 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/5.37.0",
"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 conve:engine_status")
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)
get_vcc_api_keys()
get_vehicles()
check_supported_endpoints()
Thread(target=backend_status_loop).start()
else:
message = auth.json()
raise Exception(message["error_description"])
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 +177,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():
@@ -346,7 +450,7 @@ def api_call(url, method, vin, sensor_id=None, force_update=False, key_change=Fa
refresh_auth()
if url in [RECHARGE_STATE_URL, WINDOWS_STATE_URL, LOCK_STATE_URL, TYRE_STATE_URL,
STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, FUEL_BATTERY_STATE_URL, WARNINGS_URL]:
STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, VEHICLE_DIAGNOSTICS_URL, FUEL_BATTERY_STATE_URL, WARNINGS_URL]:
# Minimize API calls for endpoints with multiple values
response = cached_request(url, method, vin, force_update, key_change)
if response is None: