From fdd76c7c7efbf634f1c3bce462ac6128c53924a3 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Sun, 22 Sep 2024 12:35:25 +0100 Subject: [PATCH] Givenergy stable release --- custom_components/givenergy_local/__init__.py | 2 - .../givenergy_local/binary_sensor.py | 21 ++-- custom_components/givenergy_local/const.py | 5 +- .../givenergy_local/coordinator.py | 5 +- .../givenergy_modbus/client/client.py | 34 +----- .../givenergy_modbus/client/commands.py | 64 +--------- .../givenergy_modbus/model/inverter.py | 14 --- .../givenergy_modbus/model/plant.py | 2 +- .../givenergy_modbus/model/register.py | 11 +- custom_components/givenergy_local/select.py | 78 ------------- custom_components/givenergy_local/time.py | 110 ------------------ 11 files changed, 33 insertions(+), 313 deletions(-) delete mode 100644 custom_components/givenergy_local/select.py delete mode 100644 custom_components/givenergy_local/time.py diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 62973dbd..9b75c4e1 100755 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -14,9 +14,7 @@ Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR, - Platform.SELECT, Platform.SWITCH, - Platform.TIME, ] diff --git a/custom_components/givenergy_local/binary_sensor.py b/custom_components/givenergy_local/binary_sensor.py index ee06320c..d4e113d0 100644 --- a/custom_components/givenergy_local/binary_sensor.py +++ b/custom_components/givenergy_local/binary_sensor.py @@ -106,12 +106,15 @@ def _schedule_next_update(self) -> None: Work out when we next need to update the state due to the current time passing over the start of end time of the slot. """ + if (slot := self.slot) is None: + return + now = dt.now() # Get slot details current_time = now.time() - start = self.slot.start - end = self.slot.end + start = slot.start + end = slot.end # We don't need to be notified about entering/leaving an undefined slot if start == end: @@ -146,7 +149,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() @property - def slot(self) -> TimeSlot: + def slot(self) -> TimeSlot | None: """Get the slot definition.""" slot: TimeSlot = self.data.dict().get(self.entity_description.key) return slot @@ -154,14 +157,16 @@ def slot(self) -> TimeSlot: @property def is_on(self) -> bool | None: """Determine whether we're currently within the slot.""" - now: time = dt.now().time() - is_on: bool = self.slot.start <= now < self.slot.end - return is_on + if slot := self.slot: + now: time = dt.now().time() + is_on: bool = slot.start <= now < self.slot.end + return is_on + return False @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Attach charge slot configuration.""" return { - "start": self.slot.start.strftime("%H:%M"), - "end": self.slot.end.strftime("%H:%M"), + "start": self.slot.start.strftime("%H:%M") if self.slot else None, + "end": self.slot.end.strftime("%H:%M") if self.slot else None, } diff --git a/custom_components/givenergy_local/const.py b/custom_components/givenergy_local/const.py index 3734c14a..cdf7d028 100755 --- a/custom_components/givenergy_local/const.py +++ b/custom_components/givenergy_local/const.py @@ -1,6 +1,6 @@ """Constants for the GivEnergy integration.""" -from enum import StrEnum +from enum import Enum from logging import Logger, getLogger DOMAIN = "givenergy_local" @@ -14,7 +14,7 @@ BATTERY_NOMINAL_VOLTAGE = 51.2 -class Icon(StrEnum): +class Icon(str, Enum): """Icon styles.""" PV = "mdi:solar-power" @@ -24,7 +24,6 @@ class Icon(StrEnum): BATTERY_TEMPERATURE = "mdi:thermometer" BATTERY_MINUS = "mdi:battery-minus" BATTERY_PLUS = "mdi:battery-plus" - BATTERY_PAUSE = "mdi:battery-clock" INVERTER = "mdi:flash" GRID_IMPORT = "mdi:transmission-tower-export" GRID_EXPORT = "mdi:transmission-tower-import" diff --git a/custom_components/givenergy_local/coordinator.py b/custom_components/givenergy_local/coordinator.py index 1e951e60..7967a246 100644 --- a/custom_components/givenergy_local/coordinator.py +++ b/custom_components/givenergy_local/coordinator.py @@ -90,10 +90,7 @@ async def _async_update_data(self) -> Plant: """Fetch data from the inverter.""" if not self.client.connected: await self.client.connect() - await self.client.detect_plant() - self.require_full_refresh = False - self.last_full_refresh = datetime.utcnow() - return self.client.plant + self.require_full_refresh = True if self.last_full_refresh < (datetime.utcnow() - _FULL_REFRESH_INTERVAL): self.require_full_refresh = True diff --git a/custom_components/givenergy_local/givenergy_modbus/client/client.py b/custom_components/givenergy_local/givenergy_modbus/client/client.py index 633022c8..5800aad7 100644 --- a/custom_components/givenergy_local/givenergy_modbus/client/client.py +++ b/custom_components/givenergy_local/givenergy_modbus/client/client.py @@ -75,35 +75,6 @@ async def connect(self) -> None: self.connected = True _logger.info("Connection established to %s:%d", self.host, self.port) - async def detect_plant(self, timeout: int = 1, retries: int = 3) -> None: - """Detect inverter capabilities that influence how subsequent requests are made.""" - _logger.info("Detectig plant") - - # Refresh the core set of registers that work across all inverters - await self.refresh_plant(True, timeout=timeout, retries=retries) - - # Use that to detect the number of batteries - self.plant.detect_batteries() - _logger.info("Batteries detected: %d", self.plant.number_batteries) - - # Some devices support additional registers - # When unsupported, devices appear to simple ignore requests - possible_additional_holding_registers = [300] - for hr in possible_additional_holding_registers: - try: - reqs = commands.refresh_additional_holding_registers(hr) - await self.execute(reqs, timeout=timeout, retries=retries) - _logger.info( - "Detected additional holding register support (base_register=%d)", - hr, - ) - self.plant.additional_holding_registers.append(hr) - except asyncio.TimeoutError: - _logger.debug( - "Inverter did not respond to holder register query (base_register=%d)", - hr, - ) - async def close(self) -> None: """Disconnect from the remote host and clean up tasks and queues.""" if not self.connected: @@ -153,6 +124,10 @@ async def refresh_plant( full_refresh, self.plant.number_batteries, max_batteries ) await self.execute(reqs, timeout=timeout, retries=retries) + + if full_refresh: + self.plant.detect_batteries() + return self.plant async def watch_plant( @@ -167,6 +142,7 @@ async def watch_plant( """Refresh data about the Plant.""" await self.connect() await self.refresh_plant(True, max_batteries=max_batteries) + self.plant.detect_batteries() while True: if handler: handler() diff --git a/custom_components/givenergy_local/givenergy_modbus/client/commands.py b/custom_components/givenergy_local/givenergy_modbus/client/commands.py index 2aa908dd..e517852e 100644 --- a/custom_components/givenergy_local/givenergy_modbus/client/commands.py +++ b/custom_components/givenergy_local/givenergy_modbus/client/commands.py @@ -1,15 +1,11 @@ """High-level methods for interacting with a remote system.""" from datetime import datetime -from time import time from typing import Optional from typing_extensions import deprecated # type: ignore[attr-defined] from custom_components.givenergy_local.givenergy_modbus.model import TimeSlot -from custom_components.givenergy_local.givenergy_modbus.model.inverter import ( - BatteryPauseMode, -) from custom_components.givenergy_local.givenergy_modbus.pdu import ( ReadHoldingRegistersRequest, ReadInputRegistersRequest, @@ -47,30 +43,10 @@ class RegisterMap: BATTERY_DISCHARGE_MIN_POWER_RESERVE = 114 CHARGE_TARGET_SOC = 116 REBOOT = 163 - BATTERY_PAUSE_MODE = 318 - BATTERY_PAUSE_SLOT_START = 319 - BATTERY_PAUSE_SLOT_END = 320 - - -def refresh_additional_holding_registers( - base_register: int, -) -> list[TransparentRequest]: - """Requests one specific set of holding registers. - - This is intended to be used in cases where registers may or may not be present, - depending on device capabilities.""" - return [ - ReadHoldingRegistersRequest( - base_register=base_register, register_count=60, slave_address=0x32 - ) - ] def refresh_plant_data( - complete: bool, - number_batteries: int = 1, - max_batteries: int = 5, - additional_holding_registers: Optional[list[int]] = None, + complete: bool, number_batteries: int = 1, max_batteries: int = 5 ) -> list[TransparentRequest]: """Refresh plant data.""" requests: list[TransparentRequest] = [ @@ -102,11 +78,6 @@ def refresh_plant_data( base_register=120, register_count=60, slave_address=0x32 ) ) - - if additional_holding_registers: - for hr in additional_holding_registers: - requests.extend(refresh_additional_holding_registers(hr)) - number_batteries = max_batteries for i in range(number_batteries): requests.append( @@ -238,13 +209,6 @@ def set_battery_power_reserve(val: int) -> list[TransparentRequest]: ] -def set_battery_pause_mode(val: BatteryPauseMode) -> list[TransparentRequest]: - """Set the battery pause mode.""" - if not 0 <= val <= 3: - raise ValueError(f"Battery pause mode ({val}) must be in [0-3]") - return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_PAUSE_MODE, val)] - - def _set_charge_slot( discharge: bool, idx: int, slot: Optional[TimeSlot] ) -> list[TransparentRequest]: @@ -264,32 +228,6 @@ def _set_charge_slot( ] -def set_pause_slot_start(start: Optional[time]) -> list[TransparentRequest]: - if start: - return [ - WriteHoldingRegisterRequest( - RegisterMap.BATTERY_PAUSE_SLOT_START, int(start.strftime("%H%M")) - ), - ] - else: - return [ - WriteHoldingRegisterRequest(RegisterMap.BATTERY_PAUSE_SLOT_START, 0), - ] - - -def set_pause_slot_end(end: Optional[time]) -> list[TransparentRequest]: - if end: - return [ - WriteHoldingRegisterRequest( - RegisterMap.BATTERY_PAUSE_SLOT_END, int(end.strftime("%H%M")) - ), - ] - else: - return [ - WriteHoldingRegisterRequest(RegisterMap.BATTERY_PAUSE_SLOT_END, 0), - ] - - def set_charge_slot_1(timeslot: TimeSlot) -> list[TransparentRequest]: """Set first charge slot start & end times.""" return _set_charge_slot(False, 1, timeslot) diff --git a/custom_components/givenergy_local/givenergy_modbus/model/inverter.py b/custom_components/givenergy_local/givenergy_modbus/model/inverter.py index e6201d1a..b05d9bc6 100644 --- a/custom_components/givenergy_local/givenergy_modbus/model/inverter.py +++ b/custom_components/givenergy_local/givenergy_modbus/model/inverter.py @@ -96,15 +96,6 @@ class BatteryType(IntEnum): LITHIUM = 1 -class BatteryPauseMode(IntEnum): - """Battery pause mode.""" - - DISABLED = 0 - PAUSE_CHARGE = 1 - PAUSE_DISCHARGE = 2 - PAUSE_BOTH = 3 - - class PowerFactorFunctionModel(IntEnum): """Power Factor function model.""" @@ -242,11 +233,6 @@ class InverterRegisterGetter(RegisterGetter): "enable_standard_self_consumption_logic": Def(C.bool, None, HR(199)), "cmd_bms_flash_update": Def(C.bool, None, HR(200)), # - # Holding Registers, block 300-359 - # - "battery_pause_mode": Def(C.uint16, BatteryPauseMode, HR(318)), - "battery_pause_slot_1": Def(C.timeslot, None, HR(319), HR(320)), - # # Holding Registers, block 4080-4139 # "pv_power_setting": Def(C.uint32, None, HR(4107), HR(4108)), diff --git a/custom_components/givenergy_local/givenergy_modbus/model/plant.py b/custom_components/givenergy_local/givenergy_modbus/model/plant.py index cb2c536d..cfb16d06 100644 --- a/custom_components/givenergy_local/givenergy_modbus/model/plant.py +++ b/custom_components/givenergy_local/givenergy_modbus/model/plant.py @@ -24,7 +24,6 @@ class Plant(GivEnergyBaseModel): """Representation of a complete GivEnergy plant.""" register_caches: dict[int, RegisterCache] = {} - additional_holding_registers: list[int] = [] inverter_serial_number: str = "" data_adapter_serial_number: str = "" number_batteries: int = 0 @@ -94,6 +93,7 @@ def detect_batteries(self) -> None: assert Battery.from_orm(self.register_caches[i + 0x32]).is_valid() except (KeyError, AssertionError): break + _logger.debug("Updating connected battery count to %d", i) self.number_batteries = i @property diff --git a/custom_components/givenergy_local/givenergy_modbus/model/register.py b/custom_components/givenergy_local/givenergy_modbus/model/register.py index a841ff0a..ce5da3e8 100644 --- a/custom_components/givenergy_local/givenergy_modbus/model/register.py +++ b/custom_components/givenergy_local/givenergy_modbus/model/register.py @@ -43,8 +43,17 @@ def uint32(high_val: int, low_val: int) -> int: return (high_val << 16) + low_val @staticmethod - def timeslot(start_time: int, end_time: int) -> TimeSlot: + def timeslot(start_time: int, end_time: int) -> Optional[TimeSlot]: """Interpret register as a time slot.""" + if start_time == 60 or end_time == 60: + # Probably due to the inverter holding an invalid value in a timeslot. + # Real life example register values include: + # - [0, 60] + # - [60, 2359] + # Clearly '60' is the problem value in such cases. This seems to correspond to the + # GivEnergy portal displaying '--:--', which we can only assume means 'undefined'. + return None + if start_time is not None and end_time is not None: return TimeSlot.from_repr(start_time, end_time) diff --git a/custom_components/givenergy_local/select.py b/custom_components/givenergy_local/select.py deleted file mode 100644 index 82e18cea..00000000 --- a/custom_components/givenergy_local/select.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Select platform.""" - -from __future__ import annotations - -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from custom_components.givenergy_local.givenergy_modbus.client.commands import ( - set_battery_pause_mode, -) - -from . import GivEnergyUpdateCoordinator -from .const import DOMAIN, Icon -from .entity import InverterEntity -from .givenergy_modbus.model.inverter import BatteryPauseMode - -_BATTERY_PAUSE_MODE_OPTIONS = { - BatteryPauseMode.DISABLED: "Not Paused", - BatteryPauseMode.PAUSE_CHARGE: "Pause Charge", - BatteryPauseMode.PAUSE_DISCHARGE: "Pause Discharge", - BatteryPauseMode.PAUSE_BOTH: "Pause Charge & Discharge", -} - - -_BATTERY_PAUSE_MODE_DESCRIPTION = SelectEntityDescription( - key="battery_pause_mode", - options=list(_BATTERY_PAUSE_MODE_OPTIONS.values()), - icon=Icon.BATTERY_PAUSE, - name="Battery Pause Mode", -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up select entities.""" - coordinator: GivEnergyUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities: list[SelectEntity] = [] - - if coordinator.data.inverter.battery_pause_mode is not None: - entities.append( - BatteryPauseModeSelect( - coordinator, - config_entry, - _BATTERY_PAUSE_MODE_DESCRIPTION, - ) - ) - async_add_entities(entities) - - -class BatteryPauseModeSelect(InverterEntity, SelectEntity): - """Bubbles selection for spa devices that support 3 levels.""" - - def __init__( - self, - coordinator: GivEnergyUpdateCoordinator, - config_entry: ConfigEntry, - description: SelectEntityDescription, - ) -> None: - """Initialize thermostat.""" - super().__init__(coordinator, config_entry) - self.entity_description = description - self._attr_unique_id = f"{self.data.serial_number}_{description.key}" - - @property - def current_option(self) -> str | None: - """Return the selected entity option.""" - return _BATTERY_PAUSE_MODE_OPTIONS.get(self.data.battery_pause_mode) - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - for val in BatteryPauseMode: - if option == _BATTERY_PAUSE_MODE_OPTIONS[val]: - await self.coordinator.execute(set_battery_pause_mode(val)) diff --git a/custom_components/givenergy_local/time.py b/custom_components/givenergy_local/time.py deleted file mode 100644 index 6d056fd9..00000000 --- a/custom_components/givenergy_local/time.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Home Assistant sensor descriptions.""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import time - -from typing import Awaitable, Callable - -from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType - -from .const import DOMAIN, Icon -from .coordinator import GivEnergyUpdateCoordinator -from .entity import InverterEntity -from .givenergy_modbus.client.commands import set_pause_slot_end, set_pause_slot_start -from .givenergy_modbus.model import TimeSlot - - -@dataclass(frozen=True) -class MappedTimeRequiredKeys: - """Mixin for required keys.""" - - ge_modbus_key: str - get_fn: Callable[[TimeSlot], time] - set_fn: Callable[[GivEnergyUpdateCoordinator, time], Awaitable[None]] - - -@dataclass(frozen=True) -class MappedTimeEntityDescription(TimeEntityDescription, MappedTimeRequiredKeys): - """Sensor description providing a lookup key to obtain the value.""" - - -_GENERIC_ENTITIES: list[MappedTimeEntityDescription] = [] - -_BATTERY_PAUSE_ENTITIES = [ - MappedTimeEntityDescription( - key="battery_pause_slot_1_start", - name="Battery Pause Start", - icon=Icon.BATTERY_PAUSE, - ge_modbus_key="battery_pause_slot_1", - get_fn=lambda t: t.start, - set_fn=lambda c, t: c.execute(set_pause_slot_start(t)), - ), - MappedTimeEntityDescription( - key="battery_pause_slot_1_end", - name="Battery Pause End", - icon=Icon.BATTERY_PAUSE, - ge_modbus_key="battery_pause_slot_1", - get_fn=lambda t: t.end, - set_fn=lambda c, t: c.execute(set_pause_slot_end(t)), - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add sensors for passed config_entry in HA.""" - coordinator: GivEnergyUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - - entities = [] - entities.extend( - [ - InverterTimeslotSensor(coordinator, config_entry, entity_description) - for entity_description in _GENERIC_ENTITIES - ] - ) - - if coordinator.data.inverter.battery_pause_mode is not None: - entities.extend( - [ - InverterTimeslotSensor(coordinator, config_entry, entity_description) - for entity_description in _BATTERY_PAUSE_ENTITIES - ] - ) - - async_add_entities(entities) - - -class InverterTimeslotSensor(InverterEntity, TimeEntity): - """A sensor that derives its value from the register values fetched from the inverter.""" - - entity_description: MappedTimeEntityDescription - - def __init__( - self, - coordinator: GivEnergyUpdateCoordinator, - config_entry: ConfigEntry, - entity_description: MappedTimeEntityDescription, - ) -> None: - """Initialize a sensor based on an entity description.""" - super().__init__(coordinator, config_entry) - self._attr_unique_id = f"{self.data.serial_number}_{entity_description.key}" - self.entity_description = entity_description - - @property - def native_value(self) -> StateType: - """Return the register value as referenced by the 'key' property of the associated entity description.""" - if slot := self.data.dict().get(self.entity_description.ge_modbus_key): - return self.entity_description.get_fn(slot) - - async def async_set_value(self, value: time) -> None: - """Update the current value.""" - self.entity_description.set_fn(self.coordinator, value)