From 4b1081f76bef97a4ed9aa3418bfcf90b5b3a95aa Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Tue, 19 Nov 2024 10:48:57 -0700 Subject: [PATCH] refactor: adjust charge rate based on claims if available --- custom_components/openevse/__init__.py | 80 ++++++++++- custom_components/openevse/const.py | 8 +- custom_components/openevse/entity.py | 4 + custom_components/openevse/manifest.json | 2 +- custom_components/openevse/number.py | 19 ++- custom_components/openevse/select.py | 10 +- requirements.txt | 2 +- tests/conftest.py | 20 +++ tests/fixtures/config.json | 169 ++++++++++++++--------- tests/test_light.py | 2 +- tests/test_number.py | 16 ++- tests/test_sensor.py | 2 +- 12 files changed, 253 insertions(+), 81 deletions(-) diff --git a/custom_components/openevse/__init__.py b/custom_components/openevse/__init__.py index d08a002..15ae374 100644 --- a/custom_components/openevse/__init__.py +++ b/custom_components/openevse/__init__.py @@ -42,6 +42,7 @@ LIGHT_TYPES, MANAGER, PLATFORMS, + NUMBER_TYPES, SELECT_TYPES, SENSOR_TYPES, TIMEOUT_ERROR, @@ -323,13 +324,16 @@ async def update_sensors(self) -> dict: raise UpdateFailed(error) from error self.parse_sensors() + await self.async_parse_sensors() + _LOGGER.debug("Coordinator data: %s", self._data) return self._data @callback - def websocket_update(self): + async def websocket_update(self): """Trigger processing updated websocket data.""" _LOGGER.debug("Websocket update!") self.parse_sensors() + await self.async_parse_sensors() coordinator = self.hass.data[DOMAIN][self.config.entry_id][COORDINATOR] coordinator.async_set_updated_data(self._data) @@ -373,6 +377,8 @@ def parse_sensors(self) -> None: data.update(_sensor) for select in SELECT_TYPES: # pylint: disable=consider-using-dict-items _sensor = {} + if SELECT_TYPES[select].is_async_value: + continue try: sensor_property = SELECT_TYPES[select].key # Data can be sent as boolean or as 1/0 @@ -389,6 +395,26 @@ def parse_sensors(self) -> None: select, ) data.update(_sensor) + for number in NUMBER_TYPES: # pylint: disable=consider-using-dict-items + _sensor = {} + if NUMBER_TYPES[number].is_async_value: + continue + try: + sensor_property = NUMBER_TYPES[number].key + # Data can be sent as boolean or as 1/0 + _sensor[number] = getattr(self._manager, sensor_property) + _LOGGER.debug( + "number: %s sensor_property: %s value %s", + number, + sensor_property, + _sensor[number], + ) + except (ValueError, KeyError): + _LOGGER.info( + "Could not update status for %s", + number, + ) + data.update(_sensor) for light in LIGHT_TYPES: # pylint: disable=consider-using-dict-items _sensor = {} try: @@ -397,7 +423,7 @@ def parse_sensors(self) -> None: _sensor[light] = getattr(self._manager, sensor_property) _LOGGER.debug( "light: %s sensor_property: %s value %s", - select, + light, sensor_property, _sensor[light], ) @@ -408,7 +434,55 @@ def parse_sensors(self) -> None: ) data.update(_sensor) _LOGGER.debug("DEBUG: %s", data) - self._data = data + self._data.update(data) + + async def async_parse_sensors(self) -> None: + """Parse updated sensor data using async.""" + data = {} + for select in SELECT_TYPES: # pylint: disable=consider-using-dict-items + _sensor = {} + if not SELECT_TYPES[select].is_async_value: + continue + try: + sensor_property = SELECT_TYPES[select].key + sensor_value = SELECT_TYPES[select].value + # Data can be sent as boolean or as 1/0 + _sensor[select] = await getattr(self._manager, sensor_value) + _LOGGER.debug( + "select: %s sensor_property: %s value %s", + select, + sensor_property, + _sensor[select], + ) + except (ValueError, KeyError): + _LOGGER.info( + "Could not update status for %s", + select, + ) + data.update(_sensor) + for number in NUMBER_TYPES: # pylint: disable=consider-using-dict-items + _sensor = {} + if not NUMBER_TYPES[number].is_async_value: + continue + try: + sensor_property = NUMBER_TYPES[number].key + sensor_value = NUMBER_TYPES[number].value + # Data can be sent as boolean or as 1/0 + _sensor[number] = await getattr(self._manager, sensor_value) + _LOGGER.debug( + "number: %s sensor_property: %s value %s", + number, + sensor_property, + _sensor[number], + ) + except (ValueError, KeyError): + _LOGGER.info( + "Could not update status for %s", + number, + ) + data.update(_sensor) + _LOGGER.debug("DEBUG: %s", data) + self._data.update(data) async def send_command(handler, command) -> None: diff --git a/custom_components/openevse/const.py b/custom_components/openevse/const.py index af709fe..72ad158 100644 --- a/custom_components/openevse/const.py +++ b/custom_components/openevse/const.py @@ -420,11 +420,13 @@ # ), "max_current_soft": OpenEVSESelectEntityDescription( name="Charge Rate", - key="current_capacity", + key="max_current_soft", default_options=None, command="set_current", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, + is_async_value=True, + value="async_charge_current", ), "charge_mode": OpenEVSESelectEntityDescription( name="Divert Mode", @@ -504,13 +506,15 @@ NUMBER_TYPES: Final[dict[str, OpenEVSENumberEntityDescription]] = { "max_current_soft": OpenEVSENumberEntityDescription( name="Charge Rate", - key="current_capacity", + key="max_current_soft", default_options=None, command="set_current", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=NumberDeviceClass.CURRENT, mode=NumberMode.AUTO, + is_async_value=True, + value="async_charge_current", ), } diff --git a/custom_components/openevse/entity.py b/custom_components/openevse/entity.py index 3eb7901..8f2d58e 100644 --- a/custom_components/openevse/entity.py +++ b/custom_components/openevse/entity.py @@ -16,6 +16,8 @@ class OpenEVSESelectEntityDescription(SelectEntityDescription): command: str | None = None default_options: list | None = None + is_async_value: bool | None = False + value: int | None = None @dataclass @@ -33,6 +35,8 @@ class OpenEVSENumberEntityDescription(NumberEntityDescription): default_options: list | None = None min: int | None = None max: int | None = None + is_async_value: bool | None = False + value: int | None = None @dataclass diff --git a/custom_components/openevse/manifest.json b/custom_components/openevse/manifest.json index ba5b0c4..372a60d 100644 --- a/custom_components/openevse/manifest.json +++ b/custom_components/openevse/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/firstof9/openevse/issues", "loggers": ["openevsehttp"], - "requirements": ["python-openevse-http==0.1.63"], + "requirements": ["python-openevse-http==0.1.65"], "version": "0.0.0-dev", "zeroconf": ["_openevse._tcp.local."] } diff --git a/custom_components/openevse/number.py b/custom_components/openevse/number.py index e5f2262..be0a856 100644 --- a/custom_components/openevse/number.py +++ b/custom_components/openevse/number.py @@ -11,7 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_NAME, COORDINATOR, DOMAIN, NUMBER_TYPES, MANAGER -from .entity import OpenEVSESelectEntityDescription +from .entity import OpenEVSENumberEntityDescription from . import ( OpenEVSEManager, @@ -48,7 +48,7 @@ def __init__( self, config_entry: ConfigEntry, coordinator: OpenEVSEUpdateCoordinator, - description: OpenEVSESelectEntityDescription, + description: OpenEVSENumberEntityDescription, manager: OpenEVSEManager, ) -> None: """Initialize a ZwaveNumberEntity entity.""" @@ -62,6 +62,7 @@ def __init__( self._command = description.command self._min = description.min self._max = description.max + self._value = description.value self._manager = manager # Entity class attributes self._attr_name = f"{config_entry.data[CONF_NAME]} {self._name}" @@ -78,6 +79,17 @@ def device_info(self): } return info + @property + def available(self) -> bool: + """Return if entity is available.""" + data = self.coordinator.data + attributes = ("charge_mode", "divert_active") + if set(attributes).issubset(data.keys()) and self._type == "max_current_soft": + if data["divert_active"] and data["charge_mode"] == "eco": + _LOGGER.debug("Disabling %s due to PV Divert being active.", self._attr_name) + return False + return self.coordinator.last_update_success + @property def native_min_value(self) -> float: """Return the minimum value.""" @@ -94,9 +106,10 @@ def native_max_value(self) -> float: def native_value(self) -> float | None: """Return the entity value.""" data = self.coordinator.data + value = None if self._type in data and data is not None: value = data[self._type] - _LOGGER.debug("Select [%s] updated value: %s", self._type, value) + _LOGGER.debug("Number [%s] updated value: %s", self._type, value) return None if value is None else float(value) @property diff --git a/custom_components/openevse/select.py b/custom_components/openevse/select.py index f8c5799..dbe244c 100644 --- a/custom_components/openevse/select.py +++ b/custom_components/openevse/select.py @@ -104,13 +104,17 @@ async def async_select_option(self, option: Any) -> None: @property def available(self) -> bool: """Return if entity is available.""" - if self._type not in self.coordinator.data: - return False + data = self.coordinator.data + attributes = ("charge_mode", "divert_active") + if set(attributes).issubset(data.keys()) and self._type == "max_current_soft": + if data["divert_active"] and data["charge_mode"] == "eco": + _LOGGER.debug("Disabling %s due to PV Divert being active.", self._attr_name) + return False return self.coordinator.last_update_success def get_options(self) -> list[str]: """Return a set of selectable options.""" - if self._type == "current_capacity": + if self._type == "max_current_soft": amps_min = self.coordinator.data["min_amps"] amps_max = self.coordinator.data["max_amps"] + 1 # pylint: disable-next=consider-using-generator diff --git a/requirements.txt b/requirements.txt index d976b76..aea316e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -python-openevse-http==0.1.63 \ No newline at end of file +python-openevse-http==0.1.65 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5aa374b..3c9a0e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,8 @@ TEST_URL_STATUS = "http://openevse.test.tld/status" TEST_URL_CONFIG = "http://openevse.test.tld/config" TEST_URL_OVERRIDE = "http://openevse.test.tld/override" +TEST_URL_CLAIMS = "http://openevse.test.tld/claims" +TEST_URL_CLAIMS_TARGET = "http://openevse.test.tld/claims/target" TEST_URL_RAPI = "http://openevse.test.tld/r" TEST_URL_WS = "ws://openevse.test.tld/ws" TEST_TLD = "openevse.test.tld" @@ -112,6 +114,12 @@ def test_charger(mock_aioclient): body='{ "msg": "OK" }', repeat=True, ) + mock_aioclient.get( + TEST_URL_CLAIMS_TARGET, + status=200, + body='{"properties":{"state":"disabled","charge_current":28,"max_current":23,"auto_release":false},"claims":{"state":65540,"charge_current":65537,"max_current":65548}}', + repeat=True, + ) return main.OpenEVSE(TEST_TLD) @@ -142,6 +150,12 @@ def test_charger_bad_serial(mock_aioclient): body=load_fixture("github.json"), repeat=True, ) + mock_aioclient.get( + TEST_URL_CLAIMS_TARGET, + status=200, + body='{"properties":{"state":"disabled","charge_current":28,"max_current":23,"auto_release":false},"claims":{"state":65540,"charge_current":65537,"max_current":65548}}', + repeat=True, + ) return main.OpenEVSE(TEST_TLD) @@ -177,6 +191,12 @@ def test_charger_bad_post(mock_aioclient): body=load_fixture("github.json"), repeat=True, ) + mock_aioclient.get( + TEST_URL_CLAIMS_TARGET, + status=200, + body='{"properties":{"state":"disabled","charge_current":28,"max_current":23,"auto_release":false},"claims":{"state":65540,"charge_current":65537,"max_current":65548}}', + repeat=True, + ) return main.OpenEVSE(TEST_TLD) diff --git a/tests/fixtures/config.json b/tests/fixtures/config.json index 28dea6a..19c0c14 100644 --- a/tests/fixtures/config.json +++ b/tests/fixtures/config.json @@ -1,67 +1,108 @@ { - "firmware": "7.1.3", - "protocol": "-", - "espflash": 4194304, - "wifi_serial": "1234567890AB", - "version": "5.1.0", - "diodet": 0, - "gfcit": 0, - "groundt": 0, - "relayt": 0, - "ventt": 0, - "tempt": 0, - "service": 2, - "scale": 220, - "offset": 0, - "max_current_soft": 48, - "min_current_hard": 6, - "max_current_hard": 48, - "mqtt_supported_protocols": [ + "mqtt_supported_protocols": [ "mqtt", "mqtts" - ], - "http_supported_protocols": [ - "http", - "https" - ], - "ssid": "Datanode-IoT", - "pass": "_DUMMY_PASSWORD", - "www_username": "", - "www_password": "", - "hostname": "openevse-7b2c", - "sntp_hostname": "0.us.pool.ntp.org", - "time_zone": "America/Phoenix|MST7", - "emoncms_server": "https://emoncms.collective.lan/", - "emoncms_node": "openevse", - "emoncms_apikey": "_DUMMY_PASSWORD", - "emoncms_fingerprint": "", - "mqtt_server": "192.168.1.198", - "mqtt_port": 1883, - "mqtt_topic": "openevse", - "mqtt_user": "devices", - "mqtt_pass": "_DUMMY_PASSWORD", - "mqtt_solar": "", - "mqtt_grid_ie": "home-assistant/power/watts", - "mqtt_vrms": "home-assistant/solar/watts", - "mqtt_announce_topic": "openevse/announce/7b2c", - "ohm": "", - "divert_PV_ratio": 1.1, - "divert_attack_smoothing_factor": 0.4, - "divert_decay_smoothing_factor": 0.05, - "divert_min_charge_time": 600, - "tesla_username": "", - "tesla_password": "", - "tesla_vehidx": -1, - "led_brightness": 128, - "flags": 522, - "emoncms_enabled": false, - "mqtt_enabled": true, - "mqtt_reject_unauthorized": true, - "ohm_enabled": false, - "sntp_enabled": true, - "tesla_enabled": false, - "divert_enabled": true, - "pause_uses_disabled": false, - "mqtt_protocol": "mqtt", - "charge_mode": "fast" - } + ], + "http_supported_protocols": [ + "http" + ], + "buildenv": "openevse_wifi_v1", + "version": "v5.1.2", + "wifi_serial": "9C9C1FE57B2C", + "protocol": "-", + "espinfo": "ESP32r1 2 core WiFi BLE BT", + "espflash": 4194304, + "firmware": "7.1.3", + "evse_serial": "", + "diode_check": true, + "gfci_check": true, + "ground_check": true, + "relay_check": true, + "vent_check": true, + "temp_check": true, + "max_current_soft": 48, + "service": 2, + "scale": 220, + "offset": 0, + "min_current_hard": 6, + "max_current_hard": 48, + "ssid": "Datanode-IoT", + "pass": "_DUMMY_PASSWORD", + "ap_ssid": "", + "ap_pass": "", + "lang": "en", + "www_username": "", + "www_password": "", + "www_certificate_id": "", + "hostname": "openevse-7b2c", + "sntp_hostname": "0.us.pool.ntp.org", + "time_zone": "America/Phoenix|MST7", + "limit_default_type": "", + "limit_default_value": 0, + "emoncms_server": "http://emoncms.collective.lan/", + "emoncms_node": "openevse", + "emoncms_apikey": "_DUMMY_PASSWORD", + "emoncms_fingerprint": "", + "mqtt_server": "192.168.1.198", + "mqtt_port": 1883, + "mqtt_topic": "openevse", + "mqtt_user": "devices", + "mqtt_pass": "_DUMMY_PASSWORD", + "mqtt_certificate_id": "", + "mqtt_solar": "", + "mqtt_grid_ie": "home-assistant/power/watts", + "mqtt_vrms": "", + "mqtt_live_pwr": "", + "mqtt_vehicle_soc": "", + "mqtt_vehicle_range": "", + "mqtt_vehicle_eta": "", + "mqtt_announce_topic": "openevse/announce/7b2c", + "ocpp_server": "", + "ocpp_chargeBoxId": "", + "ocpp_authkey": "", + "ocpp_idtag": "DefaultIdTag", + "ohm": "", + "divert_type": 1, + "divert_PV_ratio": 1.1, + "divert_attack_smoothing_time": 20, + "divert_decay_smoothing_time": 600, + "divert_min_charge_time": 600, + "current_shaper_max_pwr": 0, + "current_shaper_smoothing_time": 60, + "current_shaper_min_pause_time": 300, + "current_shaper_data_maxinterval": 120, + "vehicle_data_src": 0, + "tesla_access_token": "", + "tesla_refresh_token": "", + "tesla_created_at": 18446744073709551615, + "tesla_expires_in": 18446744073709551615, + "tesla_vehicle_id": "", + "rfid_storage": "", + "led_brightness": 64, + "scheduler_start_window": 600, + "flags": 102761482, + "flags_changed": 48242178, + "emoncms_enabled": false, + "mqtt_enabled": true, + "mqtt_reject_unauthorized": true, + "mqtt_retained": false, + "ohm_enabled": false, + "sntp_enabled": true, + "tesla_enabled": false, + "divert_enabled": false, + "current_shaper_enabled": false, + "pause_uses_disabled": false, + "mqtt_vehicle_range_miles": false, + "ocpp_enabled": false, + "ocpp_auth_auto": false, + "ocpp_auth_offline": false, + "ocpp_suspend_evse": false, + "ocpp_energize_plug": false, + "rfid_enabled": false, + "factory_write_lock": true, + "is_threephase": false, + "wizard_passed": true, + "default_state": true, + "mqtt_protocol": "mqtt", + "charge_mode": "eco" +} \ No newline at end of file diff --git a/tests/test_light.py b/tests/test_light.py index 624dec6..861b258 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -56,7 +56,7 @@ async def test_light( state = hass.states.get(entity_id) assert state assert state.state == "on" - assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_BRIGHTNESS] == 64 mock_aioclient.post( TEST_URL_CONFIG, diff --git a/tests/test_number.py b/tests/test_number.py index f9b4b7c..eac91d5 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -50,7 +50,7 @@ async def test_number( state = hass.states.get(entity_id) assert state - assert state.state == "40.0" + assert state.state == "28.0" servicedata = { "entity_id": entity_id, @@ -64,7 +64,7 @@ async def test_number( with caplog.at_level(logging.DEBUG): coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - coordinator._data["current_capacity"] = 21 + coordinator._data["max_current_soft"] = 21 updated_data = coordinator._data coordinator.async_set_updated_data(updated_data) await hass.async_block_till_done() @@ -78,3 +78,15 @@ async def test_number( state = hass.states.get(entity_id) assert state assert state.state == "30.0" + + with caplog.at_level(logging.DEBUG): + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator._data["divert_active"] = True + updated_data = coordinator._data + coordinator.async_set_updated_data(updated_data) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == 'unavailable' + assert "Disabling openevse Charge Rate due to PV Divert being active." in caplog.text diff --git a/tests/test_sensor.py b/tests/test_sensor.py index ec28c88..b346d0e 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -48,7 +48,7 @@ async def test_sensors( state = hass.states.get("sensor.openevse_wifi_firmware_version") assert state - assert state.state == "5.1.0" + assert state.state == "v5.1.2" state = hass.states.get("sensor.openevse_charge_time_elapsed") assert state assert state.state == "4.1"