diff --git a/custom_components/iotawatt/coordinator.py b/custom_components/iotawatt/coordinator.py index ef89d37..ada9c9f 100644 --- a/custom_components/iotawatt/coordinator.py +++ b/custom_components/iotawatt/coordinator.py @@ -32,23 +32,15 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: update_interval=timedelta(seconds=30), ) - self._last_run = None - self._refresh_requested = False + self._last_run: datetime | None = None - def update_last_run(self, last_run: datetime): + def update_last_run(self, last_run: datetime) -> None: """Notify coordinator of a sensor last update time.""" # We want to fetch the data from the iotawatt since HA was last shutdown. # We retrieve from the sensor last updated. # This method is called from each sensor upon their state being restored. if self._last_run is None or last_run > self._last_run: - self._last_run = last_run # type: ignore - - async def request_refresh(self): - """Request a refresh of the iotawatt sensors.""" - if self._refresh_requested: - return - self._refresh_requested = True - await self.async_request_refresh() + self._last_run = last_run async def _async_update_data(self): """Fetch sensors from IoTaWatt device.""" @@ -72,5 +64,4 @@ async def _async_update_data(self): await self.api.update(lastUpdate=self._last_run) self._last_run = None - self._refresh_requested = False return self.api.getSensors() diff --git a/custom_components/iotawatt/manifest.json b/custom_components/iotawatt/manifest.json index 6f35760..2f0f18f 100644 --- a/custom_components/iotawatt/manifest.json +++ b/custom_components/iotawatt/manifest.json @@ -8,8 +8,9 @@ "iotawattpy==0.1.0" ], "codeowners": [ - "@gtdiehl" + "@gtdiehl", + "@jyavenard" ], - "version": "0.1.1", + "version": "0.2.1", "iot_class": "local_polling" } diff --git a/custom_components/iotawatt/sensor.py b/custom_components/iotawatt/sensor.py index 1eec4b8..04112c2 100644 --- a/custom_components/iotawatt/sensor.py +++ b/custom_components/iotawatt/sensor.py @@ -128,13 +128,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def _create_entity(key: str) -> IotaWattSensor: """Create a sensor entity.""" created.add(key) + data = coordinator.data["sensors"][key] + description = ENTITY_DESCRIPTION_KEY_MAP.get( + data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + ) + if data.getUnit() == "WattHours" and not data.getFromStart(): + return IotaWattAccumulatingSensor( + coordinator=coordinator, key=key, entity_description=description + ) + return IotaWattSensor( coordinator=coordinator, key=key, - entity_description=ENTITY_DESCRIPTION_KEY_MAP.get( - coordinator.data["sensors"][key].getUnit(), - IotaWattSensorEntityDescription("base_sensor"), - ), + entity_description=description, ) async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) @@ -153,30 +159,29 @@ def new_data_received(): coordinator.async_add_listener(new_data_received) -class IotaWattSensor(update_coordinator.CoordinatorEntity, RestoreEntity, SensorEntity): +class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Defines a IoTaWatt Energy Sensor.""" entity_description: IotaWattSensorEntityDescription - _attr_force_update = True + coordinator: IotawattUpdater def __init__( self, - coordinator, - key, + coordinator: IotawattUpdater, + key: str, entity_description: IotaWattSensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator) self._key = key data = self._sensor_data - self._accumulating = data.getUnit() == "WattHours" and not data.getFromStart() - self._accumulated_value = None if data.getType() == "Input": - unit = data.getUnit() + self._name_suffix self._attr_unique_id = ( - f"{data.hub_mac_address}-input-{data.getChannel()}-{unit}" + f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}" ) + elif data.getType() == "Output": + self._attr_unique_id = f"{data.hub_mac_address}-output-{data.getSourceName()}" self.entity_description = entity_description @property @@ -184,14 +189,10 @@ def _sensor_data(self) -> Sensor: """Return sensor data.""" return self.coordinator.data["sensors"][self._key] - @property - def _name_suffix(self) -> str: - return ".accumulated" if self._accumulating else "" - @property def name(self) -> str | None: """Return name of the entity.""" - return self._sensor_data.getSourceName() + self._name_suffix + return self._sensor_data.getName() @property def device_info(self) -> entity.DeviceInfo | None: @@ -213,68 +214,96 @@ def _handle_coordinator_update(self) -> None: else: self.hass.async_create_task(self.async_remove()) return - - if self._accumulating: - assert ( - self._accumulated_value is not None - ), "async_added_to_hass must have been called first" - self._accumulated_value += float(self._sensor_data.getValue()) - super()._handle_coordinator_update() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the extra state attributes of the entity.""" data = self._sensor_data attrs = {"type": data.getType()} if attrs["type"] == "Input": attrs["channel"] = data.getChannel() - if self._accumulating: - attrs[ - ATTR_LAST_UPDATE - ] = self.coordinator.api.getLastUpdateTime().isoformat() - return attrs - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - if self.state_class == STATE_CLASS_MEASUREMENT: - return None - - if self._accumulating: - return datetime.min # an accumulating sensor never reset. - last_reset = self._sensor_data.getBegin() - if last_reset is None: - return None - return dt.parse_datetime(last_reset) - - async def async_added_to_hass(self): - """Load the last known state value of the entity if the accumulated type.""" - await super().async_added_to_hass() - if self._accumulating: - state = await self.async_get_last_state() - self._accumulated_value = 0.0 - if state: - try: - self._accumulated_value = float(state.state) - if ATTR_LAST_UPDATE in state.attributes: - self.coordinator.update_last_run( - dt.parse_datetime(state.attributes.get(ATTR_LAST_UPDATE)) - ) - except (ValueError) as err: - _LOGGER.warning("Could not restore last state: %s", err) - # Force a second update from the iotawatt to ensure that sensors are up to date. - await self.coordinator.request_refresh() - @property def native_value(self) -> entity.StateType: """Return the state of the sensor.""" if func := self.entity_description.value: return func(self._sensor_data.getValue()) - if not self._accumulating: - return self._sensor_data.getValue() + return self._sensor_data.getValue() + + +class IotaWattAccumulatingSensor(IotaWattSensor, RestoreEntity): + """Defines a IoTaWatt Accumulative Energy (High Accuracy) Sensor.""" + + def __init__( + self, + coordinator: IotawattUpdater, + key: str, + entity_description: IotaWattSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + + super().__init__(coordinator, key, entity_description) + + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + if self._attr_unique_id is not None: + self._attr_unique_id += ".accumulated" + + self._accumulated_value: float | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + assert ( + self._accumulated_value is not None + ), "async_added_to_hass must have been called first" + self._accumulated_value += float(self._sensor_data.getValue()) + + super()._handle_coordinator_update() + + @property + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" if self._accumulated_value is None: return None return round(self._accumulated_value, 1) + + async def async_added_to_hass(self) -> None: + """Load the last known state value of the entity if the accumulated type.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + self._accumulated_value = 0.0 + if state: + try: + # Previous value could be `unknown` if the connection didn't originally + # complete. + self._accumulated_value = float(state.state) + except (ValueError) as err: + _LOGGER.warning("Could not restore last state: %s", err) + else: + if ATTR_LAST_UPDATE in state.attributes: + last_run = dt.parse_datetime(state.attributes[ATTR_LAST_UPDATE]) + if last_run is not None: + self.coordinator.update_last_run(last_run) + # Force a second update from the iotawatt to ensure that sensors are up to date. + await self.coordinator.async_request_refresh() + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return f"{self._sensor_data.getSourceName()} Accumulated" + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the extra state attributes of the entity.""" + attrs = super().extra_state_attributes + + assert ( + self.coordinator.api is not None + and self.coordinator.api.getLastUpdateTime() is not None + ) + attrs[ATTR_LAST_UPDATE] = self.coordinator.api.getLastUpdateTime().isoformat() + + return attrs diff --git a/custom_components/iotawatt/translations/el.json b/custom_components/iotawatt/translations/el.json new file mode 100644 index 0000000..0030674 --- /dev/null +++ b/custom_components/iotawatt/translations/el.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03bd\u03b5\u03c0\u03ac\u03bd\u03c4\u03b5\u03c7\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "auth": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/es.json b/custom_components/iotawatt/translations/es.json new file mode 100644 index 0000000..07540d1 --- /dev/null +++ b/custom_components/iotawatt/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "La conexi\u00f3n ha fallado", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "auth": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + } + }, + "user": { + "data": { + "host": "Anfitri\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/he.json b/custom_components/iotawatt/translations/he.json new file mode 100644 index 0000000..ce440eb --- /dev/null +++ b/custom_components/iotawatt/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "auth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/hu.json b/custom_components/iotawatt/translations/hu.json new file mode 100644 index 0000000..1c545b3 --- /dev/null +++ b/custom_components/iotawatt/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "auth": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Az IoTawatt eszk\u00f6z hiteles\u00edt\u00e9st ig\u00e9nyel. K\u00e9rj\u00fck, adja meg felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t, majd kattintson a K\u00fcld\u00e9s gombra." + }, + "user": { + "data": { + "host": "Gazdag\u00e9p" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/iotawatt/translations/zh-Hans.json b/custom_components/iotawatt/translations/zh-Hans.json new file mode 100644 index 0000000..7f36e76 --- /dev/null +++ b/custom_components/iotawatt/translations/zh-Hans.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u65e0\u6548\u51ed\u8bc1", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "auth": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "IoTawatt \u8bbe\u5907\u9700\u8981\u8eab\u4efd\u9a8c\u8bc1\u3002\u8bf7\u8f93\u5165\u7528\u6237\u540d\u548c\u5bc6\u7801\uff0c\u7136\u540e\u5355\u51fb\u63d0\u4ea4\u6309\u94ae\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740" + } + } + } + } +} \ No newline at end of file