Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
963a9ff
Remove un-used function calls
nanomad May 12, 2024
19b499f
Disable dumping of ABRP and API message to MQTT by default
nanomad May 12, 2024
758b02e
Move configuration handling into its own module
nanomad May 12, 2024
f5d8b43
Move handlers into their own module
nanomad May 12, 2024
b767245
Cleanup datetime_to_str
nanomad May 12, 2024
8e01709
Fix hardcoded True param for car activity
nanomad Jun 27, 2024
fad8db1
Do not rely on unread message satus, keep track of the last message w…
nanomad Jun 27, 2024
ffcb2e3
Add suffix to battery heating stop reason topic to allow discriminate…
pakozm Jul 27, 2024
29913ac
Draft OsmAnd Integration
nanomad Sep 11, 2024
8046786
Update parser documentation
nanomad Sep 11, 2024
859e2f6
Documentation update
nanomad Sep 11, 2024
4163c06
Merge pull request #258 from nanomad/develop
nanomad Sep 11, 2024
005daa5
Merge branch 'main' into develop
nanomad Sep 11, 2024
da5c639
Fix mistaken StopReason suffix in incorrect topic
pakozm Sep 21, 2024
7abcb9e
Merge pull request #253 from pakozm/patch-1
nanomad Sep 28, 2024
15f5541
Gracefully handle scenarios where we don't know the real battery capa…
nanomad Sep 28, 2024
7b28062
Merge pull request #261 from nanomad/develop
nanomad Sep 28, 2024
f753262
Initial support for fossil fuel cars like MG3 Hybrid
nanomad Sep 28, 2024
6aa9243
Fix value_in_range check
nanomad Sep 28, 2024
04d0d01
Merge pull request #262 from nanomad/develop
nanomad Sep 28, 2024
c593554
Move relogin logic to the MQTT Gateway instead of the API library
nanomad Oct 6, 2024
5428f90
Merge pull request #265 from nanomad/develop
nanomad Oct 6, 2024
729bdef
Expose find my car functionality #264
Oct 6, 2024
cb7a118
'find my car' command topic corrected
Oct 6, 2024
8ec3294
'find my car' switch added to HomeAssistantDiscovery
Oct 6, 2024
61a25bf
Update README.md
tosate Oct 7, 2024
695110c
Merge pull request #266 from tosate/develop
nanomad Oct 8, 2024
9405d45
New exception hierarchy for integrations
nanomad Oct 8, 2024
9ddd066
Better logging for time drift
nanomad Oct 8, 2024
694e86e
Fix #73: Allow running the gateway without an MQTT connection
nanomad Oct 8, 2024
4b7821c
Merge pull request #267 from nanomad/develop
nanomad Oct 9, 2024
00223be
Fix tests
nanomad Oct 9, 2024
b9e1f02
Bump API client version
nanomad Oct 9, 2024
b9a0f0f
Merge remote-tracking branch 'upstream/main' into develop
nanomad Oct 9, 2024
c5ba6da
Fix ruff installation step
nanomad Oct 9, 2024
a6d8a52
Merge pull request #268 from nanomad/develop
nanomad Oct 9, 2024
8022190
Bump saic-ismart-client-ng to fix crashes during the relogin flow
nanomad Nov 3, 2024
c0d6729
Vehicle handler should only react to explicit and well-known set comm…
nanomad Dec 5, 2024
b6f043e
Drop car max admissible range to < 2046.0 km
nanomad Dec 5, 2024
14eda7f
Change the way we auto-detect the max battery capacity on the MG4.
nanomad Dec 5, 2024
d0a676d
Fix tests
nanomad Dec 5, 2024
172f021
#279: Normalize email address when matching commands
nanomad Dec 15, 2024
86613d1
#282: Do not publish SOC and Range twice
nanomad Dec 15, 2024
26f900d
#283: Publish HA discovery once per car
nanomad Dec 15, 2024
ab86deb
Merge pull request #284 from nanomad/develop
nanomad Dec 15, 2024
18a5214
#279: Restore the original normalization rule
nanomad Dec 16, 2024
d4d25a0
Merge pull request #285 from nanomad/develop
nanomad Dec 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build_and_test_python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 75 additions & 40 deletions README.md

Large diffs are not rendered by default.

23 changes: 20 additions & 3 deletions configuration.py → configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,38 @@ 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
self.tls_server_cert_path: str | None = None
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
self.ha_discovery_enabled: bool = True
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
50 changes: 50 additions & 0 deletions configuration/argparse_extensions.py
Original file line number Diff line number Diff line change
@@ -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']
257 changes: 257 additions & 0 deletions configuration/parser.py

Large diffs are not rendered by default.

Empty file added handlers/__init__.py
Empty file.
146 changes: 146 additions & 0 deletions handlers/message.py
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions handlers/relogin.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading