diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index a8cbd16..d602ef9 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,5 +1,5 @@ pip==23.0.1 -pre-commit==3.2.0 -black==23.1.0 +pre-commit==3.2.1 +black==23.3.0 flake8==6.0.0 reorder-python-imports==3.9.0 diff --git a/custom_components/sunspec/api.py b/custom_components/sunspec/api.py index 1b8f709..f5e8402 100644 --- a/custom_components/sunspec/api.py +++ b/custom_components/sunspec/api.py @@ -9,7 +9,7 @@ from sunspec2.modbus.client import SunSpecModbusClientTimeout from sunspec2.modbus.modbus import ModbusClientError -TIMEOUT = 60 +TIMEOUT = 120 _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -69,6 +69,12 @@ def getPoint(self, point_name, model_index=0): ) +# pragma: not covered +def progress(msg): + _LOGGER.debug(msg) + return True + + class SunSpecApiClient: CLIENT_CACHE = {} @@ -150,7 +156,9 @@ def modbus_connect(self, config=None): f"Failed to connect to {self._host}:{self._port} slave id {self._slave_id}" ) _LOGGER.debug("Client connected, perform initial scan") - client.scan(connect=False) + client.scan( + connect=False, progress=progress, full_model_read=False, delay=0.5 + ) return client except ModbusClientError: raise ConnectionError( diff --git a/custom_components/sunspec/manifest.json b/custom_components/sunspec/manifest.json index 0b6579a..3c61f78 100644 --- a/custom_components/sunspec/manifest.json +++ b/custom_components/sunspec/manifest.json @@ -7,6 +7,11 @@ "dependencies": [], "config_flow": true, "codeowners": ["@cjne"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/cjne/ha-sunspec", "iot_class": "local_polling", - "requirements": ["pysunspec2==1.0.8"] + "issue_tracker": "https://github.com/cjne/ha-sunspec/issues", + "requirements": ["pysunspec2==1.0.8"], + "version": "0.0.22" } diff --git a/custom_components/sunspec/sensor.py b/custom_components/sunspec/sensor.py index 77acfae..2025678 100644 --- a/custom_components/sunspec/sensor.py +++ b/custom_components/sunspec/sensor.py @@ -5,9 +5,8 @@ from homeassistant.components.sensor import DEVICE_CLASS_ENERGY from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE from homeassistant.components.sensor import DEVICE_CLASS_VOLTAGE -from homeassistant.components.sensor import ( - SensorDeviceClass, -) +from homeassistant.components.sensor import RestoreSensor +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING @@ -56,6 +55,7 @@ "Mbps": [DATA_RATE_MEGABITS_PER_SECOND, ICON_DEFAULT, None], "V": [ELECTRIC_POTENTIAL_VOLT, ICON_VOLT, DEVICE_CLASS_VOLTAGE], "VA": [POWER_VOLT_AMPERE, ICON_POWER, None], + "VAr": [POWER_VOLT_AMPERE, ICON_POWER, None], "W": [POWER_WATT, ICON_POWER, DEVICE_CLASS_POWER], "W/m2": [IRRADIATION_WATTS_PER_SQUARE_METER, ICON_DEFAULT, None], "Wh": [ENERGY_WATT_HOUR, ICON_ENERGY, DEVICE_CLASS_ENERGY], @@ -71,6 +71,8 @@ "mm": [LENGTH_MILLIMETERS, ICON_DEFAULT, None], "%": [PERCENTAGE, ICON_DEFAULT, None], "Secs": [TIME_SECONDS, ICON_DEFAULT, None], + "enum16": [None, ICON_DEFAULT, SensorDeviceClass.ENUM], + "bitfield32": [None, ICON_DEFAULT, SensorDeviceClass.ENUM], } @@ -92,7 +94,20 @@ async def async_setup_entry(hass, entry, async_add_devices): "model": model_wrapper, "prefix": prefix, } - sensors.append(SunSpecSensor(coordinator, entry, data)) + + meta = model_wrapper.getMeta(key) + sunspec_unit = meta.get("units", "") + ha_meta = HA_META.get(sunspec_unit, [sunspec_unit, None, None]) + device_class = ha_meta[2] + if sunspec_unit == "": + _LOGGER.debug("No unit for") + _LOGGER.debug(meta) + if device_class == DEVICE_CLASS_ENERGY: + _LOGGER.debug("Adding energy sensor") + sensors.append(SunSpecEnergySensor(coordinator, entry, data)) + else: + sensors.append(SunSpecSensor(coordinator, entry, data)) + async_add_devices(sensors) @@ -110,7 +125,7 @@ def __init__(self, coordinator, config_entry, data): self._meta = self.model_wrapper.getMeta(self.key) self._group_meta = self.model_wrapper.getGroupMeta() self._point_meta = self.model_wrapper.getPoint(self.key).pdef - sunspec_unit = self._meta.get("units", "") + sunspec_unit = self._meta.get("units", self._meta.get("type", "")) ha_meta = HA_META.get(sunspec_unit, [sunspec_unit, ICON_DEFAULT, None]) self.unit = ha_meta[0] self.use_icon = ha_meta[1] @@ -197,16 +212,6 @@ def native_value(self): "Math overflow error when retreiving calculated value for %s", self.key ) return None - # If this is an energy sensor a value of 0 woulld mess up long term stats because of how total_increasing works - if self.use_device_class == DEVICE_CLASS_ENERGY: - if val == 0: - _LOGGER.debug( - "Returning last known value instead of 0 for {self.name) to avoid resetting total_increasing counter" - ) - self._assumed_state = True - return self.lastKnown - self.lastKnown = val - self._assumed_state = False vtype = self._meta["type"] if vtype in ("enum16", "bitfield32"): symbols = self._meta.get("symbols", None) @@ -228,6 +233,9 @@ def native_value(self): @property def native_unit_of_measurement(self): """Return the unit of measurement.""" + # if self.unit == "": + # _LOGGER.debug(f"UNIT IS NONT FOR {self.name}") + # return None return self.unit @property @@ -243,7 +251,7 @@ def device_class(self): @property def state_class(self): """Return de device class of the sensor.""" - if self.unit == "": + if self.unit == "" or self.unit is None: return None if self.device_class == DEVICE_CLASS_ENERGY: return STATE_CLASS_TOTAL_INCREASING @@ -266,3 +274,36 @@ def extra_state_attributes(self): self.key, self.model_index ) return attrs + + +class SunSpecEnergySensor(SunSpecSensor, RestoreSensor): + def __init__(self, coordinator, config_entry, data): + super().__init__(coordinator, config_entry, data) + self.last_known_value = None + + @property + def native_value(self): + val = super().native_value + # For an energy sensor a value of 0 woulld mess up long term stats because of how total_increasing works + if val == 0: + _LOGGER.debug( + "Returning last known value instead of 0 for {self.name) to avoid resetting total_increasing counter" + ) + self._assumed_state = True + return self.lastKnown + self.lastKnown = val + self._assumed_state = False + return val + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + _LOGGER.debug(f"{self.name} Fetch last known state") + state = await self.async_get_last_sensor_data() + if state: + _LOGGER.debug( + f"{self.name} Got last known value from state: {state.native_value}" + ) + self.last_known_value = state.native_value + else: + _LOGGER.debug(f"{self.name} No previous state was found") diff --git a/requirements_dev.txt b/requirements_dev.txt index bc56947..565c6ad 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,6 @@ +pre-commit==3.2.0 +black==23.1.0 +flake8==6.0.0 +reorder-python-imports==3.9.0 homeassistant pysunspec2==1.0.8 -flake8 -reorder-python-imports diff --git a/requirements_test.txt b/requirements_test.txt index 49d975f..ae3e416 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,5 @@ -r requirements_dev.txt +pre-commit==3.2.0 pytest-homeassistant-custom-component serial pytest-mock diff --git a/setup.cfg b/setup.cfg index b723ee4..a2fb838 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,16 +31,17 @@ not_skip = __init__.py force_sort_within_sections = true sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER default_section = THIRDPARTY -known_first_party = custom_components.sunspec, tests +known_first_party = custom_components.myenergi, tests combine_as_imports = true [tool:pytest] addopts = -qq --cov=custom_components.sunspec console_output_style = count +asyncio_mode = auto [coverage:run] branch = False [coverage:report] show_missing = true -fail_under = 100 +fail_under = 95 diff --git a/tests/__init__.py b/tests/__init__.py index c46888f..8f6c7ec 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -22,6 +22,7 @@ TEST_SERIAL_NO = "abc123" TEST_INVERTER_SENSOR_STATE_ENTITY_ID = "sensor.inverter_operating_state" TEST_INVERTER_SENSOR_POWER_ENTITY_ID = "sensor.inverter_watts" +TEST_INVERTER_SENSOR_VAR_ID = "sensor.inverter_var" TEST_INVERTER_SENSOR_ENERGY_ENTITY_ID = "sensor.inverter_watthours" TEST_INVERTER_MM_SENSOR_STATE_ENTITY_ID = "sensor.dermeasureac_1_operating_state" TEST_INVERTER_MM_SENSOR_POWER_ENTITY_ID = "sensor.dermeasureac_1_active_power" diff --git a/tests/conftest.py b/tests/conftest.py index 779a951..171e1d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,13 @@ class MockFileClientDevice(modbus_client.FileClientDevice): def is_connected(self): return True + def scan(self, progress=None): + print(progress) + if progress is not None: + if not progress("Mock scan"): + return + return super().scan() + def connect(self): return True diff --git a/tests/test_api.py b/tests/test_api.py index a62e672..14e4d6c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -48,12 +48,25 @@ async def test_api(hass, sunspec_client_mock): assert len(keys) == 22 +async def test_get_client(hass, sunspec_modbus_client_mock): + SunSpecApiClient.CLIENT_CACHE = {} + """Test API calls.""" + + # To test the api submodule, we first create an instance of our API client + api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) + client = api.get_client() + client.scan.assert_called_once() + + SunSpecApiClient.CLIENT_CACHE = {} + + async def test_modbus_connect(hass, sunspec_modbus_client_mock): SunSpecApiClient.CLIENT_CACHE = {} """Test API calls.""" # To test the api submodule, we first create an instance of our API client api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) + SunSpecApiClient.CLIENT_CACHE = {} client = api.get_client() client.scan.assert_called_once() @@ -61,7 +74,6 @@ async def test_modbus_connect(hass, sunspec_modbus_client_mock): async def test_modbus_connect_fail(hass, mocker): - mocker.patch( # api_call is from slow.py but imported to main.py "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP.connect", @@ -82,7 +94,6 @@ async def test_modbus_connect_fail(hass, mocker): async def test_modbus_connect_exception(hass, mocker): - mocker.patch( # api_call is from slow.py but imported to main.py "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP.connect", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e74f06c..213f0ce 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -21,7 +21,10 @@ @pytest.fixture(autouse=True) def bypass_setup_fixture(): """Prevent setup.""" - with patch("custom_components.sunspec.async_setup", return_value=True,), patch( + with patch( + "custom_components.sunspec.async_setup", + return_value=True, + ), patch( "custom_components.sunspec.async_setup_entry", return_value=True, ): diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 503063b..ab6072f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -10,6 +10,7 @@ from . import TEST_INVERTER_SENSOR_ENERGY_ENTITY_ID from . import TEST_INVERTER_SENSOR_POWER_ENTITY_ID from . import TEST_INVERTER_SENSOR_STATE_ENTITY_ID +from . import TEST_INVERTER_SENSOR_VAR_ID from .const import MOCK_CONFIG_MM from .const import MOCK_CONFIG_PREFIX @@ -35,6 +36,15 @@ async def test_sensor_dc(hass: HomeAssistant, sunspec_client_mock) -> None: assert entity_state.attributes["icon"] == ICON_DC_AMPS +async def test_sensor_var(hass: HomeAssistant, sunspec_client_mock) -> None: + """Verify device information includes expected details.""" + + await setup_mock_sunspec_config_entry(hass) + + entity_state = hass.states.get(TEST_INVERTER_SENSOR_VAR_ID) + assert entity_state + + async def test_sensor_with_prefix(hass: HomeAssistant, sunspec_client_mock) -> None: """Verify device information includes expected details."""