From 41a689148c59c50867a228ecc681b88736a42f3f Mon Sep 17 00:00:00 2001 From: fboundy Date: Tue, 12 Nov 2024 13:03:38 +0000 Subject: [PATCH 1/3] 13.7.2 - Tidy up log file --- README.md | 2 +- apps/pv_opt/pv_opt.py | 16 +++++++++------- apps/pv_opt/pvpy.py | 6 +++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5da9010..7466d85 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.17.1 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.17.2

This documentation needs updating!

diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 8a410db..556ecfb 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -12,7 +12,7 @@ from numpy import nan import re -VERSION = "3.17.1" +VERSION = "3.17.2" OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" @@ -594,11 +594,13 @@ def _run_test(self): def _check_for_io(self): self.ulog("Checking for Intelligent Octopus") entity_id = f"binary_sensor.octopus_energy_{self.get_config('octopus_account').lower().replace('-', '_')}_intelligent_dispatching" - self.rlog(f">>> {entity_id}") io_dispatches = self.get_state(entity_id) - self.log(f">>> IO entity state: {io_dispatches}") + if self.debug: + self.rlog(f">>> {entity_id}") + self.log(f">>> IO entity state: {io_dispatches}") self.io = io_dispatches is not None if self.io: + self.rlog(f"IO entity {entity_id} found") self.log("") self.io_entity = entity_id @@ -873,9 +875,9 @@ def _load_contract(self): exp=tariffs["export"], host=self, ) - self.log("Contract =") - self.log(self.contract) - self.log("") + # self.log("Contract =") + # self.log(self.contract) + # self.log("") self.rlog("Contract tariffs loaded OK") except Exception as e: @@ -2504,7 +2506,7 @@ def load_consumption(self, start, end): self.log(f" - {days} days was expected. {str_days}") - if (len(self.zappi_entities) > 0) and (self.get_config("ev_charger") == "Zappi"): + if (len(self.zappi_entities) > 0) and (self.get_config("ev_charger", "None") == "Zappi"): ev_power = self._get_zappi(start=df.index[0], end=df.index[-1], log=True) if len(ev_power) > 0: self.log("") diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index 325f349..dd7d42a 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -695,7 +695,8 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg prices = prices.set_axis([t for t in contract.tariffs.keys() if contract.tariffs[t] is not None], axis=1) if not use_export: - self.log(f"Ignoring export pricing because Use Export is turned off") + if log: + self.log(f"Ignoring export pricing because Use Export is turned off") discharge = False prices["export"] = 0 @@ -744,7 +745,6 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg self.log("---------------------") self.log("") - self.log(slots) net_cost = [] net_cost_opt = base_cost @@ -844,7 +844,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg min_power = min( slot_power_required, slot_charger_power_available, slot_available_capacity ) - if log: + if log and self.host.debug: str_log_x = ( f">>> Slot: {slot.strftime(TIME_FORMAT)} Factor: {factor:0.3f} Forced: {x['forced'].loc[slot]:6.0f}W " + f"End SOC: {x['soc_end'].loc[slot]:4.1f}% SPR: {slot_power_required:6.0f}W " From 34afbf9a10f6bcbd78ad2a0e900317850f0ec7e6 Mon Sep 17 00:00:00 2001 From: fboundy Date: Wed, 13 Nov 2024 18:55:25 +0000 Subject: [PATCH 2/3] Add Solis_Cloud as inverter type --- apps/pv_opt/config/config.yaml | 92 +++++++++++++++++++++-------- apps/pv_opt/pv_opt.py | 65 ++++++++++++--------- apps/pv_opt/solis.py | 104 ++++++++++++++++++++++----------- 3 files changed, 174 insertions(+), 87 deletions(-) diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index 0c868c0..23d6b51 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -96,25 +96,25 @@ pv_opt: # use_consumption_history: false # # consumption_history_days: 6 # # - # daily_consumption_kwh: 17 - # shape_consumption_profile: true - # consumption_shape: - # - hour: 0 - # consumption: 300 - # - hour: 0.5 - # consumption: 200 - # - hour: 6 - # consumption: 150 - # - hour: 8 - # consumption: 500 - # - hour: 15.5 - # consumption: 500 - # - hour: 17 - # consumption: 750 - # - hour: 22 - # consumption: 750 - # - hour: 24 - # consumption: 300 + daily_consumption_kwh: 17 + shape_consumption_profile: true + consumption_shape: + - hour: 0 + consumption: 300 + - hour: 0.5 + consumption: 200 + - hour: 6 + consumption: 150 + - hour: 8 + consumption: 500 + - hour: 15.5 + consumption: 500 + - hour: 17 + consumption: 750 + - hour: 22 + consumption: 750 + - hour: 24 + consumption: 300 # ======================================== # Octopus account parameters @@ -195,10 +195,10 @@ pv_opt: # update_cycle_seconds: 15 # maximum_dod_percent: number.{device_name}_battery_minimum_soc - id_consumption_today: sensor.{device_name}_consumption_today - id_consumption: - - sensor.{device_name}_house_load - - sensor.{device_name}_bypass_load + # id_consumption_today: sensor.{device_name}_consumption_today + # id_consumption: + # - sensor.{device_name}_house_load + # - sensor.{device_name}_bypass_load # id_grid_import_today: sensor.{device_name}_grid_import_today # id_grid_export_today: sensor.{device_name}_grid_export_today @@ -310,12 +310,56 @@ pv_opt: # update_cycle_seconds: 300 + # =============================================================================================================== + # Brand / Integration Specific Config: SOLIS_CLOUD: https://github.com/hultenvp/solis-sensor + # =============================================================================================================== + # + # These are the default entities used with the Solis Solax Modbus integration. You can change them here and over-ride the defaults + + inverter_type: SOLIS_CLOUD + device_name: soliscloud + + battery_voltage: sensor.{device_name}_battery_voltage + # update_cycle_seconds: 15 + maximum_dod_percent: sensor.{device_name}_force_discharge_soc + + id_consumption_today: sensor.{device_name}_daily_grid_energy_used + # id_consumption: + # - sensor.{device_name}_total_consumption_power + # - sensor.{device_name}_backup_load_power + + id_grid_import_today: sensor.{device_name}_daily_grid_energy_purchased + id_grid_export_today: sensor.{device_name}_daily_on_grid_energy + + id_battery_soc: sensor.{device_name}_remaining_battery_capacity + + # id_grid_import_today: sensor.{device_name}_grid_import_today + # id_grid_export_today: sensor.{device_name}_grid_export_today + + # id_battery_soc: sensor.{device_name}_battery_soc + # id_timed_charge_start_hours: number.{device_name}_timed_charge_start_hours + # id_timed_charge_start_minutes: number.{device_name}_timed_charge_start_minutes + # id_timed_charge_end_hours: number.{device_name}_timed_charge_end_hours + # id_timed_charge_end_minutes: number.{device_name}_timed_charge_end_minutes + # id_timed_charge_current: number.{device_name}_timed_charge_current + + # id_timed_discharge_start_hours: number.{device_name}_timed_discharge_start_hours + # id_timed_discharge_start_minutes: number.{device_name}_timed_discharge_start_minutes + # id_timed_discharge_end_hours: number.{device_name}_timed_discharge_end_hours + # id_timed_discharge_end_minutes: number.{device_name}_timed_discharge_end_minutes + # id_timed_discharge_current: number.{device_name}_timed_discharge_current + + # id_timed_charge_discharge_button: button.{device_name}_update_charge_discharge_times + # id_inverter_mode: select.{device_name}_energy_storage_control_switch + # Tariff comparison # id_daily_solar: sensor.{device_name}_power_generation_today # id_solar_power: # # - sensor.{device_name}_pv_power_1 # # - sensor.{device_name}_pv_power_2 - id_solar_power: sensor.{device_name}_pv_total_power + id_solar_power: + - sensor.{device_name}_dc_power_pv1 + - sensor.{device_name}_dc_power_pv2 # alt_tariffs: # - name: Agile_Fix diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 8a410db..12b46e1 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -54,7 +54,14 @@ } -INVERTER_TYPES = ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SUNSYNK_SOLARSYNK2", "SOLAX_X1"] +INVERTER_TYPES = [ + "SOLIS_SOLAX_MODBUS", + "SOLIS_CORE_MODBUS", + "SOLIS_SOLARMAN", + "SUNSYNK_SOLARSYNK2", + "SOLAX_X1", + "SOLIS_CLOUD", +] SYSTEM_ARGS = [ "module", @@ -493,7 +500,7 @@ def initialize(self): if not self.inverter.is_online(): e = "Unable to get expected response from Inverter Controller for {self.inverter_type}" - self._status(e) + self.status(e) self.log(e, level="ERROR") raise Exception(e) else: @@ -570,7 +577,7 @@ def _run_test(self): test["enable"] = test["enable"].lower() == "enable" function = test.pop("function").lower() - self._log_inverter_status(self.inverter.status) + self._log_inverterstatus(self.inverter.status) if function == "charge": self.inverter.control_charge(**test) @@ -585,11 +592,11 @@ def _run_test(self): i = int(self.get_config("update_cycle_seconds") * 1.2) self.log(f"Waiting for Modbus Read cycle: {i} seconds") while i > 0: - self._status(f"Waiting for Modbus Read cycle: {i}") + self.status(f"Waiting for Modbus Read cycle: {i}") time.sleep(1) i -= 1 - self._log_inverter_status(self.inverter.status) + self._log_inverterstatus(self.inverter.status) def _check_for_io(self): self.ulog("Checking for Intelligent Octopus") @@ -704,7 +711,7 @@ def _load_inverter(self): self.log(e, level="ERROR") def _load_pv_system_model(self): - self._status("Initialising PV Model") + self.status("Initialising PV Model") self.inverter_model = pv.InverterModel( inverter_efficiency=self.get_config("inverter_efficiency_percent") / 100, @@ -808,7 +815,7 @@ def _setup_schedule(self): def _load_contract(self): self.rlog("") self.rlog("Loading Contract:") - self._status("Loading Tariffs") + self.status("Loading Tariffs") self.rlog("-----------------") self.tariff_codes = {} self.agile = False @@ -1107,7 +1114,7 @@ def get_ha_value(self, entity_id): if state is not None: if (state in ["unknown", "unavailable"]) and (entity_id[:6] != "button"): e = f"HA returned {state} for state of {entity_id}" - self._status(f"ERROR: {e}") + self.status(f"ERROR: {e}") self.log(e, level="ERROR") # if the state is 'on' or 'off' then it's a bool elif state.lower() in ["on", "off", "true", "false"]: @@ -1149,7 +1156,7 @@ def _load_args(self, items=None): self.prefix = self.args.get("prefix", "solis") - self._status("Loading Configuation") + self.status("Loading Configuation") over_write = self.args.get("overwrite_ha_on_restart", True) change_entities = [] @@ -1554,8 +1561,8 @@ def _expose_configs(self, over_write=True): callback=self.optimise_state_change, ) - def _status(self, status): - entity_id = f"sensor.{self.prefix.lower()}_status" + def status(self, status): + entity_id = f"sensor.{self.prefix.lower()}status" attributes = {"last_updated": pd.Timestamp.now().strftime(DATE_TIME_FORMAT_LONG)} self.set_state(state=status, entity_id=entity_id, attributes=attributes) @@ -1742,7 +1749,7 @@ def optimise(self): if not isinstance(self.initial_soc, float): self.log("") self.log("Unable to optimise without initial SOC", level="ERROR") - self._status("ERROR: No initial SOC") + self.status("ERROR: No initial SOC") return self.log("") @@ -1760,7 +1767,7 @@ def optimise(self): if len(self.flows["Base"]) == 0: self.log("") self.log(" Unable to calculate baseline perfoormance", level="ERROR") - self._status("ERROR: Baseline performance") + self.status("ERROR: Baseline performance") return self.optimised_cost = {"Base": self.contract.net_cost(self.flows["Base"])} @@ -1802,7 +1809,7 @@ def optimise(self): else: self.selected_case = "Forced Discharge" - self._status("Optimising charge plan") + self.status("Optimising charge plan") for case in cases: self.flows[case] = self.pv_system.optimised_force( @@ -1859,24 +1866,24 @@ def optimise(self): }, ) - self._status("Writing to HA") + self.status("Writing to HA") self._write_output() if self.get_config("read_only"): self.log("Read only mode enabled. Not querying inverter.") - self._status("Idle (Read Only)") + self.status("Idle (Read Only)") else: # Get the current status of the inverter did_something = True - self._status("Updating Inverter") + self.status("Updating Inverter") inverter_update_count = 0 while did_something and inverter_update_count < MAX_INVERTER_UPDATES: inverter_update_count += 1 status = self.inverter.status - self._log_inverter_status(status) + self._log_inverterstatus(status) time_to_slot_start = (self.charge_start_datetime - pd.Timestamp.now(self.tz)).total_seconds() / 60 time_to_slot_end = (self.charge_end_datetime - pd.Timestamp.now(self.tz)).total_seconds() / 60 @@ -2063,12 +2070,12 @@ def optimise(self): i = int(self.get_config("update_cycle_seconds") * 1.2) self.log(f"Waiting for inverter Read cycle: {i} seconds") while i > 0: - self._status(f"Waiting for inverter Read cycle: {i}") + self.status(f"Waiting for inverter Read cycle: {i}") time.sleep(1) i -= 1 # status = self.inverter.status - # self._log_inverter_status(status) + # self._log_inverterstatus(status) status_switches = { "charge": "off", @@ -2077,19 +2084,19 @@ def optimise(self): } if status["hold_soc"]["active"]: - self._status(f"Holding SOC at {status['hold_soc']['soc']:0.0f}%") + self.status(f"Holding SOC at {status['hold_soc']['soc']:0.0f}%") status_switches["hold_soc"] = "on" elif status["charge"]["active"]: - self._status("Charging") + self.status("Charging") status_switches["charge"] = "on" elif status["discharge"]["active"]: - self._status("Discharging") + self.status("Discharging") status_switches["discharge"] = "on" else: - self._status("Idle") + self.status("Idle") for switch in status_switches: service = f"switch/turn_{status_switches[switch]}" @@ -2177,7 +2184,7 @@ def _create_windows(self): self.hold = [] self.windows = pd.DataFrame() - def _log_inverter_status(self, status): + def _log_inverterstatus(self, status): self.log("") self.log(f"Current inverter status:") self.log("------------------------") @@ -2483,7 +2490,7 @@ def load_consumption(self, start, end): self.log(df.to_string()) if df is None: - self._status("ERROR: No consumption history.") + self.status("ERROR: No consumption history.") return actual_days = int( @@ -2499,7 +2506,7 @@ def load_consumption(self, start, end): if int(actual_days) == days: str_days = "OK" else: - self._status(f"WARNING: Consumption < {days} days.") + self.status(f"WARNING: Consumption < {days} days.") str_days = "Potential error. <<<" self.log(f" - {days} days was expected. {str_days}") @@ -2656,7 +2663,7 @@ def load_grid(self, start, end): return df def _compare_tariffs(self): - self._status("Comparing Tariffs") + self.status("Comparing Tariffs") self.ulog("Comparing yesterday's tariffs") end = pd.Timestamp.now(tz="UTC").normalize() start = end - pd.Timedelta(24, "hours") @@ -2832,7 +2839,7 @@ def _check_tariffs_vs_bottlecap(self): ) if round(df["delta"].abs().mean(), 2) > 0: str_log += " <<< ERROR" - self._status("ERROR: Tariff inconsistency") + self.status("ERROR: Tariff inconsistency") err = True self.log(f" {direction.title()}: {str_log}") diff --git a/apps/pv_opt/solis.py b/apps/pv_opt/solis.py index 36f68a0..db7fd51 100644 --- a/apps/pv_opt/solis.py +++ b/apps/pv_opt/solis.py @@ -204,6 +204,22 @@ "id_backup_mode_soc": "sensor.{device_name}_backup_mode_soc", }, }, + "SOLIS_CLOUD": { + "online": "sensor.{device_name}_temperature", + "default_config": { + "maximum_dod_percent": "sensor.{device_name}_force_discharge_soc", + "id_consumption_today": "sensor.{device_name}_daily_grid_energy_used", + "id_grid_import_today": "sensor.{device_name}_daily_grid_energy_purchased", + "id_grid_export_today": "sensor.{device_name}_daily_on_grid_energy", + "id_battery_soc": "sensor.{device_name}_remaining_battery_capacity", + "supports_hold_soc": False, + "supports_forced_discharge": True, + "update_cycle_seconds": 300, + }, + "brand_config": { + "battery_voltage": "sensor.{device_name}_battery_voltage", + }, + }, } @@ -239,6 +255,8 @@ def is_online(self): def enable_timed_mode(self): if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": self._solis_set_mode_switch(SelfUse=True, Timed=True, GridCharge=True, Backup=False) + elif self.type == "SOLIS_CLOUD": + pass else: self._unknown_inverter() @@ -263,6 +281,8 @@ def hold_soc(self, enable, soc=None, **kwargs): end=end, power=0, ) + elif self.type == "SOLIS_CLOUD": + pass else: self._unknown_inverter() @@ -297,7 +317,8 @@ def hold_soc_old(self, enable, soc=None): value=soc, entity_id=entity_id, ) - + elif self.type == "SOLIS_CLOUD": + pass else: self._unknown_inverter() @@ -306,7 +327,8 @@ def status(self): status = None if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": status = self._solis_state() - + elif self.type == "SOLIS_CLOUD": + status = self._solis_state() return status def _monitor_target_soc(self, target_soc, mode="charge"): @@ -315,6 +337,8 @@ def _monitor_target_soc(self, target_soc, mode="charge"): def _control_charge_discharge(self, direction, enable, **kwargs): if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": self._solis_control_charge_discharge(direction, enable, **kwargs) + elif self.type == "SOLIS_CLOUD": + pass def _solis_control_charge_discharge(self, direction, enable, **kwargs): status = self._solis_state() @@ -433,44 +457,54 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): self.log("Inverter already at correct current") def _solis_set_mode_switch(self, **kwargs): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": - status = self._solis_solax_solarman_mode_switch() + # Read the mode switch + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": + status = self._solis_solax_solarman_mode_switch() - elif self.type == "SOLIS_CORE_MODBUS": - status = self._solis_core_mode_switch() + elif self.type == "SOLIS_CORE_MODBUS": + status = self._solis_core_mode_switch() - switches = status["switches"] - if self.host.debug: - self.log(f">>> kwargs: {kwargs}") - self.log(">>> Solis switch status:") + switches = status["switches"] + if self.host.debug: + self.log(f">>> kwargs: {kwargs}") + self.log(">>> Solis switch status:") - for switch in switches: - if switch in kwargs: - if self.host.debug: - self.log(f">>> {switch}: {kwargs[switch]}") - switches[switch] = kwargs[switch] + for switch in switches: + if switch in kwargs: + if self.host.debug: + self.log(f">>> {switch}: {kwargs[switch]}") + switches[switch] = kwargs[switch] - bits = INVERTER_DEFS[self.type]["bits"] - bin_list = [2**i * switches[bit] for i, bit in enumerate(bits)] - code = sum(bin_list) - entity_id = self.host.config["id_inverter_mode"] + elif self.type == "SOLIS_CLOUD": + pass - if self.type == "SOLIS_SOLAX_MODBUS": - entity_modes = self.host.get_state_retry(entity_id, attribute="options") - modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} - # mode = INVERTER_DEFS[self.type]["modes"].get(code) - mode = modes.get(code) - if self.host.debug: - self.log(f">>> Inverter Code: {code}") - self.log(f">>> Entity modes: {entity_modes}") - self.log(f">>> Modes: {modes}") - self.log(f">>> Inverter Mode: {mode}") + # Set the mode switch + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: + bits = INVERTER_DEFS[self.type]["bits"] + bin_list = [2**i * switches[bit] for i, bit in enumerate(bits)] + code = sum(bin_list) + entity_id = self.host.config["id_inverter_mode"] + + if self.type == "SOLIS_SOLAX_MODBUS": + entity_modes = self.host.get_state_retry(entity_id, attribute="options") + modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} + # mode = INVERTER_DEFS[self.type]["modes"].get(code) + mode = modes.get(code) + if self.host.debug: + self.log(f">>> Inverter Code: {code}") + self.log(f">>> Entity modes: {entity_modes}") + self.log(f">>> Modes: {modes}") + self.log(f">>> Inverter Mode: {mode}") - self.host.set_select("inverter_mode", mode) + self.host.set_select("inverter_mode", mode) + + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] + self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] - self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) + elif self.type == "SOLIS_CLOUD": + pass def _solis_solax_solarman_mode_switch(self): inverter_mode = self.host.get_state_retry(entity_id=self.host.config["id_inverter_mode"]) @@ -497,8 +531,10 @@ def _solis_state(self): limits = ["start", "end"] if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": status = self._solis_solax_solarman_mode_switch() - else: + elif self.type == "SOLIS_CORE_MODBUS": status = self._solis_core_mode_switch() + else: + status = {} for direction in ["charge", "discharge"]: status[direction] = {} From a2a3a221acc6f303fe4797893b79704e9d65892e Mon Sep 17 00:00:00 2001 From: fboundy Date: Thu, 14 Nov 2024 01:59:19 +0000 Subject: [PATCH 3/3] Add full support for SolisCloud --- .test/solis_cloud_test.py | 227 +++++++++++++++ README.md | 125 +-------- apps/pv_opt/config/config.yaml | 27 +- apps/pv_opt/pv_opt.py | 6 +- apps/pv_opt/solis.py | 491 ++++++++++++++++++++++++--------- 5 files changed, 596 insertions(+), 280 deletions(-) create mode 100644 .test/solis_cloud_test.py diff --git a/.test/solis_cloud_test.py b/.test/solis_cloud_test.py new file mode 100644 index 0000000..2146a40 --- /dev/null +++ b/.test/solis_cloud_test.py @@ -0,0 +1,227 @@ +# %% +import hashlib +import hmac +import base64 +import json +import re +import requests +from http import HTTPStatus +from datetime import datetime, timezone +import pandas as pd + +# def getInverterList(config): +# body = getBody(stationId=config['plantId']) +# print(body) +# body = '{"stationId":"'+config['plantId']+'"}' +# print(body) +# header = prepare_header(config, body, INVERTER_URL) +# response = requests.post("https://www.soliscloud.com:13333"+INVERTER_URL, data = body, headers = header) +# inverterList = response.json() +# inverterId = "" +# for record in inverterList['data']['page']['records']: +# inverterId = record.get('id') +# return inverterList['data']['page']['records'][0] +INVERTER_DEFS = { + "SOLIS_CLOUD": { + "bits": [ + "SelfUse", + "Timed", + "OffGrid", + "BatteryWake", + "Backup", + "GridCharge", + "FeedInPriority", + ], + }, +} + + +class SolisCloud: + URLS = { + "root": "https://www.soliscloud.com:13333", + "login": "/v2/api/login", + "control": "/v2/api/control", + "inverterList": "/v1/api/inverterList", + "inverterDetail": "/v1/api/inverterDetail", + "atRead": "/v2/api/atRead", + } + + def __init__(self, username, password, key_id, key_secret, plant_id): + self.username = username + self.key_id = key_id + self.key_secret = key_secret + self.plant_id = plant_id + self.md5passowrd = hashlib.md5(password.encode("utf-8")).hexdigest() + self.token = "" + + def get_body(self, **params): + body = "{" + for key in params: + body += f'"{key}":"{params[key]}",' + body = body[:-1] + "}" + return body + + def digest(self, body: str) -> str: + return base64.b64encode(hashlib.md5(body.encode("utf-8")).digest()).decode("utf-8") + + def header(self, body: str, canonicalized_resource: str) -> dict[str, str]: + content_md5 = self.digest(body) + content_type = "application/json" + + now = datetime.now(timezone.utc) + date = now.strftime("%a, %d %b %Y %H:%M:%S GMT") + + encrypt_str = "POST" + "\n" + content_md5 + "\n" + content_type + "\n" + date + "\n" + canonicalized_resource + hmac_obj = hmac.new(self.key_secret.encode("utf-8"), msg=encrypt_str.encode("utf-8"), digestmod=hashlib.sha1) + sign = base64.b64encode(hmac_obj.digest()) + authorization = "API " + self.key_id + ":" + sign.decode("utf-8") + + header = { + "Content-MD5": content_md5, + "Content-Type": content_type, + "Date": date, + "Authorization": authorization, + } + return header + + @property + def inverter_id(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("id", "") + + @property + def inverter_sn(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("sn", "") + + @property + def inverter_details(self): + body = self.get_body(id=self.inverter_id, sn=self.inverter_sn) + header = self.header(body, self.URLS["inverterDetail"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterDetail"], data=body, headers=header) + + if response.status_code == HTTPStatus.OK: + return response.json()["data"] + + @property + def is_online(self): + return self.inverter_details["state"] == 1 + + @property + def last_seen(self): + return pd.to_datetime(int(self.inverter_details["dataTimestamp"]), unit="ms") + + def set_code(self, cid, value): + if self.token == "": + self.login() + + if self.token != "": + body = self.get_body(inverterSn=self.inverter_sn, cid=cid, value=value) + headers = self.header(body, self.URLS["control"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["control"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json() + + def read_code(self, cid): + if self.token == "": + self.login() + + if self.token != "": + body = self.get_body(inverterSn=self.inverter_sn, cid=cid) + headers = self.header(body, self.URLS["atRead"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["atRead"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["msg"] + + def login(self): + body = self.get_body(username=self.username, password=self.md5passowrd) + header = self.header(body, self.URLS["login"]) + response = requests.post(self.URLS["root"] + self.URLS["login"], data=body, headers=header) + status = response.status_code + if status == HTTPStatus.OK: + result = response.json() + self.token = result["csrfToken"] + print("Logged in to SolisCloud OK") + + else: + print(status) + + def mode_switch(self): + bits = INVERTER_DEFS["SOLIS_CLOUD"]["bits"] + code = int(self.read_code("636")) + switches = {bit: (code & 2**i == 2**i) for i, bit in enumerate(bits)} + return {"code": code, "switches": switches} + + def timed_status(self, tz="GB"): + data = self.read_code("103").split(",") + return { + "charge": { + "current": float(data[0]), + "start": pd.Timestamp(data[2].split("-")[0], tz=tz), + "end": pd.Timestamp(data[2].split("-")[1], tz=tz), + }, + "discharge": { + "current": float(data[1]), + "start": pd.Timestamp(data[3].split("-")[0], tz=tz), + "end": pd.Timestamp(data[3].split("-")[1], tz=tz), + }, + } + + def read_backup_mode_soc(self): + return int(self.read_code("157")) + + def set_mode_switch(self, code): + return self.set_code("636", code) + + def get_time_string(self, time_status): + time_string = ",".join( + [ + str(int(time_status["charge"]["current"])), + str(int(time_status["discharge"]["current"])), + f'{time_status["charge"]["start"].strftime("%H:%M")}-{time_status["charge"]["end"].strftime("%H:%M")}', + f'{time_status["discharge"]["start"].strftime("%H:%M")}-{time_status["discharge"]["end"].strftime("%H:%M")}', + ] + ) + return f"{time_string},0,0,00:00-00:00,00:00-00:00,0,0,00:00-00:00,00:00-00:00" + + def set_timer(self, direction, start, end, power): + voltage = 50 + current_times = self.timed_status() + new_times = current_times.copy() + new_times[direction]["start"] = start + new_times[direction]["end"] = end + new_times[direction]["current"] = power / voltage + current_time_string = self.read_code(103) + new_time_string = self.get_time_string(new_times) + if new_time_string != current_time_string: + return self.set_code("103", new_time_string) + else: + return {"code": -1} + + +# %% +if __name__ == "__main__": + config = { + "key_secret": "735f96b6131b4691af944de80d2f1a1f", + "key_id": "1300386381676670076", + "plant_id": "1298491919448891215", + "username": "boundywindsor@gmail.com", + "password": "7y@-Ekdh&@F9", + } + + sc = SolisCloud(**config) + sc.login() + print(sc.mode_switch()) + print(sc.timed_status()) + +# %% +sc.set_timer("charge", pd.Timestamp("00:50"), pd.Timestamp("01:00"), 3000) +# %% diff --git a/README.md b/README.md index 7466d85..ebe1409 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.17.2 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.18.0

This documentation needs updating!

@@ -9,8 +9,7 @@ The application will integrate fully with Solis inverters which are controlled u - [Home Assistant Solax Modbus Integration](https://github.com/wills106/homeassistant-solax-modbus) - [Home Assistant Core Modbus Integration](https://github.com/fboundy/ha_solis_modbus) - [Home Assistant Solarman Integration](https://github.com/StephanJoubert/home_assistant_solarman) -- [Home Assistant Solis Sensor Integration](https://github.com/hultenvp/solis-sensor) (read-only mode) -- [Home Assistant Solis Control Integration](https://github.com/stevegal/solis_control) (allows inverter control via solis_cloud and HA automations) +- [Home Assistant Solis Sensor Integration](https://github.com/hultenvp/solis-sensor) Once installed it should require miminal configuration. Other inverters/integrations can be added if required or can be controlled indirectly using automations. @@ -324,126 +323,6 @@ Restarts between Home Assistant and Add-Ons are not synchronised so it is helpfu addon: a0d7b954_appdaemon mode: single -

14. For Solis-Control: Add Automation to Control Inverter

- -If you're using the solis-sensor and solis_control integrations through Solis Cloud, you'll need to add the following automation which will send the messages to Solis Cloud in order to control your inverter. N.B: It's important that you've set up the solis_control integration correctly and requested API access via Solis Cloud Technical Support. - -``` -alias: "Solis: Use PV_Opt" -description: "Use the output of pv_opt to control your Solis inverter via Solis Cloud." -trigger: - - platform: state - entity_id: - - sensor.pvopt_status - to: Idle (Read Only) - for: - hours: 0 - minutes: 0 - seconds: 5 - enabled: false - - platform: time_pattern - hours: /1 - minutes: "00" - seconds: "05" - - platform: time_pattern - hours: /1 - minutes: "30" - seconds: "05" - - platform: state - entity_id: - - sensor.pvopt_charge_start -condition: [] -action: - - if: - - condition: template - value_template: >- - {{ states('sensor.pvopt_charge_start') | as_datetime | as_local <= - today_at("23:59") }} - then: - - data: - days: - - chargeCurrent: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set chargeAmps = min((max(direction, 0.0) | - round(method='floor')), 50)%} {{ chargeAmps }} - dischargeCurrent: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set dischargeAmps = min((min(direction, 0.0) | abs | - round(method='floor')), 50) %} {{ dischargeAmps }} - chargeStartTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set startChargeTime = '00:00' %} {% if direction >= - 0.0 -%} - {% set startChargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_start')))|string)[11:16] %} - {%- endif %} {{ startChargeTime }} - chargeEndTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set endChargeTime = '00:00' %} {% if direction >= 0.0 - -%} - {% set endChargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_end')))|string)[11:16] %} - {%- endif %} {{ endChargeTime }} - dischargeStartTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set startDischargeTime = '00:00' %} {% if direction < - 0.0 -%} - {% set startDischargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_start')))|string)[11:16] %} - {%- endif %} {{ startDischargeTime }} - dischargeEndTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set endDischargeTime = '00:00' %} {% if direction < - 0.0 -%} - {% set endDischargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_end')))|string)[11:16] %} - {%- endif %} {{ endDischargeTime }} - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - config: - secret: <> - key_id: "<>" - username: <> - password: <> - plantId: "<>" - action: pyscript.solis_control - else: - - data: - days: - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - config: - secret: <> - key_id: "<>" - username: <> - password: <> - plantId: "<>" - action: pyscript.solis_control -mode: single -``` -

Configuration

diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index 23d6b51..e7276c6 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -318,39 +318,26 @@ pv_opt: inverter_type: SOLIS_CLOUD device_name: soliscloud + + soliscloud_username: !secret soliscloud_username + soliscloud_password: !secret soliscloud_password + soliscloud_key_id: !secret soliscloud_key_id + soliscloud_key_secret: !secret soliscloud_key_secret + soliscloud_plant_id: !secret soliscloud_plant_id battery_voltage: sensor.{device_name}_battery_voltage - # update_cycle_seconds: 15 + update_cycle_seconds: 0 maximum_dod_percent: sensor.{device_name}_force_discharge_soc id_consumption_today: sensor.{device_name}_daily_grid_energy_used # id_consumption: # - sensor.{device_name}_total_consumption_power - # - sensor.{device_name}_backup_load_power id_grid_import_today: sensor.{device_name}_daily_grid_energy_purchased id_grid_export_today: sensor.{device_name}_daily_on_grid_energy id_battery_soc: sensor.{device_name}_remaining_battery_capacity - # id_grid_import_today: sensor.{device_name}_grid_import_today - # id_grid_export_today: sensor.{device_name}_grid_export_today - - # id_battery_soc: sensor.{device_name}_battery_soc - # id_timed_charge_start_hours: number.{device_name}_timed_charge_start_hours - # id_timed_charge_start_minutes: number.{device_name}_timed_charge_start_minutes - # id_timed_charge_end_hours: number.{device_name}_timed_charge_end_hours - # id_timed_charge_end_minutes: number.{device_name}_timed_charge_end_minutes - # id_timed_charge_current: number.{device_name}_timed_charge_current - - # id_timed_discharge_start_hours: number.{device_name}_timed_discharge_start_hours - # id_timed_discharge_start_minutes: number.{device_name}_timed_discharge_start_minutes - # id_timed_discharge_end_hours: number.{device_name}_timed_discharge_end_hours - # id_timed_discharge_end_minutes: number.{device_name}_timed_discharge_end_minutes - # id_timed_discharge_current: number.{device_name}_timed_discharge_current - - # id_timed_charge_discharge_button: button.{device_name}_update_charge_discharge_times - # id_inverter_mode: select.{device_name}_energy_storage_control_switch # Tariff comparison # id_daily_solar: sensor.{device_name}_power_generation_today diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 160b1f2..640201e 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -12,7 +12,7 @@ from numpy import nan import re -VERSION = "3.17.2" +VERSION = "3.18.0" OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" @@ -459,6 +459,8 @@ class PVOpt(hass.Hass): @ad.app_lock def initialize(self): self.config = {} + self.change_items = {} + self.config_state = {} self.log("") self.log(f"******************* PV Opt v{VERSION} *******************") self.log("") @@ -509,8 +511,6 @@ def initialize(self): if self.debug or self.args.get("list_entities", True): self._list_entities() - self.change_items = {} - self.config_state = {} self.timer_handle = None self.handles = {} self.mqtt_handles = {} diff --git a/apps/pv_opt/solis.py b/apps/pv_opt/solis.py index db7fd51..cbba11f 100644 --- a/apps/pv_opt/solis.py +++ b/apps/pv_opt/solis.py @@ -1,5 +1,22 @@ import pandas as pd import time +import hashlib +import hmac +import base64 +import json +import re +import requests +from http import HTTPStatus +from datetime import datetime, timezone + +URLS = { + "root": "https://www.soliscloud.com:13333", + "login": "/v2/api/login", + "control": "/v2/api/control", + "inverterList": "/v1/api/inverterList", + "atRead": "/v2/api/atRead", +} + TIMEFORMAT = "%H:%M" INVERTER_DEFS = { @@ -205,16 +222,24 @@ }, }, "SOLIS_CLOUD": { - "online": "sensor.{device_name}_temperature", + "bits": [ + "SelfUse", + "Timed", + "OffGrid", + "BatteryWake", + "Backup", + "GridCharge", + "FeedInPriority", + ], "default_config": { "maximum_dod_percent": "sensor.{device_name}_force_discharge_soc", "id_consumption_today": "sensor.{device_name}_daily_grid_energy_used", "id_grid_import_today": "sensor.{device_name}_daily_grid_energy_purchased", "id_grid_export_today": "sensor.{device_name}_daily_on_grid_energy", "id_battery_soc": "sensor.{device_name}_remaining_battery_capacity", - "supports_hold_soc": False, + "supports_hold_soc": True, "supports_forced_discharge": True, - "update_cycle_seconds": 300, + "update_cycle_seconds": 0, }, "brand_config": { "battery_voltage": "sensor.{device_name}_battery_voltage", @@ -223,6 +248,177 @@ } +class SolisCloud: + URLS = { + "root": "https://www.soliscloud.com:13333", + "login": "/v2/api/login", + "control": "/v2/api/control", + "inverterList": "/v1/api/inverterList", + "inverterDetail": "/v1/api/inverterDetail", + "atRead": "/v2/api/atRead", + } + + def __init__(self, username, password, key_id, key_secret, plant_id, **kwargs): + self.username = username + self.key_id = key_id + self.key_secret = key_secret + self.plant_id = plant_id + self.md5password = hashlib.md5(password.encode("utf-8")).hexdigest() + self.token = "" + self.log = kwargs.get("log", print) + + def get_body(self, **params): + body = "{" + for key in params: + body += f'"{key}":"{params[key]}",' + body = body[:-1] + "}" + return body + + def digest(self, body: str) -> str: + return base64.b64encode(hashlib.md5(body.encode("utf-8")).digest()).decode("utf-8") + + def header(self, body: str, canonicalized_resource: str) -> dict[str, str]: + content_md5 = self.digest(body) + content_type = "application/json" + + now = datetime.now(timezone.utc) + date = now.strftime("%a, %d %b %Y %H:%M:%S GMT") + + encrypt_str = "POST" + "\n" + content_md5 + "\n" + content_type + "\n" + date + "\n" + canonicalized_resource + hmac_obj = hmac.new(self.key_secret.encode("utf-8"), msg=encrypt_str.encode("utf-8"), digestmod=hashlib.sha1) + sign = base64.b64encode(hmac_obj.digest()) + authorization = "API " + str(self.key_id) + ":" + sign.decode("utf-8") + + header = { + "Content-MD5": content_md5, + "Content-Type": content_type, + "Date": date, + "Authorization": authorization, + } + return header + + @property + def inverter_id(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("id", "") + + @property + def inverter_sn(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("sn", "") + + @property + def inverter_details(self): + body = self.get_body(id=self.inverter_id, sn=self.inverter_sn) + header = self.header(body, self.URLS["inverterDetail"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterDetail"], data=body, headers=header) + + if response.status_code == HTTPStatus.OK: + return response.json()["data"] + + @property + def is_online(self): + return self.inverter_details["state"] == 1 + + @property + def last_seen(self): + return pd.to_datetime(int(self.inverter_details["dataTimestamp"]), unit="ms") + + def read_code(self, cid): + if self.token == "": + self.login() + body = self.get_body(inverterSn=self.inverter_sn, cid=cid) + headers = self.header(body, self.URLS["atRead"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["atRead"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["msg"] + + def set_code(self, cid, value): + if self.token == "": + self.login() + + if self.token != "": + body = self.get_body(inverterSn=self.inverter_sn, cid=cid, value=value) + headers = self.header(body, self.URLS["control"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["control"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json() + + def login(self): + body = self.get_body(username=self.username, password=self.md5password) + header = self.header(body, self.URLS["login"]) + response = requests.post(self.URLS["root"] + self.URLS["login"], data=body, headers=header) + status = response.status_code + if status == HTTPStatus.OK: + result = response.json() + self.token = result["csrfToken"] + print("Logged in to SolisCloud OK") + + else: + print(status) + + def read_mode_switch(self): + bits = INVERTER_DEFS["SOLIS_CLOUD"]["bits"] + code = int(self.read_code("636")) + switches = {bit: (code & 2**i == 2**i) for i, bit in enumerate(bits)} + return {"code": code, "switches": switches} + + def timed_status(self, tz="GB"): + data = self.read_code("103").split(",") + return { + "charge": { + "current": float(data[0]), + "start": pd.Timestamp(data[2].split("-")[0], tz=tz), + "end": pd.Timestamp(data[2].split("-")[1], tz=tz), + }, + "discharge": { + "current": float(data[1]), + "start": pd.Timestamp(data[3].split("-")[0], tz=tz), + "end": pd.Timestamp(data[3].split("-")[1], tz=tz), + }, + } + + def read_backup_mode_soc(self): + return int(self.read_code("157")) + + def set_mode_switch(self, code): + return self.set_code("636", code) + + def get_time_string(self, time_status): + time_string = ",".join( + [ + str(int(time_status["charge"]["current"])), + str(int(time_status["discharge"]["current"])), + f'{time_status["charge"]["start"].strftime("%H:%M")}-{time_status["charge"]["end"].strftime("%H:%M")}', + f'{time_status["discharge"]["start"].strftime("%H:%M")}-{time_status["discharge"]["end"].strftime("%H:%M")}', + ] + ) + return f"{time_string},0,0,00:00-00:00,00:00-00:00,0,0,00:00-00:00,00:00-00:00" + + def set_timer(self, direction, start, end, current): + current_times = self.timed_status() + new_times = current_times.copy() + if start is not None: + new_times[direction]["start"] = start + if end is not None: + new_times[direction]["end"] = end + new_times[direction]["current"] = current + current_time_string = self.read_code(103) + new_time_string = self.get_time_string(new_times) + if new_time_string != current_time_string: + return self.set_code("103", new_time_string) + else: + return {"code": -1} + + class InverterController: def __init__(self, inverter_type, host) -> None: self.host = host @@ -243,20 +439,30 @@ def __init__(self, inverter_type, host) -> None: conf[item] = [z.replace("{device_name}", self.host.device_name) for z in defs[item]] else: conf[item] = defs[item] + if self.type == "SOLIS_CLOUD": + params = { + item: host.args.get(f"soliscloud_{item}") + for item in ["username", "password", "key_id", "key_secret", "plant_id"] + } + if all([x is not None for x in params.values()]): + self.cloud = SolisCloud(**params, log=self.log) + else: + raise Exception("Unable to create Solis Cloud controller") def is_online(self): - entity_id = INVERTER_DEFS[self.type].get("online", (None, None)) - if entity_id is not None: - entity_id = entity_id.replace("{device_name}", self.host.device_name) - return self.host.get_state_retry(entity_id) not in ["unknown", "unavailable"] + if self.type == "SOLIS_CLOUD": + return self.cloud.is_online else: - return True + entity_id = INVERTER_DEFS[self.type].get("online", (None, None)) + if entity_id is not None: + entity_id = entity_id.replace("{device_name}", self.host.device_name) + return self.host.get_state_retry(entity_id) not in ["unknown", "unavailable"] + else: + return True def enable_timed_mode(self): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: self._solis_set_mode_switch(SelfUse=True, Timed=True, GridCharge=True, Backup=False) - elif self.type == "SOLIS_CLOUD": - pass else: self._unknown_inverter() @@ -271,7 +477,7 @@ def control_discharge(self, enable, **kwargs): self._control_charge_discharge("discharge", enable, **kwargs) def hold_soc(self, enable, soc=None, **kwargs): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: start = kwargs.get("start", pd.Timestamp.now(tz=self.tz).floor("1min")) end = kwargs.get("end", pd.Timestamp.now(tz=self.tz).ceil("30min")) self._solis_control_charge_discharge( @@ -281,8 +487,6 @@ def hold_soc(self, enable, soc=None, **kwargs): end=end, power=0, ) - elif self.type == "SOLIS_CLOUD": - pass else: self._unknown_inverter() @@ -325,9 +529,7 @@ def hold_soc_old(self, enable, soc=None): @property def status(self): status = None - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - status = self._solis_state() - elif self.type == "SOLIS_CLOUD": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: status = self._solis_state() return status @@ -335,10 +537,8 @@ def _monitor_target_soc(self, target_soc, mode="charge"): pass def _control_charge_discharge(self, direction, enable, **kwargs): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: self._solis_control_charge_discharge(direction, enable, **kwargs) - elif self.type == "SOLIS_CLOUD": - pass def _solis_control_charge_discharge(self, direction, enable, **kwargs): status = self._solis_state() @@ -379,132 +579,146 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): write_flag = True value_changed = False - for limit in times: - if times[limit] is not None: - for unit in ["hours", "minutes"]: - entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] - if unit == "hours": - value = times[limit].hour - else: - value = times[limit].minute + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: + for limit in times: + if times[limit] is not None: + for unit in ["hours", "minutes"]: + entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] + if unit == "hours": + value = times[limit].hour + else: + value = times[limit].minute - if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self.host.write_and_poll_value( - entity_id=entity_id, value=value, verbose=True - ) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - changed, written = self._solis_write_time_register(direction, limit, unit, value) + if self.type == "SOLIS_SOLAX_MODBUS": + changed, written = self.host.write_and_poll_value( + entity_id=entity_id, value=value, verbose=True + ) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + changed, written = self._solis_write_time_register(direction, limit, unit, value) + + else: + e = "Unknown inverter type" + self.log(e, level="ERROR") + raise Exception(e) + + if changed: + if written: + self.log(f"Wrote {direction} {limit} {unit} of {value} to inverter") + value_changed = True + else: + self.log( + f"Failed to write {direction} {limit} {unit} to inverter", + level="ERROR", + ) + write_flag = False + + if value_changed: + if self.type == "SOLIS_SOLAX_MODBUS" and write_flag: + entity_id = self.host.config["id_timed_charge_discharge_button"] + self.host.call_service("button/press", entity_id=entity_id) + time.sleep(0.5) + try: + time_pressed = pd.Timestamp(self.host.get_state_retry(entity_id)) + + dt = (pd.Timestamp.now(self.host.tz) - time_pressed).total_seconds() + if dt < 10: + self.log(f"Successfully pressed button {entity_id}") - else: - e = "Unknown inverter type" - self.log(e, level="ERROR") - raise Exception(e) - - if changed: - if written: - self.log(f"Wrote {direction} {limit} {unit} of {value} to inverter") - value_changed = True else: self.log( - f"Failed to write {direction} {limit} {unit} to inverter", - level="ERROR", + f"Failed to press button {entity_id}. Last pressed at {time_pressed.strftime(TIMEFORMAT)} ({dt:0.2f} seconds ago)" ) - write_flag = False + except: + self.log(f"Failed to press button {entity_id}: it appears to never have been pressed.") - if value_changed: - if self.type == "SOLIS_SOLAX_MODBUS" and write_flag: - entity_id = self.host.config["id_timed_charge_discharge_button"] - self.host.call_service("button/press", entity_id=entity_id) - time.sleep(0.5) - try: - time_pressed = pd.Timestamp(self.host.get_state_retry(entity_id)) - - dt = (pd.Timestamp.now(self.host.tz) - time_pressed).total_seconds() - if dt < 10: - self.log(f"Successfully pressed button {entity_id}") + else: + self.log("Inverter already at correct time settings") + + if power is not None: + entity_id = self.host.config[f"id_timed_{direction}_current"] + + current = abs(round(power / self.host.get_config("battery_voltage"), 1)) + current = min(current, self.host.get_config("battery_current_limit_amps")) + self.log(f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V") + if self.type == "SOLIS_SOLAX_MODBUS": + changed, written = self.host.write_and_poll_value(entity_id=entity_id, value=current, tolerance=1) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + changed, written = self._solis_write_current_register(direction, current, tolerance=1) + else: + e = "Unknown inverter type" + self.log(e, level="ERROR") + raise Exception(e) + if changed: + if written: + self.log(f"Current {current}A written to inverter") else: - self.log( - f"Failed to press button {entity_id}. Last pressed at {time_pressed.strftime(TIMEFORMAT)} ({dt:0.2f} seconds ago)" - ) - except: - self.log(f"Failed to press button {entity_id}: it appears to never have been pressed.") - - else: - self.log("Inverter already at correct time settings") - - if power is not None: - entity_id = self.host.config[f"id_timed_{direction}_current"] + self.log(f"Failed to write {current} to inverter") + else: + self.log("Inverter already at correct current") - current = abs(round(power / self.host.get_config("battery_voltage"), 1)) + elif self.type == "SOLIS_CLOUD": + current = abs(round(power / self.host.get_config("battery_voltage"), 0)) current = min(current, self.host.get_config("battery_current_limit_amps")) - self.log(f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V") - if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self.host.write_and_poll_value(entity_id=entity_id, value=current, tolerance=1) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - changed, written = self._solis_write_current_register(direction, current, tolerance=1) - else: - e = "Unknown inverter type" - self.log(e, level="ERROR") - raise Exception(e) - - if changed: - if written: - self.log(f"Current {current} written to inverter") - else: - self.log(f"Failed to write {current} to inverter") - else: - self.log("Inverter already at correct current") + self.log(f"Power {power:0.0f} = {current:0.0f}A at {self.host.get_config('battery_voltage')}V") + response = self.cloud.set_timer(direction, times["start"], times["end"], current) + if response["code"] == -1: + self.log("Inverter already at correct time and current settings") + elif response["code"] == 0: + self.log( + f"Wrote {direction} time of {times['start'].strftime('%H:%M')}-{times['end'].strftime('%H:%M')} to inverter" + ) + self.log(f"Current {current}A written to inverter") def _solis_set_mode_switch(self, **kwargs): # Read the mode switch - if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": - status = self._solis_solax_solarman_mode_switch() + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": + status = self._solis_solax_solarman_mode_switch() - elif self.type == "SOLIS_CORE_MODBUS": - status = self._solis_core_mode_switch() + elif self.type == "SOLIS_CORE_MODBUS": + status = self._solis_core_mode_switch() - switches = status["switches"] - if self.host.debug: - self.log(f">>> kwargs: {kwargs}") - self.log(">>> Solis switch status:") + elif self.type == "SOLIS_CLOUD": + status = self.cloud.read_mode_switch() - for switch in switches: - if switch in kwargs: - if self.host.debug: - self.log(f">>> {switch}: {kwargs[switch]}") - switches[switch] = kwargs[switch] + switches = status["switches"] + if self.host.debug: + self.log(f">>> kwargs: {kwargs}") + self.log(">>> Solis switch status:") - elif self.type == "SOLIS_CLOUD": - pass + for switch in switches: + if switch in kwargs: + if self.host.debug: + self.log(f">>> {switch}: {kwargs[switch]}") + switches[switch] = kwargs[switch] # Set the mode switch - if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: - bits = INVERTER_DEFS[self.type]["bits"] - bin_list = [2**i * switches[bit] for i, bit in enumerate(bits)] - code = sum(bin_list) + bits = INVERTER_DEFS[self.type]["bits"] + bin_list = [2**i * switches[bit] for i, bit in enumerate(bits)] + code = sum(bin_list) + + if self.type != "SOLIS_CLOUD": entity_id = self.host.config["id_inverter_mode"] - if self.type == "SOLIS_SOLAX_MODBUS": - entity_modes = self.host.get_state_retry(entity_id, attribute="options") - modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} - # mode = INVERTER_DEFS[self.type]["modes"].get(code) - mode = modes.get(code) - if self.host.debug: - self.log(f">>> Inverter Code: {code}") - self.log(f">>> Entity modes: {entity_modes}") - self.log(f">>> Modes: {modes}") - self.log(f">>> Inverter Mode: {mode}") + if self.type == "SOLIS_SOLAX_MODBUS": + entity_modes = self.host.get_state_retry(entity_id, attribute="options") + modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} + # mode = INVERTER_DEFS[self.type]["modes"].get(code) + mode = modes.get(code) + if self.host.debug: + self.log(f">>> Inverter Code: {code}") + self.log(f">>> Entity modes: {entity_modes}") + self.log(f">>> Modes: {modes}") + self.log(f">>> Inverter Mode: {mode}") - self.host.set_select("inverter_mode", mode) + self.host.set_select("inverter_mode", mode) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] - self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] + self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) elif self.type == "SOLIS_CLOUD": - pass + self.cloud.set_mode_switch(code) def _solis_solax_solarman_mode_switch(self): inverter_mode = self.host.get_state_retry(entity_id=self.host.config["id_inverter_mode"]) @@ -529,28 +743,35 @@ def _solis_core_mode_switch(self): def _solis_state(self): limits = ["start", "end"] + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": status = self._solis_solax_solarman_mode_switch() elif self.type == "SOLIS_CORE_MODBUS": status = self._solis_core_mode_switch() - else: - status = {} + elif self.type == "SOLIS_CLOUD": + status = self.cloud.read_mode_switch() + + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: + for direction in ["charge", "discharge"]: + status[direction] = {} + for limit in limits: + states = {} + for unit in ["hours", "minutes"]: + entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] + states[unit] = int(float(self.host.get_state_retry(entity_id=entity_id))) + status[direction][limit] = pd.Timestamp( + f"{states['hours']:02d}:{states['minutes']:02d}", tz=self.host.tz + ) - for direction in ["charge", "discharge"]: - status[direction] = {} - for limit in limits: - states = {} - for unit in ["hours", "minutes"]: - entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] - states[unit] = int(float(self.host.get_state_retry(entity_id=entity_id))) - status[direction][limit] = pd.Timestamp( - f"{states['hours']:02d}:{states['minutes']:02d}", tz=self.host.tz - ) - time_now = pd.Timestamp.now(tz=self.tz) status[direction]["current"] = float( self.host.get_state_retry(self.host.config[f"id_timed_{direction}_current"]) ) + elif self.type == "SOLIS_CLOUD": + status = status | self.cloud.timed_status(tz=self.host.tz) + + time_now = pd.Timestamp.now(tz=self.tz) + for direction in ["charge", "discharge"]: status[direction]["active"] = ( time_now >= status[direction]["start"] and time_now < status[direction]["end"] @@ -562,6 +783,8 @@ def _solis_state(self): status["hold_soc"] = {"active": status["switches"]["Backup"]} if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS": status["hold_soc"]["soc"] = self.host.get_config("id_backup_mode_soc") + elif self.type == "SOLIS_CLOUD": + status["hold_soc"]["soc"] = self.cloud.read_backup_mode_soc() else: status["hold_soc"]["soc"] = None