From 963a9ffd22ce80ff857f7d0dc5e854d5ff0aca1d Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 12 May 2024 14:06:57 +0200 Subject: [PATCH 01/35] Remove un-used function calls --- mqtt_gateway.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mqtt_gateway.py b/mqtt_gateway.py index 729055f..0ce7475 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -322,7 +322,8 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): raise MqttGatewayException(f'Error setting value for payload {payload}') else: logging.info( - 'Unknown Target SOC: waiting for state update before changing charge current limit') + 'Unknown Target SOC: waiting for state update before changing charge current limit' + ) raise MqttGatewayException( f'Error setting charge current limit - SOC {self.vehicle_state.target_soc}') case mqtt_topics.DRIVETRAIN_SOC_TARGET: @@ -431,8 +432,6 @@ def __init__(self, config: Configuration): ), listener=MqttGatewaySaicApiListener(self.publisher) ) - self.saic_api.on_publish_json_value = self.__on_publish_json_value - self.saic_api.on_publish_raw_value = self.__on_publish_raw_value async def run(self): scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() From 19b499f1ed696a3866259dfdbeab7fa4c6a9cb12 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 12 May 2024 14:18:05 +0200 Subject: [PATCH 02/35] Disable dumping of ABRP and API message to MQTT by default --- README.md | 16 +++++++++------- configuration.py | 2 ++ mqtt_gateway.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 31c79a1..c932dbc 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,15 @@ The key-value pairs in the JSON express the following: ## Advanced settings -| CMD param | ENV variable | Description | -|---------------------------------|-------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 | -| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default | -| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | -| | LOG_LEVEL | Log level: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | -| | MQTT_LOG_LEVEL | Log level of the MQTT Client: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | +| CMD param | ENV variable | Description | +|----------------------------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 | +| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default | +| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | +| --publish-raw-api-data | PUBLISH_RAW_API_DATA_ENABLED | Publish raw SAIC API request/response to MQTT. Disabled (False) by default. | +| --publish-raw-abrp-data | PUBLISH_RAW_ABRP_DATA_ENABLED | Publish raw ABRP API request/response to MQTT. Disabled (False) by default. | +| | LOG_LEVEL | Log level: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | +| | MQTT_LOG_LEVEL | Log level of the MQTT Client: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | ## Running the service diff --git a/configuration.py b/configuration.py index d173d42..b15a6a0 100644 --- a/configuration.py +++ b/configuration.py @@ -40,3 +40,5 @@ def __init__(self): self.ha_discovery_prefix: str = 'homeassistant' self.ha_show_unavailable: bool = True self.charge_dynamic_polling_min_percentage: float = 1.0 + self.publish_raw_api_data: bool = False + self.publish_raw_abrp_data: bool = False diff --git a/mqtt_gateway.py b/mqtt_gateway.py index 0ce7475..ba80b93 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -71,10 +71,14 @@ def __init__(self, config: Configuration, saicapi: SaicApi, publisher: Publisher abrp_user_token = self.configuration.abrp_token_map[vin_info.vin] else: abrp_user_token = None + if config.publish_raw_abrp_data: + listener = MqttGatewayAbrpListener(self.publisher) + else: + listener = None self.abrp_api = AbrpApi( self.configuration.abrp_api_key, abrp_user_token, - listener=MqttGatewayAbrpListener(self.publisher) + listener=listener ) async def handle_vehicle(self) -> None: @@ -419,6 +423,10 @@ def __init__(self, config: Configuration): self.publisher = MqttClient(self.configuration) self.publisher.command_listener = self username_is_email = "@" in self.configuration.saic_user + if config.publish_raw_api_data: + listener = MqttGatewaySaicApiListener(self.publisher) + else: + listener = None self.saic_api = SaicApi( configuration=SaicApiConfiguration( username=self.configuration.saic_user, @@ -430,7 +438,7 @@ def __init__(self, config: Configuration): region=self.configuration.saic_region, tenant_id=self.configuration.saic_tenant_id ), - listener=MqttGatewaySaicApiListener(self.publisher) + listener=listener ) async def run(self): @@ -796,6 +804,17 @@ def process_arguments() -> Configuration: help='How many % points we should try to refresh the charge state. Environment Variable: ' 'CHARGE_MIN_PERCENTAGE', dest='charge_dynamic_polling_min_percentage', required=False, action=EnvDefault, envvar='CHARGE_MIN_PERCENTAGE', default='1.0', type=check_positive_float) + parser.add_argument('--publish-raw-api-data', + help='Publish raw SAIC API request/response to MQTT. Environment Variable: ' + 'PUBLISH_RAW_API_DATA_ENABLED', + dest='publish_raw_api_data', required=False, + action=EnvDefault, + envvar='PUBLISH_RAW_API_DATA_ENABLED', default=False, type=check_bool) + parser.add_argument('--publish-raw-abrp-data', + help='Publish raw ABRP API request/response to MQTT. Environment Variable: ' + 'PUBLISH_RAW_ABRP_DATA_ENABLED', + dest='publish_raw_abrp_data', required=False, action=EnvDefault, + envvar='PUBLISH_RAW_ABRP_DATA_ENABLED', default=False, type=check_bool) args = parser.parse_args() config.mqtt_user = args.mqtt_user @@ -830,6 +849,12 @@ def process_arguments() -> Configuration: if args.ha_discovery_enabled is not None: config.ha_discovery_enabled = args.ha_discovery_enabled + if args.publish_raw_api_data is not None: + config.publish_raw_api_data = args.publish_raw_api_data + + if args.publish_raw_abrp_data is not None: + config.publish_raw_abrp_data = args.publish_raw_abrp_data + if args.ha_show_unavailable is not None: config.ha_show_unavailable = args.ha_show_unavailable From 758b02e285853f3dad88384b8ef4c8e079b9f7ce Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 12 May 2024 14:29:00 +0200 Subject: [PATCH 03/35] Move configuration handling into its own module --- configuration.py => configuration/__init__.py | 0 configuration/argparse_extensions.py | 50 ++++ configuration/parser.py | 227 +++++++++++++++ mqtt_gateway.py | 270 +----------------- 4 files changed, 280 insertions(+), 267 deletions(-) rename configuration.py => configuration/__init__.py (100%) create mode 100644 configuration/argparse_extensions.py create mode 100644 configuration/parser.py diff --git a/configuration.py b/configuration/__init__.py similarity index 100% rename from configuration.py rename to configuration/__init__.py diff --git a/configuration/argparse_extensions.py b/configuration/argparse_extensions.py new file mode 100644 index 0000000..ee25870 --- /dev/null +++ b/configuration/argparse_extensions.py @@ -0,0 +1,50 @@ +import argparse +import os +from typing import Callable + + +class EnvDefault(argparse.Action): + def __init__(self, envvar, required=True, default=None, **kwargs): + if ( + envvar in os.environ + and os.environ[envvar] + ): + default = os.environ[envvar] + if required and default: + required = False + super(EnvDefault, self).__init__(default=default, required=required, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values) + + +def cfg_value_to_dict(cfg_value: str, result_map: dict, value_type: Callable[[str], any] = str): + if ',' in cfg_value: + map_entries = cfg_value.split(',') + else: + map_entries = [cfg_value] + + for entry in map_entries: + if '=' in entry: + key_value_pair = entry.split('=') + key = key_value_pair[0] + value = key_value_pair[1] + result_map[key] = value_type(value) + + +def check_positive(value): + ivalue = int(value) + if ivalue <= 0: + raise argparse.ArgumentTypeError(f'{ivalue} is an invalid positive int value') + return ivalue + + +def check_positive_float(value): + fvalue = float(value) + if fvalue <= 0: + raise argparse.ArgumentTypeError(f'{fvalue} is an invalid positive float value') + return fvalue + + +def check_bool(value): + return str(value).lower() in ['true', '1', 'yes', 'y'] diff --git a/configuration/parser.py b/configuration/parser.py new file mode 100644 index 0000000..02eb421 --- /dev/null +++ b/configuration/parser.py @@ -0,0 +1,227 @@ +import argparse +import json +import logging +import urllib.parse + +from configuration import Configuration, TransportProtocol +from configuration.argparse_extensions import EnvDefault, check_positive, check_bool, check_positive_float, \ + cfg_value_to_dict +from integrations.openwb.charging_station import ChargingStation + +LOG = logging.getLogger(__name__) +CHARGING_STATIONS_FILE = 'charging-stations.json' + + +def __process_charging_stations_file(config: Configuration, json_file: str): + try: + with open(json_file, 'r') as f: + data = json.load(f) + + for item in data: + charge_state_topic = item['chargeStateTopic'] + charging_value = item['chargingValue'] + vin = item['vin'] + if 'socTopic' in item: + charging_station = ChargingStation(vin, charge_state_topic, charging_value, item['socTopic']) + else: + charging_station = ChargingStation(vin, charge_state_topic, charging_value) + if 'rangeTopic' in item: + charging_station.range_topic = item['rangeTopic'] + if 'chargerConnectedTopic' in item: + charging_station.connected_topic = item['chargerConnectedTopic'] + if 'chargerConnectedValue' in item: + charging_station.connected_value = item['chargerConnectedValue'] + config.charging_stations_by_vin[vin] = charging_station + except FileNotFoundError: + LOG.warning(f'File {json_file} does not exist') + except json.JSONDecodeError as e: + LOG.exception(f'Reading {json_file} failed', exc_info=e) + + +def process_arguments() -> Configuration: + config = Configuration() + parser = argparse.ArgumentParser(prog='MQTT Gateway') + try: + parser.add_argument('-m', '--mqtt-uri', + help='The URI to the MQTT Server. Environment Variable: MQTT_URI,' + + 'TCP: tcp://mqtt.eclipseprojects.io:1883 ' + + 'WebSocket: ws://mqtt.eclipseprojects.io:9001' + + 'TLS: tls://mqtt.eclipseprojects.io:8883', + dest='mqtt_uri', required=True, action=EnvDefault, envvar='MQTT_URI') + parser.add_argument('--mqtt-server-cert', + help='Path to the server certificate authority file in PEM format for TLS.', + dest='tls_server_cert_path', required=False, action=EnvDefault, envvar='MQTT_SERVER_CERT') + parser.add_argument('--mqtt-user', help='The MQTT user name. Environment Variable: MQTT_USER', + dest='mqtt_user', required=False, action=EnvDefault, envvar='MQTT_USER') + parser.add_argument('--mqtt-password', + help='The MQTT password. Environment Variable: MQTT_PASSWORD', dest='mqtt_password', + required=False, action=EnvDefault, envvar='MQTT_PASSWORD') + parser.add_argument('--mqtt-client-id', help='The MQTT Client Identifier. Environment Variable: ' + + 'MQTT_CLIENT_ID ' + + 'Default is saic-python-mqtt-gateway', + default='saic-python-mqtt-gateway', dest='mqtt_client_id', required=False, + action=EnvDefault, envvar='MQTT_CLIENT_ID') + parser.add_argument('--mqtt-topic-prefix', + help='MQTT topic prefix. Environment Variable: MQTT_TOPIC Default is saic', default='saic', + dest='mqtt_topic', required=False, action=EnvDefault, envvar='MQTT_TOPIC') + parser.add_argument('-s', '--saic-rest-uri', + help='The SAIC uri. Environment Variable: SAIC_REST_URI Default is the European ' + 'Production Endpoint: https://tap-eu.soimt.com', + default='https://gateway-mg-eu.soimt.com/api.app/v1/', dest='saic_rest_uri', required=False, + action=EnvDefault, + envvar='SAIC_REST_URI') + parser.add_argument('-u', '--saic-user', + help='The SAIC user name. Environment Variable: SAIC_USER', dest='saic_user', required=True, + action=EnvDefault, envvar='SAIC_USER') + parser.add_argument('-p', '--saic-password', + help='The SAIC password. Environment Variable: SAIC_PASSWORD', dest='saic_password', + required=True, action=EnvDefault, envvar='SAIC_PASSWORD') + parser.add_argument('--saic-phone-country-code', + help='The SAIC phone country code. Environment Variable: SAIC_PHONE_COUNTRY_CODE', + dest='saic_phone_country_code', required=False, action=EnvDefault, + envvar='SAIC_PHONE_COUNTRY_CODE') + parser.add_argument('--saic-region', '--saic-region', + help='The SAIC API region. Environment Variable: SAIC_REGION', default='eu', + dest='saic_region', required=False, action=EnvDefault, envvar='SAIC_REGION') + parser.add_argument('--saic-tenant-id', + help='The SAIC API tenant id. Environment Variable: SAIC_TENANT_ID', default='459771', + dest='saic_tenant_id', required=False, action=EnvDefault, + envvar='SAIC_TENANT_ID') + parser.add_argument('--abrp-api-key', + help='The API key for the A Better Route Planer telemetry API.' + + ' Default is the open source telemetry' + + ' API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d.' + + ' Environment Variable: ABRP_API_KEY', + default='8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d', dest='abrp_api_key', required=False, + action=EnvDefault, envvar='ABRP_API_KEY') + parser.add_argument('--abrp-user-token', help='The mapping of VIN to ABRP User Token.' + + ' Multiple mappings can be provided seperated by ,' + + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' + + ' Environment Variable: ABRP_USER_TOKEN', + dest='abrp_user_token', required=False, action=EnvDefault, envvar='ABRP_USER_TOKEN') + parser.add_argument('--battery-capacity-mapping', help='The mapping of VIN to full batteryc' + + ' apacity. Multiple mappings can be provided separated' + + ' by , Example: LSJXXXX=54.0,LSJYYYY=64.0,' + + ' Environment Variable: BATTERY_CAPACITY_MAPPING', + dest='battery_capacity_mapping', required=False, action=EnvDefault, + envvar='BATTERY_CAPACITY_MAPPING') + parser.add_argument('--charging-stations-json', + help='Custom charging stations configuration file name', dest='charging_stations_file', + required=False, action=EnvDefault, envvar='CHARGING_STATIONS_JSON') + parser.add_argument('--saic-relogin-delay', + help='How long to wait before attempting another login to the SAIC API. Environment ' + 'Variable: SAIC_RELOGIN_DELAY', dest='saic_relogin_delay', required=False, + action=EnvDefault, envvar='SAIC_RELOGIN_DELAY', type=check_positive) + parser.add_argument('--ha-discovery', + help='Enable Home Assistant Discovery. Environment Variable: HA_DISCOVERY_ENABLED', + dest='ha_discovery_enabled', required=False, + action=EnvDefault, + envvar='HA_DISCOVERY_ENABLED', default=True, type=check_bool) + parser.add_argument('--ha-discovery-prefix', + help='Home Assistant Discovery Prefix. Environment Variable: HA_DISCOVERY_PREFIX', + dest='ha_discovery_prefix', required=False, action=EnvDefault, envvar='HA_DISCOVERY_PREFIX', + default='homeassistant') + parser.add_argument('--ha-show-unavailable', + help='Show entities as Unavailable in Home Assistant when car polling fails. ' + 'Environment Variable: HA_SHOW_UNAVAILABLE', dest='ha_show_unavailable', + required=False, action=EnvDefault, envvar='HA_SHOW_UNAVAILABLE', default=True, + type=check_bool) + parser.add_argument('--messages-request-interval', + help='The interval for retrieving messages in seconds. Environment Variable: ' + 'MESSAGES_REQUEST_INTERVAL', dest='messages_request_interval', + required=False, action=EnvDefault, + envvar='MESSAGES_REQUEST_INTERVAL', default=60) + parser.add_argument('--charge-min-percentage', + help='How many % points we should try to refresh the charge state. Environment Variable: ' + 'CHARGE_MIN_PERCENTAGE', dest='charge_dynamic_polling_min_percentage', required=False, + action=EnvDefault, envvar='CHARGE_MIN_PERCENTAGE', default='1.0', type=check_positive_float) + parser.add_argument('--publish-raw-api-data', + help='Publish raw SAIC API request/response to MQTT. Environment Variable: ' + 'PUBLISH_RAW_API_DATA_ENABLED', + dest='publish_raw_api_data', required=False, + action=EnvDefault, + envvar='PUBLISH_RAW_API_DATA_ENABLED', default=False, type=check_bool) + parser.add_argument('--publish-raw-abrp-data', + help='Publish raw ABRP API request/response to MQTT. Environment Variable: ' + 'PUBLISH_RAW_ABRP_DATA_ENABLED', + dest='publish_raw_abrp_data', required=False, action=EnvDefault, + envvar='PUBLISH_RAW_ABRP_DATA_ENABLED', default=False, type=check_bool) + + args = parser.parse_args() + config.mqtt_user = args.mqtt_user + config.mqtt_password = args.mqtt_password + config.mqtt_client_id = args.mqtt_client_id + config.charge_dynamic_polling_min_percentage = args.charge_dynamic_polling_min_percentage + if args.saic_relogin_delay: + config.saic_relogin_delay = args.saic_relogin_delay + config.mqtt_topic = args.mqtt_topic + config.saic_rest_uri = args.saic_rest_uri + config.saic_region = args.saic_region + config.saic_tenant_id = str(args.saic_tenant_id) + config.saic_user = args.saic_user + config.saic_password = args.saic_password + config.saic_phone_country_code = args.saic_phone_country_code + config.abrp_api_key = args.abrp_api_key + if args.abrp_user_token: + cfg_value_to_dict(args.abrp_user_token, config.abrp_token_map) + if args.battery_capacity_mapping: + cfg_value_to_dict( + args.battery_capacity_mapping, + config.battery_capacity_map, + value_type=check_positive_float + ) + if args.charging_stations_file: + __process_charging_stations_file(config, args.charging_stations_file) + else: + __process_charging_stations_file(config, f'./{CHARGING_STATIONS_FILE}') + + config.saic_password = args.saic_password + + if args.ha_discovery_enabled is not None: + config.ha_discovery_enabled = args.ha_discovery_enabled + + if args.publish_raw_api_data is not None: + config.publish_raw_api_data = args.publish_raw_api_data + + if args.publish_raw_abrp_data is not None: + config.publish_raw_abrp_data = args.publish_raw_abrp_data + + if args.ha_show_unavailable is not None: + config.ha_show_unavailable = args.ha_show_unavailable + + if args.ha_discovery_prefix: + config.ha_discovery_prefix = args.ha_discovery_prefix + + try: + config.messages_request_interval = int(args.messages_request_interval) + except ValueError: + raise SystemExit(f'No valid integer value for messages_request_interval: {args.messages_request_interval}') + + parse_result = urllib.parse.urlparse(args.mqtt_uri) + if parse_result.scheme == 'tcp': + config.mqtt_transport_protocol = TransportProtocol.TCP + elif parse_result.scheme == 'ws': + config.mqtt_transport_protocol = TransportProtocol.WS + elif parse_result.scheme == 'tls': + config.mqtt_transport_protocol = TransportProtocol.TLS + if args.tls_server_cert_path: + config.tls_server_cert_path = args.tls_server_cert_path + else: + raise SystemExit(f'No server certificate authority file provided for TLS MQTT URI {args.mqtt_uri}') + else: + raise SystemExit(f'Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws') + + if not parse_result.port: + if config.mqtt_transport_protocol == 'tcp': + config.mqtt_port = 1883 + else: + config.mqtt_port = 9001 + else: + config.mqtt_port = parse_result.port + + config.mqtt_host = str(parse_result.hostname) + + return config + except argparse.ArgumentError as err: + parser.print_help() + SystemExit(err) diff --git a/mqtt_gateway.py b/mqtt_gateway.py index ba80b93..6c75408 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -1,4 +1,3 @@ -import argparse import asyncio import datetime import faulthandler @@ -8,8 +7,7 @@ import signal import sys import time -import urllib.parse -from typing import Callable, override +from typing import override import apscheduler.schedulers.asyncio from saic_ismart_client_ng import SaicApi @@ -23,7 +21,8 @@ from saic_ismart_client_ng.model import SaicApiConfiguration import mqtt_topics -from configuration import Configuration, TransportProtocol +from configuration import Configuration +from configuration.parser import process_arguments from exceptions import MqttGatewayException from integrations.abrp.api import AbrpApi, AbrpApiException from integrations.home_assistant.discovery import HomeAssistantDiscovery @@ -34,7 +33,6 @@ from vehicle import RefreshMode, VehicleState MSG_CMD_SUCCESSFUL = 'Success' -CHARGING_STATIONS_FILE = 'charging-stations.json' def epoch_value_to_str(time_value: int) -> str: @@ -692,268 +690,6 @@ def __should_poll(self): return True -class EnvDefault(argparse.Action): - def __init__(self, envvar, required=True, default=None, **kwargs): - if ( - envvar in os.environ - and os.environ[envvar] - ): - default = os.environ[envvar] - if required and default: - required = False - super(EnvDefault, self).__init__(default=default, required=required, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, values) - - -def process_arguments() -> Configuration: - config = Configuration() - parser = argparse.ArgumentParser(prog='MQTT Gateway') - try: - parser.add_argument('-m', '--mqtt-uri', - help='The URI to the MQTT Server. Environment Variable: MQTT_URI,' - + 'TCP: tcp://mqtt.eclipseprojects.io:1883 ' - + 'WebSocket: ws://mqtt.eclipseprojects.io:9001' - + 'TLS: tls://mqtt.eclipseprojects.io:8883', - dest='mqtt_uri', required=True, action=EnvDefault, envvar='MQTT_URI') - parser.add_argument('--mqtt-server-cert', - help='Path to the server certificate authority file in PEM format for TLS.', - dest='tls_server_cert_path', required=False, action=EnvDefault, envvar='MQTT_SERVER_CERT') - parser.add_argument('--mqtt-user', help='The MQTT user name. Environment Variable: MQTT_USER', - dest='mqtt_user', required=False, action=EnvDefault, envvar='MQTT_USER') - parser.add_argument('--mqtt-password', - help='The MQTT password. Environment Variable: MQTT_PASSWORD', dest='mqtt_password', - required=False, action=EnvDefault, envvar='MQTT_PASSWORD') - parser.add_argument('--mqtt-client-id', help='The MQTT Client Identifier. Environment Variable: ' - + 'MQTT_CLIENT_ID ' - + 'Default is saic-python-mqtt-gateway', - default='saic-python-mqtt-gateway', dest='mqtt_client_id', required=False, - action=EnvDefault, envvar='MQTT_CLIENT_ID') - parser.add_argument('--mqtt-topic-prefix', - help='MQTT topic prefix. Environment Variable: MQTT_TOPIC Default is saic', default='saic', - dest='mqtt_topic', required=False, action=EnvDefault, envvar='MQTT_TOPIC') - parser.add_argument('-s', '--saic-rest-uri', - help='The SAIC uri. Environment Variable: SAIC_REST_URI Default is the European ' - 'Production Endpoint: https://tap-eu.soimt.com', - default='https://gateway-mg-eu.soimt.com/api.app/v1/', dest='saic_rest_uri', required=False, - action=EnvDefault, - envvar='SAIC_REST_URI') - parser.add_argument('-u', '--saic-user', - help='The SAIC user name. Environment Variable: SAIC_USER', dest='saic_user', required=True, - action=EnvDefault, envvar='SAIC_USER') - parser.add_argument('-p', '--saic-password', - help='The SAIC password. Environment Variable: SAIC_PASSWORD', dest='saic_password', - required=True, action=EnvDefault, envvar='SAIC_PASSWORD') - parser.add_argument('--saic-phone-country-code', - help='The SAIC phone country code. Environment Variable: SAIC_PHONE_COUNTRY_CODE', - dest='saic_phone_country_code', required=False, action=EnvDefault, - envvar='SAIC_PHONE_COUNTRY_CODE') - parser.add_argument('--saic-region', '--saic-region', - help='The SAIC API region. Environment Variable: SAIC_REGION', default='eu', - dest='saic_region', required=False, action=EnvDefault, envvar='SAIC_REGION') - parser.add_argument('--saic-tenant-id', - help='The SAIC API tenant id. Environment Variable: SAIC_TENANT_ID', default='459771', - dest='saic_tenant_id', required=False, action=EnvDefault, - envvar='SAIC_TENANT_ID') - parser.add_argument('--abrp-api-key', - help='The API key for the A Better Route Planer telemetry API.' - + ' Default is the open source telemetry' - + ' API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d.' - + ' Environment Variable: ABRP_API_KEY', - default='8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d', dest='abrp_api_key', required=False, - action=EnvDefault, envvar='ABRP_API_KEY') - parser.add_argument('--abrp-user-token', help='The mapping of VIN to ABRP User Token.' - + ' Multiple mappings can be provided seperated by ,' - + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' - + ' Environment Variable: ABRP_USER_TOKEN', - dest='abrp_user_token', required=False, action=EnvDefault, envvar='ABRP_USER_TOKEN') - parser.add_argument('--battery-capacity-mapping', help='The mapping of VIN to full batteryc' - + ' apacity. Multiple mappings can be provided separated' - + ' by , Example: LSJXXXX=54.0,LSJYYYY=64.0,' - + ' Environment Variable: BATTERY_CAPACITY_MAPPING', - dest='battery_capacity_mapping', required=False, action=EnvDefault, - envvar='BATTERY_CAPACITY_MAPPING') - parser.add_argument('--charging-stations-json', - help='Custom charging stations configuration file name', dest='charging_stations_file', - required=False, action=EnvDefault, envvar='CHARGING_STATIONS_JSON') - parser.add_argument('--saic-relogin-delay', - help='How long to wait before attempting another login to the SAIC API. Environment ' - 'Variable: SAIC_RELOGIN_DELAY', dest='saic_relogin_delay', required=False, - action=EnvDefault, envvar='SAIC_RELOGIN_DELAY', type=check_positive) - parser.add_argument('--ha-discovery', - help='Enable Home Assistant Discovery. Environment Variable: HA_DISCOVERY_ENABLED', - dest='ha_discovery_enabled', required=False, - action=EnvDefault, - envvar='HA_DISCOVERY_ENABLED', default=True, type=check_bool) - parser.add_argument('--ha-discovery-prefix', - help='Home Assistant Discovery Prefix. Environment Variable: HA_DISCOVERY_PREFIX', - dest='ha_discovery_prefix', required=False, action=EnvDefault, envvar='HA_DISCOVERY_PREFIX', - default='homeassistant') - parser.add_argument('--ha-show-unavailable', - help='Show entities as Unavailable in Home Assistant when car polling fails. ' - 'Environment Variable: HA_SHOW_UNAVAILABLE', dest='ha_show_unavailable', - required=False, action=EnvDefault, envvar='HA_SHOW_UNAVAILABLE', default=True, - type=check_bool) - parser.add_argument('--messages-request-interval', - help='The interval for retrieving messages in seconds. Environment Variable: ' - 'MESSAGES_REQUEST_INTERVAL', dest='messages_request_interval', - required=False, action=EnvDefault, - envvar='MESSAGES_REQUEST_INTERVAL', default=60) - parser.add_argument('--charge-min-percentage', - help='How many % points we should try to refresh the charge state. Environment Variable: ' - 'CHARGE_MIN_PERCENTAGE', dest='charge_dynamic_polling_min_percentage', required=False, - action=EnvDefault, envvar='CHARGE_MIN_PERCENTAGE', default='1.0', type=check_positive_float) - parser.add_argument('--publish-raw-api-data', - help='Publish raw SAIC API request/response to MQTT. Environment Variable: ' - 'PUBLISH_RAW_API_DATA_ENABLED', - dest='publish_raw_api_data', required=False, - action=EnvDefault, - envvar='PUBLISH_RAW_API_DATA_ENABLED', default=False, type=check_bool) - parser.add_argument('--publish-raw-abrp-data', - help='Publish raw ABRP API request/response to MQTT. Environment Variable: ' - 'PUBLISH_RAW_ABRP_DATA_ENABLED', - dest='publish_raw_abrp_data', required=False, action=EnvDefault, - envvar='PUBLISH_RAW_ABRP_DATA_ENABLED', default=False, type=check_bool) - - args = parser.parse_args() - config.mqtt_user = args.mqtt_user - config.mqtt_password = args.mqtt_password - config.mqtt_client_id = args.mqtt_client_id - config.charge_dynamic_polling_min_percentage = args.charge_dynamic_polling_min_percentage - if args.saic_relogin_delay: - config.saic_relogin_delay = args.saic_relogin_delay - config.mqtt_topic = args.mqtt_topic - config.saic_rest_uri = args.saic_rest_uri - config.saic_region = args.saic_region - config.saic_tenant_id = str(args.saic_tenant_id) - config.saic_user = args.saic_user - config.saic_password = args.saic_password - config.saic_phone_country_code = args.saic_phone_country_code - config.abrp_api_key = args.abrp_api_key - if args.abrp_user_token: - cfg_value_to_dict(args.abrp_user_token, config.abrp_token_map) - if args.battery_capacity_mapping: - cfg_value_to_dict( - args.battery_capacity_mapping, - config.battery_capacity_map, - value_type=check_positive_float - ) - if args.charging_stations_file: - process_charging_stations_file(config, args.charging_stations_file) - else: - process_charging_stations_file(config, f'./{CHARGING_STATIONS_FILE}') - - config.saic_password = args.saic_password - - if args.ha_discovery_enabled is not None: - config.ha_discovery_enabled = args.ha_discovery_enabled - - if args.publish_raw_api_data is not None: - config.publish_raw_api_data = args.publish_raw_api_data - - if args.publish_raw_abrp_data is not None: - config.publish_raw_abrp_data = args.publish_raw_abrp_data - - if args.ha_show_unavailable is not None: - config.ha_show_unavailable = args.ha_show_unavailable - - if args.ha_discovery_prefix: - config.ha_discovery_prefix = args.ha_discovery_prefix - - try: - config.messages_request_interval = int(args.messages_request_interval) - except ValueError: - raise SystemExit(f'No valid integer value for messages_request_interval: {args.messages_request_interval}') - - parse_result = urllib.parse.urlparse(args.mqtt_uri) - if parse_result.scheme == 'tcp': - config.mqtt_transport_protocol = TransportProtocol.TCP - elif parse_result.scheme == 'ws': - config.mqtt_transport_protocol = TransportProtocol.WS - elif parse_result.scheme == 'tls': - config.mqtt_transport_protocol = TransportProtocol.TLS - if args.tls_server_cert_path: - config.tls_server_cert_path = args.tls_server_cert_path - else: - raise SystemExit(f'No server certificate authority file provided for TLS MQTT URI {args.mqtt_uri}') - else: - raise SystemExit(f'Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws') - - if not parse_result.port: - if config.mqtt_transport_protocol == 'tcp': - config.mqtt_port = 1883 - else: - config.mqtt_port = 9001 - else: - config.mqtt_port = parse_result.port - - config.mqtt_host = str(parse_result.hostname) - - return config - except argparse.ArgumentError as err: - parser.print_help() - SystemExit(err) - - -def process_charging_stations_file(config: Configuration, json_file: str): - try: - with open(json_file, 'r') as f: - data = json.load(f) - - for item in data: - charge_state_topic = item['chargeStateTopic'] - charging_value = item['chargingValue'] - vin = item['vin'] - if 'socTopic' in item: - charging_station = ChargingStation(vin, charge_state_topic, charging_value, item['socTopic']) - else: - charging_station = ChargingStation(vin, charge_state_topic, charging_value) - if 'rangeTopic' in item: - charging_station.range_topic = item['rangeTopic'] - if 'chargerConnectedTopic' in item: - charging_station.connected_topic = item['chargerConnectedTopic'] - if 'chargerConnectedValue' in item: - charging_station.connected_value = item['chargerConnectedValue'] - config.charging_stations_by_vin[vin] = charging_station - except FileNotFoundError: - LOG.warning(f'File {json_file} does not exist') - except json.JSONDecodeError as e: - LOG.exception(f'Reading {json_file} failed', exc_info=e) - - -def cfg_value_to_dict(cfg_value: str, result_map: dict, value_type: Callable[[str], any] = str): - if ',' in cfg_value: - map_entries = cfg_value.split(',') - else: - map_entries = [cfg_value] - - for entry in map_entries: - if '=' in entry: - key_value_pair = entry.split('=') - key = key_value_pair[0] - value = key_value_pair[1] - result_map[key] = value_type(value) - - -def check_positive(value): - ivalue = int(value) - if ivalue <= 0: - raise argparse.ArgumentTypeError(f'{ivalue} is an invalid positive int value') - return ivalue - - -def check_positive_float(value): - fvalue = float(value) - if fvalue <= 0: - raise argparse.ArgumentTypeError(f'{fvalue} is an invalid positive float value') - return fvalue - - -def check_bool(value): - return str(value).lower() in ['true', '1', 'yes', 'y'] - - if __name__ == '__main__': # Enable fault handler to get a thread dump on SIGQUIT faulthandler.enable(file=sys.stderr, all_threads=True) From f5d8b434c93f0983290c9122c64c35bd3ce03b0d Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 12 May 2024 14:50:43 +0200 Subject: [PATCH 04/35] Move handlers into their own module --- handlers/__init__.py | 0 handlers/message.py | 119 ++++++++++ handlers/vehicle.py | 402 ++++++++++++++++++++++++++++++++++ mqtt_gateway.py | 507 ++----------------------------------------- 4 files changed, 539 insertions(+), 489 deletions(-) create mode 100644 handlers/__init__.py create mode 100644 handlers/message.py create mode 100644 handlers/vehicle.py diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/message.py b/handlers/message.py new file mode 100644 index 0000000..30cf894 --- /dev/null +++ b/handlers/message.py @@ -0,0 +1,119 @@ +import logging + +from saic_ismart_client_ng import SaicApi +from saic_ismart_client_ng.api.message.schema import MessageEntity +from saic_ismart_client_ng.exceptions import SaicApiException + +from handlers.vehicle import VehicleHandlerLocator +from vehicle import RefreshMode + +LOG = logging.getLogger(__name__) + + +class MessageHandler: + def __init__(self, gateway: VehicleHandlerLocator, saicapi: SaicApi): + self.gateway = gateway + self.saicapi = saicapi + + async def check_for_new_messages(self) -> None: + if self.__should_poll(): + try: + LOG.debug("Checking for new messages") + await self.__polling() + except Exception as e: + LOG.exception('MessageHandler poll loop failed', exc_info=e) + else: + LOG.debug("Not checking for new messages since all cars have RefreshMode.OFF") + + async def __polling(self): + try: + unread_count = await self.saicapi.get_unread_messages_count() + LOG.info(f'{unread_count} unread messages') + if unread_count.alarmNumber == 0: + return + all_messages = await self.__get_all_messages() + LOG.info(f'{len(all_messages)} messages received') + + vehicle_start_messages = [m for m in all_messages if m.messageType == '323'] + latest_vehicle_start_message = self.__get_latest_message(vehicle_start_messages) + + new_messages = [m for m in all_messages if m.read_status != 'read'] + latest_message = self.__get_latest_message(new_messages) + + for message in new_messages: + LOG.info(message.details) + await self.__read_message(message) + + if latest_vehicle_start_message is not None: + LOG.info( + f'{latest_vehicle_start_message.title} detected at {latest_vehicle_start_message.message_time}') + vehicle_handler = self.gateway.get_vehicle_handler(latest_vehicle_start_message.vin) + if vehicle_handler: + # delete the vehicle start message after processing it + vehicle_handler.vehicle_state.notify_message(latest_vehicle_start_message) + await self.__delete_message(latest_vehicle_start_message) + elif latest_message is not None: + vehicle_handler = self.gateway.get_vehicle_handler(latest_message.vin) + if vehicle_handler: + vehicle_handler.vehicle_state.notify_message(latest_message) + except SaicApiException as e: + LOG.exception('MessageHandler poll loop failed during SAIC API Call', exc_info=e) + except Exception as e: + LOG.exception('MessageHandler poll loop failed unexpectedly', exc_info=e) + + async def __get_all_messages(self) -> list[MessageEntity]: + idx = 1 + all_messages = [] + while True: + try: + message_list = await self.saicapi.get_alarm_list(page_num=idx, page_size=1) + if message_list.messages and len(message_list.messages) > 0: + all_messages.extend(message_list.messages) + else: + return all_messages + except Exception as e: + LOG.exception( + 'Error while fetching a message from the SAIC API, please open the app and clear them, ' + 'then report this as a bug.', + exc_info=e + ) + finally: + idx = idx + 1 + + async def __delete_message(self, latest_vehicle_start_message): + try: + message_id = latest_vehicle_start_message.messageId + await self.saicapi.delete_message(message_id=message_id) + LOG.info(f'{latest_vehicle_start_message.title} message with ID {message_id} deleted') + except Exception as e: + LOG.exception('Could not delete message from server', exc_info=e) + + async def __read_message(self, message): + try: + message_id = message.messageId + await self.saicapi.read_message(message_id=message_id) + LOG.info(f'{message.title} message with ID {message_id} marked as read') + except Exception as e: + LOG.exception('Could not mark message as read from server', exc_info=e) + + def __should_poll(self): + vehicle_handlers = self.gateway.vehicle_handlers or dict() + refresh_modes = [ + vh.vehicle_state.refresh_mode + for vh in vehicle_handlers.values() + if vh.vehicle_state is not None + ] + # We do not poll if we have no cars or all cars have RefreshMode.OFF + if len(refresh_modes) == 0 or all(mode == RefreshMode.OFF for mode in refresh_modes): + return False + else: + return True + + @staticmethod + def __get_latest_message(vehicle_start_messages): + return next(iter(reversed( + sorted( + vehicle_start_messages, + key=lambda m: m.message_time + ) + )), None) diff --git a/handlers/vehicle.py b/handlers/vehicle.py new file mode 100644 index 0000000..cee93b1 --- /dev/null +++ b/handlers/vehicle.py @@ -0,0 +1,402 @@ +import asyncio +import datetime +import json +import logging +from abc import ABC +from typing import Optional + +from saic_ismart_client_ng import SaicApi +from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleStatusResp +from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp, ScheduledBatteryHeatingResp, \ + ChargeCurrentLimitCode, TargetBatteryCode, ScheduledChargingMode +from saic_ismart_client_ng.exceptions import SaicApiException + +import mqtt_topics +from configuration import Configuration +from exceptions import MqttGatewayException +from integrations.abrp.api import AbrpApi, AbrpApiException +from integrations.home_assistant.discovery import HomeAssistantDiscovery +from publisher.core import Publisher +from saic_api_listener import MqttGatewayAbrpListener +from vehicle import VehicleState, RefreshMode + +LOG = logging.getLogger(__name__) + + +class VehicleHandler: + def __init__(self, config: Configuration, saicapi: SaicApi, publisher: Publisher, vin_info: VinInfo, + vehicle_state: VehicleState): + self.configuration = config + self.saic_api = saicapi + self.publisher = publisher + self.vin_info = vin_info + self.vehicle_prefix = f'{self.configuration.saic_user}/vehicles/{self.vin_info.vin}' + self.vehicle_state = vehicle_state + self.ha_discovery = HomeAssistantDiscovery(vehicle_state, vin_info, config) + if vin_info.vin in self.configuration.abrp_token_map: + abrp_user_token = self.configuration.abrp_token_map[vin_info.vin] + else: + abrp_user_token = None + if config.publish_raw_abrp_data: + listener = MqttGatewayAbrpListener(self.publisher) + else: + listener = None + self.abrp_api = AbrpApi( + self.configuration.abrp_api_key, + abrp_user_token, + listener=listener + ) + + async def handle_vehicle(self) -> None: + start_time = datetime.datetime.now() + self.vehicle_state.publish_vehicle_info() + self.vehicle_state.notify_car_activity_time(start_time, True) + + while True: + if self.__should_complete_configuration(start_time): + self.vehicle_state.configure_missing() + + if self.__should_poll(): + try: + LOG.debug('Polling vehicle status') + await self.__polling() + except SaicApiException as e: + self.vehicle_state.mark_failed_refresh() + LOG.exception( + 'handle_vehicle loop failed during SAIC API call', + exc_info=e + ) + except AbrpApiException as ae: + LOG.exception('handle_vehicle loop failed during ABRP API call', exc_info=ae) + except Exception as e: + self.vehicle_state.mark_failed_refresh() + LOG.exception( + 'handle_vehicle loop failed with an unexpected exception', + exc_info=e + ) + finally: + if self.configuration.ha_discovery_enabled: + self.ha_discovery.publish_ha_discovery_messages() + else: + # car not active, wait a second + await asyncio.sleep(1.0) + + async def __polling(self): + vehicle_status = await self.update_vehicle_status() + + try: + charge_status = await self.update_charge_status() + except Exception as e: + LOG.exception('Error updating charge status', exc_info=e) + charge_status = None + + try: + await self.update_scheduled_battery_heating_status() + except Exception as e: + LOG.exception('Error updating scheduled battery heating status', exc_info=e) + + self.vehicle_state.mark_successful_refresh() + LOG.info('Refreshing vehicle status succeeded...') + + await self.__refresh_abrp(charge_status, vehicle_status) + + def __should_poll(self) -> bool: + return ( + self.vehicle_state.is_complete() + and self.vehicle_state.should_refresh() + ) + + def __should_complete_configuration(self, start_time) -> bool: + return ( + not self.vehicle_state.is_complete() + and datetime.datetime.now() > start_time + datetime.timedelta(seconds=10) + ) + + async def __refresh_abrp(self, charge_status, vehicle_status): + abrp_refreshed, abrp_response = await self.abrp_api.update_abrp(vehicle_status, charge_status) + self.publisher.publish_str(f'{self.vehicle_prefix}/{mqtt_topics.INTERNAL_ABRP}', abrp_response) + if abrp_refreshed: + LOG.info('Refreshing ABRP status succeeded...') + else: + LOG.info(f'ABRP not refreshed, reason {abrp_response}') + + async def update_vehicle_status(self) -> VehicleStatusResp: + LOG.info('Updating vehicle status') + vehicle_status_response = await self.saic_api.get_vehicle_status(self.vin_info.vin) + self.vehicle_state.handle_vehicle_status(vehicle_status_response) + + return vehicle_status_response + + async def update_charge_status(self) -> ChrgMgmtDataResp: + LOG.info('Updating charging status') + charge_mgmt_data = await self.saic_api.get_vehicle_charging_management_data(self.vin_info.vin) + self.vehicle_state.handle_charge_status(charge_mgmt_data) + return charge_mgmt_data + + async def update_scheduled_battery_heating_status(self) -> ScheduledBatteryHeatingResp: + LOG.info('Updating scheduled battery heating status') + scheduled_battery_heating_status = await self.saic_api.get_vehicle_battery_heating_schedule(self.vin_info.vin) + self.vehicle_state.handle_scheduled_battery_heating_status(scheduled_battery_heating_status) + return scheduled_battery_heating_status + + async def handle_mqtt_command(self, *, topic: str, payload: str): + topic = self.get_topic_without_vehicle_prefix(topic) + try: + should_force_refresh = True + match topic: + case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE: + match payload.strip().lower(): + case 'true': + LOG.info("HV battery is now active") + self.vehicle_state.set_hv_battery_active(True) + case 'false': + LOG.info("HV battery is now inactive") + self.vehicle_state.set_hv_battery_active(False) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.DRIVETRAIN_CHARGING: + match payload.strip().lower(): + case 'true': + LOG.info("Charging will be started") + await self.saic_api.control_charging(self.vin_info.vin, stop_charging=False) + case 'false': + LOG.info("Charging will be stopped") + await self.saic_api.control_charging(self.vin_info.vin, stop_charging=True) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.DRIVETRAIN_BATTERY_HEATING: + match payload.strip().lower(): + case 'true': + LOG.info("Battery heater wil be will be switched on") + response = await self.saic_api.control_battery_heating(self.vin_info.vin, enable=True) + case 'false': + LOG.info("Battery heater wil be will be switched off") + response = await self.saic_api.control_battery_heating(self.vin_info.vin, enable=False) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + if response is not None and response.ptcHeatResp is not None: + decoded = response.heating_stop_reason + self.publisher.publish_str( + self.vehicle_state.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON), + f'UNKNOWN ({response.ptcHeatResp})' if decoded is None else decoded.name + ) + + case mqtt_topics.CLIMATE_REMOTE_TEMPERATURE: + payload = payload.strip() + try: + LOG.info("Setting remote climate target temperature to %s", payload) + temp = int(payload) + changed = self.vehicle_state.set_ac_temperature(temp) + if changed and self.vehicle_state.is_remote_ac_running: + await self.saic_api.start_ac( + self.vin_info.vin, + temperature_idx=self.vehicle_state.get_ac_temperature_idx() + ) + + except ValueError as e: + raise MqttGatewayException(f'Error setting temperature target: {e}') + case mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE: + match payload.strip().lower(): + case 'off': + LOG.info('A/C will be switched off') + await self.saic_api.stop_ac(self.vin_info.vin) + case 'blowingonly': + LOG.info('A/C will be set to blowing only') + await self.saic_api.start_ac_blowing(self.vin_info.vin) + case 'on': + LOG.info('A/C will be switched on') + await self.saic_api.start_ac( + self.vin_info.vin, + temperature_idx=self.vehicle_state.get_ac_temperature_idx() + ) + case 'front': + LOG.info("A/C will be set to front seats only") + await self.saic_api.start_front_defrost(self.vin_info.vin) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL: + try: + LOG.info("Setting heated seats front left level to %s", payload) + level = int(payload.strip().lower()) + changed = self.vehicle_state.update_heated_seats_front_left_level(level) + if changed: + await self.saic_api.control_heated_seats( + self.vin_info.vin, + left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, + right_side_level=self.vehicle_state.remote_heated_seats_front_right_level + ) + else: + LOG.info("Heated seats front left level not changed") + except Exception as e: + raise MqttGatewayException(f'Error setting heated seats: {e}') + + case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL: + try: + LOG.info("Setting heated seats front right level to %s", payload) + level = int(payload.strip().lower()) + changed = self.vehicle_state.update_heated_seats_front_right_level(level) + if changed: + await self.saic_api.control_heated_seats( + self.vin_info.vin, + left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, + right_side_level=self.vehicle_state.remote_heated_seats_front_right_level + ) + else: + LOG.info("Heated seats front right level not changed") + except Exception as e: + raise MqttGatewayException(f'Error setting heated seats: {e}') + + case mqtt_topics.DOORS_BOOT: + match payload.strip().lower(): + case 'true': + LOG.info(f'We cannot lock vehicle {self.vin_info.vin} boot remotely') + case 'false': + LOG.info(f'Vehicle {self.vin_info.vin} boot will be unlocked') + await self.saic_api.open_tailgate(self.vin_info.vin) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.DOORS_LOCKED: + match payload.strip().lower(): + case 'true': + LOG.info(f'Vehicle {self.vin_info.vin} will be locked') + await self.saic_api.lock_vehicle(self.vin_info.vin) + case 'false': + LOG.info(f'Vehicle {self.vin_info.vin} will be unlocked') + await self.saic_api.unlock_vehicle(self.vin_info.vin) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.CLIMATE_BACK_WINDOW_HEAT: + match payload.strip().lower(): + case 'off': + LOG.info('Rear window heating will be switched off') + await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=False) + case 'on': + LOG.info('Rear window heating will be switched on') + await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=True) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT: + match payload.strip().lower(): + case 'off': + LOG.info('Front window heating will be switched off') + await self.saic_api.stop_ac(self.vin_info.vin) + case 'on': + LOG.info('Front window heating will be switched on') + await self.saic_api.start_front_defrost(self.vin_info.vin) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + case mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT: + payload = payload.strip().upper() + if self.vehicle_state.target_soc is not None: + try: + LOG.info("Setting charging current limit to %s", payload) + raw_charge_current_limit = str(payload) + charge_current_limit = ChargeCurrentLimitCode.to_code(raw_charge_current_limit) + await self.saic_api.set_target_battery_soc( + self.vin_info.vin, + target_soc=self.vehicle_state.target_soc, + charge_current_limit=charge_current_limit + ) + self.vehicle_state.update_charge_current_limit(charge_current_limit) + except ValueError: + raise MqttGatewayException(f'Error setting value for payload {payload}') + else: + logging.info( + 'Unknown Target SOC: waiting for state update before changing charge current limit' + ) + raise MqttGatewayException( + f'Error setting charge current limit - SOC {self.vehicle_state.target_soc}') + case mqtt_topics.DRIVETRAIN_SOC_TARGET: + payload = payload.strip() + try: + LOG.info("Setting SoC target to %s", payload) + target_battery_code = TargetBatteryCode.from_percentage(int(payload)) + await self.saic_api.set_target_battery_soc(self.vin_info.vin, target_soc=target_battery_code) + self.vehicle_state.update_target_soc(target_battery_code) + except ValueError as e: + raise MqttGatewayException(f'Error setting SoC target: {e}') + case mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE: + payload = payload.strip() + try: + LOG.info("Setting charging schedule to %s", payload) + payload_json = json.loads(payload) + start_time = datetime.time.fromisoformat(payload_json['startTime']) + end_time = datetime.time.fromisoformat(payload_json['endTime']) + mode = ScheduledChargingMode[payload_json['mode'].upper()] + await self.saic_api.set_schedule_charging( + self.vin_info.vin, + start_time=start_time, + end_time=end_time, + mode=mode + ) + self.vehicle_state.update_scheduled_charging(start_time, mode) + except Exception as e: + raise MqttGatewayException(f'Error setting charging schedule: {e}') + case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE: + payload = payload.strip() + try: + LOG.info("Setting battery heating schedule to %s", payload) + payload_json = json.loads(payload) + start_time = datetime.time.fromisoformat(payload_json['startTime']) + mode = payload_json['mode'].upper() + should_enable = mode == 'ON' + changed = self.vehicle_state.update_scheduled_battery_heating(start_time, should_enable) + if changed: + if should_enable: + LOG.info(f'Setting battery heating schedule to {start_time}') + await self.saic_api.enable_schedule_battery_heating( + self.vin_info.vin, + start_time=start_time + ) + else: + LOG.info('Disabling battery heating schedule') + await self.saic_api.disable_schedule_battery_heating(self.vin_info.vin) + else: + LOG.info('Battery heating schedule not changed') + except Exception as e: + raise MqttGatewayException(f'Error setting battery heating schedule: {e}') + case mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK: + match payload.strip().lower(): + case 'false': + LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be unlocked') + await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=True) + case 'true': + LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be locked') + await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=False) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') + + case _: + # set mode, period (in)-active,... + should_force_refresh = False + await self.vehicle_state.configure_by_message(topic=topic, payload=payload) + self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', 'Success') + if should_force_refresh: + self.vehicle_state.set_refresh_mode(RefreshMode.FORCE, f'after command execution on topic {topic}') + except MqttGatewayException as e: + self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {e.message}') + LOG.exception(e.message, exc_info=e) + except SaicApiException as se: + self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') + LOG.exception(se.message, exc_info=se) + except Exception as se: + self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', 'Failed unexpectedly') + LOG.exception("handle_mqtt_command failed with an unexpected exception", exc_info=se) + + def get_topic_without_vehicle_prefix(self, topic: str) -> str: + global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1:] + elements = global_topic_removed.split('/') + result = '' + for i in range(3, len(elements) - 1): + result += f'/{elements[i]}' + return result[1:] + + +class VehicleHandlerLocator(ABC): + + def get_vehicle_handler(self, vin: str) -> Optional[VehicleHandler]: + raise NotImplementedError() + + @property + def vehicle_handlers(self) -> dict[str, VehicleHandler]: + raise NotImplementedError() diff --git a/mqtt_gateway.py b/mqtt_gateway.py index 6c75408..7f805c2 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -1,36 +1,27 @@ import asyncio import datetime import faulthandler -import json import logging import os import signal import sys import time -from typing import override +from typing import override, Optional import apscheduler.schedulers.asyncio from saic_ismart_client_ng import SaicApi -from saic_ismart_client_ng.api.message.schema import MessageEntity -from saic_ismart_client_ng.api.vehicle import VehicleStatusResp from saic_ismart_client_ng.api.vehicle.alarm import AlarmType -from saic_ismart_client_ng.api.vehicle.schema import VinInfo -from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp, ChargeCurrentLimitCode, TargetBatteryCode, \ - ScheduledChargingMode, ScheduledBatteryHeatingResp -from saic_ismart_client_ng.exceptions import SaicApiException from saic_ismart_client_ng.model import SaicApiConfiguration import mqtt_topics from configuration import Configuration from configuration.parser import process_arguments -from exceptions import MqttGatewayException -from integrations.abrp.api import AbrpApi, AbrpApiException -from integrations.home_assistant.discovery import HomeAssistantDiscovery +from handlers.message import MessageHandler +from handlers.vehicle import VehicleHandler, VehicleHandlerLocator from integrations.openwb.charging_station import ChargingStation -from publisher.core import Publisher from publisher.mqtt_publisher import MqttClient, MqttCommandListener -from saic_api_listener import MqttGatewaySaicApiListener, MqttGatewayAbrpListener -from vehicle import RefreshMode, VehicleState +from saic_api_listener import MqttGatewaySaicApiListener +from vehicle import VehicleState MSG_CMD_SUCCESSFUL = 'Success' @@ -55,369 +46,10 @@ def debug_log_enabled(): return LOG_LEVEL == 'DEBUG' -class VehicleHandler: - def __init__(self, config: Configuration, saicapi: SaicApi, publisher: Publisher, vin_info: VinInfo, - vehicle_state: VehicleState): - self.configuration = config - self.saic_api = saicapi - self.publisher = publisher - self.vin_info = vin_info - self.vehicle_prefix = f'{self.configuration.saic_user}/vehicles/{self.vin_info.vin}' - self.vehicle_state = vehicle_state - self.ha_discovery = HomeAssistantDiscovery(vehicle_state, vin_info, config) - if vin_info.vin in self.configuration.abrp_token_map: - abrp_user_token = self.configuration.abrp_token_map[vin_info.vin] - else: - abrp_user_token = None - if config.publish_raw_abrp_data: - listener = MqttGatewayAbrpListener(self.publisher) - else: - listener = None - self.abrp_api = AbrpApi( - self.configuration.abrp_api_key, - abrp_user_token, - listener=listener - ) - - async def handle_vehicle(self) -> None: - start_time = datetime.datetime.now() - self.vehicle_state.publish_vehicle_info() - self.vehicle_state.notify_car_activity_time(start_time, True) - - while True: - if ( - not self.vehicle_state.is_complete() - and datetime.datetime.now() > start_time + datetime.timedelta(seconds=10) - ): - self.vehicle_state.configure_missing() - if ( - self.vehicle_state.is_complete() - and self.vehicle_state.should_refresh() - ): - try: - vehicle_status = await self.update_vehicle_status() - - try: - charge_status = await self.update_charge_status() - except Exception as e: - LOG.exception('Error updating charge status', exc_info=e) - charge_status = None - - try: - await self.update_scheduled_battery_heating_status() - except Exception as e: - LOG.exception('Error updating scheduled battery heating status', exc_info=e) - - self.vehicle_state.mark_successful_refresh() - LOG.info('Refreshing vehicle status succeeded...') - - await self.__refresh_abrp(charge_status, vehicle_status) - - except SaicApiException as e: - self.vehicle_state.mark_failed_refresh() - LOG.exception( - 'handle_vehicle loop failed during SAIC API call', - exc_info=e - ) - except AbrpApiException as ae: - LOG.exception('handle_vehicle loop failed during ABRP API call', exc_info=ae) - except Exception as e: - self.vehicle_state.mark_failed_refresh() - LOG.exception( - 'handle_vehicle loop failed with an unexpected exception', - exc_info=e - ) - finally: - if self.configuration.ha_discovery_enabled: - self.ha_discovery.publish_ha_discovery_messages() - else: - # car not active, wait a second - await asyncio.sleep(1.0) - - async def __refresh_abrp(self, charge_status, vehicle_status): - abrp_refreshed, abrp_response = await self.abrp_api.update_abrp(vehicle_status, charge_status) - self.publisher.publish_str(f'{self.vehicle_prefix}/{mqtt_topics.INTERNAL_ABRP}', abrp_response) - if abrp_refreshed: - LOG.info('Refreshing ABRP status succeeded...') - else: - LOG.info(f'ABRP not refreshed, reason {abrp_response}') - - async def update_vehicle_status(self) -> VehicleStatusResp: - LOG.info('Updating vehicle status') - vehicle_status_response = await self.saic_api.get_vehicle_status(self.vin_info.vin) - self.vehicle_state.handle_vehicle_status(vehicle_status_response) - - return vehicle_status_response - - async def update_charge_status(self) -> ChrgMgmtDataResp: - LOG.info('Updating charging status') - charge_mgmt_data = await self.saic_api.get_vehicle_charging_management_data(self.vin_info.vin) - self.vehicle_state.handle_charge_status(charge_mgmt_data) - return charge_mgmt_data - - async def update_scheduled_battery_heating_status(self) -> ScheduledBatteryHeatingResp: - LOG.info('Updating scheduled battery heating status') - scheduled_battery_heating_status = await self.saic_api.get_vehicle_battery_heating_schedule(self.vin_info.vin) - self.vehicle_state.handle_scheduled_battery_heating_status(scheduled_battery_heating_status) - return scheduled_battery_heating_status - - async def handle_mqtt_command(self, *, topic: str, payload: str): - topic = self.get_topic_without_vehicle_prefix(topic) - try: - should_force_refresh = True - match topic: - case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE: - match payload.strip().lower(): - case 'true': - LOG.info("HV battery is now active") - self.vehicle_state.set_hv_battery_active(True) - case 'false': - LOG.info("HV battery is now inactive") - self.vehicle_state.set_hv_battery_active(False) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_CHARGING: - match payload.strip().lower(): - case 'true': - LOG.info("Charging will be started") - await self.saic_api.control_charging(self.vin_info.vin, stop_charging=False) - case 'false': - LOG.info("Charging will be stopped") - await self.saic_api.control_charging(self.vin_info.vin, stop_charging=True) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_BATTERY_HEATING: - match payload.strip().lower(): - case 'true': - LOG.info("Battery heater wil be will be switched on") - response = await self.saic_api.control_battery_heating(self.vin_info.vin, enable=True) - case 'false': - LOG.info("Battery heater wil be will be switched off") - response = await self.saic_api.control_battery_heating(self.vin_info.vin, enable=False) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - if response is not None and response.ptcHeatResp is not None: - decoded = response.heating_stop_reason - self.publisher.publish_str( - self.vehicle_state.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON), - f'UNKNOWN ({response.ptcHeatResp})' if decoded is None else decoded.name - ) - - case mqtt_topics.CLIMATE_REMOTE_TEMPERATURE: - payload = payload.strip() - try: - LOG.info("Setting remote climate target temperature to %s", payload) - temp = int(payload) - changed = self.vehicle_state.set_ac_temperature(temp) - if changed and self.vehicle_state.is_remote_ac_running: - await self.saic_api.start_ac( - self.vin_info.vin, - temperature_idx=self.vehicle_state.get_ac_temperature_idx() - ) - - except ValueError as e: - raise MqttGatewayException(f'Error setting temperature target: {e}') - case mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE: - match payload.strip().lower(): - case 'off': - LOG.info('A/C will be switched off') - await self.saic_api.stop_ac(self.vin_info.vin) - case 'blowingonly': - LOG.info('A/C will be set to blowing only') - await self.saic_api.start_ac_blowing(self.vin_info.vin) - case 'on': - LOG.info('A/C will be switched on') - await self.saic_api.start_ac( - self.vin_info.vin, - temperature_idx=self.vehicle_state.get_ac_temperature_idx() - ) - case 'front': - LOG.info("A/C will be set to front seats only") - await self.saic_api.start_front_defrost(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL: - try: - LOG.info("Setting heated seats front left level to %s", payload) - level = int(payload.strip().lower()) - changed = self.vehicle_state.update_heated_seats_front_left_level(level) - if changed: - await self.saic_api.control_heated_seats( - self.vin_info.vin, - left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, - right_side_level=self.vehicle_state.remote_heated_seats_front_right_level - ) - else: - LOG.info("Heated seats front left level not changed") - except Exception as e: - raise MqttGatewayException(f'Error setting heated seats: {e}') - - case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL: - try: - LOG.info("Setting heated seats front right level to %s", payload) - level = int(payload.strip().lower()) - changed = self.vehicle_state.update_heated_seats_front_right_level(level) - if changed: - await self.saic_api.control_heated_seats( - self.vin_info.vin, - left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, - right_side_level=self.vehicle_state.remote_heated_seats_front_right_level - ) - else: - LOG.info("Heated seats front right level not changed") - except Exception as e: - raise MqttGatewayException(f'Error setting heated seats: {e}') - - case mqtt_topics.DOORS_BOOT: - match payload.strip().lower(): - case 'true': - LOG.info(f'We cannot lock vehicle {self.vin_info.vin} boot remotely') - case 'false': - LOG.info(f'Vehicle {self.vin_info.vin} boot will be unlocked') - await self.saic_api.open_tailgate(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DOORS_LOCKED: - match payload.strip().lower(): - case 'true': - LOG.info(f'Vehicle {self.vin_info.vin} will be locked') - await self.saic_api.lock_vehicle(self.vin_info.vin) - case 'false': - LOG.info(f'Vehicle {self.vin_info.vin} will be unlocked') - await self.saic_api.unlock_vehicle(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_BACK_WINDOW_HEAT: - match payload.strip().lower(): - case 'off': - LOG.info('Rear window heating will be switched off') - await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=False) - case 'on': - LOG.info('Rear window heating will be switched on') - await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=True) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT: - match payload.strip().lower(): - case 'off': - LOG.info('Front window heating will be switched off') - await self.saic_api.stop_ac(self.vin_info.vin) - case 'on': - LOG.info('Front window heating will be switched on') - await self.saic_api.start_front_defrost(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT: - payload = payload.strip().upper() - if self.vehicle_state.target_soc is not None: - try: - LOG.info("Setting charging current limit to %s", payload) - raw_charge_current_limit = str(payload) - charge_current_limit = ChargeCurrentLimitCode.to_code(raw_charge_current_limit) - await self.saic_api.set_target_battery_soc( - self.vin_info.vin, - target_soc=self.vehicle_state.target_soc, - charge_current_limit=charge_current_limit - ) - self.vehicle_state.update_charge_current_limit(charge_current_limit) - except ValueError: - raise MqttGatewayException(f'Error setting value for payload {payload}') - else: - logging.info( - 'Unknown Target SOC: waiting for state update before changing charge current limit' - ) - raise MqttGatewayException( - f'Error setting charge current limit - SOC {self.vehicle_state.target_soc}') - case mqtt_topics.DRIVETRAIN_SOC_TARGET: - payload = payload.strip() - try: - LOG.info("Setting SoC target to %s", payload) - target_battery_code = TargetBatteryCode.from_percentage(int(payload)) - await self.saic_api.set_target_battery_soc(self.vin_info.vin, target_soc=target_battery_code) - self.vehicle_state.update_target_soc(target_battery_code) - except ValueError as e: - raise MqttGatewayException(f'Error setting SoC target: {e}') - case mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE: - payload = payload.strip() - try: - LOG.info("Setting charging schedule to %s", payload) - payload_json = json.loads(payload) - start_time = datetime.time.fromisoformat(payload_json['startTime']) - end_time = datetime.time.fromisoformat(payload_json['endTime']) - mode = ScheduledChargingMode[payload_json['mode'].upper()] - await self.saic_api.set_schedule_charging( - self.vin_info.vin, - start_time=start_time, - end_time=end_time, - mode=mode - ) - self.vehicle_state.update_scheduled_charging(start_time, mode) - except Exception as e: - raise MqttGatewayException(f'Error setting charging schedule: {e}') - case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE: - payload = payload.strip() - try: - LOG.info("Setting battery heating schedule to %s", payload) - payload_json = json.loads(payload) - start_time = datetime.time.fromisoformat(payload_json['startTime']) - mode = payload_json['mode'].upper() - should_enable = mode == 'ON' - changed = self.vehicle_state.update_scheduled_battery_heating(start_time, should_enable) - if changed: - if should_enable: - LOG.info(f'Setting battery heating schedule to {start_time}') - await self.saic_api.enable_schedule_battery_heating( - self.vin_info.vin, - start_time=start_time - ) - else: - LOG.info('Disabling battery heating schedule') - await self.saic_api.disable_schedule_battery_heating(self.vin_info.vin) - else: - LOG.info('Battery heating schedule not changed') - except Exception as e: - raise MqttGatewayException(f'Error setting battery heating schedule: {e}') - case mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK: - match payload.strip().lower(): - case 'false': - LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be unlocked') - await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=True) - case 'true': - LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be locked') - await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=False) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - - case _: - # set mode, period (in)-active,... - should_force_refresh = False - await self.vehicle_state.configure_by_message(topic=topic, payload=payload) - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', 'Success') - if should_force_refresh: - self.vehicle_state.set_refresh_mode(RefreshMode.FORCE, f'after command execution on topic {topic}') - except MqttGatewayException as e: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {e.message}') - LOG.exception(e.message, exc_info=e) - except SaicApiException as se: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') - LOG.exception(se.message, exc_info=se) - except Exception as se: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', 'Failed unexpectedly') - LOG.exception("handle_mqtt_command failed with an unexpected exception", exc_info=se) - - def get_topic_without_vehicle_prefix(self, topic: str) -> str: - global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1:] - elements = global_topic_removed.split('/') - result = '' - for i in range(3, len(elements) - 1): - result += f'/{elements[i]}' - return result[1:] - - -class MqttGateway(MqttCommandListener): +class MqttGateway(MqttCommandListener, VehicleHandlerLocator): def __init__(self, config: Configuration): self.configuration = config - self.vehicle_handler: dict[str, VehicleHandler] = {} + self.__vehicle_handlers: dict[str, VehicleHandler] = dict() self.publisher = MqttClient(self.configuration) self.publisher.command_listener = self username_is_email = "@" in self.configuration.saic_user @@ -498,7 +130,7 @@ async def run(self): vin_info, vehicle_state ) - self.vehicle_handler[vin_info.vin] = vehicle_handler + self.vehicle_handlers[vin_info.vin] = vehicle_handler message_handler = MessageHandler(self, self.saic_api) scheduler.add_job( func=message_handler.check_for_new_messages, @@ -512,13 +144,19 @@ async def run(self): scheduler.start() await self.__main_loop() - def get_vehicle_handler(self, vin: str) -> VehicleHandler | None: - if vin in self.vehicle_handler: - return self.vehicle_handler[vin] + @override + def get_vehicle_handler(self, vin: str) -> Optional[VehicleHandler]: + if vin in self.vehicle_handlers: + return self.vehicle_handlers[vin] else: LOG.error(f'No vehicle handler found for VIN {vin}') return None + @property + @override + def vehicle_handlers(self) -> dict[str, VehicleHandler]: + return self.__vehicle_handlers + @override async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: vehicle_handler = self.get_vehicle_handler(vin) @@ -552,7 +190,7 @@ def get_charging_station(self, vin) -> ChargingStation | None: async def __main_loop(self): tasks = [] - for (key, vh) in self.vehicle_handler.items(): + for (key, vh) in self.vehicle_handlers.items(): LOG.debug(f'Starting process for car {key}') task = asyncio.create_task(vh.handle_vehicle(), name=f'handle_vehicle_{key}') tasks.append(task) @@ -581,115 +219,6 @@ async def __shutdown_handler(tasks): LOG.warning(f'There are still {len(pending)} tasks... waiting for them to complete') -def get_latest_message(vehicle_start_messages): - return next(iter(reversed( - sorted( - vehicle_start_messages, - key=lambda m: m.message_time - ) - )), None) - - -class MessageHandler: - def __init__(self, gateway: MqttGateway, saicapi: SaicApi): - self.gateway = gateway - self.saicapi = saicapi - - async def check_for_new_messages(self) -> None: - if self.__should_poll(): - try: - LOG.debug("Checking for new messages") - await self.__polling() - except Exception as e: - LOG.exception('MessageHandler poll loop failed', exc_info=e) - else: - LOG.debug("Not checking for new messages since all cars have RefreshMode.OFF") - - async def __polling(self): - try: - unread_count = await self.saicapi.get_unread_messages_count() - LOG.info(f'{unread_count} unread messages') - if unread_count.alarmNumber == 0: - return - all_messages = await self.__get_all_messages() - LOG.info(f'{len(all_messages)} messages received') - - vehicle_start_messages = [m for m in all_messages if m.messageType == '323'] - latest_vehicle_start_message = get_latest_message(vehicle_start_messages) - - new_messages = [m for m in all_messages if m.read_status != 'read'] - latest_message = get_latest_message(new_messages) - - for message in new_messages: - LOG.info(message.details) - await self.__read_message(message) - - if latest_vehicle_start_message is not None: - LOG.info( - f'{latest_vehicle_start_message.title} detected at {latest_vehicle_start_message.message_time}') - vehicle_handler = self.gateway.get_vehicle_handler(latest_vehicle_start_message.vin) - if vehicle_handler: - # delete the vehicle start message after processing it - vehicle_handler.vehicle_state.notify_message(latest_vehicle_start_message) - await self.__delete_message(latest_vehicle_start_message) - elif latest_message is not None: - vehicle_handler = self.gateway.get_vehicle_handler(latest_message.vin) - if vehicle_handler: - vehicle_handler.vehicle_state.notify_message(latest_message) - except SaicApiException as e: - LOG.exception('MessageHandler poll loop failed during SAIC API Call', exc_info=e) - except Exception as e: - LOG.exception('MessageHandler poll loop failed unexpectedly', exc_info=e) - - async def __get_all_messages(self) -> list[MessageEntity]: - idx = 1 - all_messages = [] - while True: - try: - message_list = await self.saicapi.get_alarm_list(page_num=idx, page_size=1) - if message_list.messages and len(message_list.messages) > 0: - all_messages.extend(message_list.messages) - else: - return all_messages - except Exception as e: - LOG.exception( - 'Error while fetching a message from the SAIC API, please open the app and clear them, ' - 'then report this as a bug.', - exc_info=e - ) - finally: - idx = idx + 1 - - async def __delete_message(self, latest_vehicle_start_message): - try: - message_id = latest_vehicle_start_message.messageId - await self.saicapi.delete_message(message_id=message_id) - LOG.info(f'{latest_vehicle_start_message.title} message with ID {message_id} deleted') - except Exception as e: - LOG.exception('Could not delete message from server', exc_info=e) - - async def __read_message(self, message): - try: - message_id = message.messageId - await self.saicapi.read_message(message_id=message_id) - LOG.info(f'{message.title} message with ID {message_id} marked as read') - except Exception as e: - LOG.exception('Could not mark message as read from server', exc_info=e) - - def __should_poll(self): - vehicle_handlers = self.gateway.vehicle_handler or dict() - refresh_modes = [ - vh.vehicle_state.refresh_mode - for vh in vehicle_handlers.values() - if vh.vehicle_state is not None - ] - # We do not poll if we have no cars or all cars have RefreshMode.OFF - if len(refresh_modes) == 0 or all(mode == RefreshMode.OFF for mode in refresh_modes): - return False - else: - return True - - if __name__ == '__main__': # Enable fault handler to get a thread dump on SIGQUIT faulthandler.enable(file=sys.stderr, all_threads=True) From b76724531e26161bd72fb486964a1de6e5f4ce28 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 12 May 2024 14:53:13 +0200 Subject: [PATCH 05/35] Cleanup datetime_to_str --- mqtt_gateway.py | 11 ----------- utils.py | 4 ++++ vehicle.py | 16 ++++++---------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/mqtt_gateway.py b/mqtt_gateway.py index 7f805c2..e5a0084 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -1,11 +1,9 @@ import asyncio -import datetime import faulthandler import logging import os import signal import sys -import time from typing import override, Optional import apscheduler.schedulers.asyncio @@ -25,15 +23,6 @@ MSG_CMD_SUCCESSFUL = 'Success' - -def epoch_value_to_str(time_value: int) -> str: - return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time_value)) - - -def datetime_to_str(dt: datetime.datetime) -> str: - return dt.strftime('%Y-%m-%d %H:%M:%S') - - logging.root.handlers = [] logging.basicConfig(format='{asctime:s} [{levelname:^8s}] {message:s} - {name:s}', style='{') LOG = logging.getLogger(__name__) diff --git a/utils.py b/utils.py index 32b1516..dcd75f2 100644 --- a/utils.py +++ b/utils.py @@ -33,3 +33,7 @@ def get_update_timestamp(vehicle_status: VehicleStatusResp) -> datetime: return reference_time else: return now_time + + +def datetime_to_str(dt: datetime) -> str: + return datetime.astimezone(dt, tz=timezone.utc).isoformat() diff --git a/vehicle.py b/vehicle.py index a7083a7..af59164 100644 --- a/vehicle.py +++ b/vehicle.py @@ -22,7 +22,7 @@ from integrations.openwb.charging_station import ChargingStation from exceptions import MqttGatewayException from publisher.core import Publisher -from utils import value_in_range, is_valid_temperature +from utils import value_in_range, is_valid_temperature, datetime_to_str DEFAULT_AC_TEMP = 22 PRESSURE_TO_BAR_FACTOR = 0.04 @@ -341,7 +341,7 @@ def handle_vehicle_status(self, vehicle_status: VehicleStatusResp) -> None: }) self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_VEHICLE_STATE), - VehicleState.datetime_to_str(datetime.datetime.now())) + datetime_to_str(datetime.datetime.now())) def __publish_tyre(self, raw_value: int, topic: str): if value_in_range(raw_value, 1, 255): @@ -388,7 +388,7 @@ def notify_car_activity_time(self, now: datetime.datetime, force: bool): ): self.last_car_activity = datetime.datetime.now() self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_ACTIVITY), - VehicleState.datetime_to_str(self.last_car_activity)) + datetime_to_str(self.last_car_activity)) def notify_message(self, message: MessageEntity): if ( @@ -400,7 +400,7 @@ def notify_message(self, message: MessageEntity): self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_TYPE), message.messageType) self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_TITLE), message.title) self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_TIME), - VehicleState.datetime_to_str(self.last_car_vehicle_message)) + datetime_to_str(self.last_car_vehicle_message)) self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_SENDER), message.sender) if message.content: self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_CONTENT), message.content) @@ -498,7 +498,7 @@ def last_failed_refresh(self, value: datetime.datetime | None): self.__failed_refresh_counter = self.__failed_refresh_counter + 1 self.publisher.publish_str( self.get_topic(mqtt_topics.REFRESH_LAST_ERROR), - VehicleState.datetime_to_str(value) + datetime_to_str(value) ) self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_ERROR), self.__refresh_period_error) @@ -742,7 +742,7 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: ) self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_CHARGE_STATE), - VehicleState.datetime_to_str(datetime.datetime.now())) + datetime_to_str(datetime.datetime.now())) real_total_battery_capacity = self.get_actual_battery_capacity() raw_total_battery_capacity = None @@ -893,10 +893,6 @@ def to_remote_climate(rmt_htd_rr_wnd_st: int) -> str: return f'unknown ({rmt_htd_rr_wnd_st})' - @staticmethod - def datetime_to_str(dt: datetime.datetime) -> str: - return datetime.datetime.astimezone(dt, tz=datetime.timezone.utc).isoformat() - def set_refresh_mode(self, mode: RefreshMode, cause: str): if ( mode is not None and From 8e01709cdcaf06aa0179bb28d7bb147c3c4f6ae9 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Thu, 27 Jun 2024 22:44:26 +0200 Subject: [PATCH 06/35] Fix hardcoded True param for car activity --- handlers/vehicle.py | 2 +- vehicle.py | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/handlers/vehicle.py b/handlers/vehicle.py index cee93b1..07e4aef 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -50,7 +50,7 @@ def __init__(self, config: Configuration, saicapi: SaicApi, publisher: Publisher async def handle_vehicle(self) -> None: start_time = datetime.datetime.now() self.vehicle_state.publish_vehicle_info() - self.vehicle_state.notify_car_activity_time(start_time, True) + self.vehicle_state.notify_car_activity() while True: if self.__should_complete_configuration(start_time): diff --git a/vehicle.py b/vehicle.py index af59164..1e72b01 100644 --- a/vehicle.py +++ b/vehicle.py @@ -378,17 +378,14 @@ def set_hv_battery_active(self, hv_battery_active: bool): self.publisher.publish_bool(self.get_topic(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE), hv_battery_active) if hv_battery_active: - self.notify_car_activity_time(datetime.datetime.now(), True) + self.notify_car_activity() - def notify_car_activity_time(self, now: datetime.datetime, force: bool): - if ( - self.last_car_activity == datetime.datetime.min - or force - or self.last_car_activity < now - ): - self.last_car_activity = datetime.datetime.now() - self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_ACTIVITY), - datetime_to_str(self.last_car_activity)) + def notify_car_activity(self): + self.last_car_activity = datetime.datetime.now() + self.publisher.publish_str( + self.get_topic(mqtt_topics.REFRESH_LAST_ACTIVITY), + datetime_to_str(self.last_car_activity) + ) def notify_message(self, message: MessageEntity): if ( @@ -407,7 +404,7 @@ def notify_message(self, message: MessageEntity): self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_STATUS), message.read_status) if message.vin: self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_VIN), message.vin) - self.notify_car_activity_time(message.message_time, True) + self.notify_car_activity() def should_refresh(self) -> bool: match self.refresh_mode: From fad8db17b427c0cee3f4a36c27ff0d8f2e961c03 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Thu, 27 Jun 2024 23:33:22 +0200 Subject: [PATCH 07/35] Do not rely on unread message satus, keep track of the last message we processed instead --- handlers/message.py | 58 ++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/handlers/message.py b/handlers/message.py index 30cf894..b4cc63e 100644 --- a/handlers/message.py +++ b/handlers/message.py @@ -1,4 +1,6 @@ +import datetime import logging +from typing import Union from saic_ismart_client_ng import SaicApi from saic_ismart_client_ng.api.message.schema import MessageEntity @@ -14,6 +16,8 @@ class MessageHandler: def __init__(self, gateway: VehicleHandlerLocator, saicapi: SaicApi): self.gateway = gateway self.saicapi = saicapi + self.last_message_ts = datetime.datetime.min + self.last_message_id = None async def check_for_new_messages(self) -> None: if self.__should_poll(): @@ -27,41 +31,39 @@ async def check_for_new_messages(self) -> None: async def __polling(self): try: - unread_count = await self.saicapi.get_unread_messages_count() - LOG.info(f'{unread_count} unread messages') - if unread_count.alarmNumber == 0: - return - all_messages = await self.__get_all_messages() + all_messages = await self.__get_all_alarm_messages() LOG.info(f'{len(all_messages)} messages received') - vehicle_start_messages = [m for m in all_messages if m.messageType == '323'] - latest_vehicle_start_message = self.__get_latest_message(vehicle_start_messages) - new_messages = [m for m in all_messages if m.read_status != 'read'] - latest_message = self.__get_latest_message(new_messages) - for message in new_messages: LOG.info(message.details) await self.__read_message(message) - if latest_vehicle_start_message is not None: + latest_message = self.__get_latest_message(all_messages) + if latest_message.messageId != self.last_message_id and latest_message.message_time > self.last_message_ts: + self.last_message_id = latest_message.messageId + self.last_message_ts = latest_message.message_time LOG.info( - f'{latest_vehicle_start_message.title} detected at {latest_vehicle_start_message.message_time}') - vehicle_handler = self.gateway.get_vehicle_handler(latest_vehicle_start_message.vin) - if vehicle_handler: - # delete the vehicle start message after processing it - vehicle_handler.vehicle_state.notify_message(latest_vehicle_start_message) - await self.__delete_message(latest_vehicle_start_message) - elif latest_message is not None: + f'{latest_message.title} detected at {latest_message.message_time}' + ) vehicle_handler = self.gateway.get_vehicle_handler(latest_message.vin) if vehicle_handler: vehicle_handler.vehicle_state.notify_message(latest_message) + + # Delete vehicle start messages unless they are the latest + vehicle_start_messages = [ + m for m in all_messages + if m.messageType == '323' and m.messageId != self.last_message_id + ] + for vehicle_start_message in vehicle_start_messages: + await self.__delete_message(vehicle_start_message) + except SaicApiException as e: LOG.exception('MessageHandler poll loop failed during SAIC API Call', exc_info=e) except Exception as e: LOG.exception('MessageHandler poll loop failed unexpectedly', exc_info=e) - async def __get_all_messages(self) -> list[MessageEntity]: + async def __get_all_alarm_messages(self) -> list[MessageEntity]: idx = 1 all_messages = [] while True: @@ -71,6 +73,9 @@ async def __get_all_messages(self) -> list[MessageEntity]: all_messages.extend(message_list.messages) else: return all_messages + oldest_message = self.__get_oldest_message(all_messages) + if oldest_message is not None and oldest_message.message_time < self.last_message_ts: + return all_messages except Exception as e: LOG.exception( 'Error while fetching a message from the SAIC API, please open the app and clear them, ' @@ -80,7 +85,7 @@ async def __get_all_messages(self) -> list[MessageEntity]: finally: idx = idx + 1 - async def __delete_message(self, latest_vehicle_start_message): + async def __delete_message(self, latest_vehicle_start_message: MessageEntity): try: message_id = latest_vehicle_start_message.messageId await self.saicapi.delete_message(message_id=message_id) @@ -88,7 +93,7 @@ async def __delete_message(self, latest_vehicle_start_message): except Exception as e: LOG.exception('Could not delete message from server', exc_info=e) - async def __read_message(self, message): + async def __read_message(self, message: MessageEntity): try: message_id = message.messageId await self.saicapi.read_message(message_id=message_id) @@ -110,10 +115,19 @@ def __should_poll(self): return True @staticmethod - def __get_latest_message(vehicle_start_messages): + def __get_latest_message(vehicle_start_messages: list[MessageEntity]) -> Union[MessageEntity, None]: return next(iter(reversed( sorted( vehicle_start_messages, key=lambda m: m.message_time ) )), None) + + @staticmethod + def __get_oldest_message(vehicle_start_messages: list[MessageEntity]) -> Union[MessageEntity, None]: + return next(iter( + sorted( + vehicle_start_messages, + key=lambda m: m.message_time + ) + ), None) From ffcb2e326278f5e8aaabba66389a41d7fd4ca72a Mon Sep 17 00:00:00 2001 From: Francisco Zamora-Martinez Date: Sat, 27 Jul 2024 13:29:49 +0200 Subject: [PATCH 08/35] Add suffix to battery heating stop reason topic to allow discriminate it from battery heating topic --- mqtt_topics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mqtt_topics.py b/mqtt_topics.py index 4808fba..babda7b 100644 --- a/mqtt_topics.py +++ b/mqtt_topics.py @@ -41,7 +41,7 @@ DRIVETRAIN_BATTERY_HEATING = DRIVETRAIN + '/batteryHeating' DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + '/batteryHeating' DRIVETRAIN_CHARGING_SCHEDULE = DRIVETRAIN + '/chargingSchedule' -DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + '/batteryHeatingSchedule' +DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + '/batteryHeatingScheduleStopReason' DRIVETRAIN_CHARGING_TYPE = DRIVETRAIN + '/chargingType' DRIVETRAIN_CURRENT = DRIVETRAIN + '/current' DRIVETRAIN_HV_BATTERY_ACTIVE = DRIVETRAIN + '/hvBatteryActive' From 29913ac265baa132af2b49dcb352991167c9c1ba Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 11 Sep 2024 16:29:10 +0200 Subject: [PATCH 09/35] Draft OsmAnd Integration --- configuration/__init__.py | 11 +- configuration/parser.py | 63 ++++++--- handlers/vehicle.py | 38 +++++- integrations/osmand/__init__.py | 0 integrations/osmand/api.py | 232 ++++++++++++++++++++++++++++++++ mqtt_topics.py | 1 + saic_api_listener.py | 16 ++- 7 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 integrations/osmand/__init__.py create mode 100644 integrations/osmand/api.py diff --git a/configuration/__init__.py b/configuration/__init__.py index b15a6a0..94bed6f 100644 --- a/configuration/__init__.py +++ b/configuration/__init__.py @@ -22,9 +22,7 @@ def __init__(self): self.saic_region: str = 'eu' self.saic_tenant_id: str = '459771' self.saic_relogin_delay: int = 15 * 60 # in seconds - self.abrp_token_map: dict[str, str] = {} self.battery_capacity_map: dict[str, float] = {} - self.abrp_api_key: str | None = None self.mqtt_host: str | None = None self.mqtt_port: int | None = None self.mqtt_transport_protocol: TransportProtocol | None = None @@ -41,4 +39,13 @@ def __init__(self): self.ha_show_unavailable: bool = True self.charge_dynamic_polling_min_percentage: float = 1.0 self.publish_raw_api_data: bool = False + + # ABRP Integration + self.abrp_token_map: dict[str, str] = {} + self.abrp_api_key: str | None = None self.publish_raw_abrp_data: bool = False + + # OsmAnd Integration + self.osmand_device_id_map: dict[str, str] = {} + self.osmand_server_uri: str | None = None + self.publish_raw_osmand_data: bool = False diff --git a/configuration/parser.py b/configuration/parser.py index 02eb421..970d1ab 100644 --- a/configuration/parser.py +++ b/configuration/parser.py @@ -87,18 +87,6 @@ def process_arguments() -> Configuration: help='The SAIC API tenant id. Environment Variable: SAIC_TENANT_ID', default='459771', dest='saic_tenant_id', required=False, action=EnvDefault, envvar='SAIC_TENANT_ID') - parser.add_argument('--abrp-api-key', - help='The API key for the A Better Route Planer telemetry API.' - + ' Default is the open source telemetry' - + ' API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d.' - + ' Environment Variable: ABRP_API_KEY', - default='8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d', dest='abrp_api_key', required=False, - action=EnvDefault, envvar='ABRP_API_KEY') - parser.add_argument('--abrp-user-token', help='The mapping of VIN to ABRP User Token.' - + ' Multiple mappings can be provided seperated by ,' - + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' - + ' Environment Variable: ABRP_USER_TOKEN', - dest='abrp_user_token', required=False, action=EnvDefault, envvar='ABRP_USER_TOKEN') parser.add_argument('--battery-capacity-mapping', help='The mapping of VIN to full batteryc' + ' apacity. Multiple mappings can be provided separated' + ' by , Example: LSJXXXX=54.0,LSJYYYY=64.0,' @@ -141,11 +129,42 @@ def process_arguments() -> Configuration: dest='publish_raw_api_data', required=False, action=EnvDefault, envvar='PUBLISH_RAW_API_DATA_ENABLED', default=False, type=check_bool) + + # ABRP Integration + parser.add_argument('--abrp-api-key', + help='The API key for the A Better Route Planer telemetry API.' + + ' Default is the open source telemetry' + + ' API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d.' + + ' Environment Variable: ABRP_API_KEY', + default='8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d', dest='abrp_api_key', required=False, + action=EnvDefault, envvar='ABRP_API_KEY') + parser.add_argument('--abrp-user-token', help='The mapping of VIN to ABRP User Token.' + + ' Multiple mappings can be provided seperated by ,' + + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' + + ' Environment Variable: ABRP_USER_TOKEN', + dest='abrp_user_token', required=False, action=EnvDefault, envvar='ABRP_USER_TOKEN') parser.add_argument('--publish-raw-abrp-data', help='Publish raw ABRP API request/response to MQTT. Environment Variable: ' 'PUBLISH_RAW_ABRP_DATA_ENABLED', dest='publish_raw_abrp_data', required=False, action=EnvDefault, envvar='PUBLISH_RAW_ABRP_DATA_ENABLED', default=False, type=check_bool) + # OsmAnd Integration + parser.add_argument('--osmand--server-uri', + help='The URL of your OsmAnd Server.' + + ' Default unset' + + ' Environment Variable: OSMAND_SERVER_URI', + default=None, dest='osmand_server_uri', required=False, + action=EnvDefault, envvar='OSMAND_SERVER_URI') + parser.add_argument('--osmand-device-id', help='The mapping of VIN to OsmAnd Device ID.' + + ' Multiple mappings can be provided seperated by ,' + + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' + + ' Environment Variable: OSMAND_DEVICE_ID', + dest='osmand_device_id', required=False, action=EnvDefault, envvar='OSMAND_DEVICE_ID') + parser.add_argument('--publish-raw-osmand-data', + help='Publish raw ABRP OsmAnd request/response to MQTT. Environment Variable: ' + 'PUBLISH_RAW_OSMAND_DATA_ENABLED', + dest='publish_raw_osmand_data', required=False, action=EnvDefault, + envvar='PUBLISH_RAW_OSMAND_DATA_ENABLED', default=False, type=check_bool) args = parser.parse_args() config.mqtt_user = args.mqtt_user @@ -161,9 +180,6 @@ def process_arguments() -> Configuration: config.saic_user = args.saic_user config.saic_password = args.saic_password config.saic_phone_country_code = args.saic_phone_country_code - config.abrp_api_key = args.abrp_api_key - if args.abrp_user_token: - cfg_value_to_dict(args.abrp_user_token, config.abrp_token_map) if args.battery_capacity_mapping: cfg_value_to_dict( args.battery_capacity_mapping, @@ -183,9 +199,6 @@ def process_arguments() -> Configuration: if args.publish_raw_api_data is not None: config.publish_raw_api_data = args.publish_raw_api_data - if args.publish_raw_abrp_data is not None: - config.publish_raw_abrp_data = args.publish_raw_abrp_data - if args.ha_show_unavailable is not None: config.ha_show_unavailable = args.ha_show_unavailable @@ -221,6 +234,20 @@ def process_arguments() -> Configuration: config.mqtt_host = str(parse_result.hostname) + # ABRP Integration + config.abrp_api_key = args.abrp_api_key + if args.abrp_user_token: + cfg_value_to_dict(args.abrp_user_token, config.abrp_token_map) + if args.publish_raw_abrp_data is not None: + config.publish_raw_abrp_data = args.publish_raw_abrp_data + + # OsmAnd Integration + config.osmand_server_uri = args.osmand_server_uri + if args.osmand_device_id: + cfg_value_to_dict(args.osmand_device_id, config.osmand_device_id_map) + if args.publish_raw_osmand_data is not None: + config.publish_raw_osmand_data = args.publish_raw_osmand_data + return config except argparse.ArgumentError as err: parser.print_help() diff --git a/handlers/vehicle.py b/handlers/vehicle.py index 07e4aef..20e9e13 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -16,8 +16,9 @@ from exceptions import MqttGatewayException from integrations.abrp.api import AbrpApi, AbrpApiException from integrations.home_assistant.discovery import HomeAssistantDiscovery +from integrations.osmand.api import OsmAndApi from publisher.core import Publisher -from saic_api_listener import MqttGatewayAbrpListener +from saic_api_listener import MqttGatewayAbrpListener, OsmAndApiListener, MqttGatewayOsmAndListener from vehicle import VehicleState, RefreshMode LOG = logging.getLogger(__name__) @@ -33,18 +34,38 @@ def __init__(self, config: Configuration, saicapi: SaicApi, publisher: Publisher self.vehicle_prefix = f'{self.configuration.saic_user}/vehicles/{self.vin_info.vin}' self.vehicle_state = vehicle_state self.ha_discovery = HomeAssistantDiscovery(vehicle_state, vin_info, config) + + self.__setup_abrp(config, vin_info) + self.__setup_osmand(config, vin_info) + + def __setup_abrp(self, config, vin_info): if vin_info.vin in self.configuration.abrp_token_map: abrp_user_token = self.configuration.abrp_token_map[vin_info.vin] else: abrp_user_token = None if config.publish_raw_abrp_data: - listener = MqttGatewayAbrpListener(self.publisher) + abrp_api_listener = MqttGatewayAbrpListener(self.publisher) else: - listener = None + abrp_api_listener = None self.abrp_api = AbrpApi( self.configuration.abrp_api_key, abrp_user_token, - listener=listener + listener=abrp_api_listener + ) + + def __setup_osmand(self, config, vin_info): + if vin_info.vin in self.configuration.osmand_device_id_map: + osmand_device_id = self.configuration.osmand_device_id_map[vin_info.vin] + else: + osmand_device_id = vin_info.vin + if config.publish_raw_osmand_data: + api_listener = MqttGatewayOsmAndListener(self.publisher) + else: + api_listener = None + self.osmand_api = OsmAndApi( + server_uri=self.configuration.osmand_server_uri, + device_id=osmand_device_id, + listener=api_listener ) async def handle_vehicle(self) -> None: @@ -99,6 +120,7 @@ async def __polling(self): LOG.info('Refreshing vehicle status succeeded...') await self.__refresh_abrp(charge_status, vehicle_status) + await self.__refresh_osmand(charge_status, vehicle_status) def __should_poll(self) -> bool: return ( @@ -112,6 +134,14 @@ def __should_complete_configuration(self, start_time) -> bool: and datetime.datetime.now() > start_time + datetime.timedelta(seconds=10) ) + async def __refresh_osmand(self, charge_status, vehicle_status): + refreshed, response = await self.osmand_api.update_osmand(vehicle_status, charge_status) + self.publisher.publish_str(f'{self.vehicle_prefix}/{mqtt_topics.INTERNAL_OSMAND}', response) + if refreshed: + LOG.info('Refreshing OsmAnd status succeeded...') + else: + LOG.info(f'OsmAnd not refreshed, reason {response}') + async def __refresh_abrp(self, charge_status, vehicle_status): abrp_refreshed, abrp_response = await self.abrp_api.update_abrp(vehicle_status, charge_status) self.publisher.publish_str(f'{self.vehicle_prefix}/{mqtt_topics.INTERNAL_ABRP}', abrp_response) diff --git a/integrations/osmand/__init__.py b/integrations/osmand/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/integrations/osmand/api.py b/integrations/osmand/api.py new file mode 100644 index 0000000..d70351d --- /dev/null +++ b/integrations/osmand/api.py @@ -0,0 +1,232 @@ +import json +import logging +from abc import ABC +from typing import Any, Tuple, Optional + +import httpx +from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus +from saic_ismart_client_ng.api.vehicle import VehicleStatusResp +from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus +from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp +from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus + +from utils import value_in_range, get_update_timestamp + +LOG = logging.getLogger(__name__) + + +class OsmAndApiException(Exception): + def __init__(self, msg: str): + self.message = msg + + def __str__(self): + return self.message + + +class OsmAndApiListener(ABC): + async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + pass + + async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + pass + + +class OsmAndApi: + def __init__(self, *, server_uri: str, device_id: str, listener: Optional[OsmAndApiListener] = None) -> None: + self.__device_id = device_id + self.__listener = listener + self.__server_uri = server_uri + self.client = httpx.AsyncClient( + event_hooks={ + "request": [self.invoke_request_listener], + "response": [self.invoke_response_listener] + } + ) + + async def update_osmand(self, vehicle_status: VehicleStatusResp, charge_info: ChrgMgmtDataResp) \ + -> Tuple[bool, Any | None]: + + charge_status = None if charge_info is None else charge_info.chrgMgmtData + + if ( + self.__device_id is not None + and self.__server_uri is not None + and vehicle_status is not None + and charge_status is not None + ): + # Request + data = { + 'id': self.__device_id, + # Guess the timestamp from either the API, GPS info or current machine time + 'timestamp': int(get_update_timestamp(vehicle_status).timestamp()), + 'soc': (charge_status.bmsPackSOCDsp / 10.0), + 'is_charging': vehicle_status.is_charging, + 'is_parked': vehicle_status.is_parked, + } + + if vehicle_status.is_parked: + data.update({ + # We assume the vehicle is stationary, we will update it later from GPS if available + 'speed': 0.0, + }) + + # Skip invalid current values reported by the API + is_valid_current = ( + charge_status.bmsPackCrntV != 1 + and value_in_range(charge_status.bmsPackCrnt, 0, 65535) + ) + if is_valid_current: + data.update({ + 'power': charge_status.decoded_power, + 'voltage': charge_status.decoded_voltage, + 'current': charge_status.decoded_current + }) + + basic_vehicle_status = vehicle_status.basicVehicleStatus + if basic_vehicle_status is not None: + data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) + + data.update(self.__extract_electric_range(basic_vehicle_status, charge_info.rvsChargeStatus)) + + gps_position = vehicle_status.gpsPosition + if gps_position is not None: + data.update(self.__extract_gps_position(gps_position)) + + try: + response = await self.client.post(url=self.__server_uri, params=data) + await response.aread() + return True, response.text + except httpx.ConnectError as ece: + raise OsmAndApiException(f'Connection error: {ece}') + except httpx.TimeoutException as et: + raise OsmAndApiException(f'Timeout error {et}') + except httpx.RequestError as e: + raise OsmAndApiException(f'{e}') + except httpx.HTTPError as ehttp: + raise OsmAndApiException(f'HTTP error {ehttp}') + else: + return False, 'OsmAnd request skipped because of missing configuration' + + @staticmethod + def __extract_basic_vehicle_status(basic_vehicle_status: BasicVehicleStatus) -> dict: + data = {} + + exterior_temperature = basic_vehicle_status.exteriorTemperature + if exterior_temperature is not None and value_in_range(exterior_temperature, -127, 127): + data['ext_temp'] = exterior_temperature + mileage = basic_vehicle_status.mileage + # Skip invalid range readings + if mileage is not None and value_in_range(mileage, 1, 2147483647): + data['odometer'] = 100 * mileage + + return data + + @staticmethod + def __extract_gps_position(gps_position: GpsPosition) -> dict: + data = {} + + # Do not use GPS data if it is not available + if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: + return data + + way_point = gps_position.wayPoint + if way_point is None: + return data + + speed = way_point.speed + if value_in_range(speed, -999, 4500): + data['speed'] = speed / 10 + + heading = way_point.heading + if value_in_range(heading, 0, 360): + data['heading'] = heading + + position = way_point.position + if position is None: + return data + + altitude = position.altitude + if value_in_range(altitude, -500, 8900): + data['altitude'] = altitude + + lat_degrees = position.latitude / 1000000.0 + lon_degrees = position.longitude / 1000000.0 + + if ( + abs(lat_degrees) <= 90 + and abs(lon_degrees) <= 180 + ): + data.update({ + 'hdop': way_point.hdop, + 'lat': lat_degrees, + 'lon': lon_degrees, + }) + + return data + + def __extract_electric_range( + self, + basic_vehicle_status: BasicVehicleStatus | None, + charge_status: RvsChargeStatus | None + ) -> dict: + + data = {} + + range_elec_vehicle = 0.0 + if basic_vehicle_status is not None: + range_elec_vehicle = self.__parse_electric_range(raw_value=basic_vehicle_status.fuelRangeElec) + + range_elec_bms = 0.0 + if charge_status is not None: + range_elec_bms = self.__parse_electric_range(raw_value=charge_status.fuelRangeElec) + + range_elec = max(range_elec_vehicle, range_elec_bms) + if range_elec > 0: + data['est_battery_range'] = range_elec + + return data + + @staticmethod + def __parse_electric_range(raw_value) -> float: + if value_in_range(raw_value, 1, 65535): + return float(raw_value) / 10.0 + return 0.0 + + async def invoke_request_listener(self, request: httpx.Request): + if not self.__listener: + return + try: + body = None + if request.content: + try: + + body = request.content.decode("utf-8") + except Exception as e: + LOG.warning(f"Error decoding request content: {e}") + + await self.__listener.on_request( + path=str(request.url).replace(self.__server_uri, "/"), + body=body, + headers=dict(request.headers), + ) + except Exception as e: + LOG.warning(f"Error invoking request listener: {e}", exc_info=e) + + async def invoke_response_listener(self, response: httpx.Response): + if not self.__listener: + return + try: + body = await response.aread() + if body: + try: + body = body.decode("utf-8") + except Exception as e: + LOG.warning(f"Error decoding request content: {e}") + + await self.__listener.on_response( + path=str(response.url).replace(self.__server_uri, "/"), + body=body, + headers=dict(response.headers), + ) + except Exception as e: + LOG.warning(f"Error invoking request listener: {e}", exc_info=e) diff --git a/mqtt_topics.py b/mqtt_topics.py index 4808fba..8182035 100644 --- a/mqtt_topics.py +++ b/mqtt_topics.py @@ -99,6 +99,7 @@ INTERNAL_API = INTERNAL + '/api' INTERNAL_LWT = INTERNAL + '/lwt' INTERNAL_ABRP = INTERNAL + '/abrp' +INTERNAL_OSMAND = INTERNAL + '/osmand' INTERNAL_CONFIGURATION_RAW = INTERNAL + '/configuration/raw' LOCATION = 'location' diff --git a/saic_api_listener.py b/saic_api_listener.py index cdacead..ce40b8e 100644 --- a/saic_api_listener.py +++ b/saic_api_listener.py @@ -8,7 +8,8 @@ from saic_ismart_client_ng.listener import SaicApiListener from integrations.abrp.api import AbrpApiListener -from mqtt_topics import INTERNAL_API, INTERNAL_ABRP +from integrations.osmand.api import OsmAndApiListener +from mqtt_topics import INTERNAL_API, INTERNAL_ABRP, INTERNAL_OSMAND from publisher.core import Publisher LOG = logging.getLogger(__name__) @@ -69,6 +70,19 @@ def __internal_publish(self, *, key: str, data: dict): LOG.info(f"Not publishing API response to MQTT since publisher is not connected. {data}") +class MqttGatewayOsmAndListener(OsmAndApiListener, MqttGatewayListenerApiListener): + def __init__(self, publisher: Publisher): + super().__init__(publisher, INTERNAL_OSMAND) + + @override + async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + await self.publish_request(path, body, headers) + + @override + async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + await self.publish_response(path, body, headers) + + class MqttGatewayAbrpListener(AbrpApiListener, MqttGatewayListenerApiListener): def __init__(self, publisher: Publisher): super().__init__(publisher, INTERNAL_ABRP) From 80467860a9e8bbdf9e4c036e7b0af3116d2e765e Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 11 Sep 2024 16:56:37 +0200 Subject: [PATCH 10/35] Update parser documentation --- configuration/parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/configuration/parser.py b/configuration/parser.py index 970d1ab..7e7cebe 100644 --- a/configuration/parser.py +++ b/configuration/parser.py @@ -158,6 +158,7 @@ def process_arguments() -> Configuration: parser.add_argument('--osmand-device-id', help='The mapping of VIN to OsmAnd Device ID.' + ' Multiple mappings can be provided seperated by ,' + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' + ' Default is to use the car VIN as Device ID, ' + ' Environment Variable: OSMAND_DEVICE_ID', dest='osmand_device_id', required=False, action=EnvDefault, envvar='OSMAND_DEVICE_ID') parser.add_argument('--publish-raw-osmand-data', From 859e2f6bf9f4a22fc0f05a4cd5fe6021a1d5fb85 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 11 Sep 2024 17:00:44 +0200 Subject: [PATCH 11/35] Documentation update --- README.md | 39 ++++++++++++++++++++++++++++----------- configuration/parser.py | 2 +- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c932dbc..faef397 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,32 @@ do when you run the service from a docker container). | --saic-rest-uri | SAIC_REST_URI | SAIC API URI. Default is the European Production endpoint: https://gateway-mg-eu.soimt.com/api.app/v1/ | | --saic-region | SAIC_REGION | SAIC API region. Default is eu. | | --saic-tenant-id | SAIC_TENANT_ID | SAIC API tenant ID. Default is 459771. | -| --abrp-api-key | ABRP_API_KEY | API key for the A Better Route Planner telemetry API. Default is the open source telemetry API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d. | -| --abrp-user-token | ABRP_USER_TOKEN | Mapping of VIN to ABRP User Token. Multiple mappings can be provided separated by ',' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl | | --charging-stations-json | CHARGING_STATIONS_JSON | Custom charging stations configuration file name | | --saic-relogin-delay | SAIC_RELOGIN_DELAY | The gateway detects logins from other devices (e.g. the iSMART app). It then pauses it's activity for 900 seconds (default value). The delay can be configured with this parameter. | | --ha-discovery | HA_DISCOVERY_ENABLED | Home Assistant auto-discovery is enabled (True) by default. It can be disabled (False) with this parameter. | | --ha-discovery-prefix | HA_DISCOVERY_PREFIX | The default MQTT prefix for Home Assistant auto-discovery is 'homeassistant'. Another prefix can be configured with this parameter | | --messages-request-interval | MESSAGES_REQUEST_INTERVAL | The interval for retrieving messages in seconds. Default is 60 seconds. | +### ABRP Integration Configuration + +Those parameters can be used to allow the MQTT Gateway to send data to ABRP API + +| CMD param | ENV variable | Description | +|-------------------------|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| --abrp-api-key | ABRP_API_KEY | API key for the A Better Route Planner telemetry API. Default is the open source telemetry API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d. | +| --abrp-user-token | ABRP_USER_TOKEN | Mapping of VIN to ABRP User Token. Multiple mappings can be provided separated by ',' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl | +| --publish-raw-abrp-data | PUBLISH_RAW_ABRP_DATA_ENABLED | Publish raw ABRP API request/response to MQTT. Disabled (False) by default. | + +### OsmAnd Integration Configuration + +Those parameters can be used to allow the MQTT Gateway to send data to an OsmAnd-compatibile server like Traccar + +| CMD param | ENV variable | Description | +|---------------------------|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --osmand-server-uri | OSMAND_SERVER_URI | The URL of your OsmAnd Server | +| --osmand-device-id | OSMAND_DEVICE_ID | Mapping of VIN to OsmAnd Device Id. Multiple mappings can be provided separated by ',' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl. Defaults to use the car VIN as Device Id if unset | +| --publish-raw-osmand-data | PUBLISH_RAW_OSMAND_DATA_ENABLED | Publish raw ABRP OSMAND request/response to MQTT. Disabled (False) by default. | + ### Charging Station Configuration If your charging station also provides information over MQTT or if you somehow manage to publish information from your @@ -65,15 +83,14 @@ The key-value pairs in the JSON express the following: ## Advanced settings -| CMD param | ENV variable | Description | -|----------------------------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 | -| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default | -| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | -| --publish-raw-api-data | PUBLISH_RAW_API_DATA_ENABLED | Publish raw SAIC API request/response to MQTT. Disabled (False) by default. | -| --publish-raw-abrp-data | PUBLISH_RAW_ABRP_DATA_ENABLED | Publish raw ABRP API request/response to MQTT. Disabled (False) by default. | -| | LOG_LEVEL | Log level: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | -| | MQTT_LOG_LEVEL | Log level of the MQTT Client: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | +| CMD param | ENV variable | Description | +|----------------------------|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 | +| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default | +| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | +| --publish-raw-api-data | PUBLISH_RAW_API_DATA_ENABLED | Publish raw SAIC API request/response to MQTT. Disabled (False) by default. | +| | LOG_LEVEL | Log level: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | +| | MQTT_LOG_LEVEL | Log level of the MQTT Client: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | ## Running the service diff --git a/configuration/parser.py b/configuration/parser.py index 7e7cebe..9f9adc2 100644 --- a/configuration/parser.py +++ b/configuration/parser.py @@ -149,7 +149,7 @@ def process_arguments() -> Configuration: dest='publish_raw_abrp_data', required=False, action=EnvDefault, envvar='PUBLISH_RAW_ABRP_DATA_ENABLED', default=False, type=check_bool) # OsmAnd Integration - parser.add_argument('--osmand--server-uri', + parser.add_argument('--osmand-server-uri', help='The URL of your OsmAnd Server.' + ' Default unset' + ' Environment Variable: OSMAND_SERVER_URI', From da5c63952d6160ececae5f2c0f1181baaaaa6c07 Mon Sep 17 00:00:00 2001 From: Francisco Zamora-Martinez Date: Sun, 22 Sep 2024 00:42:06 +0200 Subject: [PATCH 12/35] Fix mistaken StopReason suffix in incorrect topic --- mqtt_topics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mqtt_topics.py b/mqtt_topics.py index babda7b..5b2922c 100644 --- a/mqtt_topics.py +++ b/mqtt_topics.py @@ -39,9 +39,9 @@ DRIVETRAIN_CHARGING_LAST_START = DRIVETRAIN_CHARGING + '/lastStart' DRIVETRAIN_CHARGING_LAST_END = DRIVETRAIN_CHARGING + '/lastEnd' DRIVETRAIN_BATTERY_HEATING = DRIVETRAIN + '/batteryHeating' -DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + '/batteryHeating' +DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + '/batteryHeatingStopReason' DRIVETRAIN_CHARGING_SCHEDULE = DRIVETRAIN + '/chargingSchedule' -DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + '/batteryHeatingScheduleStopReason' +DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + '/batteryHeatingSchedule' DRIVETRAIN_CHARGING_TYPE = DRIVETRAIN + '/chargingType' DRIVETRAIN_CURRENT = DRIVETRAIN + '/current' DRIVETRAIN_HV_BATTERY_ACTIVE = DRIVETRAIN + '/hvBatteryActive' From 15f55418b905ba19d29fe9f4d0f8c16b130b955f Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 28 Sep 2024 19:40:49 +0200 Subject: [PATCH 13/35] Gracefully handle scenarios where we don't know the real battery capacity of the car. Fixes #259 --- vehicle.py | 69 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/vehicle.py b/vehicle.py index 1e72b01..5608f07 100644 --- a/vehicle.py +++ b/vehicle.py @@ -741,34 +741,10 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_CHARGE_STATE), datetime_to_str(datetime.datetime.now())) - real_total_battery_capacity = self.get_actual_battery_capacity() - raw_total_battery_capacity = None - - if ( - charge_status.totalBatteryCapacity is not None - and charge_status.totalBatteryCapacity > 0 - ): - raw_total_battery_capacity = charge_status.totalBatteryCapacity / 10.0 + real_total_battery_capacity, battery_capacity_correction_factor = self.get_actual_battery_capacity( + charge_status) - battery_capacity_correction_factor = 1.0 - if real_total_battery_capacity is None and raw_total_battery_capacity is not None: - LOG.debug(f"Setting real battery capacity to raw battery capacity {raw_total_battery_capacity}") - real_total_battery_capacity = raw_total_battery_capacity - battery_capacity_correction_factor = 1.0 - elif real_total_battery_capacity is not None and raw_total_battery_capacity is None: - LOG.debug(f"Setting raw battery capacity to real battery capacity {real_total_battery_capacity}") - battery_capacity_correction_factor = 1.0 - elif real_total_battery_capacity is not None and raw_total_battery_capacity is not None: - LOG.debug( - f"Calculating full battery capacity correction factor based on " - f"real={real_total_battery_capacity} and raw={raw_total_battery_capacity}" - ) - battery_capacity_correction_factor = real_total_battery_capacity / raw_total_battery_capacity - elif real_total_battery_capacity is None and raw_total_battery_capacity is None: - LOG.warning("No battery capacity information available") - battery_capacity_correction_factor = 1.0 - - if real_total_battery_capacity is not None and real_total_battery_capacity > 0: + if real_total_battery_capacity > 0: self.publisher.publish_float( self.get_topic(mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY), real_total_battery_capacity @@ -806,7 +782,7 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: and charge_mgmt_data.decoded_power < -1 ): # Only compute a dynamic refresh period if we have detected at least 1kW of power during charging - time_for_1pct = 36.0 * self.get_actual_battery_capacity() / abs(charge_mgmt_data.decoded_power) + time_for_1pct = 36.0 * real_total_battery_capacity / abs(charge_mgmt_data.decoded_power) time_for_min_pct = math.ceil(self.charge_polling_min_percent * time_for_1pct) # It doesn't make sense to refresh less often than the estimated time for completion if remaining_charging_time is not None and remaining_charging_time > 0: @@ -974,7 +950,42 @@ def series(self): def model(self): return str(self.__vin_info.modelName).strip().upper() - def get_actual_battery_capacity(self) -> float | None: + def get_actual_battery_capacity(self, charge_status) -> tuple[float, float]: + + real_total_battery_capacity = self.__get_actual_battery_capacity() + if ( + real_total_battery_capacity is not None + and real_total_battery_capacity <= 0 + ): + # Negative or 0 value for real capacity means we don't know that info + real_total_battery_capacity = None + + raw_total_battery_capacity = None + if ( + charge_status.totalBatteryCapacity is not None + and charge_status.totalBatteryCapacity > 0 + ): + raw_total_battery_capacity = charge_status.totalBatteryCapacity / 10.0 + + if raw_total_battery_capacity is not None: + if real_total_battery_capacity is not None: + LOG.debug( + f"Calculating full battery capacity correction factor based on " + f"real={real_total_battery_capacity} and raw={raw_total_battery_capacity}" + ) + return real_total_battery_capacity, real_total_battery_capacity / raw_total_battery_capacity + else: + LOG.debug(f"Setting real battery capacity to raw battery capacity {raw_total_battery_capacity}") + return raw_total_battery_capacity, 1.0 + else: + if real_total_battery_capacity is not None: + LOG.debug(f"Setting raw battery capacity to real battery capacity {real_total_battery_capacity}") + return real_total_battery_capacity, 1.0 + else: + LOG.warning("No battery capacity information available") + return 0, 1.0 + + def __get_actual_battery_capacity(self) -> float | None: if self.__total_battery_capacity is not None and self.__total_battery_capacity > 0: return float(self.__total_battery_capacity) # MG4 "Lux/Trophy" From f753262dbceb2e34a99fd406607d28e318a686a1 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 28 Sep 2024 22:32:12 +0200 Subject: [PATCH 14/35] Initial support for fossil fuel cars like MG3 Hybrid --- handlers/vehicle.py | 22 +++++++------ integrations/home_assistant/discovery.py | 13 ++++++++ integrations/osmand/api.py | 41 +++++++++++++----------- mqtt_topics.py | 3 ++ vehicle.py | 20 ++++++++++++ 5 files changed, 72 insertions(+), 27 deletions(-) diff --git a/handlers/vehicle.py b/handlers/vehicle.py index 20e9e13..a0cc61a 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -105,17 +105,21 @@ async def handle_vehicle(self) -> None: async def __polling(self): vehicle_status = await self.update_vehicle_status() - try: - charge_status = await self.update_charge_status() - except Exception as e: - LOG.exception('Error updating charge status', exc_info=e) + if self.vehicle_state.is_ev: + try: + charge_status = await self.update_charge_status() + except Exception as e: + LOG.exception('Error updating charge status', exc_info=e) + charge_status = None + + try: + await self.update_scheduled_battery_heating_status() + except Exception as e: + LOG.exception('Error updating scheduled battery heating status', exc_info=e) + else: + LOG.debug("Skipping EV-related updates as the vehicle is not an EV") charge_status = None - try: - await self.update_scheduled_battery_heating_status() - except Exception as e: - LOG.exception('Error updating scheduled battery heating status', exc_info=e) - self.vehicle_state.mark_successful_refresh() LOG.info('Refreshing vehicle status succeeded...') diff --git a/integrations/home_assistant/discovery.py b/integrations/home_assistant/discovery.py index 07cbd14..a7a7e44 100644 --- a/integrations/home_assistant/discovery.py +++ b/integrations/home_assistant/discovery.py @@ -289,6 +289,19 @@ def publish_ha_discovery_messages(self): device_class='voltage', state_class='measurement', unit_of_measurement='V', icon='mdi:car-battery') self.__publish_sensor(mqtt_topics.DRIVETRAIN_RANGE, 'Range', device_class='distance', unit_of_measurement='km') + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE, 'Fossil fuel range', + device_class='distance', + unit_of_measurement='km', + enabled=self.__vehicle_state.has_fossil_fuel + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE, 'Fossil fuel percentage', + state_class='measurement', + unit_of_measurement='%', + icon='mdi:fuel', + enabled=self.__vehicle_state.has_fossil_fuel + ) self.__publish_sensor(mqtt_topics.DRIVETRAIN_CURRENT, 'Current', device_class='current', state_class='measurement', unit_of_measurement='A') self.__publish_sensor(mqtt_topics.DRIVETRAIN_VOLTAGE, 'Voltage', device_class='voltage', diff --git a/integrations/osmand/api.py b/integrations/osmand/api.py index d70351d..7c167ab 100644 --- a/integrations/osmand/api.py +++ b/integrations/osmand/api.py @@ -43,23 +43,22 @@ def __init__(self, *, server_uri: str, device_id: str, listener: Optional[OsmAnd } ) - async def update_osmand(self, vehicle_status: VehicleStatusResp, charge_info: ChrgMgmtDataResp) \ + async def update_osmand(self, vehicle_status: VehicleStatusResp, charge_info: ChrgMgmtDataResp | None) \ -> Tuple[bool, Any | None]: - charge_status = None if charge_info is None else charge_info.chrgMgmtData + charge_mgmt_data = None if charge_info is None else charge_info.chrgMgmtData + charge_status = None if charge_info is None else charge_info.rvsChargeStatus if ( self.__device_id is not None and self.__server_uri is not None and vehicle_status is not None - and charge_status is not None ): # Request data = { 'id': self.__device_id, # Guess the timestamp from either the API, GPS info or current machine time 'timestamp': int(get_update_timestamp(vehicle_status).timestamp()), - 'soc': (charge_status.bmsPackSOCDsp / 10.0), 'is_charging': vehicle_status.is_charging, 'is_parked': vehicle_status.is_parked, } @@ -70,28 +69,34 @@ async def update_osmand(self, vehicle_status: VehicleStatusResp, charge_info: Ch 'speed': 0.0, }) - # Skip invalid current values reported by the API - is_valid_current = ( - charge_status.bmsPackCrntV != 1 - and value_in_range(charge_status.bmsPackCrnt, 0, 65535) - ) - if is_valid_current: - data.update({ - 'power': charge_status.decoded_power, - 'voltage': charge_status.decoded_voltage, - 'current': charge_status.decoded_current - }) - basic_vehicle_status = vehicle_status.basicVehicleStatus if basic_vehicle_status is not None: data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) - data.update(self.__extract_electric_range(basic_vehicle_status, charge_info.rvsChargeStatus)) - gps_position = vehicle_status.gpsPosition if gps_position is not None: data.update(self.__extract_gps_position(gps_position)) + if charge_mgmt_data is not None: + data.update({ + 'soc': (charge_mgmt_data.bmsPackSOCDsp / 10.0) + }) + + # Skip invalid current values reported by the API + is_valid_current = ( + charge_mgmt_data.bmsPackCrntV != 1 + and value_in_range(charge_mgmt_data.bmsPackCrnt, 0, 65535) + ) + if is_valid_current: + data.update({ + 'power': charge_mgmt_data.decoded_power, + 'voltage': charge_mgmt_data.decoded_voltage, + 'current': charge_mgmt_data.decoded_current + }) + + # Extract electric range if available + data.update(self.__extract_electric_range(basic_vehicle_status, charge_status)) + try: response = await self.client.post(url=self.__server_uri, params=data) await response.aread() diff --git a/mqtt_topics.py b/mqtt_topics.py index da1d72a..105ff92 100644 --- a/mqtt_topics.py +++ b/mqtt_topics.py @@ -64,6 +64,9 @@ DRIVETRAIN_VOLTAGE = DRIVETRAIN + '/voltage' DRIVETRAIN_CHARGING_CABLE_LOCK = DRIVETRAIN + '/chargingCableLock' DRIVETRAIN_CURRENT_JOURNEY = DRIVETRAIN + '/currentJourney' +DRIVETRAIN_FOSSIL_FUEL = DRIVETRAIN + '/fossilFuel' +DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE = DRIVETRAIN_FOSSIL_FUEL + '/percentage' +DRIVETRAIN_FOSSIL_FUEL_RANGE = DRIVETRAIN_FOSSIL_FUEL + '/range' OBC = 'obc' OBC_CURRENT = OBC + '/current' diff --git a/vehicle.py b/vehicle.py index 5608f07..b11dc56 100644 --- a/vehicle.py +++ b/vehicle.py @@ -331,6 +331,15 @@ def handle_vehicle_status(self, vehicle_status: VehicleStatusResp) -> None: self.__publish_electric_range(basic_vehicle_status.fuelRangeElec) self.__publish_soc(basic_vehicle_status.extendedData1) + # Standard fossil fuels vehicles + if value_in_range(basic_vehicle_status.fuelRange, 1, 65535): + fuel_range = basic_vehicle_status.fuelRange / 10.0 + self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE), fuel_range) + + if value_in_range(basic_vehicle_status.fuelLevelPrc, 0, 100, is_max_excl=False): + self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE), + basic_vehicle_status.fuelLevelPrc) + if ( basic_vehicle_status.currentJourneyId is not None and basic_vehicle_status.currentJourneyDistance is not None @@ -883,6 +892,17 @@ def set_refresh_mode(self, mode: RefreshMode, cause: str): self.refresh_mode = mode LOG.debug(f'Refresh mode set to {new_mode_value} due to {cause}') + @property + def is_ev(self): + if self.series.startswith('ZP22'): + return False + else: + return True + + @property + def has_fossil_fuel(self): + return not self.is_ev + @property def has_sunroof(self): return self.__get_property_value('Sunroof') != '0' From 6aa924355a2e8588a25fc94648545c2a5edc5cda Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 28 Sep 2024 22:36:57 +0200 Subject: [PATCH 15/35] Fix value_in_range check --- utils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/utils.py b/utils.py index dcd75f2..4056d85 100644 --- a/utils.py +++ b/utils.py @@ -5,11 +5,12 @@ def value_in_range(value, min_value, max_value, is_max_excl: bool = True) -> bool: - return ( - value is not None - and - min_value <= value < max_value if is_max_excl else min_value <= value <= max_value - ) + if value is None: + return False + elif is_max_excl: + return min_value <= value < max_value + else: + return min_value <= value <= max_value def is_valid_temperature(value) -> bool: From c5935542225bd6bec5d67a06ebd22973be9ab98c Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 6 Oct 2024 11:53:07 +0200 Subject: [PATCH 16/35] Move relogin logic to the MQTT Gateway instead of the API library --- handlers/message.py | 23 ++++++++++++---- handlers/relogin.py | 51 +++++++++++++++++++++++++++++++++++ handlers/vehicle.py | 28 +++++++++++++++---- mqtt_gateway.py | 26 +++++++++++------- requirements.txt | 4 +-- tests/test_vehicle_handler.py | 8 +++++- 6 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 handlers/relogin.py diff --git a/handlers/message.py b/handlers/message.py index b4cc63e..3dbcf37 100644 --- a/handlers/message.py +++ b/handlers/message.py @@ -4,8 +4,9 @@ from saic_ismart_client_ng import SaicApi from saic_ismart_client_ng.api.message.schema import MessageEntity -from saic_ismart_client_ng.exceptions import SaicApiException +from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException +from handlers.relogin import ReloginHandler from handlers.vehicle import VehicleHandlerLocator from vehicle import RefreshMode @@ -13,9 +14,15 @@ class MessageHandler: - def __init__(self, gateway: VehicleHandlerLocator, saicapi: SaicApi): + def __init__( + self, + gateway: VehicleHandlerLocator, + relogin_handler: ReloginHandler, + saicapi: SaicApi + ): self.gateway = gateway self.saicapi = saicapi + self.relogin_handler = relogin_handler self.last_message_ts = datetime.datetime.min self.last_message_id = None @@ -26,8 +33,6 @@ async def check_for_new_messages(self) -> None: await self.__polling() except Exception as e: LOG.exception('MessageHandler poll loop failed', exc_info=e) - else: - LOG.debug("Not checking for new messages since all cars have RefreshMode.OFF") async def __polling(self): try: @@ -57,7 +62,9 @@ async def __polling(self): ] for vehicle_start_message in vehicle_start_messages: await self.__delete_message(vehicle_start_message) - + except SaicLogoutException as e: + LOG.error("API Client was logged out, waiting for a new login", exc_info=e) + self.relogin_handler.relogin() except SaicApiException as e: LOG.exception('MessageHandler poll loop failed during SAIC API Call', exc_info=e) except Exception as e: @@ -76,6 +83,8 @@ async def __get_all_alarm_messages(self) -> list[MessageEntity]: oldest_message = self.__get_oldest_message(all_messages) if oldest_message is not None and oldest_message.message_time < self.last_message_ts: return all_messages + except SaicLogoutException as e: + raise e except Exception as e: LOG.exception( 'Error while fetching a message from the SAIC API, please open the app and clear them, ' @@ -110,6 +119,10 @@ def __should_poll(self): ] # We do not poll if we have no cars or all cars have RefreshMode.OFF if len(refresh_modes) == 0 or all(mode == RefreshMode.OFF for mode in refresh_modes): + logging.debug("Not checking for new messages as all cars have RefreshMode.OFF") + return False + elif self.relogin_handler.relogin_in_progress: + logging.warning("Not checking for new messages as we are waiting to log back in") return False else: return True diff --git a/handlers/relogin.py b/handlers/relogin.py new file mode 100644 index 0000000..24e4608 --- /dev/null +++ b/handlers/relogin.py @@ -0,0 +1,51 @@ +import logging +from datetime import timedelta, datetime + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from saic_ismart_client_ng import SaicApi + +LOG = logging.getLogger(__name__) +JOB_ID = 'relogin_task' + + +class ReloginHandler(): + + def __init__( + self, + *, + relogin_relay: int, + api: SaicApi, + scheduler: AsyncIOScheduler + ): + self.__relogin_relay = relogin_relay + self.__scheduler = scheduler + self.__api = api + self.__login_task = None + + @property + def relogin_in_progress(self) -> bool: + return self.__login_task is not None + + def relogin(self): + if self.__login_task is None: + logging.warning(f"API Client got logged out, logging back in {self.__relogin_relay} seconds") + self.__login_task = self.__scheduler.add_job( + func=self.login, + trigger='date', + run_date=datetime.now() + timedelta(seconds=self.__relogin_relay), + id=JOB_ID, + name='Re-login the API client after a set delay', + max_instances=1 + ) + + async def login(self): + try: + LOG.info("Logging in to SAIC API") + login_response_message = await self.__api.login() + LOG.info("Logged in as %s", login_response_message.account) + except Exception as e: + logging.exception("Could not login to the SAIC API due to an error", exc_info=e) + finally: + if self.__scheduler.get_job(JOB_ID) is not None: + self.__scheduler.remove_job(JOB_ID) + self.__login_task = None diff --git a/handlers/vehicle.py b/handlers/vehicle.py index a0cc61a..a9bb4af 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -9,25 +9,34 @@ from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleStatusResp from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp, ScheduledBatteryHeatingResp, \ ChargeCurrentLimitCode, TargetBatteryCode, ScheduledChargingMode -from saic_ismart_client_ng.exceptions import SaicApiException +from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException import mqtt_topics from configuration import Configuration from exceptions import MqttGatewayException +from handlers.relogin import ReloginHandler from integrations.abrp.api import AbrpApi, AbrpApiException from integrations.home_assistant.discovery import HomeAssistantDiscovery from integrations.osmand.api import OsmAndApi from publisher.core import Publisher -from saic_api_listener import MqttGatewayAbrpListener, OsmAndApiListener, MqttGatewayOsmAndListener +from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener from vehicle import VehicleState, RefreshMode LOG = logging.getLogger(__name__) class VehicleHandler: - def __init__(self, config: Configuration, saicapi: SaicApi, publisher: Publisher, vin_info: VinInfo, - vehicle_state: VehicleState): + def __init__( + self, + config: Configuration, + relogin_handler: ReloginHandler, + saicapi: SaicApi, + publisher: Publisher, + vin_info: VinInfo, + vehicle_state: VehicleState + ): self.configuration = config + self.relogin_handler = relogin_handler self.saic_api = saicapi self.publisher = publisher self.vin_info = vin_info @@ -81,6 +90,10 @@ async def handle_vehicle(self) -> None: try: LOG.debug('Polling vehicle status') await self.__polling() + except SaicLogoutException as e: + self.vehicle_state.mark_failed_refresh() + LOG.error("API Client was logged out, waiting for a new login", exc_info=e) + self.relogin_handler.relogin() except SaicApiException as e: self.vehicle_state.mark_failed_refresh() LOG.exception( @@ -128,7 +141,8 @@ async def __polling(self): def __should_poll(self) -> bool: return ( - self.vehicle_state.is_complete() + not self.relogin_handler.relogin_in_progress + and self.vehicle_state.is_complete() and self.vehicle_state.should_refresh() ) @@ -410,6 +424,10 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): except MqttGatewayException as e: self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {e.message}') LOG.exception(e.message, exc_info=e) + except SaicLogoutException as se: + self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') + LOG.error("API Client was logged out, waiting for a new login", exc_info=e) + self.relogin_handler.relogin() except SaicApiException as se: self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') LOG.exception(se.message, exc_info=se) diff --git a/mqtt_gateway.py b/mqtt_gateway.py index e5a0084..251fc33 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -15,6 +15,7 @@ from configuration import Configuration from configuration.parser import process_arguments from handlers.message import MessageHandler +from handlers.relogin import ReloginHandler from handlers.vehicle import VehicleHandler, VehicleHandlerLocator from integrations.openwb.charging_station import ChargingStation from publisher.mqtt_publisher import MqttClient, MqttCommandListener @@ -52,20 +53,22 @@ def __init__(self, config: Configuration): password=self.configuration.saic_password, username_is_email=username_is_email, phone_country_code=None if username_is_email else self.configuration.saic_phone_country_code, - relogin_delay=self.configuration.saic_relogin_delay, base_uri=self.configuration.saic_rest_uri, region=self.configuration.saic_region, tenant_id=self.configuration.saic_tenant_id ), listener=listener ) + self.__scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() + self.__relogin_handler = ReloginHandler( + relogin_relay=self.configuration.saic_relogin_delay, + api=self.saic_api, + scheduler=self.__scheduler + ) async def run(self): - scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() try: - LOG.info("Logging in to SAIC API") - login_response_message = await self.saic_api.login() - LOG.info("Logged in as %s", login_response_message.account) + await self.__relogin_handler.login() except Exception as e: LOG.exception('MqttGateway crashed due to an Exception during startup', exc_info=e) raise SystemExit(e) @@ -104,7 +107,7 @@ async def run(self): total_battery_capacity = configuration.battery_capacity_map.get(vin_info.vin, None) vehicle_state = VehicleState( self.publisher, - scheduler, + self.__scheduler, account_prefix, vin_info, charging_station, @@ -114,14 +117,19 @@ async def run(self): vehicle_handler = VehicleHandler( self.configuration, + self.__relogin_handler, self.saic_api, self.publisher, # Gateway pointer vin_info, vehicle_state ) self.vehicle_handlers[vin_info.vin] = vehicle_handler - message_handler = MessageHandler(self, self.saic_api) - scheduler.add_job( + message_handler = MessageHandler( + gateway=self, + relogin_handler=self.__relogin_handler, + saicapi=self.saic_api + ) + self.__scheduler.add_job( func=message_handler.check_for_new_messages, trigger='interval', seconds=self.configuration.messages_request_interval, @@ -130,7 +138,7 @@ async def run(self): max_instances=1 ) await self.publisher.connect() - scheduler.start() + self.__scheduler.start() await self.__main_loop() @override diff --git a/requirements.txt b/requirements.txt index 68d809a..0d1d147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -saic-ismart-client-ng==0.2.2 -httpx==0.26.0 +saic-ismart-client-ng==0.4.0 +httpx~=0.27.0 gmqtt~=0.6.13 inflection~=0.5.1 apscheduler~=3.10.1 diff --git a/tests/test_vehicle_handler.py b/tests/test_vehicle_handler.py index e5fe9c7..78bcf9c 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -13,6 +13,7 @@ import mqtt_topics from configuration import Configuration +from handlers.relogin import ReloginHandler from mqtt_gateway import VehicleHandler from publisher.log_publisher import Logger from vehicle import VehicleState @@ -172,7 +173,12 @@ def setUp(self) -> None: account_prefix = f'/vehicles/{VIN}' scheduler = BlockingScheduler() vehicle_state = VehicleState(publisher, scheduler, account_prefix, vin_info) - self.vehicle_handler = VehicleHandler(config, saicapi, publisher, vin_info, vehicle_state) + mock_relogin_handler = ReloginHandler( + relogin_relay=30, + api=saicapi, + scheduler=None + ) + self.vehicle_handler = VehicleHandler(config, mock_relogin_handler, saicapi, publisher, vin_info, vehicle_state) @patch.object(SaicApi, 'get_vehicle_status') async def test_update_vehicle_status(self, mocked_vehicle_status): From 729bdef53414dea12ccd838ce22b2f6ff95a06f3 Mon Sep 17 00:00:00 2001 From: Thomas Salm Date: Sun, 6 Oct 2024 20:46:15 +0200 Subject: [PATCH 17/35] Expose find my car functionality #264 --- README.md | 3 ++- handlers/vehicle.py | 20 ++++++++++++++++++-- mqtt_topics.py | 1 + 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index faef397..35437fc 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ with the default vehicle prefix: `saic//vehicles/` | /doors/locked/set | true/false | Lock or unlock your car. This is not always working. It might take some time until it takes effect. Don't trust this feature. Use your car key! | | /climate/remoteTemperature/set | temperature | Set A/C temperature | | /climate/remoteClimateState/set | on/off/front/blowingonly | Turn A/C on or off, activate A/C blowing (front) or blowing only (blowingonly) | -| /climate/heatedSeatsFrontLeftLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front left seat. Some cars have three levels while others just an on-off switch. 0 means OFF | +| /climate/heatedSeatsFrontLeftLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front left seat. Some cars havedefault three levels while others just an on-off switch. 0 means OFF | | /climate/heatedSeatsFrontRightLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front right seat. Some cars have three levels while others just an on-off switch. 0 means OFF | | /climate/rearWindowDefrosterHeating/set | on/off | Turn rear window defroster heating on or off. This is not always working. It might take some time until it takes effect. | | /climate/frontWindowDefrosterHeating/set | on/off | Turn front window defroster heating on or off | @@ -149,6 +149,7 @@ with the default vehicle prefix: `saic//vehicles/` | /refresh/period/inActive/set | refresh interval (sec) | Vehicle and charge status are queried once per day (default value: 86400) independently from any event. Changing this to a lower value might affect the 12V battery of your vehicle. Be very careful! | | /refresh/period/afterShutdown/set | refresh interval (sec) | After the vehicle has been shutdown, the gateway queries the status every 120 seconds (default value). The refresh interval can be modified with this topic. | | /refresh/period/inActiveGrace/set | grace period (sec) | After the vehicle has been shutdown, the gateway continues to query the state for 600 seconds (default value). The duration of this extended query period can be modified with this topic. | +| /location/findMyCar | [activate,lights_only,horn_only,stop] | Activate 'find my car' with lights and horn (activate), with lights only (lights_only), with horn only (horn_only) or deactivate it (stop). | ## Home Assistant auto-discovery diff --git a/handlers/vehicle.py b/handlers/vehicle.py index a9bb4af..d31ca02 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -413,7 +413,23 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=False) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - + case mqtt_topics.LOCATION_FIND_MY_CAR: + vin = self.vin_info.vin + match payload.strip().lower(): + case 'activate': + LOG.info(f'Activating \'find my car\' with horn and lights for vehicle {vin}') + await self.saic_api.control_find_my_car(vin) + case 'lights_only': + LOG.info(f'Activating \'find my car\' with lights only for vehicle {vin}') + await self.saic_api.control_find_my_car(vin, with_horn=False, with_lights=True) + case 'horn_only': + LOG.info(f'Activating \'find my car\' with horn only for vehicle {vin}') + await self.saic_api.control_find_my_car(vin, with_horn=True, with_lights=False) + case 'stop': + LOG.info(f'Stopping \'find my car\' for vehicle {vin}') + await self.saic_api.control_find_my_car(vin, should_stop=True) + case _: + raise MqttGatewayException(f'Unsupported payload {payload}') case _: # set mode, period (in)-active,... should_force_refresh = False @@ -426,7 +442,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): LOG.exception(e.message, exc_info=e) except SaicLogoutException as se: self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') - LOG.error("API Client was logged out, waiting for a new login", exc_info=e) + LOG.error("API Client was logged out, waiting for a new login", exc_info=se) self.relogin_handler.relogin() except SaicApiException as se: self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') diff --git a/mqtt_topics.py b/mqtt_topics.py index 105ff92..80d99a8 100644 --- a/mqtt_topics.py +++ b/mqtt_topics.py @@ -112,6 +112,7 @@ LOCATION_LONGITUDE = LOCATION + '/longitude' LOCATION_SPEED = LOCATION + '/speed' LOCATION_ELEVATION = LOCATION + '/elevation' +LOCATION_FIND_MY_CAR = LOCATION + '/findMyCar' REFRESH = 'refresh' REFRESH_LAST_ACTIVITY = REFRESH + '/lastActivity' From cb7a118dcec1d8e2e321a02a2874b81f84bf0131 Mon Sep 17 00:00:00 2001 From: Thomas Salm Date: Sun, 6 Oct 2024 21:14:06 +0200 Subject: [PATCH 18/35] 'find my car' command topic corrected --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 35437fc..7107dee 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ with the default vehicle prefix: `saic//vehicles/` | /refresh/period/inActive/set | refresh interval (sec) | Vehicle and charge status are queried once per day (default value: 86400) independently from any event. Changing this to a lower value might affect the 12V battery of your vehicle. Be very careful! | | /refresh/period/afterShutdown/set | refresh interval (sec) | After the vehicle has been shutdown, the gateway queries the status every 120 seconds (default value). The refresh interval can be modified with this topic. | | /refresh/period/inActiveGrace/set | grace period (sec) | After the vehicle has been shutdown, the gateway continues to query the state for 600 seconds (default value). The duration of this extended query period can be modified with this topic. | -| /location/findMyCar | [activate,lights_only,horn_only,stop] | Activate 'find my car' with lights and horn (activate), with lights only (lights_only), with horn only (horn_only) or deactivate it (stop). | +| /location/findMyCar/set | [activate,lights_only,horn_only,stop] | Activate 'find my car' with lights and horn (activate), with lights only (lights_only), with horn only (horn_only) or deactivate it (stop). | ## Home Assistant auto-discovery From 8ec32947c972f41fe7b57936ce3e41092b41241f Mon Sep 17 00:00:00 2001 From: Thomas Salm Date: Sun, 6 Oct 2024 21:28:56 +0200 Subject: [PATCH 19/35] 'find my car' switch added to HomeAssistantDiscovery --- integrations/home_assistant/discovery.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integrations/home_assistant/discovery.py b/integrations/home_assistant/discovery.py index a7a7e44..7678cb8 100644 --- a/integrations/home_assistant/discovery.py +++ b/integrations/home_assistant/discovery.py @@ -174,6 +174,8 @@ def publish_ha_discovery_messages(self): self.__publish_switch(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, 'Rear window defroster heating', icon='mdi:car-defrost-rear', payload_on='on', payload_off='off') + self.__publish_switch(mqtt_topics.LOCATION_FIND_MY_CAR, 'Find my car', + icon='mdi:car-search', payload_on='activate', payload_off='stop') # Locks self.__publish_lock(mqtt_topics.DOORS_LOCKED, 'Doors Lock', icon='mdi:car-door-lock') From 61a25bf2996601993a9ed54c545f3bb15498c5bc Mon Sep 17 00:00:00 2001 From: Thomas Salm <35031183+tosate@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:15:56 +0200 Subject: [PATCH 20/35] Update README.md Co-authored-by: Giovanni Condello --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7107dee..cd1db58 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ with the default vehicle prefix: `saic//vehicles/` | /doors/locked/set | true/false | Lock or unlock your car. This is not always working. It might take some time until it takes effect. Don't trust this feature. Use your car key! | | /climate/remoteTemperature/set | temperature | Set A/C temperature | | /climate/remoteClimateState/set | on/off/front/blowingonly | Turn A/C on or off, activate A/C blowing (front) or blowing only (blowingonly) | -| /climate/heatedSeatsFrontLeftLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front left seat. Some cars havedefault three levels while others just an on-off switch. 0 means OFF | +| /climate/heatedSeatsFrontLeftLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front left seat. Some cars have three levels while others just an on-off switch. 0 means OFF | | /climate/heatedSeatsFrontRightLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front right seat. Some cars have three levels while others just an on-off switch. 0 means OFF | | /climate/rearWindowDefrosterHeating/set | on/off | Turn rear window defroster heating on or off. This is not always working. It might take some time until it takes effect. | | /climate/frontWindowDefrosterHeating/set | on/off | Turn front window defroster heating on or off | From 9405d45b9ac041ee2cffa40e866545920e7c6a3b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 8 Oct 2024 17:08:02 +0200 Subject: [PATCH 21/35] New exception hierarchy for integrations --- handlers/vehicle.py | 6 +++--- integrations/__init__.py | 6 ++++++ integrations/abrp/api.py | 8 +++----- integrations/osmand/api.py | 8 +++----- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/handlers/vehicle.py b/handlers/vehicle.py index d31ca02..2b8a77a 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -15,6 +15,7 @@ from configuration import Configuration from exceptions import MqttGatewayException from handlers.relogin import ReloginHandler +from integrations import IntegrationException from integrations.abrp.api import AbrpApi, AbrpApiException from integrations.home_assistant.discovery import HomeAssistantDiscovery from integrations.osmand.api import OsmAndApi @@ -100,8 +101,8 @@ async def handle_vehicle(self) -> None: 'handle_vehicle loop failed during SAIC API call', exc_info=e ) - except AbrpApiException as ae: - LOG.exception('handle_vehicle loop failed during ABRP API call', exc_info=ae) + except IntegrationException as ae: + LOG.exception('handle_vehicle loop failed during integration processing', exc_info=ae) except Exception as e: self.vehicle_state.mark_failed_refresh() LOG.exception( @@ -172,7 +173,6 @@ async def update_vehicle_status(self) -> VehicleStatusResp: LOG.info('Updating vehicle status') vehicle_status_response = await self.saic_api.get_vehicle_status(self.vin_info.vin) self.vehicle_state.handle_vehicle_status(vehicle_status_response) - return vehicle_status_response async def update_charge_status(self) -> ChrgMgmtDataResp: diff --git a/integrations/__init__.py b/integrations/__init__.py index e69de29..93b53f7 100644 --- a/integrations/__init__.py +++ b/integrations/__init__.py @@ -0,0 +1,6 @@ +class IntegrationException(Exception): + def __init__(self, integration: str, msg: str): + self.message = f'{integration}: {msg}' + + def __str__(self): + return self.message diff --git a/integrations/abrp/api.py b/integrations/abrp/api.py index b08c61b..18cbe6b 100644 --- a/integrations/abrp/api.py +++ b/integrations/abrp/api.py @@ -10,17 +10,15 @@ from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus +from integrations import IntegrationException from utils import value_in_range, get_update_timestamp LOG = logging.getLogger(__name__) -class AbrpApiException(Exception): +class AbrpApiException(IntegrationException): def __init__(self, msg: str): - self.message = msg - - def __str__(self): - return self.message + super().__init__(__name__, msg) class AbrpApiListener(ABC): diff --git a/integrations/osmand/api.py b/integrations/osmand/api.py index 7c167ab..8ee6bd8 100644 --- a/integrations/osmand/api.py +++ b/integrations/osmand/api.py @@ -10,17 +10,15 @@ from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus +from integrations import IntegrationException from utils import value_in_range, get_update_timestamp LOG = logging.getLogger(__name__) -class OsmAndApiException(Exception): +class OsmAndApiException(IntegrationException): def __init__(self, msg: str): - self.message = msg - - def __str__(self): - return self.message + super().__init__(__name__, msg) class OsmAndApiListener(ABC): From 9ddd066b0643adc04096ad8d2a69f0de2f18135d Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 8 Oct 2024 17:08:17 +0200 Subject: [PATCH 22/35] Better logging for time drift --- vehicle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vehicle.py b/vehicle.py index b11dc56..c325cf4 100644 --- a/vehicle.py +++ b/vehicle.py @@ -207,7 +207,7 @@ def handle_vehicle_status(self, vehicle_status: VehicleStatusResp) -> None: vehicle_status_drift = abs(now_time - vehicle_status_time) if vehicle_status_drift > datetime.timedelta(minutes=15): raise MqttGatewayException( - f"Vehicle status time drifted too much from current time: {vehicle_status_drift}" + f"Vehicle status time drifted too much from current time: {vehicle_status_drift}. Server reported {vehicle_status_time}" ) is_engine_running = vehicle_status.is_engine_running From 694e86e05f7f73e6a9789cf37e605a58eef5ee0c Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 8 Oct 2024 17:46:43 +0200 Subject: [PATCH 23/35] Fix #73: Allow running the gateway without an MQTT connection --- README.md | 98 ++++++++++++++---------- configuration/__init__.py | 8 ++ configuration/parser.py | 42 +++++----- handlers/relogin.py | 1 + handlers/vehicle.py | 2 +- integrations/home_assistant/discovery.py | 6 +- integrations/osmand/api.py | 1 - mqtt_gateway.py | 15 ++-- publisher/core.py | 27 ++++++- publisher/log_publisher.py | 16 ++-- publisher/mqtt_publisher.py | 20 ++--- tests/__init__.py | 18 +++++ tests/test_mqtt_publisher.py | 5 +- tests/test_vehicle_handler.py | 4 +- 14 files changed, 163 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index cd1db58..f19fae7 100644 --- a/README.md +++ b/README.md @@ -17,27 +17,47 @@ the [SAIC-iSmart-API Documentation](https://github.com/SAIC-iSmart-API/documenta Configuration parameters can be provided as command line parameters or environment variables (this is what you typically do when you run the service from a docker container). -| CMD param | ENV variable | Description | -|-----------------------------|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| -u or --saic-user | SAIC_USER | SAIC user name - **required** | -| -p or --saic-password | SAIC_PASSWORD | SAIC password - **required** | -| --saic-phone-country-code | SAIC_PHONE_COUNTRY_CODE | Phone country code, used if the username is not an email address | -| -m or --mqtt-uri | MQTT_URI | URI to the MQTT Server. TCP: tcp://mqtt.eclipseprojects.io:1883, WebSocket: ws://mqtt.eclipseprojects.io:9001 or TLS: tls://mqtt.eclipseprojects.io:8883 - **required** | -| --mqtt-server-cert | MQTT_SERVER_CERT | Path to the server certificate authority file in PEM format is required for TLS | -| --mqtt-user | MQTT_USER | MQTT user name | -| --mqtt-password | MQTT_PASSWORD | MQTT password | -| --mqtt-client-id | MQTT_CLIENT_ID | MQTT Client Identifier. Defaults to saic-python-mqtt-gateway. | -| --mqtt-topic-prefix | MQTT_TOPIC | Provide a custom MQTT prefix to replace the default: saic | -| --saic-rest-uri | SAIC_REST_URI | SAIC API URI. Default is the European Production endpoint: https://gateway-mg-eu.soimt.com/api.app/v1/ | -| --saic-region | SAIC_REGION | SAIC API region. Default is eu. | -| --saic-tenant-id | SAIC_TENANT_ID | SAIC API tenant ID. Default is 459771. | -| --charging-stations-json | CHARGING_STATIONS_JSON | Custom charging stations configuration file name | -| --saic-relogin-delay | SAIC_RELOGIN_DELAY | The gateway detects logins from other devices (e.g. the iSMART app). It then pauses it's activity for 900 seconds (default value). The delay can be configured with this parameter. | -| --ha-discovery | HA_DISCOVERY_ENABLED | Home Assistant auto-discovery is enabled (True) by default. It can be disabled (False) with this parameter. | -| --ha-discovery-prefix | HA_DISCOVERY_PREFIX | The default MQTT prefix for Home Assistant auto-discovery is 'homeassistant'. Another prefix can be configured with this parameter | -| --messages-request-interval | MESSAGES_REQUEST_INTERVAL | The interval for retrieving messages in seconds. Default is 60 seconds. | - -### ABRP Integration Configuration +### SAIC API + +| CMD param | ENV variable | Description | +|-----------------------------|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| -u or --saic-user | SAIC_USER | SAIC user name - **required** | +| -p or --saic-password | SAIC_PASSWORD | SAIC password - **required** | +| --saic-phone-country-code | SAIC_PHONE_COUNTRY_CODE | Phone country code, used if the username is not an email address | +| --saic-rest-uri | SAIC_REST_URI | SAIC API URI. Default is the European Production endpoint: https://gateway-mg-eu.soimt.com/api.app/v1/ | +| --saic-region | SAIC_REGION | SAIC API region. Default is eu. | +| --saic-tenant-id | SAIC_TENANT_ID | SAIC API tenant ID. Default is 459771. | +| --saic-relogin-delay | SAIC_RELOGIN_DELAY | The gateway detects logins from other devices (e.g. the iSMART app). It then pauses it's activity for 900 seconds (default value). The delay can be configured with this parameter. | +| --messages-request-interval | MESSAGES_REQUEST_INTERVAL | The interval for retrieving messages in seconds. Default is 60 seconds. | +| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 | +| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default | +| --publish-raw-api-data | PUBLISH_RAW_API_DATA_ENABLED | Publish raw SAIC API request/response to MQTT. Disabled (False) by default. | + +### MQTT Broker + +| CMD param | ENV variable | Description | +|---------------------|------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| -m or --mqtt-uri | MQTT_URI | URI to the MQTT Server. TCP: tcp://mqtt.eclipseprojects.io:1883, WebSocket: ws://mqtt.eclipseprojects.io:9001 or TLS: tls://mqtt.eclipseprojects.io:8883 - Leave it empty to disable MQTT connection | +| --mqtt-server-cert | MQTT_SERVER_CERT | Path to the server certificate authority file in PEM format is required for TLS | +| --mqtt-user | MQTT_USER | MQTT user name | +| --mqtt-password | MQTT_PASSWORD | MQTT password | +| --mqtt-client-id | MQTT_CLIENT_ID | MQTT Client Identifier. Defaults to saic-python-mqtt-gateway. | +| --mqtt-topic-prefix | MQTT_TOPIC | Provide a custom MQTT prefix to replace the default: saic | +| | MQTT_LOG_LEVEL | Log level of the MQTT Client: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | + +### Home Assistant Integration + +| CMD param | ENV variable | Description | +|-----------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --ha-discovery | HA_DISCOVERY_ENABLED | Home Assistant auto-discovery is enabled (True) by default. It can be disabled (False) with this parameter. | +| --ha-discovery-prefix | HA_DISCOVERY_PREFIX | The default MQTT prefix for Home Assistant auto-discovery is 'homeassistant'. Another prefix can be configured with this parameter | +| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | + +### A Better Route Planner (ABRP) integration + +Telemetry data from your car can be provided to [ABRP](https://abetterrouteplanner.com/). **Be aware that this is not +done by default.** The data will be sent only if you provide the mapping of your vehicle identification number (VIN) to +an ABRP user token. Those parameters can be used to allow the MQTT Gateway to send data to ABRP API @@ -47,9 +67,12 @@ Those parameters can be used to allow the MQTT Gateway to send data to ABRP API | --abrp-user-token | ABRP_USER_TOKEN | Mapping of VIN to ABRP User Token. Multiple mappings can be provided separated by ',' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl | | --publish-raw-abrp-data | PUBLISH_RAW_ABRP_DATA_ENABLED | Publish raw ABRP API request/response to MQTT. Disabled (False) by default. | -### OsmAnd Integration Configuration +### OsmAnd Integration (e.g. Traccar) + +Telemetry data from your car can be provided to a generic fleet tracking software supporting +the [OsmAnd](https://www.traccar.org/osmand/) protocol like [Traccar](https://www.traccar.org/) -Those parameters can be used to allow the MQTT Gateway to send data to an OsmAnd-compatibile server like Traccar +Those parameters can be used to allow the MQTT Gateway to send data to an OsmAnd-compatibile server. | CMD param | ENV variable | Description | |---------------------------|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -57,7 +80,11 @@ Those parameters can be used to allow the MQTT Gateway to send data to an OsmAnd | --osmand-device-id | OSMAND_DEVICE_ID | Mapping of VIN to OsmAnd Device Id. Multiple mappings can be provided separated by ',' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl. Defaults to use the car VIN as Device Id if unset | | --publish-raw-osmand-data | PUBLISH_RAW_OSMAND_DATA_ENABLED | Publish raw ABRP OSMAND request/response to MQTT. Disabled (False) by default. | -### Charging Station Configuration +### OpenWB Integration + +| CMD param | ENV variable | Description | +|--------------------------|------------------------|--------------------------------------------------| +| --charging-stations-json | CHARGING_STATIONS_JSON | Custom charging stations configuration file name | If your charging station also provides information over MQTT or if you somehow manage to publish information from your charging station, the MQTT gateway can benefit from it. In addition, the MQTT gateway can provide the SoC to your @@ -81,23 +108,18 @@ The key-value pairs in the JSON express the following: | chargerConnectedValue | payload that indicates that the charger is connected - optional | | vin | vehicle identification number to map the charging station information to a vehicle - **required** | -## Advanced settings +### Advanced settings -| CMD param | ENV variable | Description | -|----------------------------|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| --battery-capacity-mapping | BATTERY_CAPACITY_MAPPING | Mapping of VIN to full battery capacity. Multiple mappings can be provided separated by ',' Example: LSJXXXX=54.0,LSJYYYY=64.0 | -| --charge-min-percentage | CHARGE_MIN_PERCENTAGE | How many % points we should try to refresh the charge state. 1.0 by default | -| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | -| --publish-raw-api-data | PUBLISH_RAW_API_DATA_ENABLED | Publish raw SAIC API request/response to MQTT. Disabled (False) by default. | -| | LOG_LEVEL | Log level: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | -| | MQTT_LOG_LEVEL | Log level of the MQTT Client: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | +| CMD param | ENV variable | Description | +|-----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| | LOG_LEVEL | Log level: INFO (default), use DEBUG for detailed output, use CRITICAL for no output, [more info](https://docs.python.org/3/library/logging.html#levels) | ## Running the service ### From Command-line To run the service from the command line you need to have Python version 3.12 or later. -Launch the MQTT gateway with the mandatory parameters. +Launch the MQTT gateway with the mandatory parametersn and, optionally, the url to the MQTT broker. ``` $ python ./mqtt_gateway.py -m tcp://my-broker-host:1883 -u -p @@ -116,12 +138,6 @@ $ docker build -t saic-mqtt-gateway . There is a [docker compose file](docker-compose.yml) that shows how to set up the service. -## A Better Route Planner (ABRP) integration - -Telemetry data from your car can be provided to [ABRP](https://abetterrouteplanner.com/). **Be aware that this is not -done by default.** The data will be sent only if you provide the mapping of your vehicle identification number (VIN) to -an ABRP user token. - ## Commands over MQTT The MQTT Gateway subscribes to MQTT topics where it is listening for commands. Every topic in the table below starts @@ -140,7 +156,7 @@ with the default vehicle prefix: `saic//vehicles/` | /doors/locked/set | true/false | Lock or unlock your car. This is not always working. It might take some time until it takes effect. Don't trust this feature. Use your car key! | | /climate/remoteTemperature/set | temperature | Set A/C temperature | | /climate/remoteClimateState/set | on/off/front/blowingonly | Turn A/C on or off, activate A/C blowing (front) or blowing only (blowingonly) | -| /climate/heatedSeatsFrontLeftLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front left seat. Some cars have three levels while others just an on-off switch. 0 means OFF | +| /climate/heatedSeatsFrontLeftLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front left seat. Some cars have three levels while others just an on-off switch. 0 means OFF | | /climate/heatedSeatsFrontRightLevel/set | 0-3 or 0-1 depending on model | Set heated seats level for the front right seat. Some cars have three levels while others just an on-off switch. 0 means OFF | | /climate/rearWindowDefrosterHeating/set | on/off | Turn rear window defroster heating on or off. This is not always working. It might take some time until it takes effect. | | /climate/frontWindowDefrosterHeating/set | on/off | Turn front window defroster heating on or off | diff --git a/configuration/__init__.py b/configuration/__init__.py index 94bed6f..a37daea 100644 --- a/configuration/__init__.py +++ b/configuration/__init__.py @@ -49,3 +49,11 @@ def __init__(self): self.osmand_device_id_map: dict[str, str] = {} self.osmand_server_uri: str | None = None self.publish_raw_osmand_data: bool = False + + @property + def is_mqtt_enabled(self) -> bool: + return self.mqtt_host is not None and len(str(self.mqtt_host)) > 0 + + @property + def username_is_email(self): + return '@' in self.saic_user diff --git a/configuration/parser.py b/configuration/parser.py index 9f9adc2..3491708 100644 --- a/configuration/parser.py +++ b/configuration/parser.py @@ -47,7 +47,7 @@ def process_arguments() -> Configuration: + 'TCP: tcp://mqtt.eclipseprojects.io:1883 ' + 'WebSocket: ws://mqtt.eclipseprojects.io:9001' + 'TLS: tls://mqtt.eclipseprojects.io:8883', - dest='mqtt_uri', required=True, action=EnvDefault, envvar='MQTT_URI') + dest='mqtt_uri', required=False, action=EnvDefault, envvar='MQTT_URI') parser.add_argument('--mqtt-server-cert', help='Path to the server certificate authority file in PEM format for TLS.', dest='tls_server_cert_path', required=False, action=EnvDefault, envvar='MQTT_SERVER_CERT') @@ -211,29 +211,31 @@ def process_arguments() -> Configuration: except ValueError: raise SystemExit(f'No valid integer value for messages_request_interval: {args.messages_request_interval}') - parse_result = urllib.parse.urlparse(args.mqtt_uri) - if parse_result.scheme == 'tcp': - config.mqtt_transport_protocol = TransportProtocol.TCP - elif parse_result.scheme == 'ws': - config.mqtt_transport_protocol = TransportProtocol.WS - elif parse_result.scheme == 'tls': - config.mqtt_transport_protocol = TransportProtocol.TLS - if args.tls_server_cert_path: - config.tls_server_cert_path = args.tls_server_cert_path + if args.mqtt_uri is not None and len(args.mqtt_uri) > 0: + print(f'MQTT URI: {args.mqtt_uri}') + parse_result = urllib.parse.urlparse(args.mqtt_uri) + if parse_result.scheme == 'tcp': + config.mqtt_transport_protocol = TransportProtocol.TCP + elif parse_result.scheme == 'ws': + config.mqtt_transport_protocol = TransportProtocol.WS + elif parse_result.scheme == 'tls': + config.mqtt_transport_protocol = TransportProtocol.TLS + if args.tls_server_cert_path: + config.tls_server_cert_path = args.tls_server_cert_path + else: + raise SystemExit(f'No server certificate authority file provided for TLS MQTT URI {args.mqtt_uri}') else: - raise SystemExit(f'No server certificate authority file provided for TLS MQTT URI {args.mqtt_uri}') - else: - raise SystemExit(f'Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws') + raise SystemExit(f'Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws') - if not parse_result.port: - if config.mqtt_transport_protocol == 'tcp': - config.mqtt_port = 1883 + if not parse_result.port: + if config.mqtt_transport_protocol == 'tcp': + config.mqtt_port = 1883 + else: + config.mqtt_port = 9001 else: - config.mqtt_port = 9001 - else: - config.mqtt_port = parse_result.port + config.mqtt_port = parse_result.port - config.mqtt_host = str(parse_result.hostname) + config.mqtt_host = str(parse_result.hostname) # ABRP Integration config.abrp_api_key = args.abrp_api_key diff --git a/handlers/relogin.py b/handlers/relogin.py index 24e4608..024ac31 100644 --- a/handlers/relogin.py +++ b/handlers/relogin.py @@ -45,6 +45,7 @@ async def login(self): LOG.info("Logged in as %s", login_response_message.account) except Exception as e: logging.exception("Could not login to the SAIC API due to an error", exc_info=e) + raise e finally: if self.__scheduler.get_job(JOB_ID) is not None: self.__scheduler.remove_job(JOB_ID) diff --git a/handlers/vehicle.py b/handlers/vehicle.py index 2b8a77a..d03627d 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -16,7 +16,7 @@ from exceptions import MqttGatewayException from handlers.relogin import ReloginHandler from integrations import IntegrationException -from integrations.abrp.api import AbrpApi, AbrpApiException +from integrations.abrp.api import AbrpApi from integrations.home_assistant.discovery import HomeAssistantDiscovery from integrations.osmand.api import OsmAndApi from publisher.core import Publisher diff --git a/integrations/home_assistant/discovery.py b/integrations/home_assistant/discovery.py index 7678cb8..1873ebf 100644 --- a/integrations/home_assistant/discovery.py +++ b/integrations/home_assistant/discovery.py @@ -9,7 +9,7 @@ import mqtt_topics from configuration import Configuration -from publisher.mqtt_publisher import MqttClient +from publisher.mqtt_publisher import MqttPublisher from vehicle import VehicleState, RefreshMode LOG = logging.getLogger(__name__) @@ -695,14 +695,14 @@ def __get_vin(self): def __get_system_topic(self, topic: str) -> str: publisher = self.__vehicle_state.publisher - if isinstance(publisher, MqttClient): + if isinstance(publisher, MqttPublisher): return publisher.get_topic(topic, no_prefix=False) return topic def __get_vehicle_topic(self, topic: str) -> str: vehicle_topic = self.__vehicle_state.get_topic(topic) publisher = self.__vehicle_state.publisher - if isinstance(publisher, MqttClient): + if isinstance(publisher, MqttPublisher): return publisher.get_topic(vehicle_topic, no_prefix=False) return vehicle_topic diff --git a/integrations/osmand/api.py b/integrations/osmand/api.py index 8ee6bd8..b2088aa 100644 --- a/integrations/osmand/api.py +++ b/integrations/osmand/api.py @@ -1,4 +1,3 @@ -import json import logging from abc import ABC from typing import Any, Tuple, Optional diff --git a/mqtt_gateway.py b/mqtt_gateway.py index 251fc33..365123b 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -18,7 +18,9 @@ from handlers.relogin import ReloginHandler from handlers.vehicle import VehicleHandler, VehicleHandlerLocator from integrations.openwb.charging_station import ChargingStation -from publisher.mqtt_publisher import MqttClient, MqttCommandListener +from publisher.core import Publisher, MqttCommandListener +from publisher.log_publisher import ConsolePublisher +from publisher.mqtt_publisher import MqttPublisher from saic_api_listener import MqttGatewaySaicApiListener from vehicle import VehicleState @@ -40,9 +42,12 @@ class MqttGateway(MqttCommandListener, VehicleHandlerLocator): def __init__(self, config: Configuration): self.configuration = config self.__vehicle_handlers: dict[str, VehicleHandler] = dict() - self.publisher = MqttClient(self.configuration) + if config.is_mqtt_enabled: + self.publisher: Publisher = MqttPublisher(self.configuration) + else: + LOG.warning("MQTT support disabled") + self.publisher: Publisher = ConsolePublisher(self.configuration) self.publisher.command_listener = self - username_is_email = "@" in self.configuration.saic_user if config.publish_raw_api_data: listener = MqttGatewaySaicApiListener(self.publisher) else: @@ -51,8 +56,8 @@ def __init__(self, config: Configuration): configuration=SaicApiConfiguration( username=self.configuration.saic_user, password=self.configuration.saic_password, - username_is_email=username_is_email, - phone_country_code=None if username_is_email else self.configuration.saic_phone_country_code, + username_is_email=config.username_is_email, + phone_country_code=None if config.username_is_email else self.configuration.saic_phone_country_code, base_uri=self.configuration.saic_rest_uri, region=self.configuration.saic_region, tenant_id=self.configuration.saic_tenant_id diff --git a/publisher/core.py b/publisher/core.py index 743bcc0..5cebe2c 100644 --- a/publisher/core.py +++ b/publisher/core.py @@ -1,14 +1,27 @@ import json import re from abc import ABC +from typing import Optional import mqtt_topics from configuration import Configuration +class MqttCommandListener(ABC): + async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: + raise NotImplementedError("Should have implemented this") + + async def on_charging_detected(self, vin: str) -> None: + raise NotImplementedError("Should have implemented this") + + class Publisher(ABC): def __init__(self, config: Configuration): - self.configuration = config + self.__configuration = config + self.__command_listener = None + + async def connect(self): + pass def is_connected(self) -> bool: raise NotImplementedError() @@ -86,3 +99,15 @@ def dict_to_anonymized_json(self, data): else: result = no_binary_strings return json.dumps(result, indent=2) + + @property + def configuration(self) -> Configuration: + return self.__configuration + + @property + def command_listener(self) -> Optional[MqttCommandListener]: + return self.__command_listener + + @command_listener.setter + def command_listener(self, listener: MqttCommandListener): + self.__command_listener = listener diff --git a/publisher/log_publisher.py b/publisher/log_publisher.py index 39aace0..92b97e8 100644 --- a/publisher/log_publisher.py +++ b/publisher/log_publisher.py @@ -8,10 +8,9 @@ LOG.setLevel(level="DEBUG") -class Logger(Publisher): +class ConsolePublisher(Publisher): def __init__(self, configuration: Configuration): super().__init__(configuration) - self.map = {} @override def is_connected(self) -> bool: @@ -20,15 +19,15 @@ def is_connected(self) -> bool: @override def publish_json(self, key: str, data: dict, no_prefix: bool = False) -> None: anonymized_json = self.dict_to_anonymized_json(data) - self.__internal_publish(key, anonymized_json) + self.internal_publish(key, anonymized_json) @override def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: - self.__internal_publish(key, value) + self.internal_publish(key, value) @override def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: - self.__internal_publish(key, value) + self.internal_publish(key, value) @override def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: @@ -36,12 +35,11 @@ def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: value = False elif isinstance(value, int): value = value == 1 - self.__internal_publish(key, value) + self.internal_publish(key, value) @override def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: - self.__internal_publish(key, value) + self.internal_publish(key, value) - def __internal_publish(self, key, value): - self.map[key] = value + def internal_publish(self, key, value): LOG.debug(f'{key}: {value}') diff --git a/publisher/mqtt_publisher.py b/publisher/mqtt_publisher.py index c0d7319..6fc7432 100644 --- a/publisher/mqtt_publisher.py +++ b/publisher/mqtt_publisher.py @@ -1,8 +1,7 @@ import logging import os import ssl -from abc import ABC -from typing import Optional, override +from typing import override import gmqtt @@ -18,25 +17,15 @@ MQTT_LOG.setLevel(level=os.getenv('MQTT_LOG_LEVEL', 'INFO').upper()) -class MqttCommandListener(ABC): - async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: - raise NotImplementedError("Should have implemented this") - - async def on_charging_detected(self, vin: str) -> None: - raise NotImplementedError("Should have implemented this") - - -class MqttClient(Publisher): +class MqttPublisher(Publisher): def __init__(self, configuration: Configuration): super().__init__(configuration) - self.configuration = configuration - self.publisher_id = self.configuration.mqtt_client_id + self.publisher_id = configuration.mqtt_client_id self.topic_root = configuration.mqtt_topic self.client = None self.host = self.configuration.mqtt_host self.port = self.configuration.mqtt_port self.transport_protocol = self.configuration.mqtt_transport_protocol - self.command_listener: Optional[MqttCommandListener] = None self.vin_by_charge_state_topic: dict[str, str] = {} self.last_charge_state_by_vin: [str, str] = {} self.vin_by_charger_connected_topic: dict[str, str] = {} @@ -56,7 +45,7 @@ def __init__(self, configuration: Configuration): self.client = mqtt_client def get_mqtt_account_prefix(self) -> str: - return MqttClient.remove_special_mqtt_characters( + return MqttPublisher.remove_special_mqtt_characters( f'{self.configuration.mqtt_topic}/{self.configuration.saic_user}') @staticmethod @@ -64,6 +53,7 @@ def remove_special_mqtt_characters(input_str: str) -> str: return input_str.replace('+', '_').replace('#', '_').replace('*', '_') \ .replace('>', '_').replace('$', '_') + @override async def connect(self): if self.configuration.mqtt_user is not None: if self.configuration.mqtt_password is not None: diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..511a833 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,18 @@ +import logging +from typing import override + +from configuration import Configuration +from publisher.log_publisher import ConsolePublisher + +LOG = logging.getLogger(__name__) + + +class MessageCapturingConsolePublisher(ConsolePublisher): + def __init__(self, configuration: Configuration): + super().__init__(configuration) + self.map = {} + + @override + def internal_publish(self, key, value): + self.map[key] = value + LOG.debug(f'{key}: {value}') diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py index 174cd54..b5e61db 100644 --- a/tests/test_mqtt_publisher.py +++ b/tests/test_mqtt_publisher.py @@ -2,7 +2,8 @@ from typing import override from configuration import Configuration, TransportProtocol -from publisher.mqtt_publisher import MqttClient, MqttCommandListener +from publisher.core import MqttCommandListener +from publisher.mqtt_publisher import MqttPublisher USER = 'me@home.da' VIN = 'vin10000000000000' @@ -24,7 +25,7 @@ def setUp(self) -> None: config.mqtt_topic = 'saic' config.saic_user = 'user+a#b*c>d$e' config.mqtt_transport_protocol = TransportProtocol.TCP - self.mqtt_client = MqttClient(config) + self.mqtt_client = MqttPublisher(config) self.mqtt_client.command_listener = self self.received_vin = '' self.received_payload = '' diff --git a/tests/test_vehicle_handler.py b/tests/test_vehicle_handler.py index 78bcf9c..5127a74 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -15,7 +15,7 @@ from configuration import Configuration from handlers.relogin import ReloginHandler from mqtt_gateway import VehicleHandler -from publisher.log_publisher import Logger +from . import MessageCapturingConsolePublisher from vehicle import VehicleState VIN = 'vin10000000000000' @@ -166,7 +166,7 @@ def setUp(self) -> None: username='aaa@nowhere.org', password='xxxxxxxxx' ), listener=None) - publisher = Logger(config) + publisher = MessageCapturingConsolePublisher(config) vin_info = VinInfo() vin_info.vin = VIN vin_info.series = 'EH32 S' From 00223beedb55a546f10d38759d2ee00e13bcd4d0 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 9 Oct 2024 20:46:41 +0200 Subject: [PATCH 24/35] Fix tests --- tests/test_vehicle_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_vehicle_handler.py b/tests/test_vehicle_handler.py index 5127a74..a899acd 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -15,7 +15,7 @@ from configuration import Configuration from handlers.relogin import ReloginHandler from mqtt_gateway import VehicleHandler -from . import MessageCapturingConsolePublisher +from tests import MessageCapturingConsolePublisher from vehicle import VehicleState VIN = 'vin10000000000000' From b9e1f024b13801ce638808c98c98f62de6941b79 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 9 Oct 2024 20:46:48 +0200 Subject: [PATCH 25/35] Bump API client version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0d1d147..781be9c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -saic-ismart-client-ng==0.4.0 +saic-ismart-client-ng==0.5.1 httpx~=0.27.0 gmqtt~=0.6.13 inflection~=0.5.1 From c5ba6daa1bfe517fa2f0e7a0e2df4e865e7a0f26 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 9 Oct 2024 20:50:41 +0200 Subject: [PATCH 26/35] Fix ruff installation step --- .github/workflows/build_and_test_python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_and_test_python.yml b/.github/workflows/build_and_test_python.yml index a0746c7..4ae33f6 100644 --- a/.github/workflows/build_and_test_python.yml +++ b/.github/workflows/build_and_test_python.yml @@ -34,7 +34,7 @@ jobs: python -m pytest --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml - name: Lint with Ruff run: | - python -m pip install install ruff + python -m pip install ruff ruff check --output-format=github . continue-on-error: true - name: Surface failing tests From 802219071b0311402a8b0ae9fca81d5065ac9e7b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 3 Nov 2024 19:52:29 +0000 Subject: [PATCH 27/35] Bump saic-ismart-client-ng to fix crashes during the relogin flow --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 781be9c..eddb733 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -saic-ismart-client-ng==0.5.1 +saic-ismart-client-ng==0.5.2 httpx~=0.27.0 gmqtt~=0.6.13 inflection~=0.5.1 From c0d672989c636457934c57a9bae8494796afae37 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Thu, 5 Dec 2024 18:53:03 +0100 Subject: [PATCH 28/35] Vehicle handler should only react to explicit and well-known set commands Fixes #276 --- handlers/vehicle.py | 61 ++++++++++++------------ integrations/home_assistant/discovery.py | 53 ++++++++++---------- mqtt_topics.py | 25 ++++++++++ publisher/mqtt_publisher.py | 8 ++-- vehicle.py | 10 ++-- 5 files changed, 90 insertions(+), 67 deletions(-) diff --git a/handlers/vehicle.py b/handlers/vehicle.py index d03627d..c84c59a 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -3,7 +3,7 @@ import json import logging from abc import ABC -from typing import Optional +from typing import Optional, Tuple from saic_ismart_client_ng import SaicApi from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleStatusResp @@ -19,6 +19,7 @@ from integrations.abrp.api import AbrpApi from integrations.home_assistant.discovery import HomeAssistantDiscovery from integrations.osmand.api import OsmAndApi +from mqtt_topics import SET_SUFFIX, RESULT_SUFFIX from publisher.core import Publisher from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener from vehicle import VehicleState, RefreshMode @@ -188,11 +189,11 @@ async def update_scheduled_battery_heating_status(self) -> ScheduledBatteryHeati return scheduled_battery_heating_status async def handle_mqtt_command(self, *, topic: str, payload: str): - topic = self.get_topic_without_vehicle_prefix(topic) + topic, result_topic = self.__get_command_topics(topic) try: should_force_refresh = True match topic: - case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE: + case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE_SET: match payload.strip().lower(): case 'true': LOG.info("HV battery is now active") @@ -202,7 +203,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): self.vehicle_state.set_hv_battery_active(False) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_CHARGING: + case mqtt_topics.DRIVETRAIN_CHARGING_SET: match payload.strip().lower(): case 'true': LOG.info("Charging will be started") @@ -212,7 +213,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.control_charging(self.vin_info.vin, stop_charging=True) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_BATTERY_HEATING: + case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SET: match payload.strip().lower(): case 'true': LOG.info("Battery heater wil be will be switched on") @@ -229,7 +230,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): f'UNKNOWN ({response.ptcHeatResp})' if decoded is None else decoded.name ) - case mqtt_topics.CLIMATE_REMOTE_TEMPERATURE: + case mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET: payload = payload.strip() try: LOG.info("Setting remote climate target temperature to %s", payload) @@ -243,7 +244,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): except ValueError as e: raise MqttGatewayException(f'Error setting temperature target: {e}') - case mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE: + case mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET: match payload.strip().lower(): case 'off': LOG.info('A/C will be switched off') @@ -262,7 +263,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.start_front_defrost(self.vin_info.vin) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL: + case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET: try: LOG.info("Setting heated seats front left level to %s", payload) level = int(payload.strip().lower()) @@ -278,7 +279,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): except Exception as e: raise MqttGatewayException(f'Error setting heated seats: {e}') - case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL: + case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET: try: LOG.info("Setting heated seats front right level to %s", payload) level = int(payload.strip().lower()) @@ -294,7 +295,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): except Exception as e: raise MqttGatewayException(f'Error setting heated seats: {e}') - case mqtt_topics.DOORS_BOOT: + case mqtt_topics.DOORS_BOOT_SET: match payload.strip().lower(): case 'true': LOG.info(f'We cannot lock vehicle {self.vin_info.vin} boot remotely') @@ -303,7 +304,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.open_tailgate(self.vin_info.vin) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DOORS_LOCKED: + case mqtt_topics.DOORS_LOCKED_SET: match payload.strip().lower(): case 'true': LOG.info(f'Vehicle {self.vin_info.vin} will be locked') @@ -313,7 +314,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.unlock_vehicle(self.vin_info.vin) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_BACK_WINDOW_HEAT: + case mqtt_topics.CLIMATE_BACK_WINDOW_HEAT_SET: match payload.strip().lower(): case 'off': LOG.info('Rear window heating will be switched off') @@ -323,7 +324,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=True) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT: + case mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT_SET: match payload.strip().lower(): case 'off': LOG.info('Front window heating will be switched off') @@ -333,7 +334,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.start_front_defrost(self.vin_info.vin) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT: + case mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT_SET: payload = payload.strip().upper() if self.vehicle_state.target_soc is not None: try: @@ -354,7 +355,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): ) raise MqttGatewayException( f'Error setting charge current limit - SOC {self.vehicle_state.target_soc}') - case mqtt_topics.DRIVETRAIN_SOC_TARGET: + case mqtt_topics.DRIVETRAIN_SOC_TARGET_SET: payload = payload.strip() try: LOG.info("Setting SoC target to %s", payload) @@ -363,7 +364,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): self.vehicle_state.update_target_soc(target_battery_code) except ValueError as e: raise MqttGatewayException(f'Error setting SoC target: {e}') - case mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE: + case mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE_SET: payload = payload.strip() try: LOG.info("Setting charging schedule to %s", payload) @@ -380,7 +381,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): self.vehicle_state.update_scheduled_charging(start_time, mode) except Exception as e: raise MqttGatewayException(f'Error setting charging schedule: {e}') - case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE: + case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET: payload = payload.strip() try: LOG.info("Setting battery heating schedule to %s", payload) @@ -403,7 +404,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): LOG.info('Battery heating schedule not changed') except Exception as e: raise MqttGatewayException(f'Error setting battery heating schedule: {e}') - case mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK: + case mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK_SET: match payload.strip().lower(): case 'false': LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be unlocked') @@ -413,7 +414,7 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=False) case _: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.LOCATION_FIND_MY_CAR: + case mqtt_topics.LOCATION_FIND_MY_CAR_SET: vin = self.vin_info.vin match payload.strip().lower(): case 'activate': @@ -434,30 +435,28 @@ async def handle_mqtt_command(self, *, topic: str, payload: str): # set mode, period (in)-active,... should_force_refresh = False await self.vehicle_state.configure_by_message(topic=topic, payload=payload) - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', 'Success') + self.publisher.publish_str(result_topic, 'Success') if should_force_refresh: self.vehicle_state.set_refresh_mode(RefreshMode.FORCE, f'after command execution on topic {topic}') except MqttGatewayException as e: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {e.message}') + self.publisher.publish_str(result_topic, f'Failed: {e.message}') LOG.exception(e.message, exc_info=e) except SaicLogoutException as se: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') + self.publisher.publish_str(result_topic, f'Failed: {se.message}') LOG.error("API Client was logged out, waiting for a new login", exc_info=se) self.relogin_handler.relogin() except SaicApiException as se: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', f'Failed: {se.message}') + self.publisher.publish_str(result_topic, f'Failed: {se.message}') LOG.exception(se.message, exc_info=se) except Exception as se: - self.publisher.publish_str(f'{self.vehicle_prefix}/{topic}/result', 'Failed unexpectedly') + self.publisher.publish_str(result_topic, 'Failed unexpectedly') LOG.exception("handle_mqtt_command failed with an unexpected exception", exc_info=se) - def get_topic_without_vehicle_prefix(self, topic: str) -> str: - global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1:] - elements = global_topic_removed.split('/') - result = '' - for i in range(3, len(elements) - 1): - result += f'/{elements[i]}' - return result[1:] + def __get_command_topics(self, topic: str) -> tuple[str, str]: + global_topic_removed = topic.removeprefix(self.configuration.mqtt_topic).removeprefix('/') + set_topic = global_topic_removed.removeprefix(self.vehicle_prefix).removeprefix('/') + result_topic = global_topic_removed.removesuffix(SET_SUFFIX).removesuffix('/') + '/' + RESULT_SUFFIX + return set_topic, result_topic class VehicleHandlerLocator(ABC): diff --git a/integrations/home_assistant/discovery.py b/integrations/home_assistant/discovery.py index 1873ebf..8ba28c3 100644 --- a/integrations/home_assistant/discovery.py +++ b/integrations/home_assistant/discovery.py @@ -400,39 +400,35 @@ def __publish_vehicle_tracker(self): def __publish_remote_ac(self): # This has been converted into 2 switches and a climate entity for ease of operation - self.__publish_ha_discovery_message('switch', 'Front window defroster heating', { - 'icon': 'mdi:car-defrost-front', - 'state_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), - 'command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE) + '/set', - 'value_template': '{% if value == "front" %}front{% else %}off{% endif %}', - 'state_on': 'front', - 'state_off': 'off', - 'payload_on': 'front', - 'payload_off': 'off', - }) + self.__publish_switch( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, + 'Front window defroster heating', + icon='mdi:car-defrost-front', + value_template='{% if value == "front" %}front{% else %}off{% endif %}', + payload_on='front', + payload_off='off' + ) - self.__publish_ha_discovery_message('switch', 'Vehicle climate fan only', { - 'icon': 'mdi:fan', - 'state_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), - 'command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE) + '/set', - 'value_template': '{% if value == "blowingonly" %}blowingonly{% else %}off{% endif %}', - 'state_on': 'blowingonly', - 'state_off': 'off', - 'payload_on': 'blowingonly', - 'payload_off': 'off', - }) + self.__publish_switch( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, + 'Vehicle climate fan only', + icon='mdi:fan', + value_template='{% if value == "blowingonly" %}blowingonly{% else %}off{% endif %}', + payload_on='blowingonly', + payload_off='off' + ) self.__publish_ha_discovery_message('climate', 'Vehicle climate', { 'precision': 1.0, 'temperature_unit': 'C', 'mode_state_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), - 'mode_command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE) + '/set', + 'mode_command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET), 'mode_state_template': '{% if value == "on" %}auto{% else %}off{% endif %}', 'mode_command_template': '{% if value == "auto" %}on{% else %}off{% endif %}', 'modes': ['off', 'auto'], 'current_temperature_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE), 'current_temperature_template': '{{ value }}', - 'temperature_command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE) + '/set', + 'temperature_command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET), 'temperature_command_template': '{{ value | int }}', 'temperature_state_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE), 'temperature_state_template': '{{ value | int }}', @@ -454,7 +450,7 @@ def __publish_switch( ) -> str: payload = { 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_topic(topic) + '/set', + 'command_topic': self.__get_vehicle_set_topic(topic), 'value_template': value_template, 'payload_on': payload_on, 'payload_off': payload_off, @@ -480,7 +476,7 @@ def __publish_lock( ) -> str: payload = { 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_topic(topic) + '/set', + 'command_topic': self.__get_vehicle_set_topic(topic), 'payload_lock': payload_lock, 'payload_unlock': payload_unlock, 'state_locked': state_locked, @@ -544,7 +540,7 @@ def __publish_number( ) -> str: payload = { 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_topic(topic) + '/set', + 'command_topic': self.__get_vehicle_set_topic(topic), 'value_template': value_template, 'retain': str(retain).lower(), 'mode': mode, @@ -582,7 +578,7 @@ def __publish_text( ) -> str: payload = { 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_topic(topic) + '/set', + 'command_topic': self.__get_vehicle_set_topic(topic), 'value_template': value_template, 'command_template': command_template, 'retain': str(retain).lower(), @@ -640,7 +636,7 @@ def __publish_select( ) -> str: payload = { 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_topic(topic) + '/set', + 'command_topic': self.__get_vehicle_set_topic(topic), 'value_template': value_template, 'command_template': command_template, 'options': options, @@ -706,6 +702,9 @@ def __get_vehicle_topic(self, topic: str) -> str: return publisher.get_topic(vehicle_topic, no_prefix=False) return vehicle_topic + def __get_vehicle_set_topic(self, topic: str) -> str: + return self.__get_vehicle_topic(topic) + '/' + mqtt_topics.SET_SUFFIX + def __publish_ha_discovery_message( self, sensor_type: str, diff --git a/mqtt_topics.py b/mqtt_topics.py index 80d99a8..994e0de 100644 --- a/mqtt_topics.py +++ b/mqtt_topics.py @@ -1,14 +1,23 @@ +SET_SUFFIX = 'set' +RESULT_SUFFIX = 'result' + AVAILABLE = 'available' CLIMATE = 'climate' CLIMATE_BACK_WINDOW_HEAT = CLIMATE + '/rearWindowDefrosterHeating' +CLIMATE_BACK_WINDOW_HEAT_SET = CLIMATE_BACK_WINDOW_HEAT + '/' + SET_SUFFIX CLIMATE_FRONT_WINDOW_HEAT = CLIMATE + '/frontWindowDefrosterHeating' +CLIMATE_FRONT_WINDOW_HEAT_SET = CLIMATE_FRONT_WINDOW_HEAT + '/' + SET_SUFFIX CLIMATE_EXTERIOR_TEMPERATURE = CLIMATE + '/exteriorTemperature' CLIMATE_INTERIOR_TEMPERATURE = CLIMATE + '/interiorTemperature' CLIMATE_REMOTE_CLIMATE_STATE = CLIMATE + '/remoteClimateState' +CLIMATE_REMOTE_CLIMATE_STATE_SET = CLIMATE_REMOTE_CLIMATE_STATE + '/' + SET_SUFFIX CLIMATE_REMOTE_TEMPERATURE = CLIMATE + '/remoteTemperature' +CLIMATE_REMOTE_TEMPERATURE_SET = CLIMATE_REMOTE_TEMPERATURE + '/' + SET_SUFFIX CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL = CLIMATE + '/heatedSeatsFrontLeftLevel' +CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET = CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL + '/' + SET_SUFFIX CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL = CLIMATE + '/heatedSeatsFrontRightLevel' +CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET = CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL + '/' + SET_SUFFIX WINDOWS = 'windows' WINDOWS_DRIVER = WINDOWS + '/driver' @@ -20,8 +29,10 @@ DOORS = 'doors' DOORS_BONNET = DOORS + '/bonnet' DOORS_BOOT = DOORS + '/boot' +DOORS_BOOT_SET = DOORS_BOOT + '/' + SET_SUFFIX DOORS_DRIVER = DOORS + '/driver' DOORS_LOCKED = DOORS + '/locked' +DOORS_LOCKED_SET = DOORS_LOCKED + '/' + SET_SUFFIX DOORS_PASSENGER = DOORS + '/passenger' DOORS_REAR_LEFT = DOORS + '/rearLeft' DOORS_REAR_RIGHT = DOORS + '/rearRight' @@ -35,16 +46,21 @@ DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = DRIVETRAIN + '/auxiliaryBatteryVoltage' DRIVETRAIN_CHARGER_CONNECTED = DRIVETRAIN + '/chargerConnected' DRIVETRAIN_CHARGING = DRIVETRAIN + '/charging' +DRIVETRAIN_CHARGING_SET = DRIVETRAIN_CHARGING + '/' + SET_SUFFIX DRIVETRAIN_CHARGING_STOP_REASON = DRIVETRAIN + '/chargingStopReason' DRIVETRAIN_CHARGING_LAST_START = DRIVETRAIN_CHARGING + '/lastStart' DRIVETRAIN_CHARGING_LAST_END = DRIVETRAIN_CHARGING + '/lastEnd' DRIVETRAIN_BATTERY_HEATING = DRIVETRAIN + '/batteryHeating' +DRIVETRAIN_BATTERY_HEATING_SET = DRIVETRAIN_BATTERY_HEATING + '/' + SET_SUFFIX DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + '/batteryHeatingStopReason' DRIVETRAIN_CHARGING_SCHEDULE = DRIVETRAIN + '/chargingSchedule' +DRIVETRAIN_CHARGING_SCHEDULE_SET = DRIVETRAIN_CHARGING_SCHEDULE + '/' + SET_SUFFIX DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + '/batteryHeatingSchedule' +DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET = DRIVETRAIN_BATTERY_HEATING_SCHEDULE + '/' + SET_SUFFIX DRIVETRAIN_CHARGING_TYPE = DRIVETRAIN + '/chargingType' DRIVETRAIN_CURRENT = DRIVETRAIN + '/current' DRIVETRAIN_HV_BATTERY_ACTIVE = DRIVETRAIN + '/hvBatteryActive' +DRIVETRAIN_HV_BATTERY_ACTIVE_SET = DRIVETRAIN_HV_BATTERY_ACTIVE + '/' + SET_SUFFIX DRIVETRAIN_MILEAGE = DRIVETRAIN + '/mileage' DRIVETRAIN_MILEAGE_OF_DAY = DRIVETRAIN + '/mileageOfTheDay' DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = DRIVETRAIN + '/mileageSinceLastCharge' @@ -57,12 +73,15 @@ DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = DRIVETRAIN + '/hybrid_electrical_range' DRIVETRAIN_SOC = DRIVETRAIN + '/soc' DRIVETRAIN_SOC_TARGET = DRIVETRAIN + '/socTarget' +DRIVETRAIN_SOC_TARGET_SET = DRIVETRAIN_SOC_TARGET + '/' + SET_SUFFIX DRIVETRAIN_CHARGECURRENT_LIMIT = DRIVETRAIN + '/chargeCurrentLimit' +DRIVETRAIN_CHARGECURRENT_LIMIT_SET = DRIVETRAIN_CHARGECURRENT_LIMIT + '/' + SET_SUFFIX DRIVETRAIN_SOC_KWH = DRIVETRAIN + '/soc_kwh' DRIVETRAIN_LAST_CHARGE_ENDING_POWER = DRIVETRAIN + '/lastChargeEndingPower' DRIVETRAIN_TOTAL_BATTERY_CAPACITY = DRIVETRAIN + '/totalBatteryCapacity' DRIVETRAIN_VOLTAGE = DRIVETRAIN + '/voltage' DRIVETRAIN_CHARGING_CABLE_LOCK = DRIVETRAIN + '/chargingCableLock' +DRIVETRAIN_CHARGING_CABLE_LOCK_SET = DRIVETRAIN_CHARGING_CABLE_LOCK + '/' + SET_SUFFIX DRIVETRAIN_CURRENT_JOURNEY = DRIVETRAIN + '/currentJourney' DRIVETRAIN_FOSSIL_FUEL = DRIVETRAIN + '/fossilFuel' DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE = DRIVETRAIN_FOSSIL_FUEL + '/percentage' @@ -113,6 +132,7 @@ LOCATION_SPEED = LOCATION + '/speed' LOCATION_ELEVATION = LOCATION + '/elevation' LOCATION_FIND_MY_CAR = LOCATION + '/findMyCar' +LOCATION_FIND_MY_CAR_SET = LOCATION_FIND_MY_CAR + '/' + SET_SUFFIX REFRESH = 'refresh' REFRESH_LAST_ACTIVITY = REFRESH + '/lastActivity' @@ -120,12 +140,17 @@ REFRESH_LAST_VEHICLE_STATE = REFRESH + '/lastVehicleState' REFRESH_LAST_ERROR = REFRESH + '/lastError' REFRESH_MODE = REFRESH + '/mode' +REFRESH_MODE_SET = REFRESH_MODE + '/' + SET_SUFFIX REFRESH_PERIOD = REFRESH + '/period' REFRESH_PERIOD_ACTIVE = REFRESH_PERIOD + '/active' +REFRESH_PERIOD_ACTIVE_SET = REFRESH_PERIOD_ACTIVE + '/' + SET_SUFFIX REFRESH_PERIOD_CHARGING = REFRESH_PERIOD + '/charging' REFRESH_PERIOD_INACTIVE = REFRESH_PERIOD + '/inActive' +REFRESH_PERIOD_INACTIVE_SET = REFRESH_PERIOD_INACTIVE + '/' + SET_SUFFIX REFRESH_PERIOD_AFTER_SHUTDOWN = REFRESH_PERIOD + '/afterShutdown' +REFRESH_PERIOD_AFTER_SHUTDOWN_SET = REFRESH_PERIOD_AFTER_SHUTDOWN + '/' + SET_SUFFIX REFRESH_PERIOD_INACTIVE_GRACE = REFRESH_PERIOD + '/inActiveGrace' +REFRESH_PERIOD_INACTIVE_GRACE_SET = REFRESH_PERIOD_INACTIVE_GRACE + '/' + SET_SUFFIX REFRESH_PERIOD_ERROR = REFRESH_PERIOD + '/error' TYRES = 'tyres' diff --git a/publisher/mqtt_publisher.py b/publisher/mqtt_publisher.py index 6fc7432..b7e6548 100644 --- a/publisher/mqtt_publisher.py +++ b/publisher/mqtt_publisher.py @@ -79,10 +79,10 @@ def __on_connect(self, _client, _flags, rc, _properties) -> None: if rc == gmqtt.constants.CONNACK_ACCEPTED: LOG.info('Connected to MQTT broker') mqtt_account_prefix = self.get_mqtt_account_prefix() - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/set') - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/set') - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/set') - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/set') + self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}') + self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}') + self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}') + self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}') for charging_station in self.configuration.charging_stations_by_vin.values(): LOG.debug(f'Subscribing to MQTT topic {charging_station.charge_state_topic}') self.vin_by_charge_state_topic[charging_station.charge_state_topic] = charging_station.vin diff --git a/vehicle.py b/vehicle.py index c325cf4..c8b25da 100644 --- a/vehicle.py +++ b/vehicle.py @@ -551,31 +551,31 @@ def configure_missing(self): async def configure_by_message(self, *, topic: str, payload: str): payload = payload.lower() match topic: - case mqtt_topics.REFRESH_MODE: + case mqtt_topics.REFRESH_MODE_SET: try: refresh_mode = RefreshMode.get(payload) self.set_refresh_mode(refresh_mode, "MQTT direct set refresh mode command execution") except KeyError: raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.REFRESH_PERIOD_ACTIVE: + case mqtt_topics.REFRESH_PERIOD_ACTIVE_SET: try: seconds = int(payload) self.set_refresh_period_active(seconds) except ValueError: raise MqttGatewayException(f'Error setting value for payload {payload}') - case mqtt_topics.REFRESH_PERIOD_INACTIVE: + case mqtt_topics.REFRESH_PERIOD_INACTIVE_SET: try: seconds = int(payload) self.set_refresh_period_inactive(seconds) except ValueError: raise MqttGatewayException(f'Error setting value for payload {payload}') - case mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN: + case mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN_SET: try: seconds = int(payload) self.set_refresh_period_after_shutdown(seconds) except ValueError: raise MqttGatewayException(f'Error setting value for payload {payload}') - case mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE: + case mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE_SET: try: seconds = int(payload) self.set_refresh_period_inactive_grace(seconds) From b6f043e71f66077856341c9e9e0ba9e6a019f3f7 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Thu, 5 Dec 2024 19:10:51 +0100 Subject: [PATCH 29/35] Drop car max admissible range to < 2046.0 km Fixes #71 --- integrations/abrp/api.py | 2 +- integrations/osmand/api.py | 2 +- vehicle.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integrations/abrp/api.py b/integrations/abrp/api.py index 18cbe6b..967a7a7 100644 --- a/integrations/abrp/api.py +++ b/integrations/abrp/api.py @@ -193,7 +193,7 @@ def __extract_electric_range( @staticmethod def __parse_electric_range(raw_value) -> float: - if value_in_range(raw_value, 1, 65535): + if value_in_range(raw_value, 1, 20460): return float(raw_value) / 10.0 return 0.0 diff --git a/integrations/osmand/api.py b/integrations/osmand/api.py index b2088aa..ed09e2f 100644 --- a/integrations/osmand/api.py +++ b/integrations/osmand/api.py @@ -190,7 +190,7 @@ def __extract_electric_range( @staticmethod def __parse_electric_range(raw_value) -> float: - if value_in_range(raw_value, 1, 65535): + if value_in_range(raw_value, 1, 20460): return float(raw_value) / 10.0 return 0.0 diff --git a/vehicle.py b/vehicle.py index c8b25da..899ad50 100644 --- a/vehicle.py +++ b/vehicle.py @@ -358,7 +358,7 @@ def __publish_tyre(self, raw_value: int, topic: str): self.publisher.publish_float(self.get_topic(topic), round(bar_value, 2)) def __publish_electric_range(self, raw_value): - if value_in_range(raw_value, 1, 65535): + if value_in_range(raw_value, 1, 20460): electric_range = raw_value / 10.0 self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_RANGE), electric_range) if ( @@ -653,7 +653,7 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: self.__publish_soc(soc) estd_elec_rng = charge_mgmt_data.bmsEstdElecRng - if value_in_range(estd_elec_rng, 0, 65535) and estd_elec_rng != 2047: + if value_in_range(estd_elec_rng, 0, 2046): estimated_electrical_range = estd_elec_rng self.publisher.publish_int( self.get_topic(mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE), From 14eda7fb9983882a6712dc67c457c3f04ed54636 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Thu, 5 Dec 2024 21:42:08 +0100 Subject: [PATCH 30/35] Change the way we auto-detect the max battery capacity on the MG4. --- vehicle.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/vehicle.py b/vehicle.py index 899ad50..a10badb 100644 --- a/vehicle.py +++ b/vehicle.py @@ -1008,18 +1008,26 @@ def get_actual_battery_capacity(self, charge_status) -> tuple[float, float]: def __get_actual_battery_capacity(self) -> float | None: if self.__total_battery_capacity is not None and self.__total_battery_capacity > 0: return float(self.__total_battery_capacity) - # MG4 "Lux/Trophy" + # MG4 high trim level elif self.series.startswith('EH32 S'): if self.model.startswith('EH32 X3'): # MG4 Trophy Extended Range return 77.0 - else: - # MG4 Lux/Trophy 2022 + elif self.supports_target_soc: + # MG4 high trim level with NMC battery return 64.0 - # MG4 Standard 2022 - # MG4 Standard 2023 (EH32 X7) + else: + # MG4 High trim level with LFP battery + return 51.0 + # MG4 low trim level + # Note: EH32 X/ is used for the 2023 MY with both NMC and LFP batter chem elif self.series.startswith('EH32 L'): - return 51.0 + if self.supports_target_soc: + # MG4 low trim level with NMC battery + return 64.0 + else: + # MG4 low trim level with LFP battery + return 51.0 # Model: MG5 Electric, variant MG5 SR Comfort elif self.series.startswith('EP2CP3'): return 50.3 From d0a676dbae5d45d90b07fa960dd5c1c317ac1ee4 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Thu, 5 Dec 2024 22:08:58 +0100 Subject: [PATCH 31/35] Fix tests --- tests/test_vehicle_handler.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_vehicle_handler.py b/tests/test_vehicle_handler.py index a899acd..a1be5f1 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -6,7 +6,7 @@ from saic_ismart_client_ng import SaicApi from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus from saic_ismart_client_ng.api.vehicle import VehicleStatusResp -from saic_ismart_client_ng.api.vehicle.schema import VinInfo, BasicVehicleStatus +from saic_ismart_client_ng.api.vehicle.schema import VinInfo, BasicVehicleStatus, VehicleModelConfiguration from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus, ChrgMgmtData from saic_ismart_client_ng.model import SaicApiConfiguration @@ -170,6 +170,12 @@ def setUp(self) -> None: vin_info = VinInfo() vin_info.vin = VIN vin_info.series = 'EH32 S' + vin_info.modelName = 'MG4 Electric' + vin_info.modelYear = 2022 + vin_info.vehicleModelConfiguration = [ + VehicleModelConfiguration('BATTERY', 'BATTERY', '1'), + VehicleModelConfiguration('BType', 'Battery', '1'), + ] account_prefix = f'/vehicles/{VIN}' scheduler = BlockingScheduler() vehicle_state = VehicleState(publisher, scheduler, account_prefix, vin_info) From 172f02116661955a6525c9f2c11888149e1abc2a Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 15 Dec 2024 15:31:28 +0100 Subject: [PATCH 32/35] #279: Normalize email address when matching commands --- configuration/__init__.py | 2 +- handlers/vehicle.py | 7 +++++-- publisher/core.py | 18 ++++++++++++++++++ publisher/mqtt_publisher.py | 37 +++++++++++-------------------------- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/configuration/__init__.py b/configuration/__init__.py index a37daea..6655b4b 100644 --- a/configuration/__init__.py +++ b/configuration/__init__.py @@ -30,7 +30,7 @@ def __init__(self): self.mqtt_user: str | None = None self.mqtt_password: str | None = None self.mqtt_client_id: str = 'saic-python-mqtt-gateway' - self.mqtt_topic: str | None = None + self.mqtt_topic: str = 'saic' self.charging_stations_by_vin: dict[str, ChargingStation] = {} self.anonymized_publishing: bool = False self.messages_request_interval: int = 60 # in seconds diff --git a/handlers/vehicle.py b/handlers/vehicle.py index c84c59a..ccf6572 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -3,7 +3,7 @@ import json import logging from abc import ABC -from typing import Optional, Tuple +from typing import Optional from saic_ismart_client_ng import SaicApi from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleStatusResp @@ -42,7 +42,10 @@ def __init__( self.saic_api = saicapi self.publisher = publisher self.vin_info = vin_info - self.vehicle_prefix = f'{self.configuration.saic_user}/vehicles/{self.vin_info.vin}' + self.vehicle_prefix = self.publisher.get_topic( + f'{self.configuration.saic_user}/vehicles/{self.vin_info.vin}', + True + ) self.vehicle_state = vehicle_state self.ha_discovery = HomeAssistantDiscovery(vehicle_state, vin_info, config) diff --git a/publisher/core.py b/publisher/core.py index 5cebe2c..489eeb1 100644 --- a/publisher/core.py +++ b/publisher/core.py @@ -6,6 +6,8 @@ import mqtt_topics from configuration import Configuration +INVALID_MQTT_CHARS = re.compile(r'[^a-zA-Z0-9/]') + class MqttCommandListener(ABC): async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: @@ -19,6 +21,7 @@ class Publisher(ABC): def __init__(self, config: Configuration): self.__configuration = config self.__command_listener = None + self.__topic_root = self.__remove_special_mqtt_characters(config.mqtt_topic) async def connect(self): pass @@ -41,6 +44,21 @@ def publish_bool(self, key: str, value: bool | int | None, no_prefix: bool = Fal def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: raise NotImplementedError() + def get_mqtt_account_prefix(self) -> str: + return self.__remove_special_mqtt_characters( + f'{self.__topic_root}/{self.configuration.saic_user}' + ) + + def get_topic(self, key: str, no_prefix: bool) -> str: + if no_prefix: + topic = key + else: + topic = f'{self.__topic_root}/{key}' + return self.__remove_special_mqtt_characters(topic) + + def __remove_special_mqtt_characters(self, input_str: str) -> str: + return INVALID_MQTT_CHARS.sub('_', input_str) + def __remove_byte_strings(self, data: dict) -> dict: for key in data.keys(): if isinstance(data[key], bytes): diff --git a/publisher/mqtt_publisher.py b/publisher/mqtt_publisher.py index b7e6548..9c8c871 100644 --- a/publisher/mqtt_publisher.py +++ b/publisher/mqtt_publisher.py @@ -21,7 +21,6 @@ class MqttPublisher(Publisher): def __init__(self, configuration: Configuration): super().__init__(configuration) self.publisher_id = configuration.mqtt_client_id - self.topic_root = configuration.mqtt_topic self.client = None self.host = self.configuration.mqtt_host self.port = self.configuration.mqtt_port @@ -44,15 +43,6 @@ def __init__(self, configuration: Configuration): mqtt_client.on_message = self.__on_message self.client = mqtt_client - def get_mqtt_account_prefix(self) -> str: - return MqttPublisher.remove_special_mqtt_characters( - f'{self.configuration.mqtt_topic}/{self.configuration.saic_user}') - - @staticmethod - def remove_special_mqtt_characters(input_str: str) -> str: - return input_str.replace('+', '_').replace('#', '_').replace('*', '_') \ - .replace('>', '_').replace('$', '_') - @override async def connect(self): if self.configuration.mqtt_user is not None: @@ -81,8 +71,10 @@ def __on_connect(self, _client, _flags, rc, _properties) -> None: mqtt_account_prefix = self.get_mqtt_account_prefix() self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}') self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}') - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}') - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}') + self.client.subscribe( + f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}') + self.client.subscribe( + f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}') for charging_station in self.configuration.charging_stations_by_vin.values(): LOG.debug(f'Subscribing to MQTT topic {charging_station.charge_state_topic}') self.vin_by_charge_state_topic[charging_station.charge_state_topic] = charging_station.vin @@ -134,15 +126,8 @@ async def __on_message_real(self, *, topic: str, payload: str) -> None: await self.command_listener.on_mqtt_command_received(vin=vin, topic=topic, payload=payload) return - def publish(self, topic: str, payload) -> None: - self.client.publish(self.remove_special_mqtt_characters(topic), payload, retain=True) - - def get_topic(self, key: str, no_prefix: bool) -> str: - if no_prefix: - topic = key - else: - topic = f'{self.topic_root}/{key}' - return self.remove_special_mqtt_characters(topic) + def __publish(self, topic: str, payload) -> None: + self.client.publish(topic, payload, retain=True) @override def is_connected(self) -> bool: @@ -151,15 +136,15 @@ def is_connected(self) -> bool: @override def publish_json(self, key: str, data: dict, no_prefix: bool = False) -> None: payload = self.dict_to_anonymized_json(data) - self.publish(topic=self.get_topic(key, no_prefix), payload=payload) + self.__publish(topic=self.get_topic(key, no_prefix), payload=payload) @override def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: - self.publish(topic=self.get_topic(key, no_prefix), payload=value) + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) @override def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: - self.publish(topic=self.get_topic(key, no_prefix), payload=value) + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) @override def publish_bool(self, key: str, value: bool | int | None, no_prefix: bool = False) -> None: @@ -167,11 +152,11 @@ def publish_bool(self, key: str, value: bool | int | None, no_prefix: bool = Fal value = False elif isinstance(value, int): value = value == 1 - self.publish(topic=self.get_topic(key, no_prefix), payload=value) + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) @override def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: - self.publish(topic=self.get_topic(key, no_prefix), payload=value) + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) def get_vin_from_topic(self, topic: str) -> str: global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1:] From 86613d1ab711bd3138ba7a0bdb90380950395776 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 15 Dec 2024 18:28:57 +0100 Subject: [PATCH 33/35] #282: Do not publish SOC and Range twice Also add some validation tests to ensure this doesn't happen anymore --- handlers/vehicle.py | 3 + tests/common_mocks.py | 145 +++++++++++++++++++++++++++ tests/test_vehicle_handler.py | 178 ++++++---------------------------- tests/test_vehicle_state.py | 59 +++++++++++ vehicle.py | 40 +++++--- 5 files changed, 262 insertions(+), 163 deletions(-) create mode 100644 tests/common_mocks.py create mode 100644 tests/test_vehicle_state.py diff --git a/handlers/vehicle.py b/handlers/vehicle.py index ccf6572..3bef247 100644 --- a/handlers/vehicle.py +++ b/handlers/vehicle.py @@ -122,6 +122,7 @@ async def handle_vehicle(self) -> None: async def __polling(self): vehicle_status = await self.update_vehicle_status() + charge_status = None if self.vehicle_state.is_ev: try: @@ -138,6 +139,8 @@ async def __polling(self): LOG.debug("Skipping EV-related updates as the vehicle is not an EV") charge_status = None + self.vehicle_state.update_data_conflicting_in_vehicle_and_bms(vehicle_status, charge_status) + self.vehicle_state.mark_successful_refresh() LOG.info('Refreshing vehicle status succeeded...') diff --git a/tests/common_mocks.py b/tests/common_mocks.py new file mode 100644 index 0000000..a4488b5 --- /dev/null +++ b/tests/common_mocks.py @@ -0,0 +1,145 @@ +import time + +from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus +from saic_ismart_client_ng.api.vehicle import VehicleStatusResp +from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus +from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp +from saic_ismart_client_ng.api.vehicle_charging.schema import ChrgMgmtData, RvsChargeStatus + +VIN = 'vin10000000000000' + +DRIVETRAIN_RUNNING = False +DRIVETRAIN_CHARGING = True +DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = 42 +DRIVETRAIN_MILEAGE = 4000 +DRIVETRAIN_RANGE_BMS = 250 +DRIVETRAIN_RANGE_VEHICLE = 350 +DRIVETRAIN_CURRENT = 42 +DRIVETRAIN_VOLTAGE = 42 +DRIVETRAIN_POWER = 1.764 +DRIVETRAIN_SOC_BMS = 96.3 +DRIVETRAIN_SOC_VEHICLE = 48 +DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = 0 +DRIVETRAIN_MILEAGE_OF_DAY = 200 +DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = 5 +DRIVETRAIN_SOC_KWH = 42 +DRIVETRAIN_CHARGING_TYPE = 1 +DRIVETRAIN_CHARGER_CONNECTED = True +DRIVETRAIN_REMAINING_CHARGING_TIME = 0 +DRIVETRAIN_LAST_CHARGE_ENDING_POWER = 200 +DRIVETRAIN_CHARGING_CABLE_LOCK = 1 +REAL_TOTAL_BATTERY_CAPACITY = 64.0 +RAW_TOTAL_BATTERY_CAPACITY = 72.5 +BATTERY_CAPACITY_CORRECTION_FACTOR = REAL_TOTAL_BATTERY_CAPACITY / RAW_TOTAL_BATTERY_CAPACITY + +CLIMATE_INTERIOR_TEMPERATURE = 22 +CLIMATE_EXTERIOR_TEMPERATURE = 18 +CLIMATE_REMOTE_CLIMATE_STATE = 2 +CLIMATE_BACK_WINDOW_HEAT = 1 + +LOCATION_SPEED = 2.0 +LOCATION_HEADING = 42 +LOCATION_LATITUDE = 48.8584 +LOCATION_LONGITUDE = 22.945 +LOCATION_ELEVATION = 200 + +WINDOWS_DRIVER = False +WINDOWS_PASSENGER = False +WINDOWS_REAR_LEFT = False +WINDOWS_REAR_RIGHT = False +WINDOWS_SUN_ROOF = False + +DOORS_LOCKED = True +DOORS_DRIVER = False +DOORS_PASSENGER = False +DOORS_REAR_LEFT = False +DOORS_REAR_RIGHT = False +DOORS_BONNET = False +DOORS_BOOT = False + +TYRES_FRONT_LEFT_PRESSURE = 2.8 +TYRES_FRONT_RIGHT_PRESSURE = 2.8 +TYRES_REAR_LEFT_PRESSURE = 2.8 +TYRES_REAR_RIGHT_PRESSURE = 2.8 + +LIGHTS_MAIN_BEAM = False +LIGHTS_DIPPED_BEAM = False +LIGHTS_SIDE = False + + +def get_mock_vehicle_status_resp(): + return VehicleStatusResp( + statusTime=int(time.time()), + basicVehicleStatus=BasicVehicleStatus( + engineStatus=0, + extendedData1=DRIVETRAIN_SOC_VEHICLE, + extendedData2=1 if DRIVETRAIN_CHARGING else 0, + batteryVoltage=DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE * 10, + mileage=DRIVETRAIN_MILEAGE * 10, + fuelRangeElec=DRIVETRAIN_RANGE_VEHICLE * 10, + interiorTemperature=CLIMATE_INTERIOR_TEMPERATURE, + exteriorTemperature=CLIMATE_EXTERIOR_TEMPERATURE, + remoteClimateStatus=CLIMATE_REMOTE_CLIMATE_STATE, + rmtHtdRrWndSt=CLIMATE_BACK_WINDOW_HEAT, + driverWindow=WINDOWS_DRIVER, + passengerWindow=WINDOWS_PASSENGER, + rearLeftWindow=WINDOWS_REAR_LEFT, + rearRightWindow=WINDOWS_REAR_RIGHT, + sunroofStatus=WINDOWS_SUN_ROOF, + lockStatus=DOORS_LOCKED, + driverDoor=DOORS_DRIVER, + passengerDoor=DOORS_PASSENGER, + rearRightDoor=DOORS_REAR_RIGHT, + rearLeftDoor=DOORS_REAR_LEFT, + bootStatus=DOORS_BOOT, + frontLeftTyrePressure=int(TYRES_FRONT_LEFT_PRESSURE * 25), + frontRightTyrePressure=int(TYRES_FRONT_RIGHT_PRESSURE * 25), + rearLeftTyrePressure=int(TYRES_REAR_LEFT_PRESSURE * 25), + rearRightTyrePressure=int(TYRES_REAR_RIGHT_PRESSURE * 25), + mainBeamStatus=LIGHTS_MAIN_BEAM, + dippedBeamStatus=LIGHTS_DIPPED_BEAM, + sideLightStatus=LIGHTS_SIDE, + frontLeftSeatHeatLevel=0, + frontRightSeatHeatLevel=1 + ), + gpsPosition=GpsPosition( + gpsStatus=GpsStatus.FIX_3d.value, + timeStamp=42, + wayPoint=GpsPosition.WayPoint( + position=GpsPosition.WayPoint.Position( + latitude=int(LOCATION_LATITUDE * 1000000), + longitude=int(LOCATION_LONGITUDE * 1000000), + altitude=LOCATION_ELEVATION + ), + heading=LOCATION_HEADING, + hdop=0, + satellites=3, + speed=20, + ) + ) + ) + + +def get_moc_charge_management_data_resp(): + return ChrgMgmtDataResp( + chrgMgmtData=ChrgMgmtData( + bmsPackCrntV=0, + bmsPackCrnt=int((DRIVETRAIN_CURRENT + 1000.0) * 20), + bmsPackVol=DRIVETRAIN_VOLTAGE * 4, + bmsPackSOCDsp=int(DRIVETRAIN_SOC_BMS * 10.0), + bmsEstdElecRng=int(DRIVETRAIN_HYBRID_ELECTRICAL_RANGE * 10.0), + ccuEleccLckCtrlDspCmd=1 + ), + rvsChargeStatus=RvsChargeStatus( + mileageOfDay=int(DRIVETRAIN_MILEAGE_OF_DAY * 10.0), + mileageSinceLastCharge=int(DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE * 10.0), + realtimePower=int((DRIVETRAIN_SOC_KWH / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10), + chargingType=DRIVETRAIN_CHARGING_TYPE, + chargingGunState=DRIVETRAIN_CHARGER_CONNECTED, + lastChargeEndingPower=int( + (DRIVETRAIN_LAST_CHARGE_ENDING_POWER / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10.0), + totalBatteryCapacity=int(RAW_TOTAL_BATTERY_CAPACITY * 10.0), + fuelRangeElec=int(DRIVETRAIN_RANGE_BMS * 10.0) + ), + + ) diff --git a/tests/test_vehicle_handler.py b/tests/test_vehicle_handler.py index a1be5f1..ab95bcf 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -1,14 +1,9 @@ -import time import unittest from unittest.mock import patch from apscheduler.schedulers.blocking import BlockingScheduler from saic_ismart_client_ng import SaicApi -from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus -from saic_ismart_client_ng.api.vehicle import VehicleStatusResp -from saic_ismart_client_ng.api.vehicle.schema import VinInfo, BasicVehicleStatus, VehicleModelConfiguration -from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp -from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus, ChrgMgmtData +from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleModelConfiguration from saic_ismart_client_ng.model import SaicApiConfiguration import mqtt_topics @@ -16,146 +11,16 @@ from handlers.relogin import ReloginHandler from mqtt_gateway import VehicleHandler from tests import MessageCapturingConsolePublisher +from tests.common_mocks import * from vehicle import VehicleState -VIN = 'vin10000000000000' - -DRIVETRAIN_RUNNING = False -DRIVETRAIN_CHARGING = True -DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = 42 -DRIVETRAIN_MILEAGE = 4000 -DRIVETRAIN_RANGE = 250 -DRIVETRAIN_CURRENT = 42 -DRIVETRAIN_VOLTAGE = 42 -DRIVETRAIN_POWER = 1.764 -DRIVETRAIN_SOC = 96 -DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = 0 -DRIVETRAIN_MILEAGE_OF_DAY = 200 -DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = 5 -DRIVETRAIN_SOC_KWH = 42 -DRIVETRAIN_CHARGING_TYPE = 1 -DRIVETRAIN_CHARGER_CONNECTED = True -DRIVETRAIN_REMAINING_CHARGING_TIME = 0 -DRIVETRAIN_LAST_CHARGE_ENDING_POWER = 200 -DRIVETRAIN_CHARGING_CABLE_LOCK = 1 -REAL_TOTAL_BATTERY_CAPACITY = 64.0 -RAW_TOTAL_BATTERY_CAPACITY = 72.5 -BATTERY_CAPACITY_CORRECTION_FACTOR = REAL_TOTAL_BATTERY_CAPACITY / RAW_TOTAL_BATTERY_CAPACITY - -CLIMATE_INTERIOR_TEMPERATURE = 22 -CLIMATE_EXTERIOR_TEMPERATURE = 18 -CLIMATE_REMOTE_CLIMATE_STATE = 2 -CLIMATE_BACK_WINDOW_HEAT = 1 - -LOCATION_SPEED = 2.0 -LOCATION_HEADING = 42 -LOCATION_LATITUDE = 48.8584 -LOCATION_LONGITUDE = 22.945 -LOCATION_ELEVATION = 200 - -WINDOWS_DRIVER = False -WINDOWS_PASSENGER = False -WINDOWS_REAR_LEFT = False -WINDOWS_REAR_RIGHT = False -WINDOWS_SUN_ROOF = False - -DOORS_LOCKED = True -DOORS_DRIVER = False -DOORS_PASSENGER = False -DOORS_REAR_LEFT = False -DOORS_REAR_RIGHT = False -DOORS_BONNET = False -DOORS_BOOT = False - -TYRES_FRONT_LEFT_PRESSURE = 2.8 -TYRES_FRONT_RIGHT_PRESSURE = 2.8 -TYRES_REAR_LEFT_PRESSURE = 2.8 -TYRES_REAR_RIGHT_PRESSURE = 2.8 - -LIGHTS_MAIN_BEAM = False -LIGHTS_DIPPED_BEAM = False -LIGHTS_SIDE = False - def mock_vehicle_status(mocked_vehicle_status): - vehicle_status_resp = VehicleStatusResp( - statusTime=int(time.time()), - basicVehicleStatus=BasicVehicleStatus( - engineStatus=0, - extendedData1=DRIVETRAIN_SOC, - extendedData2=1 if DRIVETRAIN_CHARGING else 0, - batteryVoltage=DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE * 10, - mileage=DRIVETRAIN_MILEAGE * 10, - fuelRangeElec=DRIVETRAIN_RANGE * 10, - interiorTemperature=CLIMATE_INTERIOR_TEMPERATURE, - exteriorTemperature=CLIMATE_EXTERIOR_TEMPERATURE, - remoteClimateStatus=CLIMATE_REMOTE_CLIMATE_STATE, - rmtHtdRrWndSt=CLIMATE_BACK_WINDOW_HEAT, - driverWindow=WINDOWS_DRIVER, - passengerWindow=WINDOWS_PASSENGER, - rearLeftWindow=WINDOWS_REAR_LEFT, - rearRightWindow=WINDOWS_REAR_RIGHT, - sunroofStatus=WINDOWS_SUN_ROOF, - lockStatus=DOORS_LOCKED, - driverDoor=DOORS_DRIVER, - passengerDoor=DOORS_PASSENGER, - rearRightDoor=DOORS_REAR_RIGHT, - rearLeftDoor=DOORS_REAR_LEFT, - bootStatus=DOORS_BOOT, - frontLeftTyrePressure=int(TYRES_FRONT_LEFT_PRESSURE * 25), - frontRightTyrePressure=int(TYRES_FRONT_RIGHT_PRESSURE * 25), - rearLeftTyrePressure=int(TYRES_REAR_LEFT_PRESSURE * 25), - rearRightTyrePressure=int(TYRES_REAR_RIGHT_PRESSURE * 25), - mainBeamStatus=LIGHTS_MAIN_BEAM, - dippedBeamStatus=LIGHTS_DIPPED_BEAM, - sideLightStatus=LIGHTS_SIDE, - frontLeftSeatHeatLevel=0, - frontRightSeatHeatLevel=1 - ), - gpsPosition=GpsPosition( - gpsStatus=GpsStatus.FIX_3d.value, - timeStamp=42, - wayPoint=GpsPosition.WayPoint( - position=GpsPosition.WayPoint.Position( - latitude=int(LOCATION_LATITUDE * 1000000), - longitude=int(LOCATION_LONGITUDE * 1000000), - altitude=LOCATION_ELEVATION - ), - heading=LOCATION_HEADING, - hdop=0, - satellites=3, - speed=20, - ) - ) - ) - - mocked_vehicle_status.return_value = vehicle_status_resp + mocked_vehicle_status.return_value = get_mock_vehicle_status_resp() def mock_charge_status(mocked_charge_status): - charge_mgmt_data_rsp_msg = ChrgMgmtDataResp( - chrgMgmtData=ChrgMgmtData( - bmsPackCrntV=0, - bmsPackCrnt=int((DRIVETRAIN_CURRENT + 1000.0) * 20), - bmsPackVol=DRIVETRAIN_VOLTAGE * 4, - bmsPackSOCDsp=int(DRIVETRAIN_SOC * 10.0), - bmsEstdElecRng=int(DRIVETRAIN_HYBRID_ELECTRICAL_RANGE * 10.0), - ccuEleccLckCtrlDspCmd=1 - ), - rvsChargeStatus=RvsChargeStatus( - mileageOfDay=int(DRIVETRAIN_MILEAGE_OF_DAY * 10.0), - mileageSinceLastCharge=int(DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE * 10.0), - realtimePower=int((DRIVETRAIN_SOC_KWH / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10), - chargingType=DRIVETRAIN_CHARGING_TYPE, - chargingGunState=DRIVETRAIN_CHARGER_CONNECTED, - lastChargeEndingPower=int( - (DRIVETRAIN_LAST_CHARGE_ENDING_POWER / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10.0), - totalBatteryCapacity=int(RAW_TOTAL_BATTERY_CAPACITY * 10.0), - fuelRangeElec=int(DRIVETRAIN_RANGE * 10.0) - ), - - ) - mocked_charge_status.return_value = charge_mgmt_data_rsp_msg + mocked_charge_status.return_value = get_moc_charge_management_data_resp() class TestVehicleHandler(unittest.IsolatedAsyncioTestCase): @@ -192,8 +57,6 @@ async def test_update_vehicle_status(self, mocked_vehicle_status): await self.vehicle_handler.update_vehicle_status() self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_RUNNING), DRIVETRAIN_RUNNING) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_RANGE), - DRIVETRAIN_RANGE) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), DRIVETRAIN_CHARGING) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE), DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE) @@ -234,13 +97,11 @@ async def test_update_vehicle_status(self, mocked_vehicle_status): self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_MAIN_BEAM), LIGHTS_MAIN_BEAM) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_DIPPED_BEAM), LIGHTS_DIPPED_BEAM) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_SIDE), LIGHTS_SIDE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC) expected_topics = { '/vehicles/vin10000000000000/drivetrain/hvBatteryActive', '/vehicles/vin10000000000000/refresh/lastActivity', '/vehicles/vin10000000000000/drivetrain/running', '/vehicles/vin10000000000000/drivetrain/charging', - '/vehicles/vin10000000000000/drivetrain/range', '/vehicles/vin10000000000000/climate/interiorTemperature', '/vehicles/vin10000000000000/climate/exteriorTemperature', '/vehicles/vin10000000000000/drivetrain/auxiliaryBatteryVoltage', @@ -275,7 +136,6 @@ async def test_update_vehicle_status(self, mocked_vehicle_status): '/vehicles/vin10000000000000/climate/heatedSeatsFrontRightLevel', '/vehicles/vin10000000000000/drivetrain/mileage', '/vehicles/vin10000000000000/refresh/lastVehicleState', - '/vehicles/vin10000000000000/drivetrain/soc' } self.assertSetEqual(expected_topics, set(self.vehicle_handler.publisher.map.keys())) @@ -287,7 +147,6 @@ async def test_update_charge_status(self, mocked_charge_status): self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CURRENT), DRIVETRAIN_CURRENT) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_VOLTAGE), DRIVETRAIN_VOLTAGE) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_POWER), DRIVETRAIN_POWER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE), DRIVETRAIN_HYBRID_ELECTRICAL_RANGE) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY), @@ -305,21 +164,15 @@ async def test_update_charge_status(self, mocked_charge_status): DRIVETRAIN_LAST_CHARGE_ENDING_POWER) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY), REAL_TOTAL_BATTERY_CAPACITY) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE), - DRIVETRAIN_HYBRID_ELECTRICAL_RANGE) self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK), DRIVETRAIN_CHARGING_CABLE_LOCK) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_RANGE), - DRIVETRAIN_RANGE) expected_topics = { '/vehicles/vin10000000000000/drivetrain/current', '/vehicles/vin10000000000000/drivetrain/voltage', '/vehicles/vin10000000000000/drivetrain/power', '/vehicles/vin10000000000000/obc/current', '/vehicles/vin10000000000000/obc/voltage', - '/vehicles/vin10000000000000/drivetrain/soc', '/vehicles/vin10000000000000/drivetrain/hybrid_electrical_range', - '/vehicles/vin10000000000000/drivetrain/range', '/vehicles/vin10000000000000/drivetrain/mileageOfTheDay', '/vehicles/vin10000000000000/drivetrain/mileageSinceLastCharge', '/vehicles/vin10000000000000/drivetrain/chargingType', @@ -334,6 +187,29 @@ async def test_update_charge_status(self, mocked_charge_status): } self.assertSetEqual(expected_topics, set(self.vehicle_handler.publisher.map.keys())) + # Note: The closer the decorator is to the function definition, the earlier it is in the parameter list + @patch.object(SaicApi, 'get_vehicle_charging_management_data') + @patch.object(SaicApi, 'get_vehicle_status') + async def test_should_not_publish_same_data_twice(self, mocked_vehicle_status, mocked_charge_status): + mock_vehicle_status(mocked_vehicle_status) + mock_charge_status(mocked_charge_status) + publisher_data: dict = self.vehicle_handler.publisher.map + + await self.vehicle_handler.update_vehicle_status() + vehicle_mqtt_map = dict(publisher_data) + publisher_data.clear() + + await self.vehicle_handler.update_charge_status() + charge_data_mqtt_map = dict(publisher_data) + publisher_data.clear() + + common_data = set(vehicle_mqtt_map.keys()).intersection(set(charge_data_mqtt_map.keys())) + + self.assertTrue( + len(common_data) == 0, + ("Some topics have been published from both car state and BMS state: %s" % str(common_data)) + ) + def assert_mqtt_topic(self, topic: str, value): mqtt_map = self.vehicle_handler.publisher.map if topic in mqtt_map: diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py new file mode 100644 index 0000000..3bec08c --- /dev/null +++ b/tests/test_vehicle_state.py @@ -0,0 +1,59 @@ +import unittest + +from apscheduler.schedulers.blocking import BlockingScheduler +from saic_ismart_client_ng.api.vehicle.schema import VinInfo + +import mqtt_topics +from configuration import Configuration +from tests import MessageCapturingConsolePublisher +from tests.common_mocks import VIN, get_mock_vehicle_status_resp, DRIVETRAIN_SOC_BMS, DRIVETRAIN_RANGE_BMS, \ + DRIVETRAIN_SOC_VEHICLE, DRIVETRAIN_RANGE_VEHICLE, get_moc_charge_management_data_resp +from vehicle import VehicleState + + +class TestVehicleState(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + config = Configuration() + config.anonymized_publishing = False + publisher = MessageCapturingConsolePublisher(config) + vin_info = VinInfo() + vin_info.vin = VIN + account_prefix = f'/vehicles/{VIN}' + scheduler = BlockingScheduler() + self.vehicle_state = VehicleState(publisher, scheduler, account_prefix, vin_info) + + async def test_update_soc_with_no_bms_data(self): + self.vehicle_state.update_data_conflicting_in_vehicle_and_bms(vehicle_status=get_mock_vehicle_status_resp(), + charge_status=None) + self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC_VEHICLE) + self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), DRIVETRAIN_RANGE_VEHICLE) + expected_topics = { + '/vehicles/vin10000000000000/drivetrain/soc', + '/vehicles/vin10000000000000/drivetrain/range', + } + self.assertSetEqual(expected_topics, set(self.vehicle_state.publisher.map.keys())) + + async def test_update_soc_with_bms_data(self): + self.vehicle_state.update_data_conflicting_in_vehicle_and_bms(vehicle_status=get_mock_vehicle_status_resp(), + charge_status=get_moc_charge_management_data_resp()) + self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC_BMS) + self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), DRIVETRAIN_RANGE_BMS) + expected_topics = { + '/vehicles/vin10000000000000/drivetrain/soc', + '/vehicles/vin10000000000000/drivetrain/range', + } + self.assertSetEqual(expected_topics, set(self.vehicle_state.publisher.map.keys())) + + def assert_mqtt_topic(self, topic: str, value): + mqtt_map = self.vehicle_state.publisher.map + if topic in mqtt_map: + if isinstance(value, float) or isinstance(mqtt_map[topic], float): + self.assertAlmostEqual(value, mqtt_map[topic], delta=1) + else: + self.assertEqual(value, mqtt_map[topic]) + else: + self.fail(f'MQTT map does not contain topic {topic}') + + @staticmethod + def get_topic(sub_topic: str) -> str: + return f'/vehicles/{VIN}/{sub_topic}' diff --git a/vehicle.py b/vehicle.py index a10badb..e46b6a7 100644 --- a/vehicle.py +++ b/vehicle.py @@ -327,10 +327,6 @@ def handle_vehicle_status(self, vehicle_status: VehicleStatusResp) -> None: mileage = basic_vehicle_status.mileage / 10.0 self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE), mileage) - # We can read this from either the BMS or the Vehicle Info - self.__publish_electric_range(basic_vehicle_status.fuelRangeElec) - self.__publish_soc(basic_vehicle_status.extendedData1) - # Standard fossil fuels vehicles if value_in_range(basic_vehicle_status.fuelRange, 1, 65535): fuel_range = basic_vehicle_status.fuelRange / 10.0 @@ -357,7 +353,7 @@ def __publish_tyre(self, raw_value: int, topic: str): bar_value = raw_value * PRESSURE_TO_BAR_FACTOR self.publisher.publish_float(self.get_topic(topic), round(bar_value, 2)) - def __publish_electric_range(self, raw_value): + def __publish_electric_range(self, raw_value) -> bool: if value_in_range(raw_value, 1, 20460): electric_range = raw_value / 10.0 self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_RANGE), electric_range) @@ -366,8 +362,10 @@ def __publish_electric_range(self, raw_value): and self.charging_station.range_topic ): self.publisher.publish_float(self.charging_station.range_topic, electric_range, True) + return True + return False - def __publish_soc(self, soc): + def __publish_soc(self, soc) -> bool: if value_in_range(soc, 0, 100.0, is_max_excl=False): self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_SOC), soc) if ( @@ -375,6 +373,8 @@ def __publish_soc(self, soc): and self.charging_station.soc_topic ): self.publisher.publish_float(self.charging_station.soc_topic, soc, True) + return True + return False def set_hv_battery_active(self, hv_battery_active: bool): if ( @@ -649,9 +649,6 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: except ValueError: LOG.warning(f'Invalid target SOC received: {raw_target_soc}') - soc = charge_mgmt_data.bmsPackSOCDsp / 10.0 - self.__publish_soc(soc) - estd_elec_rng = charge_mgmt_data.bmsEstdElecRng if value_in_range(estd_elec_rng, 0, 2046): estimated_electrical_range = estd_elec_rng @@ -688,9 +685,6 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: charge_status = charge_info_resp.rvsChargeStatus - # We can read this from either the BMS or the Vehicle Info - self.__publish_electric_range(charge_status.fuelRangeElec) - if value_in_range(charge_status.mileageOfDay, 0, 65535): mileage_of_the_day = charge_status.mileageOfDay / 10.0 self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY), mileage_of_the_day) @@ -824,6 +818,28 @@ def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: charge_mgmt_data.charging_port_locked ) + def update_data_conflicting_in_vehicle_and_bms( + self, + vehicle_status: VehicleStatusResp, + charge_status: Optional[ChrgMgmtDataResp] + ): + # We can read this from either the BMS or the Vehicle Info + electric_range_published = False + soc_published = False + + if charge_status is not None: + electric_range_published = self.__publish_electric_range(charge_status.rvsChargeStatus.fuelRangeElec) + soc_published = self.__publish_soc(charge_status.chrgMgmtData.bmsPackSOCDsp / 10.0) + basic_vehicle_status = vehicle_status.basicVehicleStatus + if not electric_range_published: + electric_range_published = self.__publish_electric_range(basic_vehicle_status.fuelRangeElec) + if not soc_published: + soc_published = self.__publish_soc(basic_vehicle_status.extendedData1) + if not electric_range_published: + logging.warning("Could not extract a valid electric range") + if not soc_published: + logging.warning("Could not extract a valid SoC") + def handle_scheduled_battery_heating_status(self, scheduled_battery_heating_status: ScheduledBatteryHeatingResp): if scheduled_battery_heating_status: is_enabled = scheduled_battery_heating_status.status From 26f900d14085850378040ebf3d1303cc26135656 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 15 Dec 2024 22:04:27 +0100 Subject: [PATCH 34/35] #283: Publish HA discovery once per car --- integrations/home_assistant/discovery.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/integrations/home_assistant/discovery.py b/integrations/home_assistant/discovery.py index 8ba28c3..17be8cb 100644 --- a/integrations/home_assistant/discovery.py +++ b/integrations/home_assistant/discovery.py @@ -94,12 +94,22 @@ def __init__(self, vehicle_state: VehicleState, vin_info: VinInfo, configuration self.__vehicle_availability ] ) + self.published = False def publish_ha_discovery_messages(self): + if self.published: + LOG.debug("Skipping Home Assistant discovery messages as it was already published") + return + if not self.__vehicle_state.is_complete(): LOG.debug("Skipping Home Assistant discovery messages as vehicle state is not yet complete") return + self.__publish_ha_discovery_messages_real() + self.published = True + + def __publish_ha_discovery_messages_real(self): + LOG.debug("Publishing Home Assistant discovery messages") # Gateway Control From 18a521419800714052db50a915e1c018134034ed Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 16 Dec 2024 17:08:28 +0100 Subject: [PATCH 35/35] #279: Restore the original normalization rule --- publisher/core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/publisher/core.py b/publisher/core.py index 489eeb1..56fd108 100644 --- a/publisher/core.py +++ b/publisher/core.py @@ -6,8 +6,7 @@ import mqtt_topics from configuration import Configuration -INVALID_MQTT_CHARS = re.compile(r'[^a-zA-Z0-9/]') - +INVALID_MQTT_CHARS = re.compile(r'[+#*$>]') class MqttCommandListener(ABC): async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: