diff --git a/custom_components/givenergy_local/coordinator.py b/custom_components/givenergy_local/coordinator.py index 3aad27a..fb330d1 100644 --- a/custom_components/givenergy_local/coordinator.py +++ b/custom_components/givenergy_local/coordinator.py @@ -4,7 +4,7 @@ import asyncio from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from logging import getLogger from homeassistant.core import HomeAssistant @@ -91,9 +91,12 @@ async def _async_update_data(self) -> Plant: if not self.client.connected: await self.client.connect() await self.client.detect_plant() - self.require_full_refresh = True + self.last_full_refresh = datetime.now(UTC) + + # Detection performs a full refresh - no need to trigger another one now + return self.client.plant - if self.last_full_refresh < (datetime.utcnow() - _FULL_REFRESH_INTERVAL): + if self.last_full_refresh < (datetime.now(UTC) - _FULL_REFRESH_INTERVAL): self.require_full_refresh = True # Allow a few attempts to pull back valid data. @@ -135,7 +138,7 @@ async def _async_update_data(self) -> Plant: if self.require_full_refresh: self.require_full_refresh = False - self.last_full_refresh = datetime.utcnow() + self.last_full_refresh = datetime.now(UTC) return plant raise UpdateFailed( diff --git a/custom_components/givenergy_local/givenergy_modbus/client/client.py b/custom_components/givenergy_local/givenergy_modbus/client/client.py index ee77d26..d5fd870 100644 --- a/custom_components/givenergy_local/givenergy_modbus/client/client.py +++ b/custom_components/givenergy_local/givenergy_modbus/client/client.py @@ -4,7 +4,9 @@ from asyncio import Future, Queue, StreamReader, StreamWriter, Task from typing import Callable, Dict, List, Optional, Tuple -from custom_components.givenergy_local.givenergy_modbus.client import commands +from custom_components.givenergy_local.givenergy_modbus.client.commands import ( + CommandBuilder, +) from custom_components.givenergy_local.givenergy_modbus.exceptions import ( CommunicationError, ExceptionBase, @@ -31,6 +33,7 @@ class Client: framer: Framer expected_responses: "Dict[int, Future[TransparentResponse]]" = {} plant: Plant + command_builder: CommandBuilder # refresh_count: int = 0 # debug_frames: Dict[str, Queue] connected = False @@ -38,7 +41,6 @@ class Client: writer: StreamWriter network_consumer_task: Task network_producer_task: Task - slave_address: int tx_queue: "Queue[Tuple[bytes, Optional[Future]]]" @@ -48,6 +50,7 @@ def __init__(self, host: str, port: int, connect_timeout: float = 2.0) -> None: self.connect_timeout = connect_timeout self.framer = ClientFramer() self.plant = Plant() + self.command_builder = CommandBuilder() self.tx_queue = Queue(maxsize=20) # self.debug_frames = { # 'all': Queue(maxsize=1000), @@ -75,32 +78,45 @@ async def connect(self) -> None: ) # asyncio.create_task(self._task_dump_queues_to_files(), name='dump_queues_to_files'), self.connected = True - self.slave_address = 0x32 _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") + """Detect inverter capabilities that influence how subsequent requests are made. + + A full refresh of all data is performed in the process.""" # Refresh the core set of registers that work across all inverters + _logger.info("Performing model detection") + await self.refresh_plant( + True, max_batteries=0, timeout=timeout, retries=retries + ) + + # Different models accept slightly different commands, for example, querying an + # AIO for battery information will time out. Now we know the model, refresh + # all data again. + _logger.info( + "Detecting additional capabilities based on model: %s", + Model(self.plant.inverter.model).name, + ) + self.command_builder = CommandBuilder(self.plant.inverter.model) await self.refresh_plant(True, timeout=timeout, retries=retries) - _logger.info("Model detected: %s", Model(self.plant.inverter.model).name) # Use that to detect the number of batteries - # TODO: Reinstate battery detection - # self.plant.detect_batteries() - # _logger.info("Batteries detected: %d", self.plant.number_batteries) - - # Update the slave address for subsequent requests - if self.plant.inverter.model != Model.ALL_IN_ONE: - self.slave_address = 0x32 + self.plant.detect_batteries() + _logger.info( + "Detected %d external %s connected", + self.plant.number_batteries, + "battery" if self.plant.number_batteries == 1 else "batteries", + ) # Some devices support additional registers # When unsupported, devices appear to simply ignore requests + # If we had a clear mapping of what's supported across models/generations, + # this could be moved to the CommandBuilder possible_additional_holding_registers = [300] for hr in possible_additional_holding_registers: try: - reqs = commands.refresh_additional_holding_registers(hr) + reqs = self.command_builder.refresh_additional_holding_registers(hr) await self.execute(reqs, timeout=timeout, retries=retries) _logger.info( "Detected additional holding register support (base_register=%d)", @@ -158,7 +174,7 @@ async def refresh_plant( retries: int = 0, ) -> Plant: """Refresh data about the Plant.""" - reqs = commands.refresh_plant_data( + reqs = self.command_builder.refresh_plant_data( full_refresh, self.plant.number_batteries, max_batteries ) await self.execute(reqs, timeout=timeout, retries=retries) @@ -176,13 +192,14 @@ async def watch_plant( """Refresh data about the Plant.""" await self.connect() await self.detect_plant() - await self.refresh_plant(True, max_batteries=max_batteries) while True: if handler: handler() await asyncio.sleep(refresh_period) if not passive: - reqs = commands.refresh_plant_data(False, self.plant.number_batteries) + reqs = self.command_builder.refresh_plant_data( + False, self.plant.number_batteries + ) await self.execute( reqs, timeout=timeout, retries=retries, return_exceptions=True ) @@ -275,11 +292,6 @@ def execute( return_exceptions: bool = False, ) -> "Future[List[TransparentResponse]]": """Helper to perform multiple requests in bulk.""" - # TODO: Will clobber the slave_address for batteries - # Not good for AIO - for request in requests: - request.slave_address = self.slave_address - return asyncio.gather( *[ self.send_request_and_await_response( diff --git a/custom_components/givenergy_local/givenergy_modbus/client/commands.py b/custom_components/givenergy_local/givenergy_modbus/client/commands.py index 516baf1..38074c5 100644 --- a/custom_components/givenergy_local/givenergy_modbus/client/commands.py +++ b/custom_components/givenergy_local/givenergy_modbus/client/commands.py @@ -6,6 +6,7 @@ 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 Model from custom_components.givenergy_local.givenergy_modbus.pdu import ( ReadHoldingRegistersRequest, ReadInputRegistersRequest, @@ -45,291 +46,332 @@ class RegisterMap: REBOOT = 163 -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)] - - -def refresh_plant_data( - complete: bool, - number_batteries: int = 1, - max_batteries: int = 5, - additional_holding_registers: Optional[list[int]] = None, -) -> list[TransparentRequest]: - """Refresh plant data.""" - requests: list[TransparentRequest] = [ - ReadInputRegistersRequest(base_register=0, register_count=60), - ReadInputRegistersRequest(base_register=180, register_count=60), - ] - if complete: - requests.append(ReadHoldingRegistersRequest(base_register=0, register_count=60)) - requests.append( - ReadHoldingRegistersRequest(base_register=60, register_count=60) - ) - requests.append( - ReadHoldingRegistersRequest(base_register=120, register_count=60) - ) - requests.append(ReadInputRegistersRequest(base_register=120, register_count=60)) - number_batteries = max_batteries +class CommandBuilder: - # TODO: Remove to reinstate battery detection - number_batteries = 0 + def __init__(self, model: Optional[Model] = None) -> None: + self.model = model + if model in [None, Model.ALL_IN_ONE]: + self.main_slave_address = 0x11 + else: + self.main_slave_address = 0x32 - for i in range(number_batteries): - requests.append( - ReadInputRegistersRequest( - # TODO: slave_address for AIO battery? - base_register=60, + def refresh_additional_holding_registers( + self, + 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( + slave_address=self.main_slave_address, + base_register=base_register, register_count=60, - slave_address=0x32 + i, ) - ) - - if additional_holding_registers: - for hr in additional_holding_registers: - requests.extend(refresh_additional_holding_registers(hr)) - - return requests - - -def disable_charge_target() -> list[TransparentRequest]: - """Removes SOC limit and target 100% charging.""" - return [ - WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, False), - WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, 100), - ] - - -def set_charge_target(target_soc: int) -> list[TransparentRequest]: - """Sets inverter to stop charging when SOC reaches the desired level. Also referred to as "winter mode".""" - if not 4 <= target_soc <= 100: - raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%") - ret = set_enable_charge(True) - if target_soc == 100: - ret.extend(disable_charge_target()) - else: - ret.append(WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, True)) - ret.append( - WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, target_soc) - ) - return ret - - -def set_enable_charge(enabled: bool) -> list[TransparentRequest]: - """Enable the battery to charge, depending on the mode and slots set.""" - return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE, enabled)] - - -def set_enable_discharge(enabled: bool) -> list[TransparentRequest]: - """Enable the battery to discharge, depending on the mode and slots set.""" - return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_DISCHARGE, enabled)] - - -def set_inverter_reboot() -> list[TransparentRequest]: - """Restart the inverter.""" - return [WriteHoldingRegisterRequest(RegisterMap.REBOOT, 100)] - - -def set_calibrate_battery_soc() -> list[TransparentRequest]: - """Set the inverter to recalibrate the battery state of charge estimation.""" - return [WriteHoldingRegisterRequest(RegisterMap.SOC_FORCE_ADJUST, 1)] - - -@deprecated("use set_enable_charge(True) instead") -def enable_charge() -> list[TransparentRequest]: - """Enable the battery to charge, depending on the mode and slots set.""" - return set_enable_charge(True) - - -@deprecated("use set_enable_charge(False) instead") -def disable_charge() -> list[TransparentRequest]: - """Prevent the battery from charging at all.""" - return set_enable_charge(False) - - -@deprecated("use set_enable_discharge(True) instead") -def enable_discharge() -> list[TransparentRequest]: - """Enable the battery to discharge, depending on the mode and slots set.""" - return set_enable_discharge(True) - - -@deprecated("use set_enable_discharge(False) instead") -def disable_discharge() -> list[TransparentRequest]: - """Prevent the battery from discharging at all.""" - return set_enable_discharge(False) - - -def set_discharge_mode_max_power() -> list[TransparentRequest]: - """Set the battery discharge mode to maximum power, exporting to the grid if it exceeds load demand.""" - return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_POWER_MODE, 0)] - - -def set_discharge_mode_to_match_demand() -> list[TransparentRequest]: - """Set the battery discharge mode to match demand, avoiding exporting power to the grid.""" - return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_POWER_MODE, 1)] - - -@deprecated("Use set_battery_soc_reserve(val) instead") -def set_shallow_charge(val: int) -> list[TransparentRequest]: - """Set the minimum level of charge to maintain.""" - return set_battery_soc_reserve(val) - - -def set_battery_soc_reserve(val: int) -> list[TransparentRequest]: - """Set the minimum level of charge to maintain.""" - # TODO what are valid values? 4-100? - val = int(val) - if not 4 <= val <= 100: - raise ValueError(f"Minimum SOC / shallow charge ({val}) must be in [4-100]%") - return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_SOC_RESERVE, val)] - - -def set_battery_charge_limit(val: int) -> list[TransparentRequest]: - """Set the battery charge power limit as percentage. 50% (2.6 kW) is the maximum for most inverters.""" - val = int(val) - if not 0 <= val <= 50: - raise ValueError(f"Specified Charge Limit ({val}%) is not in [0-50]%") - return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_CHARGE_LIMIT, val)] - + ] -def set_battery_discharge_limit(val: int) -> list[TransparentRequest]: - """Set the battery discharge power limit as percentage. 50% (2.6 kW) is the maximum for most inverters.""" - val = int(val) - if not 0 <= val <= 50: - raise ValueError(f"Specified Discharge Limit ({val}%) is not in [0-50]%") - return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_DISCHARGE_LIMIT, val)] + def refresh_plant_data( + self, + complete: bool, + number_batteries: int = 1, + max_batteries: int = 5, + additional_holding_registers: Optional[list[int]] = None, + ) -> list[TransparentRequest]: + """Refresh plant data.""" + requests: list[TransparentRequest] = [ + ReadInputRegistersRequest( + slave_address=self.main_slave_address, + base_register=0, + register_count=60, + ), + ReadInputRegistersRequest( + slave_address=self.main_slave_address, + base_register=180, + register_count=60, + ), + ] + if complete: + requests.append( + ReadHoldingRegistersRequest( + slave_address=self.main_slave_address, + base_register=0, + register_count=60, + ) + ) + requests.append( + ReadHoldingRegistersRequest( + slave_address=self.main_slave_address, + base_register=60, + register_count=60, + ) + ) + requests.append( + ReadHoldingRegistersRequest( + slave_address=self.main_slave_address, + base_register=120, + register_count=60, + ) + ) + requests.append( + ReadInputRegistersRequest( + slave_address=self.main_slave_address, + base_register=120, + register_count=60, + ) + ) + # Requests for external battery registers will time out on AIO devices + if self.model not in [None, Model.ALL_IN_ONE]: + number_batteries = max_batteries + + for i in range(number_batteries): + requests.append( + ReadInputRegistersRequest( + slave_address=0x32 + i, + base_register=60, + register_count=60, + ) + ) -def set_battery_power_reserve(val: int) -> list[TransparentRequest]: - """Set the battery power reserve to maintain.""" - # TODO what are valid values? - val = int(val) - if not 4 <= val <= 100: - raise ValueError(f"Battery power reserve ({val}) must be in [4-100]%") - return [ - WriteHoldingRegisterRequest( - RegisterMap.BATTERY_DISCHARGE_MIN_POWER_RESERVE, val - ) - ] + if additional_holding_registers: + for hr in additional_holding_registers: + requests.extend(self.refresh_additional_holding_registers(hr)) + return requests -def _set_charge_slot( - discharge: bool, idx: int, slot: Optional[TimeSlot] -) -> list[TransparentRequest]: - hr_start, hr_end = ( - getattr(RegisterMap, f'{"DIS" if discharge else ""}CHARGE_SLOT_{idx}_START'), - getattr(RegisterMap, f'{"DIS" if discharge else ""}CHARGE_SLOT_{idx}_END'), - ) - if slot: + @staticmethod + def disable_charge_target() -> list[TransparentRequest]: + """Removes SOC limit and target 100% charging.""" return [ - WriteHoldingRegisterRequest(hr_start, int(slot.start.strftime("%H%M"))), - WriteHoldingRegisterRequest(hr_end, int(slot.end.strftime("%H%M"))), + WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE_TARGET, False), + WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, 100), ] - else: + + @staticmethod + def set_charge_target(target_soc: int) -> list[TransparentRequest]: + """Sets inverter to stop charging when SOC reaches the desired level. Also referred to as "winter mode".""" + if not 4 <= target_soc <= 100: + raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%") + return [WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, target_soc)] + + @staticmethod + def set_enable_charge(enabled: bool) -> list[TransparentRequest]: + """Enable the battery to charge, depending on the mode and slots set.""" + return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_CHARGE, enabled)] + + @staticmethod + def set_enable_discharge(enabled: bool) -> list[TransparentRequest]: + """Enable the battery to discharge, depending on the mode and slots set.""" + return [WriteHoldingRegisterRequest(RegisterMap.ENABLE_DISCHARGE, enabled)] + + @staticmethod + def set_inverter_reboot() -> list[TransparentRequest]: + """Restart the inverter.""" + return [WriteHoldingRegisterRequest(RegisterMap.REBOOT, 100)] + + @staticmethod + def set_calibrate_battery_soc() -> list[TransparentRequest]: + """Set the inverter to recalibrate the battery state of charge estimation.""" + return [WriteHoldingRegisterRequest(RegisterMap.SOC_FORCE_ADJUST, 1)] + + @staticmethod + @deprecated("use set_enable_charge(True) instead") + def enable_charge() -> list[TransparentRequest]: + """Enable the battery to charge, depending on the mode and slots set.""" + return CommandBuilder.set_enable_charge(True) + + @staticmethod + @deprecated("use set_enable_charge(False) instead") + def disable_charge() -> list[TransparentRequest]: + """Prevent the battery from charging at all.""" + return CommandBuilder.set_enable_charge(False) + + @staticmethod + @deprecated("use set_enable_discharge(True) instead") + def enable_discharge() -> list[TransparentRequest]: + """Enable the battery to discharge, depending on the mode and slots set.""" + return CommandBuilder.set_enable_discharge(True) + + @staticmethod + @deprecated("use set_enable_discharge(False) instead") + def disable_discharge() -> list[TransparentRequest]: + """Prevent the battery from discharging at all.""" + return CommandBuilder.set_enable_discharge(False) + + @staticmethod + def set_discharge_mode_max_power() -> list[TransparentRequest]: + """Set the battery discharge mode to maximum power, exporting to the grid if it exceeds load demand.""" + return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_POWER_MODE, 0)] + + @staticmethod + def set_discharge_mode_to_match_demand() -> list[TransparentRequest]: + """Set the battery discharge mode to match demand, avoiding exporting power to the grid.""" + return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_POWER_MODE, 1)] + + @deprecated("Use set_battery_soc_reserve(val) instead") + def set_shallow_charge(val: int) -> list[TransparentRequest]: + """Set the minimum level of charge to maintain.""" + return CommandBuilder.set_battery_soc_reserve(val) + + @staticmethod + def set_battery_soc_reserve(val: int) -> list[TransparentRequest]: + """Set the minimum level of charge to maintain.""" + # TODO what are valid values? 4-100? + val = int(val) + if not 4 <= val <= 100: + raise ValueError( + f"Minimum SOC / shallow charge ({val}) must be in [4-100]%" + ) + return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_SOC_RESERVE, val)] + + @staticmethod + def set_battery_charge_limit(val: int) -> list[TransparentRequest]: + """Set the battery charge power limit as percentage. 50% (2.6 kW) is the maximum for most inverters.""" + val = int(val) + if not 0 <= val <= 50: + raise ValueError(f"Specified Charge Limit ({val}%) is not in [0-50]%") + return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_CHARGE_LIMIT, val)] + + @staticmethod + def set_battery_discharge_limit(val: int) -> list[TransparentRequest]: + """Set the battery discharge power limit as percentage. 50% (2.6 kW) is the maximum for most inverters.""" + val = int(val) + if not 0 <= val <= 50: + raise ValueError(f"Specified Discharge Limit ({val}%) is not in [0-50]%") + return [WriteHoldingRegisterRequest(RegisterMap.BATTERY_DISCHARGE_LIMIT, val)] + + @staticmethod + def set_battery_power_reserve(val: int) -> list[TransparentRequest]: + """Set the battery power reserve to maintain.""" + val = int(val) + if not 4 <= val <= 100: + raise ValueError(f"Battery power reserve ({val}) must be in [4-100]%") return [ - WriteHoldingRegisterRequest(hr_start, 0), - WriteHoldingRegisterRequest(hr_end, 0), + WriteHoldingRegisterRequest( + RegisterMap.BATTERY_DISCHARGE_MIN_POWER_RESERVE, val + ) ] + @staticmethod + def _set_charge_slot( + discharge: bool, idx: int, slot: Optional[TimeSlot] + ) -> list[TransparentRequest]: + hr_start, hr_end = ( + getattr( + RegisterMap, f'{"DIS" if discharge else ""}CHARGE_SLOT_{idx}_START' + ), + getattr(RegisterMap, f'{"DIS" if discharge else ""}CHARGE_SLOT_{idx}_END'), + ) + if slot: + return [ + WriteHoldingRegisterRequest(hr_start, int(slot.start.strftime("%H%M"))), + WriteHoldingRegisterRequest(hr_end, int(slot.end.strftime("%H%M"))), + ] + else: + return [ + WriteHoldingRegisterRequest(hr_start, 0), + WriteHoldingRegisterRequest(hr_end, 0), + ] + + @staticmethod + def set_charge_slot_1(timeslot: TimeSlot) -> list[TransparentRequest]: + """Set first charge slot start & end times.""" + return CommandBuilder._set_charge_slot(False, 1, timeslot) + + @staticmethod + def reset_charge_slot_1() -> list[TransparentRequest]: + """Reset first charge slot to zero/disabled.""" + return CommandBuilder._set_charge_slot(False, 1, None) + + @staticmethod + def set_charge_slot_2(timeslot: TimeSlot) -> list[TransparentRequest]: + """Set second charge slot start & end times.""" + return CommandBuilder._set_charge_slot(False, 2, timeslot) + + @staticmethod + def reset_charge_slot_2() -> list[TransparentRequest]: + """Reset second charge slot to zero/disabled.""" + return CommandBuilder._set_charge_slot(False, 2, None) + + @staticmethod + def set_discharge_slot_1(timeslot: TimeSlot) -> list[TransparentRequest]: + """Set first discharge slot start & end times.""" + return CommandBuilder._set_charge_slot(True, 1, timeslot) + + @staticmethod + def reset_discharge_slot_1() -> list[TransparentRequest]: + """Reset first discharge slot to zero/disabled.""" + return CommandBuilder._set_charge_slot(True, 1, None) + + @staticmethod + def set_discharge_slot_2(timeslot: TimeSlot) -> list[TransparentRequest]: + """Set second discharge slot start & end times.""" + return CommandBuilder._set_charge_slot(True, 2, timeslot) + + @staticmethod + def reset_discharge_slot_2() -> list[TransparentRequest]: + """Reset second discharge slot to zero/disabled.""" + return CommandBuilder._set_charge_slot(True, 2, None) + + @staticmethod + def set_system_date_time(dt: datetime) -> list[TransparentRequest]: + """Set the date & time of the inverter.""" + return [ + WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_YEAR, dt.year - 2000), + WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_MONTH, dt.month), + WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_DAY, dt.day), + WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_HOUR, dt.hour), + WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_MINUTE, dt.minute), + WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_SECOND, dt.second), + ] -def set_charge_slot_1(timeslot: TimeSlot) -> list[TransparentRequest]: - """Set first charge slot start & end times.""" - return _set_charge_slot(False, 1, timeslot) - - -def reset_charge_slot_1() -> list[TransparentRequest]: - """Reset first charge slot to zero/disabled.""" - return _set_charge_slot(False, 1, None) - - -def set_charge_slot_2(timeslot: TimeSlot) -> list[TransparentRequest]: - """Set second charge slot start & end times.""" - return _set_charge_slot(False, 2, timeslot) - - -def reset_charge_slot_2() -> list[TransparentRequest]: - """Reset second charge slot to zero/disabled.""" - return _set_charge_slot(False, 2, None) - - -def set_discharge_slot_1(timeslot: TimeSlot) -> list[TransparentRequest]: - """Set first discharge slot start & end times.""" - return _set_charge_slot(True, 1, timeslot) - - -def reset_discharge_slot_1() -> list[TransparentRequest]: - """Reset first discharge slot to zero/disabled.""" - return _set_charge_slot(True, 1, None) - - -def set_discharge_slot_2(timeslot: TimeSlot) -> list[TransparentRequest]: - """Set second discharge slot start & end times.""" - return _set_charge_slot(True, 2, timeslot) - - -def reset_discharge_slot_2() -> list[TransparentRequest]: - """Reset second discharge slot to zero/disabled.""" - return _set_charge_slot(True, 2, None) - - -def set_system_date_time(dt: datetime) -> list[TransparentRequest]: - """Set the date & time of the inverter.""" - return [ - WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_YEAR, dt.year - 2000), - WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_MONTH, dt.month), - WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_DAY, dt.day), - WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_HOUR, dt.hour), - WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_MINUTE, dt.minute), - WriteHoldingRegisterRequest(RegisterMap.SYSTEM_TIME_SECOND, dt.second), - ] - - -def set_mode_dynamic() -> list[TransparentRequest]: - """Set system to Dynamic / Eco mode. - - This mode is designed to maximise use of solar generation. The battery will charge from excess solar - generation to avoid exporting power, and discharge to meet load demand when solar power is insufficient to - avoid importing power. This mode is useful if you want to maximise self-consumption of renewable generation - and minimise the amount of energy drawn from the grid. - """ - # r27=1 r110=4 r59=0 - return ( - set_discharge_mode_to_match_demand() - + set_battery_soc_reserve(4) - + set_enable_discharge(False) - ) - - -def set_mode_storage( - discharge_slot_1: TimeSlot = TimeSlot.from_repr(1600, 700), - discharge_slot_2: Optional[TimeSlot] = None, - discharge_for_export: bool = False, -) -> list[TransparentRequest]: - """Set system to storage mode with specific discharge slots(s). - - This mode stores excess solar generation during the day and holds that energy ready for use later in the day. - By default, the battery will start to discharge from 4pm-7am to cover energy demand during typical peak - hours. This mode is particularly useful if you get charged more for your electricity at certain times to - utilise the battery when it is most effective. If the second time slot isn't specified, it will be cleared. + @staticmethod + def set_mode_dynamic() -> list[TransparentRequest]: + """Set system to Dynamic / Eco mode. + + This mode is designed to maximise use of solar generation. The battery will charge from excess solar + generation to avoid exporting power, and discharge to meet load demand when solar power is insufficient to + avoid importing power. This mode is useful if you want to maximise self-consumption of renewable generation + and minimise the amount of energy drawn from the grid. + """ + # r27=1 r110=4 r59=0 + return ( + CommandBuilder.set_discharge_mode_to_match_demand() + + CommandBuilder.set_battery_soc_reserve(4) + + CommandBuilder.set_enable_discharge(False) + ) - You can optionally also choose to export excess energy: instead of discharging to meet only your load demand, - the battery will discharge at full power and any excess will be exported to the grid. This is useful if you - have a variable export tariff (e.g. Agile export) and you want to target the peak times of day (e.g. 4pm-7pm) - when it is most valuable to export energy. - """ - if discharge_for_export: - ret = set_discharge_mode_max_power() # r27=0 - else: - ret = set_discharge_mode_to_match_demand() # r27=1 - ret.extend(set_battery_soc_reserve(100)) # r110=100 - ret.extend(set_enable_discharge(True)) # r59=1 - ret.extend(set_discharge_slot_1(discharge_slot_1)) # r56=1600, r57=700 - if discharge_slot_2: - ret.extend(set_discharge_slot_2(discharge_slot_2)) # r56=1600, r57=700 - else: - ret.extend(reset_discharge_slot_2()) - return ret + @staticmethod + def set_mode_storage( + discharge_slot_1: TimeSlot = TimeSlot.from_repr(1600, 700), + discharge_slot_2: Optional[TimeSlot] = None, + discharge_for_export: bool = False, + ) -> list[TransparentRequest]: + """Set system to storage mode with specific discharge slots(s). + + This mode stores excess solar generation during the day and holds that energy ready for use later in the day. + By default, the battery will start to discharge from 4pm-7am to cover energy demand during typical peak + hours. This mode is particularly useful if you get charged more for your electricity at certain times to + utilise the battery when it is most effective. If the second time slot isn't specified, it will be cleared. + + You can optionally also choose to export excess energy: instead of discharging to meet only your load demand, + the battery will discharge at full power and any excess will be exported to the grid. This is useful if you + have a variable export tariff (e.g. Agile export) and you want to target the peak times of day (e.g. 4pm-7pm) + when it is most valuable to export energy. + """ + if discharge_for_export: + ret = CommandBuilder.set_discharge_mode_max_power() # r27=0 + else: + ret = CommandBuilder.set_discharge_mode_to_match_demand() # r27=1 + ret.extend(CommandBuilder.set_battery_soc_reserve(100)) # r110=100 + ret.extend(CommandBuilder.set_enable_discharge(True)) # r59=1 + ret.extend( + CommandBuilder.set_discharge_slot_1(discharge_slot_1) + ) # r56=1600, r57=700 + if discharge_slot_2: + ret.extend( + CommandBuilder.set_discharge_slot_2(discharge_slot_2) + ) # r56=1600, r57=700 + else: + ret.extend(CommandBuilder.reset_discharge_slot_2()) + return ret diff --git a/custom_components/givenergy_local/givenergy_modbus/model/plant.py b/custom_components/givenergy_local/givenergy_modbus/model/plant.py index 0449b1b..cb2c536 100644 --- a/custom_components/givenergy_local/givenergy_modbus/model/plant.py +++ b/custom_components/givenergy_local/givenergy_modbus/model/plant.py @@ -94,7 +94,6 @@ 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/number.py b/custom_components/givenergy_local/number.py index adff2cb..1b05b18 100644 --- a/custom_components/givenergy_local/number.py +++ b/custom_components/givenergy_local/number.py @@ -16,13 +16,7 @@ from .const import BATTERY_NOMINAL_VOLTAGE, DOMAIN, Icon from .coordinator import GivEnergyUpdateCoordinator from .entity import InverterEntity -from .givenergy_modbus.client.commands import ( - RegisterMap, - set_battery_charge_limit, - set_battery_discharge_limit, - set_battery_power_reserve, - set_battery_soc_reserve, -) +from .givenergy_modbus.client.commands import CommandBuilder, RegisterMap from .givenergy_modbus.pdu.write_registers import WriteHoldingRegisterRequest @@ -139,7 +133,9 @@ def __init__( async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self.coordinator.execute(set_battery_soc_reserve(int(value))) + await self.coordinator.execute( + CommandBuilder.set_battery_soc_reserve(int(value)) + ) class BatteryMinPowerReserveNumber(InverterBasicNumber): @@ -168,7 +164,9 @@ def __init__( async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self.coordinator.execute(set_battery_power_reserve(int(value))) + await self.coordinator.execute( + CommandBuilder.set_battery_power_reserve(int(value)) + ) class InverterBatteryPowerLimitNumber(InverterBasicNumber): @@ -245,7 +243,9 @@ def __init__( async def async_set_native_value(self, value: float) -> None: """Update the current charge power limit.""" raw_value = self.watts_to_api_value(int(value)) - await self.coordinator.execute(set_battery_charge_limit(raw_value)) + await self.coordinator.execute( + CommandBuilder.set_battery_charge_limit(raw_value) + ) class InverterBatteryDischargeLimitNumber(InverterBatteryPowerLimitNumber): @@ -272,4 +272,6 @@ def __init__( async def async_set_native_value(self, value: float) -> None: """Update the current discharge power limit.""" raw_value = self.watts_to_api_value(int(value)) - await self.coordinator.execute(set_battery_discharge_limit(raw_value)) + await self.coordinator.execute( + CommandBuilder.set_battery_discharge_limit(raw_value) + ) diff --git a/custom_components/givenergy_local/services.py b/custom_components/givenergy_local/services.py index 14fd1d4..328d3f3 100644 --- a/custom_components/givenergy_local/services.py +++ b/custom_components/givenergy_local/services.py @@ -9,21 +9,14 @@ from homeassistant.helpers import device_registry as dr import voluptuous as vol +from custom_components.givenergy_local.givenergy_modbus.pdu.transparent import ( + TransparentRequest, +) + from .const import DOMAIN, LOGGER from .coordinator import GivEnergyUpdateCoordinator -from .givenergy_modbus.client.commands import ( - RegisterMap, - WriteHoldingRegisterRequest, - set_charge_slot_1, - set_discharge_mode_max_power, - set_discharge_mode_to_match_demand, - set_discharge_slot_1, - set_enable_charge, - set_enable_discharge, - set_mode_dynamic, -) +from .givenergy_modbus.client.commands import CommandBuilder from .givenergy_modbus.model import TimeSlot -from .givenergy_modbus.pdu.transparent import TransparentRequest _ATTR_START_TIME = "start_time" _ATTR_END_TIME = "end_time" @@ -137,7 +130,8 @@ async def _async_service_call( async def _async_activate_mode_eco(hass: HomeAssistant, data: dict[str, Any]) -> None: """Activate 'Eco' mode, as found in the GivEnergy portal.""" LOGGER.debug("Activating eco mode") - await _async_service_call(hass, data[ATTR_DEVICE_ID], set_mode_dynamic()) + commands = CommandBuilder.set_mode_dynamic() + await _async_service_call(hass, data[ATTR_DEVICE_ID], commands) async def _async_activate_mode_timed_discharge( @@ -147,9 +141,9 @@ async def _async_activate_mode_timed_discharge( start_time = datetime.time.fromisoformat(data[_ATTR_START_TIME]) end_time = datetime.time.fromisoformat(data[_ATTR_END_TIME]) - commands = set_discharge_mode_to_match_demand() - commands.extend(set_enable_discharge(True)) - commands.extend(set_discharge_slot_1(TimeSlot(start_time, end_time))) + commands = CommandBuilder.set_discharge_mode_to_match_demand() + commands.extend(CommandBuilder.set_enable_discharge(True)) + commands.extend(CommandBuilder.set_discharge_slot_1(TimeSlot(start_time, end_time))) LOGGER.debug( "Activating timed discharge mode between %s and %s", start_time, end_time @@ -164,9 +158,9 @@ async def _async_activate_mode_timed_export( start_time = datetime.time.fromisoformat(data[_ATTR_START_TIME]) end_time = datetime.time.fromisoformat(data[_ATTR_END_TIME]) - commands = set_discharge_mode_max_power() - commands.extend(set_enable_discharge(True)) - commands.extend(set_discharge_slot_1(TimeSlot(start_time, end_time))) + commands = CommandBuilder.set_discharge_mode_max_power() + commands.extend(CommandBuilder.set_enable_discharge(True)) + commands.extend(CommandBuilder.set_discharge_slot_1(TimeSlot(start_time, end_time))) LOGGER.debug("Activating timed export mode between %s and %s", start_time, end_time) await _async_service_call(hass, data[ATTR_DEVICE_ID], commands) @@ -179,20 +173,18 @@ async def _async_enable_timed_charge(hass: HomeAssistant, data: dict[str, Any]) Note that this isn't a battery mode like "Timed Discharge", "Eco", etc. It operates in parallel to those modes. """ - commands = set_enable_charge(True) + commands = CommandBuilder.set_enable_charge(True) if _ATTR_START_TIME in data and _ATTR_END_TIME in data: start_time = datetime.time.fromisoformat(data[_ATTR_START_TIME]) end_time = datetime.time.fromisoformat(data[_ATTR_END_TIME]) - commands.extend(set_charge_slot_1(TimeSlot(start_time, end_time))) + commands.extend( + CommandBuilder.set_charge_slot_1(TimeSlot(start_time, end_time)) + ) if _ATTR_CHARGE_TARGET in data: target_soc = int(data[_ATTR_CHARGE_TARGET]) - if not 4 <= target_soc <= 100: - raise ValueError(f"Charge Target SOC ({target_soc}) must be in [4-100]%") - commands.append( - WriteHoldingRegisterRequest(RegisterMap.CHARGE_TARGET_SOC, target_soc) - ) + commands.extend(CommandBuilder.set_charge_target(target_soc)) LOGGER.debug("Activating timed charge mode") await _async_service_call(hass, data[ATTR_DEVICE_ID], commands) @@ -203,4 +195,6 @@ async def _async_disable_timed_charge( ) -> None: """Disable 'Timed Charge', as found in the GivEnergy portal.""" LOGGER.debug("Deactivating timed charge mode") - await _async_service_call(hass, data[ATTR_DEVICE_ID], set_enable_charge(False)) + await _async_service_call( + hass, data[ATTR_DEVICE_ID], CommandBuilder.set_enable_charge(False) + ) diff --git a/custom_components/givenergy_local/switch.py b/custom_components/givenergy_local/switch.py index 3d8b8c3..6c87175 100644 --- a/custom_components/givenergy_local/switch.py +++ b/custom_components/givenergy_local/switch.py @@ -12,12 +12,7 @@ from .const import DOMAIN, Icon from .coordinator import GivEnergyUpdateCoordinator from .entity import InverterEntity -from .givenergy_modbus.client.commands import ( - set_discharge_mode_max_power, - set_discharge_mode_to_match_demand, - set_enable_charge, - set_enable_discharge, -) +from .givenergy_modbus.client.commands import CommandBuilder async def async_setup_entry( @@ -63,11 +58,11 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Enable AC charging, subject to charge slot configuration.""" - await self.coordinator.execute(set_enable_charge(True)) + await self.coordinator.execute(CommandBuilder.set_enable_charge(True)) async def async_turn_off(self, **kwargs: Any) -> None: """Disable AC charging, subject to charge slot configuration.""" - await self.coordinator.execute(set_enable_charge(False)) + await self.coordinator.execute(CommandBuilder.set_enable_charge(False)) class InverterDischargeSwitch(InverterEntity, SwitchEntity): @@ -97,11 +92,11 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Enable DC charging, subject to mode and discharge slot configuration.""" - await self.coordinator.execute(set_enable_discharge(True)) + await self.coordinator.execute(CommandBuilder.set_enable_discharge(True)) async def async_turn_off(self, **kwargs: Any) -> None: """Disable DC discharging, subject to mode and discharge slot configuration.""" - await self.coordinator.execute(set_enable_discharge(False)) + await self.coordinator.execute(CommandBuilder.set_enable_discharge(False)) class InverterEcoModeSwitch(InverterEntity, SwitchEntity): @@ -131,8 +126,10 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Enable Eco/Dynamic mode.""" - await self.coordinator.execute(set_discharge_mode_to_match_demand()) + await self.coordinator.execute( + CommandBuilder.set_discharge_mode_to_match_demand() + ) async def async_turn_off(self, **kwargs: Any) -> None: """Disable Eco/Dynamic mode.""" - await self.coordinator.execute(set_discharge_mode_max_power()) + await self.coordinator.execute(CommandBuilder.set_discharge_mode_max_power())