Compare commits

...
10 Commits
Author SHA1 Message Date
Linus Dietz 818d68675f Add changelog for v1.7.7 2023-07-31 09:17:58 +02:00
Linus Dietz 9412d837d0 Bump Version 2023-07-31 09:14:25 +02:00
Linus Dietz d49afa5bed Remove done climate timers from "Active schedules" sensor 2023-07-31 08:56:16 +02:00
Linus Dietz f5c8d06aa1 Fix broken json 2023-07-31 08:19:53 +02:00
Bogdan BujdeaandGitHub 6627a024a9 Add long term statistics for HA (#69) 2023-07-31 08:17:42 +02:00
Linus Dietz 9de14bd70b Add climate scheduler option 2023-07-31 08:11:50 +02:00
Ben MarengoandGitHub 880005fdb6 tested compatible with XC90 PHEV T8 2024 (#66)
tested fully compatible with week old Volvo XC90 SUV PiH 2.0Recharge T8 18.8kWh 455 GPF SS EU6 Plus Auto8
2023-07-16 11:04:37 +02:00
Linus Dietz 8bacc1a76a Redact sensitive data from debug log 2023-07-12 17:05:16 +02:00
Linus Dietz 96805d439d Add interior and exterior image entities #62 2023-07-09 16:06:13 +02:00
Linus DietzandGitHub 10c3e7bd97 Update README.md 2023-07-06 22:05:46 +02:00
7 changed files with 205 additions and 61 deletions
+2 -1
View File
@@ -29,6 +29,7 @@ If you like my work:<br>
- XC60 PHEV (2023)
- XC60 PHEV (2022)
- XC90 PHEV T8 (2023)
- XC90 PHEV T8 (2024)
- V90 PHEV T8 (2019)*
*only partly working
@@ -42,7 +43,7 @@ Please let me know if your car works with this addon so I can expand the list!<b
- Sensor "Battery Charge Level"
- Sensor "Electric Range"
- Sensor "Charging System Status"
- Sensor "Charging Connection Status"
- Sensor "Charging Connection Status" (partly broken since 2.10 car update)
- Sensor "Estimated Charging Finish Time"
- Sensor "Door Lock Status (All doors, tank lid, and engine hood)"
- Sensor "Window Lock Status (All windows and sunroof - Thanks to @navet)"
+13
View File
@@ -1,3 +1,16 @@
## v1.7.7
### 🚀 Features:
- Added long term statistics for some sensors #68. Thanks to @bogdanbujdea!
- Automatic redact sensitive data from debug log
## v1.7.6
### 🚀 Features:
- Added support for interior and exterior images via the new [MQTT Image](https://www.home-assistant.io/integrations/image.mqtt/) entity. #62
<b>NOTE: This feature is only available using homeassistant version 2023.7.1 or greater.</b>
## v1.7.5
### 🚀 Features:
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Volvo2Mqtt"
description: "Volvo AAOS MQTT bridge"
version: "1.7.5"
version: "1.7.7"
slug: "volvo2mqtt"
init: false
url: "https://github.com/Dielee/volvo2mqtt"
+11 -10
View File
@@ -1,6 +1,6 @@
from config import settings
VERSION = "v1.7.5"
VERSION = "v1.7.7"
OAUTH_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2"
VEHICLES_URL = "https://api.volvocars.com/connected-vehicle/v1/vehicles"
@@ -74,15 +74,16 @@ icon_states = {
}
supported_entities = [
{"name": "Battery Charge Level", "domain": "sensor", "id": "battery_charge_level", "unit": "%", "icon": "car-battery", "url": RECHARGE_STATE_URL},
{"name": "Battery Charge Level", "domain": "sensor", "id": "battery_charge_level", "unit": "%", "icon": "car-battery", "url": BATTERY_CHARGE_STATE_URL},
{"name": "Electric Range", "domain": "sensor", "id": "electric_range", "unit": "km" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["electric_range"]["unit"], "icon": "map-marker-distance", "url": RECHARGE_STATE_URL},
{"name": "Estimated Charging Time", "domain": "sensor", "id": "estimated_charging_time", "unit": "minutes", "icon": "timer-sync-outline", "url": RECHARGE_STATE_URL},
{"name": "Battery Charge Level", "domain": "sensor", "id": "battery_charge_level", "unit": "%", "icon": "car-battery", "url": RECHARGE_STATE_URL, "state_class": "measurement"},
{"name": "Battery Charge Level", "domain": "sensor", "id": "battery_charge_level", "unit": "%", "icon": "car-battery", "url": BATTERY_CHARGE_STATE_URL, "state_class": "measurement"},
{"name": "Electric Range", "domain": "sensor", "id": "electric_range", "unit": "km" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["electric_range"]["unit"], "icon": "map-marker-distance", "url": RECHARGE_STATE_URL, "state_class": "measurement"},
{"name": "Estimated Charging Time", "domain": "sensor", "id": "estimated_charging_time", "unit": "minutes", "icon": "timer-sync-outline", "url": RECHARGE_STATE_URL, "state_class": "measurement"},
{"name": "Charging System Status", "domain": "sensor", "id": "charging_system_status", "icon": "ev-station", "url": RECHARGE_STATE_URL},
{"name": "Charging Connection Status", "domain": "sensor", "id": "charging_connection_status", "icon": "ev-plug-ccs2", "url": RECHARGE_STATE_URL},
{"name": "Estimated Charging Finish Time", "domain": "sensor", "id": "estimated_charging_finish_time", "icon": "timer-sync-outline", "url": RECHARGE_STATE_URL},
{"name": "Odometer", "domain": "sensor", "id": "odometer", "unit": "km" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["odometer"]["unit"], "icon": "counter", "url": ODOMETER_STATE_URL},
{"name": "Estimated Charging Finish Time", "domain": "sensor", "id": "estimated_charging_finish_time", "icon": "timer-sync-outline", "url": RECHARGE_STATE_URL, "state_class": "measurement"},
{"name": "Odometer", "domain": "sensor", "id": "odometer", "unit": "km" if not units.get(settings["babelLocale"]) else units[settings["babelLocale"]]["odometer"]["unit"], "icon": "counter", "url": ODOMETER_STATE_URL, "state_class":"total_increasing"},
{"name": "Last Data Update", "domain": "sensor", "id": "last_data_update", "icon": "timer", "url": ""},
{"name": "Active schedules", "domain": "sensor", "id": "active_schedules", "icon": "timer", "url": ""},
{"name": "Window Front Left", "domain": "binary_sensor", "device_class": "window", "id": "window_front_left", "icon": "car-door-lock", "url": WINDOWS_STATE_URL},
{"name": "Window Front Right", "domain": "binary_sensor", "device_class": "window", "id": "window_front_right", "icon": "car-door-lock", "url": WINDOWS_STATE_URL},
{"name": "Window Rear Left", "domain": "binary_sensor", "device_class": "window", "id": "window_rear_left", "icon": "car-door-lock", "url": WINDOWS_STATE_URL},
@@ -105,8 +106,8 @@ supported_entities = [
{"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},
{"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},
{"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}
{"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"}
]
+143 -41
View File
@@ -19,6 +19,7 @@ last_data_update = None
climate_timer = {}
engine_status = {}
devices = {}
active_schedules = {}
def connect():
@@ -42,6 +43,43 @@ def connect():
mqtt_client = client
def send_car_images(vin, data, device):
if util.keys_exists(data, "images"):
for entity in [{"name": "Exterior Image", "id": "exterior_image"},
{"name": "Interior Image", "id": "interior_image"}]:
url_topic = f"homeassistant/image/{vin}_{entity['id']}/image_url"
config = {
"name": entity["name"],
"object_id": f"volvo_{vin}_{entity['id']}",
"schema": "state",
"icon": "mdi:image-area",
"url_topic": url_topic,
"device": device,
"unique_id": f"volvoAAOS2mqtt_{vin}_{entity['id']}",
"availability_topic": availability_topic
}
mqtt_client.publish(
f"homeassistant/image/volvoAAOS2mqtt/{vin}_{entity['id']}/config",
json.dumps(config),
retain=True
)
if entity["id"] == "exterior_image":
mqtt_client.publish(
url_topic,
data["images"]["exteriorDefaultUrl"],
retain=True
)
if entity["id"] == "interior_image":
mqtt_client.publish(
url_topic,
data["images"]["interiorDefaultUrl"],
retain=True
)
def on_connect(client, userdata, flags, rc):
send_heartbeat()
if len(subscribed_topics) > 0:
@@ -62,8 +100,98 @@ def on_message(client, userdata, msg):
payload = msg.payload.decode("UTF-8")
if "climate_status" in msg.topic:
global assumed_climate_state, climate_timer, engine_status
if payload == "ON":
start_climate(vin)
elif payload == "OFF":
stop_climate(vin)
elif "lock_status" in msg.topic:
if payload == "LOCK":
lock_car(vin)
elif payload == "UNLOCK":
unlock_car(vin)
elif "update_data" in msg.topic:
if payload == "PRESS":
update_car_data(True)
elif "schedule" in msg.topic:
try:
d = json.loads(payload)
except ValueError as e:
logging.error("Can't set timer. Error: " + str(e))
return None
if d["mode"] == "timer":
start_climate_timer(d, vin)
else:
logging.warning("No schedule mode found, doing nothing")
def start_climate_timer(d, vin):
global active_schedules
try:
minute = int(d["start_time"].split(":")[1])
hour = int(d["start_time"].split(":")[0])
local_datetime = datetime.now(util.TZ)
start_datetime = local_datetime.replace(hour=hour, minute=minute, second=0)
timer_seconds = (start_datetime - local_datetime).total_seconds()
except Exception as e:
logging.error("Error creating climate timer: " + str(e))
return None
if timer_seconds > 0:
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()
else:
logging.warning("Timer can not be set. Unusable start time entered")
def unlock_car(vin):
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CAR_UNLOCK_URL, "POST", vin)).start()
# Force set unlocking state
update_car_data(False, {"entity_id": "lock_status", "vin": vin, "state": "UNLOCKING"})
# Fetch API lock state until unlocking finished
Thread(target=volvo.check_lock_status, args=(vin, "LOCKED")).start()
def lock_car(vin):
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CAR_LOCK_URL, "POST", vin)).start()
# Force set locking state
update_car_data(False, {"entity_id": "lock_status", "vin": vin, "state": "LOCKING"})
# Fetch API lock state until locking finished
Thread(target=volvo.check_lock_status, args=(vin, "UNLOCKED")).start()
def stop_climate(vin):
global assumed_climate_state, climate_timer, engine_status
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CLIMATE_STOP_URL, "POST", vin)).start()
# Stop door check thread if running
if engine_status[vin].is_alive():
engine_status[vin].do_run = False
# Stop climate timer if active
if climate_timer[vin].is_alive():
climate_timer[vin].cancel()
# Set and update switch status
assumed_climate_state[vin] = "OFF"
update_car_data()
def activate_climate_timer(vin, start_time):
start_climate(vin)
active_schedules[vin]["timers"].remove(start_time)
update_car_data()
def start_climate(vin):
global assumed_climate_state, climate_timer, engine_status
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CLIMATE_START_URL, "POST", vin)).start()
@@ -79,41 +207,6 @@ def on_message(client, userdata, msg):
# Set and update switch status
assumed_climate_state[vin] = "ON"
update_car_data()
elif payload == "OFF":
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CLIMATE_STOP_URL, "POST", vin)).start()
# Stop door check thread if running
if engine_status[vin].is_alive():
engine_status[vin].do_run = False
# Stop climate timer if active
if climate_timer[vin].is_alive():
climate_timer[vin].cancel()
# Set and update switch status
assumed_climate_state[vin] = "OFF"
update_car_data()
elif "lock_status" in msg.topic:
if payload == "LOCK":
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CAR_LOCK_URL, "POST", vin)).start()
# Force set locking state
update_car_data(False, {"entity_id": "lock_status", "vin": vin, "state": "LOCKING"})
# Fetch API lock state until locking finished
Thread(target=volvo.check_lock_status, args=(vin, "UNLOCKED")).start()
elif payload == "UNLOCK":
# Start the api call in another thread for HA performance
Thread(target=volvo.api_call, args=(CAR_UNLOCK_URL, "POST", vin)).start()
# Force set unlocking state
update_car_data(False, {"entity_id": "lock_status", "vin": vin, "state": "UNLOCKING"})
# Fetch API lock state until unlocking finished
Thread(target=volvo.check_lock_status, args=(vin, "LOCKED")).start()
elif "update_data" in msg.topic:
if payload == "PRESS":
update_car_data(True)
def update_loop():
@@ -146,20 +239,22 @@ def update_car_data(force_update=False, overwrite={}):
state = assumed_climate_state[vin]
elif entity["id"] == "last_data_update":
state = last_data_update
elif entity["id"] == "active_schedules":
state = active_schedules[vin]
else:
if entity["id"] == ov_entity_id and vin == ov_vin:
state = ov_state
else:
state = volvo.api_call(entity["url"], "GET", vin, entity["id"], force_update)
if entity["domain"] == "device_tracker":
if entity["domain"] == "device_tracker" or entity["id"] == "active_schedules":
topic = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/attributes"
else:
topic = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/state"
mqtt_client.publish(
topic,
json.dumps(state) if isinstance(state, dict) else state
json.dumps(state) if isinstance(state, dict) or isinstance(state, list) else state
)
update_ha_device(entity, vin, state)
@@ -192,6 +287,9 @@ def update_ha_device(entity, vin, state):
if entity.get("unit"):
config["unit_of_measurement"] = entity["unit"]
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"
@@ -230,14 +328,18 @@ def create_ha_devices():
if entity.get("unit"):
config["unit_of_measurement"] = entity["unit"]
if entity.get("domain") == "device_tracker":
config["json_attributes_topic"] = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/attributes"
if entity.get("state_class"):
config["state_class"] = entity["state_class"]
if entity.get("domain") in ["switch", "lock", "button"]:
if entity.get("domain") == "device_tracker" or entity.get("id") == "active_schedules":
config["json_attributes_topic"] = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/attributes"
elif entity.get("domain") in ["switch", "lock", "button"]:
command_topic = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/command"
config["command_topic"] = command_topic
subscribed_topics.append(command_topic)
mqtt_client.subscribe(command_topic)
elif entity.get("domain") == "image":
config["url_topic"] = f"homeassistant/{entity['domain']}/{vin}_{entity['id']}/image_url"
mqtt_client.publish(
f"homeassistant/{entity['domain']}/volvoAAOS2mqtt/{vin}_{entity['id']}/config",
+19
View File
@@ -1,6 +1,7 @@
import logging
import pytz
import os
import re
import sys
import config
from logging import handlers
@@ -10,6 +11,21 @@ from config import settings
from pathlib import Path
TZ = None
SENSITIVE_PATTERNS = [
r"[A-Z0-9]{17}", # VIN
r"\d{1,2}\.\d{5,16}" # Location
]
class SensitiveDataFilter(logging.Filter):
def __init__(self, patterns=None):
super().__init__()
self.patterns = patterns or []
def filter(self, record: logging.LogRecord) -> bool:
for pattern in self.patterns:
record.msg = re.sub(pattern, "<REDACTED>", record.msg)
return True
def get_icon_between(icon_list, state):
@@ -32,6 +48,8 @@ def setup_logging():
'%(asctime)s volvo2mqtt [%(process)d] - %(levelname)s: %(message)s',
'%b %d %H:%M:%S')
file_log_handler.setFormatter(formatter)
sensitive_data_filter = SensitiveDataFilter(SENSITIVE_PATTERNS)
file_log_handler.addFilter(sensitive_data_filter)
logger = logging.getLogger()
console_log_handler = logging.StreamHandler(sys.stdout)
@@ -39,6 +57,7 @@ def setup_logging():
logger.addHandler(console_log_handler)
logger.addHandler(file_log_handler)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logger.setLevel(logging.INFO)
if "debug" in settings:
+8
View File
@@ -111,6 +111,7 @@ def get_vehicles():
raise Exception("No vehicle found, exiting application!")
else:
initialize_climate(vins)
initialize_scheduler(vins)
logging.info("Vin: " + str(vins) + " found!")
@@ -125,6 +126,7 @@ def get_vehicle_details(vin):
"model": data['descriptions']['model'],
"name": f"{data['descriptions']['model']} ({data['modelYear']}) - {vin}",
}
mqtt.send_car_images(vin, data, device)
elif response.status_code == 500 and not settings.volvoData["vin"]:
# Workaround for some cars that are not returning vehicle details
device = {
@@ -167,6 +169,12 @@ def check_supported_endpoints():
logging.info("Failed, " + entity["name"] + " is unfortunately not supported by your vehicle.")
def initialize_scheduler(vins):
for vin in vins:
mqtt.active_schedules[vin] = {"timers": []}
mqtt.subscribed_topics = [f"homeassistant/schedule/{vin}/command"]
def initialize_climate(vins):
for vin in vins:
mqtt.assumed_climate_state[vin] = "OFF"