diff --git a/custom_components/solix_ble/__init__.py b/custom_components/solix_ble/__init__.py index 94f69fa..1cf9fbd 100644 --- a/custom_components/solix_ble/__init__.py +++ b/custom_components/solix_ble/__init__.py @@ -21,6 +21,7 @@ Generic, PrimeCharger160w, PrimeCharger250w, + Solarbank2, SolixBLEDevice, ) @@ -52,6 +53,8 @@ def get_power_station_class(model: Models) -> SolixBLEDevice: return PrimeCharger160w elif model is Models.PRIME_CHARGER_250: return PrimeCharger250w + elif model is Models.SOLARBANK_2: + return Solarbank2 elif model is Models.UNKNOWN: return Generic else: diff --git a/custom_components/solix_ble/const.py b/custom_components/solix_ble/const.py index 3f3da50..93fb380 100644 --- a/custom_components/solix_ble/const.py +++ b/custom_components/solix_ble/const.py @@ -4,8 +4,18 @@ DOMAIN = "solix_ble" + +############### +# Port status # +############### + PORT_STATUS_STRINGS = ["Unknown", "Not connected", "Output", "Input"] + +################### +# Charging status # +################### + CHARGING_STATUS_C300_STRINGS = ["Unknown", "Idle", "Discharging", "Charging"] CHARGING_STATUS_F3800_STRINGS = [ @@ -16,11 +26,65 @@ "Charging (Both)", ] + +################ +# Light status # +################ + LIGHT_STATUS_STRINGS = ["Unknown", "Off", "Low", "Medium", "High"] +LIGHT_STATUS_SB2_STRINGS = ["Unknown", "Normal", "Mood"] + + +################### +# Overload status # +################### + OVERLOAD_STATUS_C300DC_STRINGS = ["Unknown", "None", "USB C1", "USB C2", "USB C3"] +################### +# Max load status # +################### + +MAX_LOAD_SB2_STRINGS = ["Unknown", "350w", "600w", "800w", "1000w"] + + +############### +# Grid status # +############### + +GRID_STATUS_STRINGS = ["Unknown", "Ok", "Ok?", "Connecting", "No grid"] + + +############## +# Usage mode # +############## + +USAGE_MODE_SB2_STRINGS = [ + "Unknown", + "Manual", + "Smart meter", + "Smart plugs", + "Backup", + "Use Time", + "Smart", + "Time slot", +] + + +################## +# Battery cutoff # +################## + +CUT_OFF_SB2_STRINGS = ["Unknown", "5%", "10%"] + + +#################### +# Supported models # +#################### + + class Models(Enum): C300 = "C300(X)" C300DC = "C300(X) DC" @@ -31,4 +95,5 @@ class Models(Enum): F3800 = "F3800" PRIME_CHARGER_160 = "Prime Charger (160w)" PRIME_CHARGER_250 = "Prime Charger (250w)" + SOLARBANK_2 = "Solarbank 2" UNKNOWN = "Unknown" diff --git a/custom_components/solix_ble/sensor.py b/custom_components/solix_ble/sensor.py index c46e92c..9d8bdd3 100644 --- a/custom_components/solix_ble/sensor.py +++ b/custom_components/solix_ble/sensor.py @@ -22,15 +22,21 @@ F3800, PrimeCharger160w, PrimeCharger250w, + Solarbank2, SolixBLEDevice, ) from .const import ( CHARGING_STATUS_C300_STRINGS, CHARGING_STATUS_F3800_STRINGS, + CUT_OFF_SB2_STRINGS, + GRID_STATUS_STRINGS, + LIGHT_STATUS_SB2_STRINGS, LIGHT_STATUS_STRINGS, + MAX_LOAD_SB2_STRINGS, OVERLOAD_STATUS_C300DC_STRINGS, PORT_STATUS_STRINGS, + USAGE_MODE_SB2_STRINGS, ) _LOGGER = logging.getLogger(__name__) @@ -101,7 +107,7 @@ async def async_setup_entry( ), # Battery percentage sensor - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -112,6 +118,30 @@ async def async_setup_entry( ) ) + # Battery charge power sensor + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Battery charge power", + "W", + "battery_charge_power", + SensorDeviceClass.POWER, + ) + ) + + # Battery discharge power sensor + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Battery discharge power", + "W", + "battery_discharge_power", + SensorDeviceClass.POWER, + ) + ) + # Battery health sensor if type(device) in [C300DC, C800, C1000, C1000G2, F2000]: sensors.append( @@ -124,8 +154,62 @@ async def async_setup_entry( ) ) + # Battery charged energy (energy in) + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Battery input energy", + "kWh", + "charged_energy", + SensorDeviceClass.ENERGY, + state_class=None, + ) + ) + + # Solarbank dispensed energy (energy out) + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Total output energy", + "kWh", + "output_energy", + SensorDeviceClass.ENERGY, + state_class=None, + ) + ) + + # Output cutoff thresholds + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Output cutoff threshold", + None, + "output_cutoff_data", + SensorDeviceClass.ENUM, + CUT_OFF_SB2_STRINGS, + state_class=None, + ) + ) + + # Input cutoff thresholds + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Input cutoff threshold", + None, + "input_cutoff_data", + SensorDeviceClass.ENUM, + CUT_OFF_SB2_STRINGS, + state_class=None, + ) + ) + # Temperature sensor - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -145,7 +229,7 @@ async def async_setup_entry( ) # Total power out sensor - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F3800]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, "Total Power Out", "W", "power_out", SensorDeviceClass.POWER @@ -162,10 +246,10 @@ async def async_setup_entry( "ac_power_in", SensorDeviceClass.POWER, ) - ), + ) # AC power out sensor - if type(device) in [C300, C800, C1000, C1000G2, F2000, F3800]: + if type(device) in [C300, C800, C1000, C1000G2, F2000, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -174,7 +258,7 @@ async def async_setup_entry( "ac_power_out", SensorDeviceClass.POWER, ) - ), + ) # AC output on/off sensor if type(device) in [C300, C800, C1000, C1000G2, F3800]: @@ -204,7 +288,7 @@ async def async_setup_entry( ) # Solar power in - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -215,6 +299,19 @@ async def async_setup_entry( ) ) + # Solar yield + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "PV Yield", + "kWh", + "pv_yield", + SensorDeviceClass.ENERGY, + state_class=None, + ) + ) + # DC power out if type(device) in [C300, C300DC, C1000G2]: sensors.append( @@ -669,8 +766,20 @@ async def async_setup_entry( ) ) + # Error status + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Error code", + None, + "error_code", + state_class=None, + ) + ) + # Firmware version - if type(device) in [C300, C300DC, C800, C1000, F2000, F3800]: + if type(device) in [C300, C300DC, C800, C1000, F2000, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -682,7 +791,7 @@ async def async_setup_entry( ) # Serial number - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -718,7 +827,7 @@ async def async_setup_entry( ) # Average battery percentage across all batteries - if type(device) in [F3800]: + if type(device) in [F3800, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -742,7 +851,7 @@ async def async_setup_entry( ) # Expansion battery firmware version - if type(device) in [C1000, F2000]: + if type(device) in [C1000, F2000, Solarbank2]: sensors.append( SolixSensorEntity( device, @@ -764,6 +873,212 @@ async def async_setup_entry( ) ) + ###################### + # Solar bank sensors # + ###################### + + # Grid to home power + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Grid to Home power", + "W", + "grid_to_home_power", + SensorDeviceClass.POWER, + ) + ) + + # PV to grid power + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "PV to Grid power", + "W", + "pv_to_grid_power", + SensorDeviceClass.POWER, + ) + ) + + # Grid import energy + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Grid import energy", + "kWh", + "grid_import_energy", + SensorDeviceClass.ENERGY, + state_class=None, + ) + ) + + # Grid export energy + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Grid export energy", + "kWh", + "grid_export_energy", + SensorDeviceClass.ENERGY, + state_class=None, + ) + ) + + # House demand (power used by house) + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "House demand power", + "W", + "house_demand", + SensorDeviceClass.POWER, + ) + ) + + # House demand (power used by house) + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "House consumed energy", + "kWh", + "consumed_energy", + SensorDeviceClass.ENERGY, + state_class=None, + ) + ) + + # Power out of the built-in sockets + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "AC Power Out Sockets", + "W", + "ac_power_out_sockets", + SensorDeviceClass.POWER, + ) + ) + + # Max load + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Maximum load", + None, + "max_load", + SensorDeviceClass.ENUM, + MAX_LOAD_SB2_STRINGS, + state_class=None, + ) + ) + + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Usage mode", + None, + "usage_mode", + SensorDeviceClass.ENUM, + USAGE_MODE_SB2_STRINGS, + state_class=None, + ) + ) + + # Solar PV power in for port 1 + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Solar Power In Port 1", + "W", + "solar_pv_1_power_in", + SensorDeviceClass.POWER, + ) + ) + + # Solar PV power in for port 2 + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Solar Power In Port 2", + "W", + "solar_pv_2_power_in", + SensorDeviceClass.POWER, + ) + ) + + # Solar PV power in for port 3 + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Solar Power In Port 3", + "W", + "solar_pv_3_power_in", + SensorDeviceClass.POWER, + ) + ) + + # Solar PV power in for port 4 + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Solar Power In Port 4", + "W", + "solar_pv_4_power_in", + SensorDeviceClass.POWER, + ) + ) + + # Solarbank light status + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Status light", + None, + "light_mode", + SensorDeviceClass.ENUM, + LIGHT_STATUS_SB2_STRINGS, + None, + ) + ) + + # Grid status + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Grid Status", + None, + "grid_status", + SensorDeviceClass.ENUM, + GRID_STATUS_STRINGS, + None, + ) + ) + + # Heater status + if type(device) in [Solarbank2]: + sensors.append( + SolixSensorEntity( + device, + "Battery Heating", + None, + "battery_heating", + None, + ) + ) + async_add_entities(sensors) @@ -825,7 +1140,9 @@ def _update_updatable_attributes(self) -> None: # If enum use enum strings elif self._attr_device_class == SensorDeviceClass.ENUM: - self._attr_native_value = self._attr_options[attribute_value.value + 1] + self._attr_native_value = self._attr_options[ + list(type(attribute_value)).index(attribute_value) + ] # Else pass through value else: diff --git a/requirements_dev.txt b/requirements_dev.txt index 63795d7..b14174d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,4 +4,6 @@ pytest-homeassistant-custom-component pyserial pytest-cov aiousbwatcher +ruff +mypy SolixBLE==3.8.0 diff --git a/tests/__init__.py b/tests/__init__.py index 3b82e21..4a10510 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -7,6 +7,13 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from habluetooth import BluetoothServiceInfoBleak from SolixBLE import LightStatus, PortStatus +from SolixBLE.devices.solarbank2 import ( + GridStatus, + LightMode, + MaxLoadSB2, + SBPowerCutoff, + SBUsageMode, +) from custom_components.solix_ble.const import Models @@ -168,6 +175,13 @@ def get_service_info(self) -> BluetoothServiceInfoBleak: model_class=Models.PRIME_CHARGER_250, ) +MOCK_SOLAR_BANK_2_DETAILS = MockDeviceDetails( + name="Solar Bank 2", + addr="AA:BB:CC:DD:00:02", + model_string="Solarbank 2", + model_class=Models.SOLARBANK_2, +) + MOCK_UNKNOWN_DETAILS = MockDeviceDetails( name="Anker SOLIX IDK", addr="AA:BB:CC:DD:EE:04", @@ -337,4 +351,44 @@ def get_service_info(self) -> BluetoothServiceInfoBleak: "usb_port_a2": ("status_usb_a2", PortStatus.NOT_CONNECTED), } + +# Sometimes the method name we are patching and the +# entity ID do not line up, so a tuple is used to +# manually specify it +MOCK_SOLAR_BANK_2_TEST_DATA = { + "battery_percentage": 56, + "software_version": ("firmware_version", "0.0.1"), + "software_version_expansion": ("expansion_battery_firmware_version", "0.0.2"), + "serial_number": "0.1.2.3", + "temperature": -5, + "solar_power_in": 150.3, + "ac_power_out": 700.1, + "battery_percentage_aggregate": ("average_battery_percentage", 90), + "battery_charge_power": 0, + "pv_yield": 1053.2, + "charged_energy": ("battery_input_energy", 124.3), + "output_energy": ("total_output_energy", 12.7), + "output_cutoff_data": ("output_cutoff_threshold", SBPowerCutoff.P5, "5%"), + "input_cutoff_data": ("input_cutoff_threshold", SBPowerCutoff.P10, "10%"), + "battery_discharge_power": 12.4, + "grid_to_home_power": 12.5, + "pv_to_grid_power": 1245.1, + "grid_import_energy": 12.3, + "grid_export_energy": 1257.3, + "house_demand": ("house_demand_power", 124.6), + "ac_power_out_sockets": 1.67, + "max_load": ("maximum_load", MaxLoadSB2.W350, "350w"), + "usage_mode": ("usage_mode", SBUsageMode.MANUAL, "Manual"), + "consumed_energy": ("house_consumed_energy", 124.5), + "solar_pv_1_power_in": ("solar_power_in_port_1", 13.1), + "solar_pv_2_power_in": ("solar_power_in_port_2", 13.2), + "solar_pv_3_power_in": ("solar_power_in_port_3", 13.3), + "solar_pv_4_power_in": ("solar_power_in_port_4", 13.4), + "power_out": ("total_power_out", 14.745), + "error_code": 0, + "light_mode": ("status_light", LightMode.NORMAL, "Normal"), + "grid_status": ("grid_status", GridStatus.OK, "Ok"), + "battery_heating": True, +} + MOCK_UNKNOWN_TEST_DATA = {} diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 895ceaf..562f50a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -24,6 +24,7 @@ MOCK_F3800_DETAILS, MOCK_PRIME_160_DETAILS, MOCK_PRIME_250_DETAILS, + MOCK_SOLAR_BANK_2_DETAILS, MOCK_UNKNOWN_DETAILS, BLEDevice, MockDeviceDetails, @@ -45,6 +46,7 @@ MOCK_F3800_DETAILS, MOCK_PRIME_160_DETAILS, MOCK_PRIME_250_DETAILS, + MOCK_SOLAR_BANK_2_DETAILS, MOCK_UNKNOWN_DETAILS, ], ids=[ @@ -59,6 +61,7 @@ "f3800", "prime_160w", "prime_250w", + "solar_bank_2", "unknown", ], ) diff --git a/tests/test_init.py b/tests/test_init.py index 98f5c02..2560c78 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -26,6 +26,7 @@ MOCK_F3800_DETAILS, MOCK_PRIME_160_DETAILS, MOCK_PRIME_250_DETAILS, + MOCK_SOLAR_BANK_2_DETAILS, MOCK_UNKNOWN_DETAILS, MockDeviceDetails, ) @@ -46,6 +47,9 @@ pytest.param(MOCK_F3800_DETAILS, MOCK_F3800_DETAILS, id="f3800"), pytest.param(MOCK_PRIME_160_DETAILS, MOCK_PRIME_160_DETAILS, id="prime_160w"), pytest.param(MOCK_PRIME_250_DETAILS, MOCK_PRIME_250_DETAILS, id="prime_250w"), + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="solar_bank_2" + ), pytest.param(MOCK_UNKNOWN_DETAILS, MOCK_UNKNOWN_DETAILS, id="unknown"), ], indirect=["mock_config_entry"], diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b184163..4f51118 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -30,6 +30,8 @@ MOCK_PRIME_160_TEST_DATA, MOCK_PRIME_250_DETAILS, MOCK_PRIME_250_TEST_DATA, + MOCK_SOLAR_BANK_2_DETAILS, + MOCK_SOLAR_BANK_2_TEST_DATA, MOCK_UNKNOWN_DETAILS, MOCK_UNKNOWN_TEST_DATA, MockDeviceDetails, @@ -94,6 +96,13 @@ MOCK_PRIME_250_TEST_DATA, id="prime_250w", ), + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, + MOCK_SOLAR_BANK_2_DETAILS, + "Solarbank2", + MOCK_SOLAR_BANK_2_TEST_DATA, + id="solar_bank_2", + ), pytest.param( MOCK_UNKNOWN_DETAILS, MOCK_UNKNOWN_DETAILS, @@ -162,8 +171,10 @@ async def test_sensor_entities( # If the entity ID is manually specified use that rather # than using the method name of the underlying class + str_value = None if type(value) is tuple: key = value[0] + str_value = value[2] if len(value) == 3 else None value = value[1] # There are not enough words in the universe to express how @@ -176,6 +187,9 @@ async def test_sensor_entities( elif type(value) is PortStatus or type(value) is LightStatus: value = value.name.capitalize().replace("_", " ") + elif str_value is not None: + value = str_value + else: value = f"{value}"