Compare commits

..
16 Commits
7 changed files with 133 additions and 66 deletions
+1
View File
@@ -32,6 +32,7 @@ If you like my work:<br>
- XC60 PHEV (2022)
- XC90 PHEV T8 (2023)
- XC90 PHEV T8 (2024)
- XC90 B5 Mildhybrid (2024)
- V90 PHEV T8 (2019)*
*only partly working
+42 -1
View File
@@ -1,3 +1,44 @@
## v1.8.15
### 🚀 Features:
- Move `batteryChargeLevel` to the new v2 fuel endpoint
- Add api backend status sensor
## v1.8.14
### 🐛 Bug Fixes:
- Fix distance to empty sensor #127
- Remove unused service warning trigger sensor
## v1.8.13
### 🐛 Bug Fixes:
- Add "Time to Service" sensor #125<p>
<b>Breaking change: The "Months to Service" sensor will be automatically deleted.<br>
"Time to Service" contains this information now.
## v1.8.12
### 🐛 Bug Fixes:
- Fix `AJAR` state for windows
- Fix deprecation from `currentThread` to `current_thread`
## v1.8.11
### 🐛 Bug Fixes:
- Remove old code from checks for running engine sensor
- Move battery charge level sensor from v2 to v1, as v2 returns 404
## v1.8.10
### 🐛 Bug Fixes:
- Add `AJAR` state for sunroof #123
## v1.8.9
### 🐛 Bug Fixes:
- Fix api changes from volvo #121
## v1.8.8
### 🐛 Bug Fixes:
@@ -48,7 +89,7 @@
### 🚀 Features:
- <b>Breaking change: The `vccapikey` setting is now a list. It is possible to
add multiple VCCAPIKEYs. This will be helpful if you are extending your 10.000 call limit some times.
add multiple VCCAPIKEYs. This will be helpful if you are extending your 10.000 call limit sometimes.
Change your vccapikey config like this:
Old:
+2
View File
@@ -4,8 +4,10 @@ profile volvo2mqtt flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# Capabilities
capability,
file,
signal (send) set=(kill,term,int,hup,cont),
network,
# S6-Overlay
/init ix,
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Volvo2Mqtt"
description: "Volvo AAOS MQTT bridge"
version: "1.8.7"
version: "1.8.15"
slug: "volvo2mqtt"
init: false
url: "https://github.com/Dielee/volvo2mqtt"
+9 -7
View File
@@ -1,6 +1,6 @@
from config import settings
VERSION = "v1.8.8"
VERSION = "v1.8.15"
OAUTH_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2"
VEHICLES_URL = "https://api.volvocars.com/connected-vehicle/v1/vehicles"
@@ -16,11 +16,12 @@ ODOMETER_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0
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"
ENGINE_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/engine-status"
BATTERY_CHARGE_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/battery-charge-level"
BATTERY_CHARGE_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/fuel"
FUEL_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/fuel"
STATISTICS_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/statistics"
ENGINE_DIAGNOSTICS_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/engine"
VEHICLE_DIAGNOSTICS_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/diagnostics"
API_BACKEND_STATUS = "https://oip-dev-bff.euwest1.production.volvo.care/api/v1/backend-status"
units = {
"en_GB": {
@@ -50,8 +51,8 @@ charging_connection_states = {"CONNECTION_STATUS_DISCONNECTED": "Disconnected",
"CONNECTION_STATUS_CONNECTED_DC": "Connected DC", "CONNECTION_STATUS_CONNECTED_AC": "Connected AC",
"CONNECTION_STATUS_FAULT": "Fault"}
window_states = {"CLOSED": "OFF", "OPEN": "ON"}
door_states = {"CLOSED": "OFF", "OPEN": "ON"}
window_states = {"CLOSED": "OFF", "OPEN": "ON", "AJAR": "ON"}
door_states = {"CLOSED": "OFF", "OPEN": "ON", "UNSPECIFIED": "UNKNOWN", "AJAR": "ON"}
engine_states = {"RUNNING": "ON", "STOPPED": "OFF", "true": "ON", "false": "OFF"}
icon_states = {
@@ -108,14 +109,15 @@ supported_entities = [
{"name": "Tire Rear Left", "domain": "sensor", "id": "tyre_rear_left", "icon": "car-tire-alert", "url": TYRE_STATE_URL},
{"name": "Tire Rear Right", "domain": "sensor", "id": "tyre_rear_right", "icon": "car-tire-alert", "url": TYRE_STATE_URL},
{"name": "Engine State", "domain": "binary_sensor", "device_class": "running", "id": "engine_state", "icon": "engine", "url": ENGINE_STATE_URL},
{"name": "Engine State", "domain": "binary_sensor", "device_class": "running", "id": "engine_state", "icon": "engine", "url": ENGINE_DIAGNOSTICS_URL},
{"name": "Fuel Level", "domain": "sensor", "id": "fuel_level", "unit": "liters", "icon": "fuel", "url": FUEL_STATE_URL, "state_class": "measurement"},
{"name": "Average Fuel Consumption", "domain": "sensor", "id": "average_fuel_consumption", "unit": "liters", "icon": "fuel", "url": STATISTICS_URL},
{"name": "Distance to Empty", "domain": "sensor", "id": "distance_to_empty", "unit": "km" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["distance_to_empty"]["unit"], "icon": "map-marker-distance", "url": STATISTICS_URL, "state_class": "measurement"},
{"name": "Average Speed", "domain": "sensor", "id": "average_speed", "unit": "km/h" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["average_speed"]["unit"], "icon": "speedometer", "url": STATISTICS_URL, "state_class": "measurement"},
{"name": "Hours to Service", "domain": "sensor", "id": "hours_to_service", "unit": "hour", "icon": "wrench-clock", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "Distance to Service", "domain": "sensor", "id": "km_to_service", "unit": "km" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["distance_to_service"]["unit"], "icon": "wrench-clock", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "Months to Service", "domain": "sensor", "id": "months_to_service", "unit": "month", "icon": "wrench-clock", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "Time to Service", "domain": "sensor", "id": "time_to_service", "icon": "wrench-clock", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "Service warning status", "domain": "sensor", "id": "service_warning_status", "icon": "alert-outline", "url": VEHICLE_DIAGNOSTICS_URL},
{"name": "Service warning trigger", "domain": "sensor", "id": "service_warning_trigger", "icon": "alert-outline", "url": VEHICLE_DIAGNOSTICS_URL}
{"name": "API Backend status", "domain": "sensor", "id": "api_backend_status", "icon": "alert"}
]
old_entity_ids = ["months_to_service", "service_warning_trigger"]
+23 -17
View File
@@ -10,8 +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
CAR_UNLOCK_URL, availability_topic, icon_states, old_entity_ids
mqtt_client: mqtt.Client
subscribed_topics = []
@@ -25,7 +24,7 @@ active_schedules = {}
def connect():
client = mqtt.Client("volvoAAOS2mqtt") if os.environ.get("IS_HA_ADDON") \
else mqtt.Client("volvoAAOS2mqtt_" + settings.volvoData["username"])
else mqtt.Client("volvoAAOS2mqtt_" + settings.volvoData["username"].replace("+", ""))
client.will_set(availability_topic, "offline", 0, False)
if settings["mqtt"]["username"] and settings["mqtt"]["password"]:
@@ -90,7 +89,7 @@ def on_connect(client, userdata, flags, rc):
mqtt_client.subscribe(topic)
def on_disconnect(client, userdata, rc):
def on_disconnect(client, userdata, rc):
logging.warning("MQTT disconnected, reconnecting automatically")
@@ -141,7 +140,7 @@ def start_climate_timer(d, vin):
return None
if timer_seconds > 0:
Timer(timer_seconds, activate_climate_timer, (vin, start_datetime.isoformat(), )).start()
Timer(timer_seconds, activate_climate_timer, (vin, start_datetime.isoformat(),)).start()
active_schedules[vin]["timers"].append(start_datetime.isoformat())
logging.debug("Climate timer set to " + str(start_datetime))
update_car_data()
@@ -244,6 +243,8 @@ def update_car_data(force_update=False, overwrite={}):
state = last_data_update
elif entity["id"] == "active_schedules":
state = active_schedules[vin]
elif entity["id"] == "api_backend_status":
state = volvo.backend_status
else:
if entity["id"] == ov_entity_id and vin == ov_vin:
state = ov_state
@@ -266,8 +267,7 @@ def update_car_data(force_update=False, overwrite={}):
def update_ha_device(entity, vin, state):
icon_config = icon_states.get(entity["id"])
if icon_config and state:
if state.replace(".", "").isnumeric():
state = float(state)
if isinstance(state, float):
icon = util.get_icon_between(icon_config, state)
else:
icon = icon_config[state]
@@ -293,7 +293,7 @@ def update_ha_device(entity, vin, state):
if entity.get("state_class"):
config["state_class"] = entity["state_class"]
if entity.get("domain") == "device_tracker":
config["json_attributes_topic"] = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/attributes"
@@ -317,15 +317,15 @@ def create_ha_devices():
devices[vin] = device
for entity in volvo.supported_endpoints[vin]:
config = {
"name": entity['name'],
"object_id": f"volvo_{vin}_{entity['id']}",
"schema": "state",
"icon": f"mdi:{entity['icon']}",
"state_topic": f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state",
"device": device,
"unique_id": f"volvoAAOS2mqtt_{vin}_{entity['id']}",
"availability_topic": availability_topic
}
"name": entity['name'],
"object_id": f"volvo_{vin}_{entity['id']}",
"schema": "state",
"icon": f"mdi:{entity['icon']}",
"state_topic": f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state",
"device": device,
"unique_id": f"volvoAAOS2mqtt_{vin}_{entity['id']}",
"availability_topic": availability_topic
}
if entity.get("device_class"):
config["device_class"] = entity["device_class"]
@@ -361,3 +361,9 @@ def send_heartbeat():
def send_offline():
mqtt_client.publish(availability_topic, "offline")
def delete_old_entities():
for vin in volvo.vins:
for entity in old_entity_ids:
topic = f"homeassistant/sensor/volvoAAOS2mqtt/{vin}_{entity}/config"
mqtt_client.publish(topic, payload="", retain=True)
+55 -40
View File
@@ -4,7 +4,7 @@ import mqtt
import util
import time
import re
from threading import currentThread
from threading import current_thread, Thread
from datetime import datetime, timedelta
from config import settings
from babel.dates import format_datetime
@@ -12,7 +12,7 @@ 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, \
WINDOWS_STATE_URL, LOCK_STATE_URL, TYRE_STATE_URL, supported_entities, BATTERY_CHARGE_STATE_URL, \
STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, engine_states
STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, API_BACKEND_STATUS, engine_states
session = requests.Session()
session.headers = {
@@ -27,6 +27,7 @@ vins = []
supported_endpoints = {}
cached_requests = {}
vcc_api_keys = []
backend_status = ""
def authorize():
@@ -54,6 +55,7 @@ def authorize():
get_vcc_api_keys()
get_vehicles()
check_supported_endpoints()
Thread(target=get_backend_status).start()
else:
message = auth.json()
raise Exception(message["error_description"])
@@ -116,6 +118,7 @@ def get_vehicles():
else:
initialize_climate(vins)
initialize_scheduler(vins)
mqtt.delete_old_entities()
logging.info("Vin: " + str(vins) + " found!")
@@ -245,11 +248,6 @@ def check_supported_endpoints():
# If battery charge level could be found in recharge-api, skip the second battery charge sensor
continue
if entity["id"] == "engine_state" and entity["url"] == ENGINE_DIAGNOSTICS_URL \
and any("engine_state" in d["id"] for d in supported_endpoints[vin]):
# If engine state could be found in engine state endpoint, skip the second engine running sensor
continue
if entity.get('url'):
state = api_call(entity["url"], "GET", vin, entity["id"])
else:
@@ -308,7 +306,7 @@ def check_engine_status(vin):
# Exit thread as engine state is unsupported by car
return None
t = currentThread()
t = current_thread()
while getattr(t, "do_run", True):
engine_state = api_call(endpoint_url, "GET", vin, "engine_state", True)
if engine_state == "ON":
@@ -318,6 +316,20 @@ def check_engine_status(vin):
time.sleep(5)
def get_backend_status():
global backend_status
while True:
response = session.get(API_BACKEND_STATUS, timeout=15)
try:
data = response.json()
backend_status = data["message"] if util.keys_exists(data, "message") else "No warnings"
except JSONDecodeError as e:
backend_status = "No warnings"
# Update every hour
time.sleep(3600)
def api_call(url, method, vin, sensor_id=None, force_update=False, key_change=False):
if datetime.now(util.TZ) >= token_expires_at:
refresh_auth()
@@ -369,7 +381,7 @@ def api_call(url, method, vin, sensor_id=None, force_update=False, key_change=Fa
return None
elif response.status_code == 403 and "message" in data:
if "Out of call volume quota" in data["message"]:
logging.warn("Quota extended. Try to change VCCAPIKEY!")
logging.warning("Quota extended. Try to change VCCAPIKEY!")
change_vcc_api_key()
api_call(url, method, vin, sensor_id, force_update, True)
else:
@@ -415,7 +427,9 @@ def cached_request(url, method, vin, force_update=False, key_change=False):
def parse_api_data(data, sensor_id=None):
data = data["data"]
if sensor_id != "api_backend_status":
data = data["data"]
if sensor_id == "battery_charge_level":
return data["batteryChargeLevel"]["value"] if util.keys_exists(data, "batteryChargeLevel") else None
elif sensor_id == "electric_range":
@@ -448,7 +462,7 @@ def parse_api_data(data, sensor_id=None):
return ""
return None
elif sensor_id == "lock_status":
return data["carLocked"]["value"] if util.keys_exists(data, "carLocked") else None
return data["centralLock"]["value"] if util.keys_exists(data, "centralLock") else None
elif sensor_id == "odometer":
multiplier = 1
if util.keys_exists(settings["volvoData"], "odometerMultiplier"):
@@ -460,44 +474,44 @@ def parse_api_data(data, sensor_id=None):
return util.convert_metric_values(int(data["odometer"]["value"]) * multiplier) \
if util.keys_exists(data, "odometer") else None
elif sensor_id == "window_front_left":
return window_states[data["frontLeftWindowOpen"]["value"]] if util.keys_exists(data, "frontLeftWindowOpen") \
return window_states[data["frontLeftWindow"]["value"]] if util.keys_exists(data, "frontLeftWindow") \
else None
elif sensor_id == "window_front_right":
return window_states[data["frontRightWindowOpen"]["value"]] if util.keys_exists(data, "frontRightWindowOpen") \
return window_states[data["frontRightWindow"]["value"]] if util.keys_exists(data, "frontRightWindow") \
else None
elif sensor_id == "window_rear_left":
return window_states[data["rearLeftWindowOpen"]["value"]] if util.keys_exists(data, "rearLeftWindowOpen") \
return window_states[data["rearLeftWindow"]["value"]] if util.keys_exists(data, "rearLeftWindow") \
else None
elif sensor_id == "window_rear_right":
return window_states[data["rearRightWindowOpen"]["value"]] if util.keys_exists(data, "rearRightWindowOpen") \
return window_states[data["rearRightWindow"]["value"]] if util.keys_exists(data, "rearRightWindow") \
else None
elif sensor_id == "door_front_left":
return door_states[data["frontLeftDoorOpen"]["value"]] if util.keys_exists(data, "frontLeftDoorOpen") else None
return door_states[data["frontLeftDoor"]["value"]] if util.keys_exists(data, "frontLeftDoor") else None
elif sensor_id == "door_front_right":
return door_states[data["frontRightDoorOpen"]["value"]] \
if util.keys_exists(data, "frontRightDoorOpen") else None
return door_states[data["frontRightDoor"]["value"]] \
if util.keys_exists(data, "frontRightDoor") else None
elif sensor_id == "door_rear_left":
return door_states[data["rearLeftDoorOpen"]["value"]] if util.keys_exists(data, "rearLeftDoorOpen") else None
return door_states[data["rearLeftDoor"]["value"]] if util.keys_exists(data, "rearLeftDoor") else None
elif sensor_id == "door_rear_right":
return door_states[data["rearRightDoorOpen"]["value"]] if util.keys_exists(data, "rearRightDoorOpen") else None
return door_states[data["rearRightDoor"]["value"]] if util.keys_exists(data, "rearRightDoor") else None
elif sensor_id == "tailgate":
return door_states[data["tailGateOpen"]["value"]] if util.keys_exists(data, "tailGateOpen") else None
return door_states[data["tailgate"]["value"]] if util.keys_exists(data, "tailgate") else None
elif sensor_id == "sunroof":
return door_states[data["sunRoofOpen"]["value"]] if util.keys_exists(data, "sunRoofOpen") else None
return door_states[data["sunroof"]["value"]] if util.keys_exists(data, "sunroof") else None
elif sensor_id == "engine_hood":
return door_states[data["hoodOpen"]["value"]] if util.keys_exists(data, "hoodOpen") else None
return door_states[data["hood"]["value"]] if util.keys_exists(data, "hood") else None
elif sensor_id == "tank_lid":
return door_states[data["tankLidOpen"]["value"]] if util.keys_exists(data, "tankLidOpen") else None
return door_states[data["tankLid"]["value"]] if util.keys_exists(data, "tankLid") else None
elif sensor_id == "tyre_front_left":
return data["frontLeftTyrePressure"]["value"] if util.keys_exists(data, "frontLeftTyrePressure") else None
return data["frontLeft"]["value"] if util.keys_exists(data, "frontLeft") else None
elif sensor_id == "tyre_front_right":
return data["frontRightTyrePressure"]["value"] if util.keys_exists(data, "frontRightTyrePressure") else None
return data["frontRight"]["value"] if util.keys_exists(data, "frontRight") else None
elif sensor_id == "tyre_rear_left":
return data["rearLeftTyrePressure"]["value"] if util.keys_exists(data, "rearLeftTyrePressure") else None
return data["rearLeft"]["value"] if util.keys_exists(data, "rearLeft") else None
elif sensor_id == "tyre_rear_right":
return data["rearRightTyrePressure"]["value"] if util.keys_exists(data, "rearRightTyrePressure") else None
return data["rearRight"]["value"] if util.keys_exists(data, "rearRight") else None
elif sensor_id == "engine_state":
return engine_states[data["engineRunning"]["value"]] if util.keys_exists(data, "engineRunning") else None
return engine_states[data["engineStatus"]["value"]] if util.keys_exists(data, "engineStatus") else None
elif sensor_id == "fuel_level":
if util.keys_exists(data, "fuelAmount"):
fuel_amount = float(data["fuelAmount"]["value"])
@@ -540,24 +554,25 @@ def parse_api_data(data, sensor_id=None):
"gps_accuracy": 1}
return coordinates
elif sensor_id == "distance_to_empty":
if util.keys_exists(data, "distanceToEmpty"):
distance_to_empty = int(data["distanceToEmpty"]["value"])
if util.keys_exists(data, "distanceToEmptyTank"):
distance_to_empty = int(data["distanceToEmptyTank"]["value"])
if distance_to_empty > 0:
return util.convert_metric_values(data["distanceToEmpty"]["value"])
return util.convert_metric_values(data["distanceToEmptyTank"]["value"])
return None
elif sensor_id == "hours_to_service":
return data["engineHoursToService"]["value"] if util.keys_exists(data, "engineHoursToService") else None
elif sensor_id == "km_to_service":
if util.keys_exists(data, "kmToService"):
km_to_service = int(data["kmToService"]["value"])
if util.keys_exists(data, "distanceToService"):
km_to_service = int(data["distanceToService"]["value"])
if km_to_service > 0:
return util.convert_metric_values(data["kmToService"]["value"])
return util.convert_metric_values(data["distanceToService"]["value"])
return None
elif sensor_id == "months_to_service":
return data["monthsToService"]["value"] if util.keys_exists(data, "monthsToService") else None
elif sensor_id == "time_to_service":
if util.keys_exists(data, "timeToService"):
return str(data["timeToService"]["value"]) + " " + data["timeToService"]["unit"]
else:
return None
elif sensor_id == "service_warning_status":
return data["serviceWarningStatus"]["value"] if util.keys_exists(data, "serviceWarningStatus") else None
elif sensor_id == "service_warning_trigger":
return data["serviceWarningTrigger"]["value"] if util.keys_exists(data, "serviceWarningTrigger") else None
return data["serviceWarning"]["value"] if util.keys_exists(data, "serviceWarning") else None
else:
return None