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 diff --git a/README.md b/README.md index fcdd715..3947fa3 100644 --- a/README.md +++ b/README.md @@ -17,39 +17,83 @@ 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. | -| --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. | - -### SAIC API Endpoints +### 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. | + +#### API Endpoints The following are the known available endpoints: -| SAIC_REST_URI | SAIC_REGION | Notes | +| SAIC_REST_URI | SAIC_REGION | Notes | |---------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------| | https://gateway-mg-au.soimt.com/api.app/v1/ | au | This endpoint is not used by the iSmart app for Australia and New Zealand but has been tested and proven to work in these countries. | | https://gateway-mg-eu.soimt.com/api.app/v1/ | eu | | +### MQTT Broker -### Charging Station Configuration +| 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 + +| 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 (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. + +| 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. | + +### 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 @@ -73,22 +117,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. | -| | 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 @@ -107,12 +147,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,6 +174,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/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 diff --git a/configuration.py b/configuration/__init__.py similarity index 74% rename from configuration.py rename to configuration/__init__.py index d173d42..6655b4b 100644 --- a/configuration.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 @@ -32,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 @@ -40,3 +38,22 @@ 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 + + # 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 + + @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/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..3491708 --- /dev/null +++ b/configuration/parser.py @@ -0,0 +1,257 @@ +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=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') + 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('--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) + + # 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,' + ' 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', + 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 + 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 + 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.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}') + + 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'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) + + # 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() + SystemExit(err) 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..3dbcf37 --- /dev/null +++ b/handlers/message.py @@ -0,0 +1,146 @@ +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 +from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException + +from handlers.relogin import ReloginHandler +from handlers.vehicle import VehicleHandlerLocator +from vehicle import RefreshMode + +LOG = logging.getLogger(__name__) + + +class MessageHandler: + 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 + + 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) + + async def __polling(self): + try: + all_messages = await self.__get_all_alarm_messages() + LOG.info(f'{len(all_messages)} messages received') + + new_messages = [m for m in all_messages if m.read_status != 'read'] + for message in new_messages: + LOG.info(message.details) + await self.__read_message(message) + + 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_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 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: + LOG.exception('MessageHandler poll loop failed unexpectedly', exc_info=e) + + async def __get_all_alarm_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 + 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, ' + 'then report this as a bug.', + exc_info=e + ) + finally: + idx = idx + 1 + + 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) + 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: MessageEntity): + 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): + 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 + + @staticmethod + 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) diff --git a/handlers/relogin.py b/handlers/relogin.py new file mode 100644 index 0000000..024ac31 --- /dev/null +++ b/handlers/relogin.py @@ -0,0 +1,52 @@ +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) + raise 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 new file mode 100644 index 0000000..3bef247 --- /dev/null +++ b/handlers/vehicle.py @@ -0,0 +1,475 @@ +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, SaicLogoutException + +import mqtt_topics +from configuration import Configuration +from exceptions import MqttGatewayException +from handlers.relogin import ReloginHandler +from integrations import IntegrationException +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 + +LOG = logging.getLogger(__name__) + + +class VehicleHandler: + 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 + 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) + + 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: + abrp_api_listener = MqttGatewayAbrpListener(self.publisher) + else: + abrp_api_listener = None + self.abrp_api = AbrpApi( + self.configuration.abrp_api_key, + abrp_user_token, + 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: + start_time = datetime.datetime.now() + self.vehicle_state.publish_vehicle_info() + self.vehicle_state.notify_car_activity() + + 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 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( + 'handle_vehicle loop failed during SAIC API call', + exc_info=e + ) + 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( + '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() + charge_status = None + + 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 + + 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...') + + await self.__refresh_abrp(charge_status, vehicle_status) + await self.__refresh_osmand(charge_status, vehicle_status) + + def __should_poll(self) -> bool: + return ( + not self.relogin_handler.relogin_in_progress + and 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_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) + 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, result_topic = self.__get_command_topics(topic) + try: + should_force_refresh = True + match topic: + case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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_SET: + 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 mqtt_topics.LOCATION_FIND_MY_CAR_SET: + 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 + await self.vehicle_state.configure_by_message(topic=topic, payload=payload) + 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(result_topic, f'Failed: {e.message}') + LOG.exception(e.message, exc_info=e) + except SaicLogoutException as se: + 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(result_topic, f'Failed: {se.message}') + LOG.exception(se.message, exc_info=se) + except Exception as se: + self.publisher.publish_str(result_topic, 'Failed unexpectedly') + LOG.exception("handle_mqtt_command failed with an unexpected exception", exc_info=se) + + 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): + + def get_vehicle_handler(self, vin: str) -> Optional[VehicleHandler]: + raise NotImplementedError() + + @property + def vehicle_handlers(self) -> dict[str, VehicleHandler]: + raise NotImplementedError() 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..967a7a7 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): @@ -195,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/home_assistant/discovery.py b/integrations/home_assistant/discovery.py index 07cbd14..17be8cb 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__) @@ -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 @@ -174,6 +184,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') @@ -289,6 +301,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', @@ -385,39 +410,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 }}', @@ -439,7 +460,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, @@ -465,7 +486,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, @@ -529,7 +550,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, @@ -567,7 +588,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(), @@ -625,7 +646,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, @@ -680,17 +701,20 @@ 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 + 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/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..ed09e2f --- /dev/null +++ b/integrations/osmand/api.py @@ -0,0 +1,234 @@ +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 integrations import IntegrationException +from utils import value_in_range, get_update_timestamp + +LOG = logging.getLogger(__name__) + + +class OsmAndApiException(IntegrationException): + def __init__(self, msg: str): + super().__init__(__name__, msg) + + +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 | None) \ + -> Tuple[bool, Any | None]: + + 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 + ): + # 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()), + '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, + }) + + basic_vehicle_status = vehicle_status.basicVehicleStatus + if basic_vehicle_status is not None: + data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) + + 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() + 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, 20460): + 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_gateway.py b/mqtt_gateway.py index 729055f..365123b 100644 --- a/mqtt_gateway.py +++ b/mqtt_gateway.py @@ -1,49 +1,30 @@ -import argparse import asyncio -import datetime import faulthandler -import json import logging import os import signal import sys -import time -import urllib.parse -from typing import Callable, 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, TransportProtocol -from exceptions import MqttGatewayException -from integrations.abrp.api import AbrpApi, AbrpApiException -from integrations.home_assistant.discovery import HomeAssistantDiscovery +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.core import Publisher -from publisher.mqtt_publisher import MqttClient, MqttCommandListener -from saic_api_listener import MqttGatewaySaicApiListener, MqttGatewayAbrpListener -from vehicle import RefreshMode, VehicleState +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 MSG_CMD_SUCCESSFUL = 'Success' -CHARGING_STATIONS_FILE = 'charging-stations.json' - - -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='{') @@ -57,389 +38,42 @@ 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 - self.abrp_api = AbrpApi( - self.configuration.abrp_api_key, - abrp_user_token, - listener=MqttGatewayAbrpListener(self.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) - - 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.publisher = MqttClient(self.configuration) + self.__vehicle_handlers: dict[str, VehicleHandler] = dict() + 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: + listener = None self.saic_api = SaicApi( 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, - relogin_delay=self.configuration.saic_relogin_delay, + 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 ), - listener=MqttGatewaySaicApiListener(self.publisher) + 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 ) - 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() 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) @@ -478,7 +112,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, @@ -488,14 +122,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_handler[vin_info.vin] = vehicle_handler - message_handler = MessageHandler(self, self.saic_api) - scheduler.add_job( + self.vehicle_handlers[vin_info.vin] = vehicle_handler + 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, @@ -504,16 +143,22 @@ async def run(self): max_instances=1 ) await self.publisher.connect() - scheduler.start() + 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) @@ -547,7 +192,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) @@ -576,360 +221,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 - - -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) - - 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.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) diff --git a/mqtt_topics.py b/mqtt_topics.py index 4808fba..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_STOP_REASON = 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,13 +73,19 @@ 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' +DRIVETRAIN_FOSSIL_FUEL_RANGE = DRIVETRAIN_FOSSIL_FUEL + '/range' OBC = 'obc' OBC_CURRENT = OBC + '/current' @@ -99,6 +121,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' @@ -108,6 +131,8 @@ LOCATION_LONGITUDE = LOCATION + '/longitude' 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' @@ -115,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/core.py b/publisher/core.py index 743bcc0..56fd108 100644 --- a/publisher/core.py +++ b/publisher/core.py @@ -1,14 +1,29 @@ import json import re from abc import ABC +from typing import Optional import mqtt_topics from configuration import Configuration +INVALID_MQTT_CHARS = re.compile(r'[+#*$>]') + +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 + self.__topic_root = self.__remove_special_mqtt_characters(config.mqtt_topic) + + async def connect(self): + pass def is_connected(self) -> bool: raise NotImplementedError() @@ -28,6 +43,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): @@ -86,3 +116,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..9c8c871 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,14 @@ 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.topic_root = configuration.mqtt_topic + self.publisher_id = configuration.mqtt_client_id 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] = {} @@ -55,15 +43,7 @@ def __init__(self, configuration: Configuration): mqtt_client.on_message = self.__on_message self.client = mqtt_client - def get_mqtt_account_prefix(self) -> str: - return MqttClient.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: if self.configuration.mqtt_password is not None: @@ -89,10 +69,12 @@ 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 @@ -144,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: @@ -161,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: @@ -177,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:] diff --git a/requirements.txt b/requirements.txt index 68d809a..eddb733 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.5.2 +httpx~=0.27.0 gmqtt~=0.6.13 inflection~=0.5.1 apscheduler~=3.10.1 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) 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/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_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 e5fe9c7..ab95bcf 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -1,160 +1,26 @@ -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 -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 from configuration import Configuration +from handlers.relogin import ReloginHandler from mqtt_gateway import VehicleHandler -from publisher.log_publisher import Logger +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): @@ -165,14 +31,25 @@ 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' + 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) - 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): @@ -180,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) @@ -222,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', @@ -263,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())) @@ -275,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), @@ -293,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', @@ -322,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/utils.py b/utils.py index 32b1516..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: @@ -33,3 +34,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..e46b6a7 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 @@ -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 @@ -327,9 +327,14 @@ 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 + 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 @@ -341,15 +346,15 @@ 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): 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): - if value_in_range(raw_value, 1, 65535): + 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) if ( @@ -357,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 ( @@ -366,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 ( @@ -378,17 +387,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), - VehicleState.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 ( @@ -400,14 +406,14 @@ 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) 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: @@ -498,7 +504,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) @@ -545,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) @@ -643,11 +649,8 @@ 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, 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), @@ -682,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) @@ -742,36 +742,12 @@ 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 + real_total_battery_capacity, battery_capacity_correction_factor = self.get_actual_battery_capacity( + charge_status) - if ( - charge_status.totalBatteryCapacity is not None - and charge_status.totalBatteryCapacity > 0 - ): - raw_total_battery_capacity = charge_status.totalBatteryCapacity / 10.0 - - 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 @@ -809,7 +785,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: @@ -842,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 @@ -893,10 +891,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 @@ -914,6 +908,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' @@ -981,21 +986,64 @@ 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" + # 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