diff --git a/README.md b/README.md index 9787d36..b3bdf42 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ The key-value pairs in the JSON express the following: | chargeStateTopic | topic indicating the charge state - **required** | | chargingValue | payload that indicates the charging - **required** | | socTopic | topic where the gateway publishes the SoC for the charging station - optional | +| socTsTopic | topic where the gateway publishes the SoC timestamp for the charging station - optional | | rangeTopic | topic where the gateway publishes the range for the charging station - optional | | chargerConnectedTopic | topic indicating that the vehicle is connected to the charging station - optional | | chargerConnectedValue | payload that indicates that the charger is connected - optional | diff --git a/examples/charging-stations.json.sample_openWB_2.0 b/examples/charging-stations.json.sample_openWB_2.0 index 544f93b..854c987 100644 --- a/examples/charging-stations.json.sample_openWB_2.0 +++ b/examples/charging-stations.json.sample_openWB_2.0 @@ -2,8 +2,9 @@ { "chargeStateTopic": "openWB/chargepoint/2/get/charge_state", "chargingValue": "true", - "socTopic": "openWB/set/vehicle/2/get/soc", - "rangeTopic": "openWB/set/vehicle/2/get/range", + "socTopic": "openWB/set/mqtt/vehicle/2/get/soc", + "socTsTopic": "openWB/set/mqtt/vehicle/2/get/soc_timestamp", + "rangeTopic": "openWB/set/mqtt/vehicle/2/get/range", "chargerConnectedTopic": "openWB/chargepoint/2/get/plug_state", "chargerConnectedValue": "true", "vin": "vin1" @@ -11,8 +12,9 @@ { "chargeStateTopic": "openWB/chargepoint/3/get/charge_state", "chargingValue": "true", - "socTopic": "openWB/set/vehicle/3/get/soc", - "rangeTopic": "openWB/set/vehicle/3/get/range", + "socTopic": "openWB/set/mqtt/vehicle/3/get/soc", + "socTsTopic": "openWB/set/mqtt/vehicle/3/get/soc_timestamp", + "rangeTopic": "openWB/set/mqtt/vehicle/3/get/range", "chargerConnectedTopic": "openWB/chargepoint/3/get/plug_state", "chargerConnectedValue": "true", "vin": "vin2" diff --git a/poetry.lock b/poetry.lock index 2899fdd..61814c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "anyio" @@ -217,6 +217,21 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "gmqtt" version = "0.7.0" @@ -675,6 +690,21 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.1.1" @@ -737,6 +767,18 @@ httpx = ">=0.27.0,<0.29.0" pycryptodome = ">=3.20.0,<4.0.0" tenacity = ">=9.0.0,<10.0.0" +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -824,4 +866,4 @@ devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3) [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "f78ad950e86b99428d134a345e435585c43bf2bbef1d53363cb45981c281631c" +content-hash = "4f7392baf839ed8a0b7cc404b94a5e4c29bdf7074521b5c3973965b8e36c7a4a" diff --git a/pyproject.toml b/pyproject.toml index 3c803f3..3b84b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ pytest-asyncio = "^1.2.0" pytest-mock = "^3.14.0" mypy = "^1.15.0" pylint = "^3.3.6" +freezegun = "^1.5.5" [tool.poetry.dependencies] saic-ismart-client-ng = { develop = true } diff --git a/src/configuration/parser.py b/src/configuration/parser.py index 6ceffba..305a833 100644 --- a/src/configuration/parser.py +++ b/src/configuration/parser.py @@ -550,15 +550,16 @@ def __process_charging_stations_file(config: Configuration, json_file: str) -> N for item in data: charge_state_topic = item["chargeStateTopic"] charging_value = item["chargingValue"] + soc_topic = item.get("socTopic", None) + soc_ts_topic = item.get("socTsTopic", None) 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 - ) + charging_station = ChargingStation( + vin, + charge_state_topic, + charging_value, + soc_topic, + soc_ts_topic, + ) if "rangeTopic" in item: charging_station.range_topic = item["rangeTopic"] if "chargerConnectedTopic" in item: diff --git a/src/integrations/openwb/__init__.py b/src/integrations/openwb/__init__.py index ca84f28..b76d00e 100644 --- a/src/integrations/openwb/__init__.py +++ b/src/integrations/openwb/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import logging from typing import TYPE_CHECKING @@ -40,7 +41,7 @@ def update_openwb( vehicle_status, charge_status ) if electric_range is not None and range_topic is not None: - LOG.info("OpenWB Integration published range to %f", range_topic) + LOG.info("OpenWB Integration published range to %s", range_topic) self.__publisher.publish_float( key=range_topic, value=electric_range, @@ -56,3 +57,13 @@ def update_openwb( value=soc, no_prefix=True, ) + + soc_ts_topic = self.__charging_station.soc_ts_topic + soc_ts = int(datetime.datetime.now().timestamp()) + if soc_ts_topic is not None: + LOG.info("OpenWB Integration published SoC timestamp to %s", soc_ts_topic) + self.__publisher.publish_int( + key=soc_ts_topic, + value=soc_ts, + no_prefix=True, + ) diff --git a/src/integrations/openwb/charging_station.py b/src/integrations/openwb/charging_station.py index b02542c..cede1bd 100644 --- a/src/integrations/openwb/charging_station.py +++ b/src/integrations/openwb/charging_station.py @@ -8,11 +8,13 @@ def __init__( charge_state_topic: str, charging_value: str, soc_topic: str | None = None, + soc_ts_topic: str | None = None ) -> None: self.vin: str = vin self.charge_state_topic: str = charge_state_topic self.charging_value: str = charging_value self.soc_topic: str | None = soc_topic + self.soc_ts_topic: str | None = soc_ts_topic self.range_topic: str | None = None self.connected_topic: str | None = None self.connected_value: str | None = None diff --git a/tests/integrations/openwb/test_openwb_integration.py b/tests/integrations/openwb/test_openwb_integration.py index 99d931e..23cdcec 100644 --- a/tests/integrations/openwb/test_openwb_integration.py +++ b/tests/integrations/openwb/test_openwb_integration.py @@ -1,9 +1,11 @@ from __future__ import annotations +from datetime import datetime from typing import Any import unittest from apscheduler.schedulers.blocking import BlockingScheduler +from freezegun import freeze_time import pytest from saic_ismart_client_ng.api.vehicle.schema import VinInfo @@ -25,6 +27,7 @@ RANGE_TOPIC = "/mock/range" CHARGE_STATE_TOPIC = "/mock/charge/state" SOC_TOPIC = "/mock/soc/state" +SOC_TS_TOPIC = "/mock/soc/timestamp" CHARGING_VALUE = "VehicleIsCharging" @@ -46,6 +49,7 @@ def setUp(self) -> None: charge_state_topic=CHARGE_STATE_TOPIC, charging_value=CHARGING_VALUE, soc_topic=SOC_TOPIC, + soc_ts_topic=SOC_TS_TOPIC, ) charging_station.range_topic = RANGE_TOPIC self.openwb_integration = OpenWBIntegration( @@ -53,6 +57,7 @@ def setUp(self) -> None: publisher=self.publisher, ) + @freeze_time("2025-01-01 12:00:00") async def test_update_soc_with_no_bms_data(self) -> None: vehicle_status_resp = get_mock_vehicle_status_resp() result = self.vehicle_state.handle_vehicle_status(vehicle_status_resp) @@ -65,16 +70,22 @@ async def test_update_soc_with_no_bms_data(self) -> None: SOC_TOPIC, float(DRIVETRAIN_SOC_VEHICLE), ) + self.assert_mqtt_topic( + SOC_TS_TOPIC, + int(datetime(2025, 1, 1, 12, 0, 0).timestamp()) + ) self.assert_mqtt_topic( RANGE_TOPIC, DRIVETRAIN_RANGE_VEHICLE, ) expected_topics = { SOC_TOPIC, + SOC_TS_TOPIC, RANGE_TOPIC, } assert expected_topics == set(self.publisher.map.keys()) + @freeze_time("2025-01-01 12:00:00") async def test_update_soc_with_bms_data(self) -> None: vehicle_status_resp = get_mock_vehicle_status_resp() chrg_mgmt_data_resp = get_mock_charge_management_data_resp() @@ -98,8 +109,13 @@ async def test_update_soc_with_bms_data(self) -> None: RANGE_TOPIC, DRIVETRAIN_RANGE_BMS, ) + self.assert_mqtt_topic( + SOC_TS_TOPIC, + int(datetime(2025, 1, 1, 12, 0, 0).timestamp()) + ) expected_topics = { SOC_TOPIC, + SOC_TS_TOPIC, RANGE_TOPIC, } assert expected_topics == set(self.publisher.map.keys())