Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 1 addition & 3 deletions configuration.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from enum import Enum

from integrations.openwb.charging_station import ChargingStation


class TransportProtocol(Enum):
def __init__(self, transport_mechanism: str, with_tls: bool):
Expand Down Expand Up @@ -33,7 +31,7 @@ def __init__(self):
self.mqtt_password: str | None = None
self.mqtt_client_id: str = 'saic-python-mqtt-gateway'
self.mqtt_topic: str | None = None
self.charging_stations_by_vin: dict[str, ChargingStation] = {}
self.charging_stations_file: str | None = None
self.anonymized_publishing: bool = False
self.messages_request_interval: int = 60 # in seconds
self.ha_discovery_enabled: bool = True
Expand Down
73 changes: 73 additions & 0 deletions integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from abc import ABC
from typing import Tuple, Any, List

from saic_ismart_client_ng.api.vehicle import VehicleStatusResp
from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp

from configuration import Configuration
from publisher.core import Publisher
from publisher.core import MqttCommandListener


class SaicMqttGatewayIntegrationException(Exception):
pass


class SaicMqttGatewayIntegration(ABC):

def __init__(
self, *,
name: str,
configuration: Configuration,
publisher: Publisher,
listener: MqttCommandListener,
):
self.__name = name
self.__configuration = configuration
self.__publisher = publisher
self.__listener = listener

async def on_full_refresh_done(
self,
*,
vin: str,
vehicle_status: VehicleStatusResp,
charge_info: ChrgMgmtDataResp
) -> Tuple[bool, Any | None]:
return False, 'Not supported'

async def on_raw_mqtt_message(
self,
topic: str,
payload: str
) -> Tuple[bool, Any | None]:
return False, 'Not supported'

async def on_mqtt_command(
self,
*,
vin: str,
topic: str,
payload: str
) -> Tuple[bool, Any | None]:
return False, 'Not supported'

@property
def additional_mqtt_topics(self) -> List[str]:
return []

@property
def name(self):
return str(self.__name).lower()

@property
def configuration(self) -> Configuration:
return self.__configuration

@property
def publisher(self):
return self.__publisher

@property
def listener(self):
return self.__listener
53 changes: 31 additions & 22 deletions integrations/abrp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,48 +11,51 @@
from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp
from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus

from configuration import Configuration
from integrations import SaicMqttGatewayIntegration, SaicMqttGatewayIntegrationException
from integrations.abrp.api_listener import MqttGatewayAbrpListener
from publisher.core import MqttCommandListener, Publisher
from utils import value_in_range

LOG = logging.getLogger(__name__)


class AbrpApiException(Exception):
class AbrpApiException(SaicMqttGatewayIntegrationException):
def __init__(self, msg: str):
self.message = msg

def __str__(self):
return self.message


class AbrpApiListener(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 AbrpApi:
def __init__(self, abrp_api_key: str, abrp_user_token: str, listener: Optional[AbrpApiListener] = None) -> None:
self.abrp_api_key = abrp_api_key
self.abrp_user_token = abrp_user_token
self.__listener = listener
class AbrpApi(SaicMqttGatewayIntegration):
def __init__(self, *, configuration: Configuration, publisher: Publisher, listener: MqttCommandListener):
super().__init__(name='ABRP', configuration=configuration, publisher=publisher, listener=listener)
self.__abrp_token_map = self.configuration.abrp_token_map
self.__abrp_api_key = self.configuration.abrp_api_key
self.__base_uri = 'https://api.iternio.com/1/'
self.client = httpx.AsyncClient(
self.__listener = MqttGatewayAbrpListener(self.publisher)
self.__client = httpx.AsyncClient(
event_hooks={
"request": [self.invoke_request_listener],
"response": [self.invoke_response_listener]
}
)

async def update_abrp(self, vehicle_status: VehicleStatusResp, charge_info: ChrgMgmtDataResp) \
-> Tuple[bool, Any | None]:
async def on_full_refresh_done(
self,
*,
vin: str,
vehicle_status: VehicleStatusResp,
charge_info: ChrgMgmtDataResp
) -> Tuple[bool, Any | None]:

abrp_user_token = self.__get_user_token(vin)
charge_status = None if charge_info is None else charge_info.chrgMgmtData

if (
self.abrp_api_key is not None
and self.abrp_user_token is not None
self.__abrp_api_key is not None
and abrp_user_token is not None
and vehicle_status is not None
and charge_status is not None
):
Expand Down Expand Up @@ -95,12 +98,12 @@ async def update_abrp(self, vehicle_status: VehicleStatusResp, charge_info: Chrg
data.update(self.__extract_gps_position(gps_position))

headers = {
'Authorization': f'APIKEY {self.abrp_api_key}'
'Authorization': f'APIKEY {self.__abrp_api_key}'
}

try:
response = await self.client.post(url=tlm_send_url, headers=headers, params={
'token': self.abrp_user_token,
response = await self.__client.post(url=tlm_send_url, headers=headers, params={
'token': abrp_user_token,
'tlm': json.dumps(data)
})
await response.aread()
Expand Down Expand Up @@ -242,3 +245,9 @@ async def invoke_response_listener(self, response: httpx.Response):
)
except Exception as e:
LOG.warning(f"Error invoking request listener: {e}", exc_info=e)

def __get_user_token(self, vin: str):
if vin in self.__abrp_token_map:
return self.__abrp_token_map[vin]
else:
return None
18 changes: 18 additions & 0 deletions integrations/abrp/api_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from typing import Optional

from mqtt_topics import INTERNAL
from publisher.core import Publisher
from saic_api_listener import MqttGatewayListenerApiListener

INTERNAL_ABRP_TOPIC = INTERNAL + '/abrp'


class MqttGatewayAbrpListener(MqttGatewayListenerApiListener):
def __init__(self, publisher: Publisher):
super().__init__(publisher, INTERNAL_ABRP_TOPIC)

async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None):
await self.publish_request(path, body, headers)

async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None):
await self.publish_response(path, body, headers)
172 changes: 172 additions & 0 deletions integrations/openwb/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import json
import logging
from typing import List, override, Tuple, Any

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 configuration import Configuration
from integrations import SaicMqttGatewayIntegration
from integrations.openwb.model import ChargingStation
from publisher.core import MqttCommandListener, Publisher
from utils import value_in_range

LOG = logging.getLogger(__name__)

CHARGING_STATIONS_FILE = 'charging-stations.json'


def process_charging_stations_file(json_file: str) -> dict[str, ChargingStation]:
result = dict()
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']
result[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)
return result


class OpenWBIntegration(SaicMqttGatewayIntegration):
def __init__(self, *, configuration: Configuration, publisher: Publisher, listener: MqttCommandListener):
super().__init__(name='OpenWB', configuration=configuration, publisher=publisher, listener=listener)
charging_stations_file = self.configuration.charging_stations_file or f'./{CHARGING_STATIONS_FILE}'
self.__charging_stations_by_vin = process_charging_stations_file(charging_stations_file)
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] = {}
for charging_station in self.__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
if charging_station.connected_topic:
LOG.debug(f'Subscribing to MQTT topic {charging_station.connected_topic}')
self.__vin_by_charger_connected_topic[charging_station.connected_topic] = charging_station.vin

@override
async def on_raw_mqtt_message(
self, *,
topic: str,
payload: str
):
handled = False
if topic in self.__vin_by_charge_state_topic:
LOG.debug(f'Received message over topic {topic} with payload {payload}')
handled = True
vin = self.__vin_by_charge_state_topic[topic]
charging_station = self.__charging_stations_by_vin[vin]
if self.__should_force_refresh(payload, charging_station):
LOG.info(f'Vehicle with vin {vin} is charging. Setting refresh mode to force')
if self.listener is not None:
await self.listener.on_charging_detected(vin)
elif topic in self.__vin_by_charger_connected_topic:
LOG.debug(f'Received message over topic {topic} with payload {payload}')
handled = True
vin = self.__vin_by_charger_connected_topic[topic]
charging_station = self.__charging_stations_by_vin[vin]
if payload == charging_station.connected_value:
LOG.debug(f'Vehicle with vin {vin} is connected to its charging station')
else:
LOG.debug(f'Vehicle with vin {vin} is disconnected from its charging station')

return handled, '' if handled else f'Topic {topic} not supported by OpenWB integration'

async def on_full_refresh_done(
self,
*,
vin: str,
vehicle_status: VehicleStatusResp,
charge_info: ChrgMgmtDataResp
) -> Tuple[bool, Any | None]:
handled = False
charging_station = self.__get_charging_station(vin)
if charging_station:
if (
charging_station.soc_topic
and charge_info
and charge_info.chrgMgmtData
and charge_info.chrgMgmtData.bmsPackSOCDsp is not None
):
soc = charge_info.chrgMgmtData.bmsPackSOCDsp / 10.0
if soc <= 100.0:
self.publisher.publish_int(charging_station.soc_topic, int(soc), True)
handled = True
if charging_station.range_topic:
electric_range = self.__extract_electric_range(
basic_vehicle_status=vehicle_status.basicVehicleStatus if vehicle_status is not None else None,
charge_status=charge_info.rvsChargeStatus if charge_info is not None else None
)
if electric_range is not None:
self.publisher.publish_float(charging_station.range_topic, electric_range, True)
handled = True
return handled, '' if handled else f'Car with vin {vin} does not have an OpenWB configuration'

@override
def additional_mqtt_topics(self) -> List[str]:
return list(self.__vin_by_charge_state_topic.keys()) + list(self.__vin_by_charge_state_topic.keys())

def __should_force_refresh(self, current_charging_value: str, charging_station: ChargingStation):
vin = charging_station.vin
last_charging_value: str | None = None
if vin in self.__last_charge_state_by_vin:
last_charging_value = self.__last_charge_state_by_vin[vin]
self.__last_charge_state_by_vin[vin] = current_charging_value

if last_charging_value:
if last_charging_value == current_charging_value:
LOG.debug('Last charging value equals current charging value. No refresh needed.')
return False
else:
LOG.info(f'Charging value has changed from {last_charging_value} to {current_charging_value}.')
return True
else:
return True

def __get_charging_station(self, vin) -> ChargingStation | None:
if vin in self.__charging_stations_by_vin:
return self.__charging_stations_by_vin[vin]
else:
return None

def __extract_electric_range(
self,
basic_vehicle_status: BasicVehicleStatus | None,
charge_status: RvsChargeStatus | None
) -> float | None:

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:
return range_elec

return None

@staticmethod
def __parse_electric_range(raw_value) -> float:
if value_in_range(raw_value, 1, 65535):
return float(raw_value) / 10.0
return 0.0
File renamed without changes.
Loading