From 9c7db5c06bd5226af57594486308130f891cfd88 Mon Sep 17 00:00:00 2001 From: ashionky <495519020@qq.com> Date: Mon, 23 Jun 2025 12:00:34 +0800 Subject: [PATCH 1/6] em16p --- custom_components/refoss_rpc/coordinator.py | 3 +- custom_components/refoss_rpc/entity.py | 115 +++++++++---- custom_components/refoss_rpc/manifest.json | 2 +- custom_components/refoss_rpc/sensor.py | 176 ++++++++++++++++++++ custom_components/refoss_rpc/utils.py | 39 ++++- 5 files changed, 299 insertions(+), 36 deletions(-) diff --git a/custom_components/refoss_rpc/coordinator.py b/custom_components/refoss_rpc/coordinator.py index fcd9107..e82b8c3 100644 --- a/custom_components/refoss_rpc/coordinator.py +++ b/custom_components/refoss_rpc/coordinator.py @@ -238,7 +238,8 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: if event_type is None: continue - if event_type in ("config_changed"): + RELOAD_EVENTS = {"config_changed", "emmerge_change"} + if event_type in RELOAD_EVENTS: LOGGER.info( "Config for %s changed, reloading entry in %s seconds", self.name, diff --git a/custom_components/refoss_rpc/entity.py b/custom_components/refoss_rpc/entity.py index a28bc79..c1e2941 100644 --- a/custom_components/refoss_rpc/entity.py +++ b/custom_components/refoss_rpc/entity.py @@ -22,6 +22,7 @@ async_remove_refoss_entity, get_refoss_entity_name, get_refoss_key_instances, + merge_channel_get_status, ) @@ -35,38 +36,51 @@ def async_setup_entry_refoss( ) -> None: """Set up entities for Refoss.""" coordinator = config_entry.runtime_data.coordinator - assert coordinator - if not coordinator.device.initialized: + # If the device is not initialized, return directly + if not coordinator or not coordinator.device.initialized: return - entities = [] - for sensor_id in sensors: - description = sensors[sensor_id] - key_instances = get_refoss_key_instances( - coordinator.device.status, description.key - ) + device_status = coordinator.device.status + device_config = coordinator.device.config + mac = coordinator.mac + entities: list[Any] = [] + + for sensor_id, description in sensors.items(): + key_instances = get_refoss_key_instances(device_status, description.key) for key in key_instances: - # Filter non-existing sensors - if description.sub_key not in coordinator.device.status[ - key - ] and not description.supported(coordinator.device.status[key]): + key_status = device_status.get(key) + if key_status is None: + continue + + # Filter out sensors that are not supported or do not match the configuration + if ( + not key.startswith("emmerge:") + and description.sub_key not in key_status + and not description.supported(key_status) + ): continue - # Filter and remove entities that according to config/status - # should not create an entity + # Filter and remove entities that should not be created according to the configuration/status if description.removal_condition and description.removal_condition( - coordinator.device.config, coordinator.device.status, key + device_config, device_status, key ): - domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{key}-{sensor_id}" + try: + domain = sensor_class.__module__.split(".")[-1] + except AttributeError: + LOGGER.error( + "Failed to get module name from sensor_class for sensor_id %s and key %s", + sensor_id, + key, + ) + continue + unique_id = f"{mac}-{key}-{sensor_id}" async_remove_refoss_entity(hass, domain, unique_id) else: entities.append(sensor_class(coordinator, key, sensor_id, description)) - if not entities: - return - async_add_entities(entities) + if entities: + async_add_entities(entities) @dataclass(frozen=True, kw_only=True) @@ -101,11 +115,13 @@ def available(self) -> bool: return super().available and (coordinator.device.initialized) @property - def status(self) -> dict: + def status(self) -> dict | None: """Device status by entity key.""" - return cast(dict, self.coordinator.device.status[self.key]) + device_status = self.coordinator.device.status.get(self.key) + if device_status is None: + LOGGER.debug("Device status not found for key: %s", self.key) + return device_status - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -164,18 +180,51 @@ def __init__( self._last_value = None @property - def sub_status(self) -> Any: - """Device status by entity key.""" - return self.status[self.entity_description.sub_key] + def sub_status(self) -> Any | None: + """Get the sub - status of the device by entity key. + + Returns the value corresponding to the sub - key in the device status. + If the device status is None or the sub - key does not exist, returns None. + """ + device_status = self.status + if device_status is None: + LOGGER.debug("Device status is None for entity %s", self.name) + return None + sub_key = self.entity_description.sub_key + sub_status = device_status.get(sub_key) + return sub_status @property def attribute_value(self) -> StateType: """Value of sensor.""" - if self.entity_description.value is not None: - self._last_value = self.entity_description.value( - self.status.get(self.entity_description.sub_key), self._last_value + try: + if self.key.startswith("emmerge:"): + # Call the merge channel attributes function + return merge_channel_get_status( + self.coordinator.device.status, + self.key, + self.entity_description.sub_key, + ) + + # Reduce repeated calls and get the sub-status + sub_status = self.sub_status + + if self.entity_description.value is not None: + # Call the custom value processing function + self._last_value = self.entity_description.value( + sub_status, self._last_value + ) + else: + self._last_value = sub_status + + return self._last_value + except Exception as e: + # Log the exception + LOGGER.error( + "Error getting attribute value for entity %s, key %s, attribute %s: %s", + self.name, + self.key, + self.attribute, + str(e), ) - else: - self._last_value = self.sub_status - - return self._last_value + return None diff --git a/custom_components/refoss_rpc/manifest.json b/custom_components/refoss_rpc/manifest.json index e117d92..c5af008 100644 --- a/custom_components/refoss_rpc/manifest.json +++ b/custom_components/refoss_rpc/manifest.json @@ -10,7 +10,7 @@ "loggers": ["aiorefoss"], "quality_scale": "bronze", "requirements": ["aiorefoss==1.0.1"], - "version": "1.0.3", + "version": "1.0.4", "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/custom_components/refoss_rpc/sensor.py b/custom_components/refoss_rpc/sensor.py index 0696c9c..fecb820 100644 --- a/custom_components/refoss_rpc/sensor.py +++ b/custom_components/refoss_rpc/sensor.py @@ -122,6 +122,182 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "em_power": RefossSensorDescription( + key="em", + sub_key="power", + name="Power", + native_unit_of_measurement=UnitOfPower.MILLIWATT, + value=lambda status, _: None if status is None else float(status), + suggested_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "em_voltage": RefossSensorDescription( + key="em", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + value=lambda status, _: None if status is None else float(status), + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "em_current": RefossSensorDescription( + key="em", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + value=lambda status, _: None if status is None else float(status), + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "em_month_energy": RefossSensorDescription( + key="em", + sub_key="month_energy", + name="This Month Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_month_ret_energy": RefossSensorDescription( + key="em", + sub_key="month_ret_energy", + name="This Month Return Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_week_energy": RefossSensorDescription( + key="em", + sub_key="week_energy", + name="This Week Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_week_ret_energy": RefossSensorDescription( + key="em", + sub_key="week_ret_energy", + name="This Week Retrun Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_day_energy": RefossSensorDescription( + key="em", + sub_key="day_energy", + name="Today Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_day_ret_energy": RefossSensorDescription( + key="em", + sub_key="day_ret_energy", + name="Today Return Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_pf": RefossSensorDescription( + key="em", + sub_key="pf", + name="Power factor", + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), + "emmerge_power": RefossSensorDescription( + key="emmerge", + sub_key="power", + name="Power", + native_unit_of_measurement=UnitOfPower.MILLIWATT, + suggested_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "emmerge_current": RefossSensorDescription( + key="emmerge", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "emmerge_month_energy": RefossSensorDescription( + key="emmerge", + sub_key="month_energy", + name="This Month Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_month_ret_energy": RefossSensorDescription( + key="emmerge", + sub_key="month_ret_energy", + name="This Month Return Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_week_energy": RefossSensorDescription( + key="emmerge", + sub_key="week_energy", + name="This Week Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_week_ret_energy": RefossSensorDescription( + key="emmerge", + sub_key="week_ret_energy", + name="This Week Retrun Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_day_energy": RefossSensorDescription( + key="emmerge", + sub_key="day_energy", + name="Today Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_day_ret_energy": RefossSensorDescription( + key="emmerge", + sub_key="day_ret_energy", + name="Today Return Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), } diff --git a/custom_components/refoss_rpc/utils.py b/custom_components/refoss_rpc/utils.py index 0cbac00..50c7e30 100644 --- a/custom_components/refoss_rpc/utils.py +++ b/custom_components/refoss_rpc/utils.py @@ -52,8 +52,12 @@ def get_refoss_channel_name(device: RpcDevice, key: str) -> str: if entity_name is None: channel = key.split(":")[0] channel_id = key.split(":")[-1] - if key.startswith(("input:", "switch:","cover:")): + if key.startswith(("input:", "switch:", "cover:", "em:")): return f"{device_name} {channel.title()} {channel_id}" + + if key.startswith(("emmerge:")): + return f"{device_name} {channel.title()}" + return device_name return entity_name @@ -145,6 +149,39 @@ def is_refoss_wifi_stations_disabled( return False +def merge_channel_get_status(_status: dict[str, Any], key: str, attr: str) -> Any: + """ + Merge channel attributes. If the key starts with 'emmerge:', sum the attribute values of the corresponding bits. + + :param _status: Device status dictionary + :param key: Key name + :param attr: Attribute name + :return: Merged attribute value or None + """ + if not key.startswith("emmerge:"): + return None + + try: + # Extract and convert the number + num = int(key.split(":")[1]) + except (IndexError, ValueError): + LOGGER.error("Failed to extract or convert number from key: %s", key) + return None + + # Find the indices of bits with a value of 1 in the binary representation + bit_positions = [i for i in range(num.bit_length()) if num & (1 << i)] + + val = 0 + for bit in bit_positions: + status_key = f"em:{bit+1}" + if status_key in _status and attr in _status[status_key]: + val += _status[status_key][attr] + else: + LOGGER.warning("Missing key %s or attribute %s in status", status_key, attr) + + return val + + def get_host(host: str) -> str: """Get the device IP address.""" try: From 6f97cab06441479a77429d019bbaf185c659b1c8 Mon Sep 17 00:00:00 2001 From: ashionky <495519020@qq.com> Date: Fri, 4 Jul 2025 14:56:52 +0800 Subject: [PATCH 2/6] sensor --- custom_components/refoss_rpc/sensor.py | 39 +++++++++++--------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/custom_components/refoss_rpc/sensor.py b/custom_components/refoss_rpc/sensor.py index fecb820..ce701cb 100644 --- a/custom_components/refoss_rpc/sensor.py +++ b/custom_components/refoss_rpc/sensor.py @@ -126,9 +126,8 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="power", name="Power", - native_unit_of_measurement=UnitOfPower.MILLIWATT, + native_unit_of_measurement=UnitOfPower.WATT, value=lambda status, _: None if status is None else float(status), - suggested_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -137,9 +136,8 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="voltage", name="Voltage", - native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, value=lambda status, _: None if status is None else float(status), - suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=2, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -148,9 +146,8 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="current", name="Current", - native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value=lambda status, _: None if status is None else float(status), - suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -159,7 +156,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="month_energy", name="This Month Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: None if status is None else float(status), suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, @@ -169,7 +166,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="month_ret_energy", name="This Month Return Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: None if status is None else float(status), suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, @@ -179,7 +176,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="week_energy", name="This Week Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: None if status is None else float(status), suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, @@ -189,7 +186,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="week_ret_energy", name="This Week Retrun Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: None if status is None else float(status), suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, @@ -199,7 +196,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="day_energy", name="Today Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: None if status is None else float(status), suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, @@ -209,7 +206,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="em", sub_key="day_ret_energy", name="Today Return Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: None if status is None else float(status), suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, @@ -228,8 +225,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="power", name="Power", - native_unit_of_measurement=UnitOfPower.MILLIWATT, - suggested_unit_of_measurement=UnitOfPower.WATT, + native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -238,8 +234,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="current", name="Current", - native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, - suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -248,7 +243,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="month_energy", name="This Month Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -257,7 +252,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="month_ret_energy", name="This Month Return Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -266,7 +261,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="week_energy", name="This Week Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -275,7 +270,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="week_ret_energy", name="This Week Retrun Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -284,7 +279,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="day_energy", name="Today Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -293,7 +288,7 @@ class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): key="emmerge", sub_key="day_ret_energy", name="Today Return Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, From 651f372f7be9dd4cc29dcdfe27647a0673cc0122 Mon Sep 17 00:00:00 2001 From: ashionky <495519020@qq.com> Date: Fri, 4 Jul 2025 14:59:18 +0800 Subject: [PATCH 3/6] sensor em06p --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 300ed74..58f015c 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ As soon as HomeAssistant is restarted, you can proceed with __component setup__. ## Supported device models -| Model | Version | -|----------------------------------|--------------------| -| `Refoss Smart Wi-Fi Switch, R11` | `all` | -| `Refoss Smart Wi-Fi Plug, P11S` | `all` | -| `Refoss Smart Wi-Fi Switch, R21` | `all` | +| Model | Version | +|-------------------------------------|--------------------| +| `Refoss Smart Wi-Fi Switch, R11` | `all` | +| `Refoss Smart Wi-Fi Plug, P11S` | `all` | +| `Refoss Smart Wi-Fi Switch, R21` | `all` | +| `Refoss Smart Energy Monito, EM06P` | `all` | From a2b366ab7ee26be879e6c7e5c35c281026f0566a Mon Sep 17 00:00:00 2001 From: ashionky <495519020@qq.com> Date: Mon, 21 Jul 2025 14:04:34 +0800 Subject: [PATCH 4/6] cover --- custom_components/refoss_rpc/coordinator.py | 2 +- custom_components/refoss_rpc/cover.py | 5 +- .../refoss_rpc/refoss_rpc/__init__.py | 91 ++++ .../refoss_rpc/refoss_rpc/binary_sensor.py | 129 ++++++ .../refoss_rpc/refoss_rpc/button.py | 123 ++++++ .../refoss_rpc/refoss_rpc/config_flow.py | 310 ++++++++++++++ .../refoss_rpc/refoss_rpc/const.py | 47 ++ .../refoss_rpc/refoss_rpc/coordinator.py | 403 ++++++++++++++++++ .../refoss_rpc/refoss_rpc/cover.py | 88 ++++ .../refoss_rpc/refoss_rpc/device_trigger.py | 120 ++++++ .../refoss_rpc/refoss_rpc/entity.py | 230 ++++++++++ .../refoss_rpc/refoss_rpc/event.py | 109 +++++ .../refoss_rpc/refoss_rpc/logbook.py | 51 +++ .../refoss_rpc/refoss_rpc/manifest.json | 20 + .../refoss_rpc/refoss_rpc/quality_scale.yaml | 26 ++ .../refoss_rpc/refoss_rpc/sensor.py | 331 ++++++++++++++ .../refoss_rpc/refoss_rpc/strings.json | 90 ++++ .../refoss_rpc/refoss_rpc/switch.py | 53 +++ .../refoss_rpc/translations/en.json | 90 ++++ .../refoss_rpc/refoss_rpc/update.py | 159 +++++++ .../refoss_rpc/refoss_rpc/utils.py | 196 +++++++++ 21 files changed, 2669 insertions(+), 4 deletions(-) create mode 100644 custom_components/refoss_rpc/refoss_rpc/__init__.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/binary_sensor.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/button.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/config_flow.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/const.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/coordinator.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/cover.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/device_trigger.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/entity.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/event.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/logbook.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/manifest.json create mode 100644 custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml create mode 100644 custom_components/refoss_rpc/refoss_rpc/sensor.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/strings.json create mode 100644 custom_components/refoss_rpc/refoss_rpc/switch.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/translations/en.json create mode 100644 custom_components/refoss_rpc/refoss_rpc/update.py create mode 100644 custom_components/refoss_rpc/refoss_rpc/utils.py diff --git a/custom_components/refoss_rpc/coordinator.py b/custom_components/refoss_rpc/coordinator.py index e82b8c3..226167f 100644 --- a/custom_components/refoss_rpc/coordinator.py +++ b/custom_components/refoss_rpc/coordinator.py @@ -238,7 +238,7 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: if event_type is None: continue - RELOAD_EVENTS = {"config_changed", "emmerge_change"} + RELOAD_EVENTS = {"config_changed", "emmerge_change", "cfg_change"} if event_type in RELOAD_EVENTS: LOGGER.info( "Config for %s changed, reloading entry in %s seconds", diff --git a/custom_components/refoss_rpc/cover.py b/custom_components/refoss_rpc/cover.py index 1c188d6..d54397e 100644 --- a/custom_components/refoss_rpc/cover.py +++ b/custom_components/refoss_rpc/cover.py @@ -44,15 +44,14 @@ def __init__(self, coordinator: RefossCoordinator, _id: int) -> None: """Initialize cover.""" super().__init__(coordinator, f"cover:{_id}") self._id = _id - if self.status["pos_control"]: + if self.status["cali_state"] == "success": self._attr_supported_features |= CoverEntityFeature.SET_POSITION @property def current_cover_position(self) -> int | None: """Position of the cover.""" - if not self.status["pos_control"]: + if not self.status["cali_state"] or self.status["cali_state"] != "success": return None - return cast(int, self.status["current_pos"]) @property diff --git a/custom_components/refoss_rpc/refoss_rpc/__init__.py b/custom_components/refoss_rpc/refoss_rpc/__init__.py new file mode 100644 index 0000000..43e70e8 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/__init__.py @@ -0,0 +1,91 @@ +"""The Refoss RPC integration.""" + +from __future__ import annotations + +from typing import Final + +from aiorefoss.common import ConnectionOptions +from aiorefoss.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, +) +from aiorefoss.rpc_device import RpcDevice + +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import ( + RefossConfigEntry, + RefossCoordinator, + RefossEntryData, +) + +PLATFORMS: Final = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.COVER, + Platform.EVENT, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: + """Set up Refoss RPC from a config entry.""" + if not entry.data.get(CONF_HOST): + raise ConfigEntryError("Invalid Host, please try again") + + options = ConnectionOptions( + entry.data.get(CONF_HOST), + entry.data.get(CONF_USERNAME), + entry.data.get(CONF_PASSWORD), + entry.data.get(CONF_MAC), + ) + + device = await RpcDevice.create( + async_get_clientsession(hass), + options, + ) + runtime_data = entry.runtime_data = RefossEntryData(PLATFORMS) + + try: + await device.initialize() + except (DeviceConnectionError, MacAddressMismatchError) as err: + await device.shutdown() + raise ConfigEntryNotReady(repr(err)) from err + except InvalidAuthError as err: + await device.shutdown() + raise ConfigEntryAuthFailed(repr(err)) from err + + runtime_data.coordinator = RefossCoordinator(hass, entry, device) + runtime_data.coordinator.async_setup() + await hass.config_entries.async_forward_entry_setups(entry, runtime_data.platforms) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: + """Unload a config entry.""" + + runtime_data = entry.runtime_data + + if runtime_data.coordinator: + await runtime_data.coordinator.shutdown() + + return await hass.config_entries.async_unload_platforms( + entry, runtime_data.platforms + ) diff --git a/custom_components/refoss_rpc/refoss_rpc/binary_sensor.py b/custom_components/refoss_rpc/refoss_rpc/binary_sensor.py new file mode 100644 index 0000000..6eecb5c --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/binary_sensor.py @@ -0,0 +1,129 @@ +"""Binary sensor entities for Refoss.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import RefossConfigEntry +from .entity import ( + RefossAttributeEntity, + RefossEntityDescription, + async_setup_entry_refoss, +) +from .utils import is_refoss_input_button + + +@dataclass(frozen=True, kw_only=True) +class RefossBinarySensorDescription( + RefossEntityDescription, BinarySensorEntityDescription +): + """Class to describe a binary sensor.""" + + +REFOSS_BINARY_SENSORS: Final = { + "input": RefossBinarySensorDescription( + key="input", + sub_key="state", + name="Input", + device_class=BinarySensorDeviceClass.POWER, + removal_condition=is_refoss_input_button, + ), + "cloud": RefossBinarySensorDescription( + key="cloud", + sub_key="connected", + name="Cloud", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "overtemp": RefossBinarySensorDescription( + key="sys", + sub_key="errors", + name="Overheating", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "overtemp" in status, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda status: status.get("temperature") is not None, + ), + "overpower": RefossBinarySensorDescription( + key="switch", + sub_key="errors", + name="Overpowering", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "overpower" in status, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda status: status.get("apower") is not None, + ), + "overvoltage": RefossBinarySensorDescription( + key="switch", + sub_key="errors", + name="Overvoltage", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "overvoltage" in status, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda status: status.get("apower") is not None, + ), + "overcurrent": RefossBinarySensorDescription( + key="switch", + sub_key="errors", + name="Overcurrent", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "overcurrent" in status, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda status: status.get("apower") is not None, + ), + "undervoltage": RefossBinarySensorDescription( + key="switch", + sub_key="errors", + name="Undervoltage", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "undervoltage" in status, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda status: status.get("apower") is not None, + ), + "restart": RefossBinarySensorDescription( + key="sys", + sub_key="restart_required", + name="Restart required", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for device.""" + coordinator = config_entry.runtime_data.coordinator + assert coordinator + + async_setup_entry_refoss( + hass, + config_entry, + async_add_entities, + REFOSS_BINARY_SENSORS, + RefossBinarySensor, + ) + + +class RefossBinarySensor(RefossAttributeEntity, BinarySensorEntity): + """Refoss binary sensor entity.""" + + entity_description: RefossBinarySensorDescription + + @property + def is_on(self) -> bool: + """Return true if sensor state is on.""" + return bool(self.attribute_value) diff --git a/custom_components/refoss_rpc/refoss_rpc/button.py b/custom_components/refoss_rpc/refoss_rpc/button.py new file mode 100644 index 0000000..dbad781 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/button.py @@ -0,0 +1,123 @@ +"""Button entities for Refoss.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Any, Final + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .const import LOGGER +from .coordinator import RefossConfigEntry, RefossCoordinator + + +@dataclass(frozen=True, kw_only=True) +class RefossButtonDescription(ButtonEntityDescription): + """Class to describe a Button entity.""" + + press_action: Callable[[RefossCoordinator], Coroutine[Any, Any, None]] + + +REFOSS_BUTTONS: Final[list] = [ + RefossButtonDescription( + key="reboot", + name="Reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.device.trigger_reboot(), + ), + RefossButtonDescription( + key="fwcheck", + name="Check latest firmware", + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.device.trigger_check_latest_firmware(), + ), +] + + +@callback +def async_migrate_unique_ids( + coordinator: RefossCoordinator, + entity_entry: er.RegistryEntry, +) -> dict[str, Any] | None: + """Migrate button unique IDs.""" + if not entity_entry.entity_id.startswith("button"): + return None + + device_name = slugify(coordinator.device.name) + + for key in ("reboot", "fwcheck"): + old_unique_id = f"{device_name}_{key}" + if entity_entry.unique_id == old_unique_id: + new_unique_id = f"{coordinator.mac}_{key}" + LOGGER.debug( + "Migrating unique_id for %s entity from [%s] to [%s]", + entity_entry.entity_id, + old_unique_id, + new_unique_id, + ) + return { + "new_unique_id": entity_entry.unique_id.replace( + old_unique_id, new_unique_id + ) + } + + return None + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set buttons for device.""" + entry_data = config_entry.runtime_data + coordinator: RefossCoordinator | None + coordinator = entry_data.coordinator + + if TYPE_CHECKING: + assert coordinator is not None + + await er.async_migrate_entries( + hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) + ) + + async_add_entities(RefossButton(coordinator, button) for button in REFOSS_BUTTONS) + + +class RefossButton(CoordinatorEntity[RefossCoordinator], ButtonEntity): + """Refoss button entity.""" + + entity_description: RefossButtonDescription + + def __init__( + self, + coordinator: RefossCoordinator, + description: RefossButtonDescription, + ) -> None: + """Initialize Refoss button.""" + super().__init__(coordinator) + self.entity_description = description + + self._attr_name = f"{coordinator.device.name} {description.name}" + self._attr_unique_id = f"{coordinator.mac}_{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + + async def async_press(self) -> None: + """Triggers the Refoss button press service.""" + await self.entity_description.press_action(self.coordinator) diff --git a/custom_components/refoss_rpc/refoss_rpc/config_flow.py b/custom_components/refoss_rpc/refoss_rpc/config_flow.py new file mode 100644 index 0000000..dcd7074 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/config_flow.py @@ -0,0 +1,310 @@ +"""Config flow for Refoss RPC integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiorefoss.common import ( + ConnectionOptions, + fmt_macaddress, + get_info, + get_info_auth, + mac_address_from_name, +) +from aiorefoss.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, +) +from aiorefoss.rpc_device import RpcDevice +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN, LOGGER +from .coordinator import async_reconnect_soon + +INTERNAL_WIFI_AP_IP = "10.10.10.1" + + +async def async_validate_input( + hass: HomeAssistant, + host: str, + info: dict[str, Any], + data: dict[str, Any], +) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + options = ConnectionOptions( + ip_address=host, + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + device_mac=info[CONF_MAC], + ) + + device = await RpcDevice.create( + async_get_clientsession(hass), + options, + ) + try: + await device.initialize() + finally: + await device.shutdown() + + return { + "name": device.name, + "model": device.model, + } + + +class RefossConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for refoss rpc.""" + + VERSION = 1 + MINOR_VERSION = 1 + + host: str = "" + info: dict[str, Any] = {} + device_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + try: + self.info = await self._async_get_info(host) + except DeviceConnectionError: + errors["base"] = "cannot_connect" + else: + mac = fmt_macaddress(self.info[CONF_MAC]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured({CONF_HOST: host}) + self.host = host + if get_info_auth(self.info): + return await self.async_step_credentials() + + try: + device_info = await async_validate_input( + self.hass, host, self.info, {} + ) + except DeviceConnectionError: + errors["base"] = "cannot_connect" + except MacAddressMismatchError: + errors["base"] = "mac_address_mismatch" + else: + if device_info["model"]: + return self.async_create_entry( + title=device_info["name"], + data={ + CONF_MAC: self.info[CONF_MAC], + CONF_HOST: self.host, + "model": device_info["model"], + }, + ) + errors["base"] = "firmware_not_fully_supported" + + schema = { + vol.Required(CONF_HOST): str, + } + return self.async_show_form( + step_id="user", data_schema=vol.Schema(schema), errors=errors + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the credentials step.""" + errors: dict[str, str] = {} + if user_input is not None: + user_input[CONF_USERNAME] = "admin" + try: + device_info = await async_validate_input( + self.hass, self.host, self.info, user_input + ) + except InvalidAuthError: + errors["base"] = "invalid_auth" + except DeviceConnectionError: + errors["base"] = "cannot_connect" + except MacAddressMismatchError: + errors["base"] = "mac_address_mismatch" + else: + if device_info["model"]: + return self.async_create_entry( + title=device_info["name"], + data={ + **user_input, + CONF_MAC: self.info[CONF_MAC], + CONF_HOST: self.host, + "model": device_info["model"], + }, + ) + errors["base"] = "firmware_not_fully_supported" + else: + user_input = {} + + schema = { + vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, + } + return self.async_show_form( + step_id="credentials", data_schema=vol.Schema(schema), errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + host = reauth_entry.data[CONF_HOST] + + if user_input is not None: + try: + info = await self._async_get_info(host) + except (DeviceConnectionError, InvalidAuthError): + return self.async_abort(reason="reauth_unsuccessful") + + user_input[CONF_USERNAME] = "admin" + try: + await async_validate_input(self.hass, host, info, user_input) + except (DeviceConnectionError, InvalidAuthError): + return self.async_abort(reason="reauth_unsuccessful") + except MacAddressMismatchError: + return self.async_abort(reason="mac_address_mismatch") + + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + schema = { + vol.Required(CONF_PASSWORD): str, + } + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema(schema), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by zeroconf discovery.""" + host = discovery_info.host + if mac := mac_address_from_name(discovery_info.name): + await self._async_discovered_mac(mac, host) + try: + self.info = await self._async_get_info(host) + except DeviceConnectionError: + return self.async_abort(reason="cannot_connect") + if not mac: + mac = fmt_macaddress(self.info[CONF_MAC]) + await self._async_discovered_mac(mac, host) + + self.host = host + self.context.update( + { + "title_placeholders": {"name": self.info["name"]}, + "configuration_url": f"http://{host}", + } + ) + + if get_info_auth(self.info): + return await self.async_step_credentials() + try: + self.device_info = await async_validate_input( + self.hass, self.host, self.info, {} + ) + except DeviceConnectionError: + return self.async_abort(reason="cannot_connect") + + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle discovery confirm.""" + errors: dict[str, str] = {} + + if not self.device_info["model"]: + errors["base"] = "firmware_not_fully_supported" + model = "Refoss" + else: + model = self.device_info["model"] + if user_input is not None: + return self.async_create_entry( + title=self.device_info["name"], + data={ + CONF_MAC: self.info[CONF_MAC], + CONF_HOST: self.host, + "model": model, + }, + ) + self._set_confirm_only() + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + "model": model, + "host": self.host, + }, + errors=errors, + ) + + async def _async_discovered_mac(self, mac: str, host: str) -> None: + """Abort and reconnect soon if the device with the mac address is already configured.""" + if ( + current_entry := await self.async_set_unique_id(mac) + ) and current_entry.data.get(CONF_HOST) == host: + LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac) + await async_reconnect_soon(self.hass, current_entry) + if host == INTERNAL_WIFI_AP_IP: + self._abort_if_unique_id_configured() + else: + self._abort_if_unique_id_configured({CONF_HOST: host}) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors = {} + reconfigure_entry = self._get_reconfigure_entry() + self.host = reconfigure_entry.data[CONF_HOST] + if user_input is not None: + host = user_input[CONF_HOST] + try: + info = await self._async_get_info(host) + except DeviceConnectionError: + errors["base"] = "cannot_connect" + else: + mac = fmt_macaddress(info[CONF_MAC]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_mismatch(reason="another_device") + + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_HOST: host}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=self.host): str}), + description_placeholders={"device_name": reconfigure_entry.title}, + errors=errors, + ) + + async def _async_get_info(self, host: str) -> dict[str, Any]: + """Get info from refoss device.""" + return await get_info(async_get_clientsession(self.hass), host) diff --git a/custom_components/refoss_rpc/refoss_rpc/const.py b/custom_components/refoss_rpc/refoss_rpc/const.py new file mode 100644 index 0000000..9138b28 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/const.py @@ -0,0 +1,47 @@ +"""Constants for the Refoss RPC integration.""" + +from __future__ import annotations + +from logging import Logger, getLogger +from typing import Final + +DOMAIN: Final = "refoss_rpc" + +LOGGER: Logger = getLogger(__package__) + +#Check interval for devices +REFOSS_CHECK_INTERVAL = 60 + + +# Button Click events for devices +EVENT_REFOSS_CLICK: Final = "refoss.click" + +ATTR_CLICK_TYPE: Final = "click_type" +ATTR_CHANNEL: Final = "channel" +ATTR_DEVICE: Final = "device" +CONF_SUBTYPE: Final = "subtype" + +INPUTS_EVENTS_TYPES: Final = { + "button_down", + "button_up", + "button_single_push", + "button_double_push", + "button_triple_push", + "button_long_push", +} + +INPUTS_EVENTS_SUBTYPES: Final = { + "button1": 1, + "button2": 2, +} + +UPTIME_DEVIATION: Final = 5 + +# Time to wait before reloading entry when device config change +ENTRY_RELOAD_COOLDOWN = 30 + + +OTA_BEGIN = "ota_begin" +OTA_ERROR = "ota_error" +OTA_PROGRESS = "ota_progress" +OTA_SUCCESS = "ota_success" diff --git a/custom_components/refoss_rpc/refoss_rpc/coordinator.py b/custom_components/refoss_rpc/refoss_rpc/coordinator.py new file mode 100644 index 0000000..226167f --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/coordinator.py @@ -0,0 +1,403 @@ +"""Coordinators for the Refoss RPC integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, cast + +from aiorefoss.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, + RpcCallError, +) +from aiorefoss.rpc_device import RpcDevice, RpcUpdateType +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_CHANNEL, + ATTR_CLICK_TYPE, + ATTR_DEVICE, + ENTRY_RELOAD_COOLDOWN, + EVENT_REFOSS_CLICK, + INPUTS_EVENTS_TYPES, + LOGGER, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, + REFOSS_CHECK_INTERVAL, +) +from .utils import get_host, update_device_fw_info + + +@dataclass +class RefossEntryData: + """Class for sharing data within a given config entry.""" + + platforms: list[Platform] + coordinator: RefossCoordinator | None = None + + +RefossConfigEntry = ConfigEntry[RefossEntryData] + + +class RefossCoordinatorBase(DataUpdateCoordinator[None]): + """Coordinator for a Refoss device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: RefossConfigEntry, + device: RpcDevice, + update_interval: float, + ) -> None: + """Initialize the Refoss device coordinator.""" + self.entry = entry + self.device = device + self.device_id: str | None = None + device_name = device.name if device.initialized else entry.title + interval_td = timedelta(seconds=update_interval) + self._came_online_once = False + super().__init__(hass, LOGGER, name=device_name, update_interval=interval_td) + + self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( + hass, + LOGGER, + cooldown=ENTRY_RELOAD_COOLDOWN, + immediate=False, + function=self._async_reload_entry, + ) + entry.async_on_unload(self._debounced_reload.async_shutdown) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + + @cached_property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @cached_property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + @property + def sw_version(self) -> str: + """Firmware version of the device.""" + return self.device.firmware_version if self.device.initialized else "" + + @property + def hw_version(self) -> str: + """Hardware version of the device.""" + return self.device.hw_version if self.device.initialized else "" + + def async_setup(self) -> None: + """Set up the coordinator.""" + dev_reg = dr.async_get(self.hass) + device_entry = dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.device.name, + connections={(CONNECTION_NETWORK_MAC, self.mac)}, + manufacturer="Refoss", + model=self.model, + sw_version=self.sw_version, + hw_version=self.hw_version, + configuration_url=f"http://{get_host(self.entry.data[CONF_HOST])}", + ) + self.device_id = device_entry.id + self.remove_old_entity() + + def remove_old_entity(self) -> None: + """Remove old entity when reload.""" + entity_reg = er.async_get(self.hass) + entities = er.async_entries_for_device( + registry=entity_reg, device_id=self.device_id + ) + for entity in entities: + LOGGER.debug("Removing old entity: %s", entity.entity_id) + entity_reg.async_remove(entity.entity_id) + + async def shutdown(self) -> None: + """Shutdown the coordinator.""" + await self.device.shutdown() + + async def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + LOGGER.debug("Stopping device coordinator for %s", self.name) + await self.shutdown() + + async def _async_device_connect_task(self) -> bool: + """Connect to a Refoss device task.""" + LOGGER.debug("Connecting to Refoss Device - %s", self.name) + try: + await self.device.initialize() + update_device_fw_info(self.hass, self.device, self.entry) + except (DeviceConnectionError, MacAddressMismatchError) as err: + LOGGER.debug( + "Error connecting to Refoss device %s, error: %r", self.name, err + ) + return False + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) + return False + + return True + + async def _async_reload_entry(self) -> None: + """Reload entry.""" + self._debounced_reload.async_cancel() + LOGGER.debug("Reloading entry %s", self.name) + await self.hass.config_entries.async_reload(self.entry.entry_id) + + async def async_shutdown_device_and_start_reauth(self) -> None: + """Shutdown Refoss device and start reauth flow.""" + self.last_update_success = False + await self.shutdown() + self.entry.async_start_reauth(self.hass) + + +class RefossCoordinator(RefossCoordinatorBase): + """Coordinator for a Refoss device.""" + + def __init__( + self, hass: HomeAssistant, entry: RefossConfigEntry, device: RpcDevice + ) -> None: + """Initialize the Refoss coordinator.""" + self.entry = entry + super().__init__(hass, entry, device, REFOSS_CHECK_INTERVAL) + + self.connected = False + self._connection_lock = asyncio.Lock() + self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._connect_task: asyncio.Task | None = None + + async def async_device_online(self, source: str) -> None: + """Handle device going online.""" + + if not self._came_online_once or not self.device.initialized: + LOGGER.debug( + "device %s is online (source: %s), trying to poll and configure", + self.name, + source, + ) + self._async_handle_refoss_device_online() + + @callback + def async_subscribe_ota_events( + self, ota_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to OTA events.""" + + def _unsubscribe() -> None: + self._ota_event_listeners.remove(ota_event_callback) + + self._ota_event_listeners.append(ota_event_callback) + + return _unsubscribe + + @callback + def async_subscribe_input_events( + self, input_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to input events.""" + + def _unsubscribe() -> None: + self._input_event_listeners.remove(input_event_callback) + + self._input_event_listeners.append(input_event_callback) + + return _unsubscribe + + @callback + def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: + """Handle device events.""" + events: list[dict[str, Any]] = event_data["events"] + for event in events: + event_type = event.get("event") + if event_type is None: + continue + + RELOAD_EVENTS = {"config_changed", "emmerge_change", "cfg_change"} + if event_type in RELOAD_EVENTS: + LOGGER.info( + "Config for %s changed, reloading entry in %s seconds", + self.name, + ENTRY_RELOAD_COOLDOWN, + ) + self._debounced_reload.async_schedule_call() + elif event_type in INPUTS_EVENTS_TYPES: + for event_callback in self._input_event_listeners: + event_callback(event) + self.hass.bus.async_fire( + EVENT_REFOSS_CLICK, + { + ATTR_DEVICE_ID: self.device_id, + ATTR_DEVICE: self.device.name, + ATTR_CHANNEL: event["id"], + ATTR_CLICK_TYPE: event["event"], + }, + ) + elif event_type in (OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS): + for event_callback in self._ota_event_listeners: + event_callback(event) + + async def _async_update_data(self) -> None: + """Fetch data.""" + if self.hass.is_stopping: + return + + async with self._connection_lock: + if not self.device.connected: + if not await self._async_device_connect_task(): + raise UpdateFailed("Device reconnect error") + return + try: + LOGGER.debug("Polling Refoss Device - %s", self.name) + await self.device.poll() + except DeviceConnectionError as err: + raise UpdateFailed(f"Device disconnected: {err!r}") from err + except RpcCallError as err: + raise UpdateFailed(f"RPC call failed: {err!r}") from err + except InvalidAuthError: + await self.async_shutdown_device_and_start_reauth() + return + + async def _async_disconnected(self, reconnect: bool) -> None: + """Handle device disconnected.""" + async with self._connection_lock: + if not self.connected: # Already disconnected + return + self.connected = False + + # Try to reconnect right away if triggered by disconnect event + if reconnect: + await self.async_request_refresh() + + async def _async_connected(self) -> None: + """Handle device connected.""" + async with self._connection_lock: + if self.connected: # Already connected + return + self.connected = True + + @callback + def _async_handle_refoss_device_online(self) -> None: + """Handle device going online.""" + if self.device.connected or ( + self._connect_task and not self._connect_task.done() + ): + LOGGER.debug("Device %s already connected/connecting", self.name) + return + self._connect_task = self.entry.async_create_background_task( + self.hass, + self._async_device_connect_task(), + "device online", + eager_start=True, + ) + + @callback + def _async_handle_update( + self, device: RpcDevice, update_type: RpcUpdateType + ) -> None: + """Handle device update.""" + LOGGER.debug("Refoss %s handle update, type: %s", self.name, update_type) + if update_type is RpcUpdateType.ONLINE: + self._came_online_once = True + self._async_handle_refoss_device_online() + elif update_type is RpcUpdateType.INITIALIZED: + self.entry.async_create_background_task( + self.hass, self._async_connected(), "device init", eager_start=True + ) + self.async_set_updated_data(None) + elif update_type is RpcUpdateType.DISCONNECTED: + self.entry.async_create_background_task( + self.hass, + self._async_disconnected(True), + "device disconnected", + eager_start=True, + ) + self.async_set_updated_data(None) + elif update_type is RpcUpdateType.STATUS: + self.async_set_updated_data(None) + + elif update_type is RpcUpdateType.EVENT and (event := self.device.event): + self._async_device_event_handler(event) + + def async_setup(self) -> None: + """Set up the coordinator.""" + super().async_setup() + self.device.subscribe_updates(self._async_handle_update) + if self.device.initialized: + # If we are already initialized, we are connected + self.entry.async_create_task( + self.hass, self._async_connected(), eager_start=True + ) + + async def shutdown(self) -> None: + """Shutdown the coordinator.""" + if self.device.connected: + try: + await super().shutdown() + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) + return + except DeviceConnectionError as err: + LOGGER.debug("Error during shutdown for device %s: %s", self.name, err) + return + await self._async_disconnected(False) + + +def get_refoss_coordinator_by_device_id( + hass: HomeAssistant, device_id: str +) -> RefossCoordinator | None: + """Get a Refoss device coordinator for the given device id.""" + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + entry = hass.config_entries.async_get_entry(config_entry) + if ( + entry + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") + and isinstance(entry.runtime_data, RefossEntryData) + and (coordinator := entry.runtime_data.coordinator) + ): + return coordinator + + return None + + +async def async_reconnect_soon(hass: HomeAssistant, entry: RefossConfigEntry) -> None: + """Try to reconnect soon.""" + if ( + not hass.is_stopping + and entry.state is ConfigEntryState.LOADED + and (coordinator := entry.runtime_data.coordinator) + ): + entry.async_create_background_task( + hass, + coordinator.async_device_online("zeroconf"), + "reconnect soon", + eager_start=True, + ) diff --git a/custom_components/refoss_rpc/refoss_rpc/cover.py b/custom_components/refoss_rpc/refoss_rpc/cover.py new file mode 100644 index 0000000..d54397e --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/cover.py @@ -0,0 +1,88 @@ +"""Cover for refoss.""" + +from __future__ import annotations +from typing import Any, cast + + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import RefossConfigEntry, RefossCoordinator +from .entity import RefossEntity +from .utils import get_refoss_key_ids + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up cover for device.""" + coordinator = config_entry.runtime_data.coordinator + assert coordinator + + cover_key_ids = get_refoss_key_ids(coordinator.device.status, "cover") + + async_add_entities(RefossCover(coordinator, _id) for _id in cover_key_ids) + + +class RefossCover(RefossEntity, CoverEntity): + """Refoss cover entity.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + def __init__(self, coordinator: RefossCoordinator, _id: int) -> None: + """Initialize cover.""" + super().__init__(coordinator, f"cover:{_id}") + self._id = _id + if self.status["cali_state"] == "success": + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + + @property + def current_cover_position(self) -> int | None: + """Position of the cover.""" + if not self.status["cali_state"] or self.status["cali_state"] != "success": + return None + return cast(int, self.status["current_pos"]) + + @property + def is_closed(self) -> bool | None: + """If cover is closed.""" + return cast(bool, self.status["state"] == "closed") + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return cast(bool, self.status["state"] == "closing") + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return cast(bool, self.status["state"] == "opening") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self.call_rpc("Cover.Action.Set", {"id": self._id, "action": "close"}) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + await self.call_rpc("Cover.Action.Set", {"id": self._id, "action": "open"}) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + await self.call_rpc( + "Cover.Pos.Set", {"id": self._id, "pos": kwargs[ATTR_POSITION]} + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.call_rpc("Cover.Action.Set", {"id": self._id, "action": "stop"}) diff --git a/custom_components/refoss_rpc/refoss_rpc/device_trigger.py b/custom_components/refoss_rpc/refoss_rpc/device_trigger.py new file mode 100644 index 0000000..2f20552 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/device_trigger.py @@ -0,0 +1,120 @@ +"""Provides device triggers for Refoss.""" + +from __future__ import annotations + +from typing import Final + +import voluptuous as vol + +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_CHANNEL, + ATTR_CLICK_TYPE, + CONF_SUBTYPE, + DOMAIN, + EVENT_REFOSS_CLICK, + INPUTS_EVENTS_SUBTYPES, + INPUTS_EVENTS_TYPES, +) +from .coordinator import get_refoss_coordinator_by_device_id +from .utils import get_input_triggers + +TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(INPUTS_EVENTS_TYPES), + vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), + } +) + + +def append_input_triggers( + triggers: list[dict[str, str]], + input_triggers: list[tuple[str, str]], + device_id: str, +) -> None: + """Add trigger to triggers list.""" + for trigger, subtype in input_triggers: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if config[CONF_TYPE] in INPUTS_EVENTS_TYPES: + coordinator = get_refoss_coordinator_by_device_id(hass, config[CONF_DEVICE_ID]) + if not coordinator or not coordinator.device.initialized: + return config + + input_triggers = get_input_triggers(coordinator.device) + if trigger in input_triggers: + return config + + raise InvalidDeviceAutomationConfig( + f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" + ) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for Refoss devices.""" + triggers: list[dict[str, str]] = [] + + if coordinator := get_refoss_coordinator_by_device_id(hass, device_id): + input_triggers = get_input_triggers(coordinator.device) + append_input_triggers(triggers, input_triggers, device_id) + return triggers + + raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + event_config = { + event_trigger.CONF_PLATFORM: CONF_EVENT, + event_trigger.CONF_EVENT_TYPE: EVENT_REFOSS_CLICK, + event_trigger.CONF_EVENT_DATA: { + ATTR_DEVICE_ID: config[CONF_DEVICE_ID], + ATTR_CHANNEL: INPUTS_EVENTS_SUBTYPES[config[CONF_SUBTYPE]], + ATTR_CLICK_TYPE: config[CONF_TYPE], + }, + } + + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/custom_components/refoss_rpc/refoss_rpc/entity.py b/custom_components/refoss_rpc/refoss_rpc/entity.py new file mode 100644 index 0000000..c1e2941 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/entity.py @@ -0,0 +1,230 @@ +"""Refoss entity helper.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, cast + +from aiorefoss.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import LOGGER +from .coordinator import RefossConfigEntry, RefossCoordinator +from .utils import ( + async_remove_refoss_entity, + get_refoss_entity_name, + get_refoss_key_instances, + merge_channel_get_status, +) + + +@callback +def async_setup_entry_refoss( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: Mapping[str, RefossEntityDescription], + sensor_class: Callable, +) -> None: + """Set up entities for Refoss.""" + coordinator = config_entry.runtime_data.coordinator + # If the device is not initialized, return directly + if not coordinator or not coordinator.device.initialized: + return + + device_status = coordinator.device.status + device_config = coordinator.device.config + mac = coordinator.mac + entities: list[Any] = [] + + for sensor_id, description in sensors.items(): + key_instances = get_refoss_key_instances(device_status, description.key) + + for key in key_instances: + key_status = device_status.get(key) + if key_status is None: + continue + + # Filter out sensors that are not supported or do not match the configuration + if ( + not key.startswith("emmerge:") + and description.sub_key not in key_status + and not description.supported(key_status) + ): + continue + + # Filter and remove entities that should not be created according to the configuration/status + if description.removal_condition and description.removal_condition( + device_config, device_status, key + ): + try: + domain = sensor_class.__module__.split(".")[-1] + except AttributeError: + LOGGER.error( + "Failed to get module name from sensor_class for sensor_id %s and key %s", + sensor_id, + key, + ) + continue + unique_id = f"{mac}-{key}-{sensor_id}" + async_remove_refoss_entity(hass, domain, unique_id) + else: + entities.append(sensor_class(coordinator, key, sensor_id, description)) + + if entities: + async_add_entities(entities) + + +@dataclass(frozen=True, kw_only=True) +class RefossEntityDescription(EntityDescription): + """Class to describe a entity.""" + + name: str = "" + sub_key: str + + value: Callable[[Any, Any], Any] | None = None + removal_condition: Callable[[dict, dict, str], bool] | None = None + supported: Callable = lambda _: False + + +class RefossEntity(CoordinatorEntity[RefossCoordinator]): + """Helper class to represent a entity.""" + + def __init__(self, coordinator: RefossCoordinator, key: str) -> None: + """Initialize Refoss entity.""" + super().__init__(coordinator) + self.key = key + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + self._attr_unique_id = f"{coordinator.mac}-{key}" + self._attr_name = get_refoss_entity_name(coordinator.device, key) + + @property + def available(self) -> bool: + """Check if device is available and initialized.""" + coordinator = self.coordinator + return super().available and (coordinator.device.initialized) + + @property + def status(self) -> dict | None: + """Device status by entity key.""" + device_status = self.coordinator.device.status.get(self.key) + if device_status is None: + LOGGER.debug("Device status not found for key: %s", self.key) + return device_status + + async def async_added_to_hass(self) -> None: + """When entity is added to HASS.""" + self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) + + @callback + def _update_callback(self) -> None: + """Handle device update.""" + self.async_write_ha_state() + + async def call_rpc(self, method: str, params: Any) -> Any: + """Call RPC method.""" + LOGGER.debug( + "Call RPC for entity %s, method: %s, params: %s", + self.name, + method, + params, + ) + try: + return await self.coordinator.device.call_rpc(method, params) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + f"Call RPC for {self.name} connection error, method: {method}, params:" + f" {params}, error: {err!r}" + ) from err + except RpcCallError as err: + raise HomeAssistantError( + f"Call RPC for {self.name} request error, method: {method}, params:" + f" {params}, error: {err!r}" + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + +class RefossAttributeEntity(RefossEntity, Entity): + """Helper class to represent a attribute.""" + + entity_description: RefossEntityDescription + + def __init__( + self, + coordinator: RefossCoordinator, + key: str, + attribute: str, + description: RefossEntityDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, key) + self.attribute = attribute + self.entity_description = description + + self._attr_unique_id = f"{super().unique_id}-{attribute}" + self._attr_name = get_refoss_entity_name( + device=coordinator.device, key=key, description=description.name + ) + self._last_value = None + + @property + def sub_status(self) -> Any | None: + """Get the sub - status of the device by entity key. + + Returns the value corresponding to the sub - key in the device status. + If the device status is None or the sub - key does not exist, returns None. + """ + device_status = self.status + if device_status is None: + LOGGER.debug("Device status is None for entity %s", self.name) + return None + sub_key = self.entity_description.sub_key + sub_status = device_status.get(sub_key) + return sub_status + + @property + def attribute_value(self) -> StateType: + """Value of sensor.""" + try: + if self.key.startswith("emmerge:"): + # Call the merge channel attributes function + return merge_channel_get_status( + self.coordinator.device.status, + self.key, + self.entity_description.sub_key, + ) + + # Reduce repeated calls and get the sub-status + sub_status = self.sub_status + + if self.entity_description.value is not None: + # Call the custom value processing function + self._last_value = self.entity_description.value( + sub_status, self._last_value + ) + else: + self._last_value = sub_status + + return self._last_value + except Exception as e: + # Log the exception + LOGGER.error( + "Error getting attribute value for entity %s, key %s, attribute %s: %s", + self.name, + self.key, + self.attribute, + str(e), + ) + return None diff --git a/custom_components/refoss_rpc/refoss_rpc/event.py b/custom_components/refoss_rpc/refoss_rpc/event.py new file mode 100644 index 0000000..e399021 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/event.py @@ -0,0 +1,109 @@ +"""Event entities for Refoss.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import INPUTS_EVENTS_TYPES +from .coordinator import RefossConfigEntry, RefossCoordinator +from .utils import ( + async_remove_refoss_entity, + get_refoss_entity_name, + get_refoss_key_instances, + is_refoss_input_button, +) + + +@dataclass(frozen=True, kw_only=True) +class RefossEventDescription(EventEntityDescription): + """Class to describe Refoss event.""" + + removal_condition: Callable[[dict, dict, str], bool] | None = None + + +REFOSS_EVENT: Final = RefossEventDescription( + key="input", + translation_key="input", + device_class=EventDeviceClass.BUTTON, + event_types=list(INPUTS_EVENTS_TYPES), + removal_condition=lambda config, status, key: not is_refoss_input_button( + config, status, key + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up event for device.""" + entities: list[RefossEvent] = [] + + coordinator = config_entry.runtime_data.coordinator + if TYPE_CHECKING: + assert coordinator + + key_instances = get_refoss_key_instances( + coordinator.device.status, REFOSS_EVENT.key + ) + + for key in key_instances: + if REFOSS_EVENT.removal_condition and REFOSS_EVENT.removal_condition( + coordinator.device.config, coordinator.device.status, key + ): + unique_id = f"{coordinator.mac}-{key}" + async_remove_refoss_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(RefossEvent(coordinator, key, REFOSS_EVENT)) + + async_add_entities(entities) + + +class RefossEvent(CoordinatorEntity[RefossCoordinator], EventEntity): + """Refoss event entity.""" + + entity_description: RefossEventDescription + + def __init__( + self, + coordinator: RefossCoordinator, + key: str, + description: RefossEventDescription, + ) -> None: + """Initialize event entity.""" + super().__init__(coordinator) + self.input_index = int(key.split(":")[-1]) + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + self._attr_unique_id = f"{coordinator.mac}-{key}" + self._attr_name = get_refoss_entity_name(coordinator.device, key) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_input_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle the button event.""" + if event["id"] == self.input_index: + self._trigger_event(event["event"]) + self.async_write_ha_state() diff --git a/custom_components/refoss_rpc/refoss_rpc/logbook.py b/custom_components/refoss_rpc/refoss_rpc/logbook.py new file mode 100644 index 0000000..71b9d5c --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/logbook.py @@ -0,0 +1,51 @@ +"""Describe Refoss logbook events.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback + +from .const import ( + ATTR_CHANNEL, + ATTR_CLICK_TYPE, + ATTR_DEVICE, + DOMAIN, + EVENT_REFOSS_CLICK, + INPUTS_EVENTS_TYPES, +) +from .coordinator import get_refoss_coordinator_by_device_id +from .utils import get_refoss_entity_name + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict]], None], +) -> None: + """Describe logbook events.""" + + @callback + def async_describe_refoss_click_event(event: Event) -> dict[str, str]: + """Describe refoss.click logbook event.""" + device_id = event.data[ATTR_DEVICE_ID] + click_type = event.data[ATTR_CLICK_TYPE] + channel = event.data[ATTR_CHANNEL] + input_name = f"{event.data[ATTR_DEVICE]} channel {channel}" + + if click_type in INPUTS_EVENTS_TYPES: + coordinator = get_refoss_coordinator_by_device_id(hass, device_id) + if coordinator and coordinator.device.initialized: + key = f"input:{channel}" + input_name = get_refoss_entity_name(coordinator.device, key) + + return { + LOGBOOK_ENTRY_NAME: "Refoss", + LOGBOOK_ENTRY_MESSAGE: ( + f"'{click_type}' click event for {input_name} Input was fired" + ), + } + + async_describe_event(DOMAIN, EVENT_REFOSS_CLICK, async_describe_refoss_click_event) diff --git a/custom_components/refoss_rpc/refoss_rpc/manifest.json b/custom_components/refoss_rpc/refoss_rpc/manifest.json new file mode 100644 index 0000000..c5af008 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/manifest.json @@ -0,0 +1,20 @@ +{ + "domain": "refoss_rpc", + "name": "Refoss RPC", + "codeowners": ["@ashionky"], + "config_flow": true, + "documentation": "https://github.com/Refoss/refoss_rpc/blob/main/README.md", + "integration_type": "device", + "iot_class": "local_push", + "issue_tracker": "https://github.com/Refoss/refoss_rpc/issues", + "loggers": ["aiorefoss"], + "quality_scale": "bronze", + "requirements": ["aiorefoss==1.0.1"], + "version": "1.0.4", + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "refoss*" + } + ] +} diff --git a/custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml b/custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml new file mode 100644 index 0000000..4109198 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml @@ -0,0 +1,26 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done diff --git a/custom_components/refoss_rpc/refoss_rpc/sensor.py b/custom_components/refoss_rpc/refoss_rpc/sensor.py new file mode 100644 index 0000000..ce701cb --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/sensor.py @@ -0,0 +1,331 @@ +"""Sensor entities for Refoss.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import RefossConfigEntry, RefossCoordinator +from .entity import ( + RefossAttributeEntity, + RefossEntityDescription, + async_setup_entry_refoss, +) +from .utils import get_device_uptime, is_refoss_wifi_stations_disabled + + +@dataclass(frozen=True, kw_only=True) +class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): + """Class to describe a sensor.""" + + +REFOSS_SENSORS: Final = { + "power": RefossSensorDescription( + key="switch", + sub_key="apower", + name="Power", + native_unit_of_measurement=UnitOfPower.MILLIWATT, + value=lambda status, _: None if status is None else float(status), + suggested_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "voltage": RefossSensorDescription( + key="switch", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + value=lambda status, _: None if status is None else float(status), + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "current": RefossSensorDescription( + key="switch", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + value=lambda status, _: None if status is None else float(status), + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "energy": RefossSensorDescription( + key="switch", + sub_key="month_consumption", + name="This Month Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "cover_energy": RefossSensorDescription( + key="cover", + sub_key="aenergy", + name="Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + "temperature": RefossSensorDescription( + key="sys", + sub_key="temperature", + name="Device temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value=lambda status, _: status["tc"], + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "rssi": RefossSensorDescription( + key="wifi", + sub_key="rssi", + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + removal_condition=is_refoss_wifi_stations_disabled, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "uptime": RefossSensorDescription( + key="sys", + sub_key="uptime", + name="Uptime", + value=get_device_uptime, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "em_power": RefossSensorDescription( + key="em", + sub_key="power", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "em_voltage": RefossSensorDescription( + key="em", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "em_current": RefossSensorDescription( + key="em", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "em_month_energy": RefossSensorDescription( + key="em", + sub_key="month_energy", + name="This Month Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_month_ret_energy": RefossSensorDescription( + key="em", + sub_key="month_ret_energy", + name="This Month Return Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_week_energy": RefossSensorDescription( + key="em", + sub_key="week_energy", + name="This Week Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_week_ret_energy": RefossSensorDescription( + key="em", + sub_key="week_ret_energy", + name="This Week Retrun Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_day_energy": RefossSensorDescription( + key="em", + sub_key="day_energy", + name="Today Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_day_ret_energy": RefossSensorDescription( + key="em", + sub_key="day_ret_energy", + name="Today Return Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "em_pf": RefossSensorDescription( + key="em", + sub_key="pf", + name="Power factor", + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), + "emmerge_power": RefossSensorDescription( + key="emmerge", + sub_key="power", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "emmerge_current": RefossSensorDescription( + key="emmerge", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "emmerge_month_energy": RefossSensorDescription( + key="emmerge", + sub_key="month_energy", + name="This Month Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_month_ret_energy": RefossSensorDescription( + key="emmerge", + sub_key="month_ret_energy", + name="This Month Return Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_week_energy": RefossSensorDescription( + key="emmerge", + sub_key="week_energy", + name="This Week Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_week_ret_energy": RefossSensorDescription( + key="emmerge", + sub_key="week_ret_energy", + name="This Week Retrun Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_day_energy": RefossSensorDescription( + key="emmerge", + sub_key="day_energy", + name="Today Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "emmerge_day_ret_energy": RefossSensorDescription( + key="emmerge", + sub_key="day_ret_energy", + name="Today Return Energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for device.""" + coordinator = config_entry.runtime_data.coordinator + assert coordinator + + async_setup_entry_refoss( + hass, config_entry, async_add_entities, REFOSS_SENSORS, RefossSensor + ) + + +class RefossSensor(RefossAttributeEntity, SensorEntity): + """Refoss sensor entity.""" + + entity_description: RefossSensorDescription + + def __init__( + self, + coordinator: RefossCoordinator, + key: str, + attribute: str, + description: RefossSensorDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, key, attribute, description) + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.attribute_value diff --git a/custom_components/refoss_rpc/refoss_rpc/strings.json b/custom_components/refoss_rpc/refoss_rpc/strings.json new file mode 100644 index 0000000..f0e74a1 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/strings.json @@ -0,0 +1,90 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Before setup, devices must be connected to the network.\n\nThis path can be configured for Refoss product models including R11,P11s, etc. \n\nFor more information, please refer to 'Help'.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Refoss device to connect to." + } + }, + "credentials": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Password for access to the device." + } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Password for access to the device." + } + }, + "confirm_discovery": { + "description": "Do you want to set up the {model} at {host}?\n\ndevices that are not password protected will be added." + }, + "reconfigure": { + "description": "Update configuration for {device_name}.\n\nBefore setup, devices must be connected to the network.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::refoss_rpc::config::step::user::data_description::host%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Refoss device was used.", + "mac_address_mismatch": "[%key:component::refoss_rpc::config::error::mac_address_mismatch%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "firmware_not_fully_supported": "Device not fully supported. Please contact Refoss support", + "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." + } + }, + "device_automation": { + "trigger_subtype": { + "button1": "First button", + "button2": "Second button" + }, + "trigger_type": { + "button_down": "{subtype} button down", + "button_up": "{subtype} button up", + "button_single_push": "{subtype} single push", + "button_double_push": "{subtype} double push", + "button_triple_push": "{subtype} triple push", + "button_long_push": "{subtype} long push" + } + }, + "entity": { + "event": { + "input": { + "state_attributes": { + "event_type": { + "state": { + "button_down": "Button down", + "button_up": "Button up", + "button_single_push": "Single push", + "button_double_push": "Double push", + "button_triple_push": "Triple push", + "button_long_push": "Long push" + } + } + } + } + } + } +} diff --git a/custom_components/refoss_rpc/refoss_rpc/switch.py b/custom_components/refoss_rpc/refoss_rpc/switch.py new file mode 100644 index 0000000..1b38acc --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/switch.py @@ -0,0 +1,53 @@ +"""Switch entities for refoss.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import RefossConfigEntry, RefossCoordinator +from .entity import RefossEntity +from .utils import get_refoss_key_ids + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch for device.""" + coordinator = config_entry.runtime_data.coordinator + assert coordinator + + switch_key_ids = get_refoss_key_ids(coordinator.device.status, "switch") + + async_add_entities(RefossSwitch(coordinator, _id) for _id in switch_key_ids) + + +class RefossSwitch(RefossEntity, SwitchEntity): + """Refoss switch entity.""" + + def __init__(self, coordinator: RefossCoordinator, _id: int) -> None: + """Initialize switch.""" + super().__init__(coordinator, f"switch:{_id}") + self._id = _id + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return bool(self.status["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + await self.call_rpc("Switch.Action.Set", {"id": self._id, "action": "on"}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + await self.call_rpc("Switch.Action.Set", {"id": self._id, "action": "off"}) + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle.""" + await self.call_rpc("Switch.Action.Set", {"id": self._id, "action": "toggle"}) diff --git a/custom_components/refoss_rpc/refoss_rpc/translations/en.json b/custom_components/refoss_rpc/refoss_rpc/translations/en.json new file mode 100644 index 0000000..c7ab056 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/translations/en.json @@ -0,0 +1,90 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Refoss device was used.", + "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again.", + "reauth_successful": "Re-authentication was successful", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", + "reconfigure_successful": "Re-configuration was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "firmware_not_fully_supported": "Device not fully supported. Please contact Refoss support", + "invalid_auth": "Invalid authentication", + "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." + }, + "flow_title": "{name}", + "step": { + "confirm_discovery": { + "description": "Do you want to set up the {model} at {host}?\n\ndevices that are not password protected will be added." + }, + "credentials": { + "data": { + "password": "Password" + }, + "data_description": { + "password": "Password for access to the device." + } + }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "data_description": { + "password": "Password for access to the device." + } + }, + "reconfigure": { + "data": { + "host": "Host" + }, + "data_description": { + "host": "The hostname or IP address of the Refoss device to connect to." + }, + "description": "Update configuration for {device_name}.\n\nBefore setup, devices must be connected to the network." + }, + "user": { + "data": { + "host": "Host" + }, + "data_description": { + "host": "The hostname or IP address of the Refoss device to connect to." + }, + "description": "Before setup, devices must be connected to the network.\n\nThis path can be configured for Refoss product models including R11,P11s, etc. \n\nFor more information, please refer to 'Help'." + } + } + }, + "device_automation": { + "trigger_subtype": { + "button1": "First button", + "button2": "Second button" + }, + "trigger_type": { + "button_double_push": "{subtype} double push", + "button_down": "{subtype} button down", + "button_long_push": "{subtype} long push", + "button_single_push": "{subtype} single push", + "button_triple_push": "{subtype} triple push", + "button_up": "{subtype} button up" + } + }, + "entity": { + "event": { + "input": { + "state_attributes": { + "event_type": { + "state": { + "button_double_push": "Double push", + "button_down": "Button down", + "button_long_push": "Long push", + "button_single_push": "Single push", + "button_triple_push": "Triple push", + "button_up": "Button up" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/refoss_rpc/refoss_rpc/update.py b/custom_components/refoss_rpc/refoss_rpc/update.py new file mode 100644 index 0000000..f96121b --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/update.py @@ -0,0 +1,159 @@ +"""Update entities for Refoss.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final, cast + +from aiorefoss.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS +from .coordinator import RefossConfigEntry, RefossCoordinator +from .entity import ( + RefossAttributeEntity, + RefossEntityDescription, + async_setup_entry_refoss, +) + +LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class RefossUpdateDescription(RefossEntityDescription, UpdateEntityDescription): + """Class to describe a update.""" + + latest_version: Callable[[dict], Any] + + +REFOSS_UPDATES: Final = { + "fwupdate": RefossUpdateDescription( + key="sys", + sub_key="available_updates", + name="Firmware", + latest_version=lambda status: status.get("version", None), + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RefossConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update for device.""" + async_setup_entry_refoss( + hass, config_entry, async_add_entities, REFOSS_UPDATES, RefossUpdateEntity + ) + + +class RefossUpdateEntity(RefossAttributeEntity, UpdateEntity): + """Refoss update entity.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + entity_description: RefossUpdateDescription + + def __init__( + self, + coordinator: RefossCoordinator, + key: str, + attribute: str, + description: RefossUpdateDescription, + ) -> None: + """Initialize update entity.""" + super().__init__(coordinator, key, attribute, description) + self._ota_in_progress = False + self._ota_progress_percentage: int | None = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_ota_events(self.firmware_upgrade_callback) + ) + + @callback + def firmware_upgrade_callback(self, event: dict[str, Any]) -> None: + """Handle device firmware upgrade progress.""" + if self.in_progress is not False: + event_type = event["event"] + if event_type == OTA_BEGIN: + self._ota_progress_percentage = 0 + elif event_type == OTA_PROGRESS: + self._ota_progress_percentage = event["progress_percent"] + elif event_type in (OTA_ERROR, OTA_SUCCESS): + self._ota_in_progress = False + self._ota_progress_percentage = None + self.async_write_ha_state() + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + return cast(str, self.coordinator.device.firmware_version) + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + new_version = self.entity_description.latest_version(self.sub_status) + if new_version: + return cast(str, new_version) + + return self.installed_version + + @property + def in_progress(self) -> bool: + """Update installation in progress.""" + return self._ota_in_progress + + @property + def update_percentage(self) -> int | None: + """Update installation progress.""" + return self._ota_progress_percentage + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + update_data = self.coordinator.device.status["sys"]["available_updates"] + LOGGER.debug("firmware update service - update_data: %s", update_data) + + new_version = update_data.get("version") + + LOGGER.info( + "Starting firmware update of device %s from '%s' to '%s'", + self.coordinator.name, + self.coordinator.device.firmware_version, + new_version, + ) + try: + await self.coordinator.device.trigger_firmware_update() + except DeviceConnectionError as err: + raise HomeAssistantError( + f"firmware update connection error: {err!r}" + ) from err + except RpcCallError as err: + raise HomeAssistantError(f"firmware update request error: {err!r}") from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + else: + self._ota_in_progress = True + self._ota_progress_percentage = None + LOGGER.debug( + "firmware update call for %s successful", self.coordinator.name + ) diff --git a/custom_components/refoss_rpc/refoss_rpc/utils.py b/custom_components/refoss_rpc/refoss_rpc/utils.py new file mode 100644 index 0000000..50c7e30 --- /dev/null +++ b/custom_components/refoss_rpc/refoss_rpc/utils.py @@ -0,0 +1,196 @@ +"""refoss helpers functions.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from ipaddress import IPv6Address, ip_address +from typing import Any, cast + +from aiorefoss.rpc_device import RpcDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.util.dt import utcnow + +from .const import DOMAIN, INPUTS_EVENTS_TYPES, LOGGER, UPTIME_DEVIATION + + +@callback +def async_remove_refoss_entity( + hass: HomeAssistant, domain: str, unique_id: str +) -> None: + """Remove a refoss entity.""" + entity_reg = er.async_get(hass) + entity_id = entity_reg.async_get_entity_id(domain, DOMAIN, unique_id) + if entity_id: + LOGGER.debug("Removing entity: %s", entity_id) + entity_reg.async_remove(entity_id) + + +def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: + """Return device uptime string, tolerate up to 5 seconds deviation.""" + delta_uptime = utcnow() - timedelta(seconds=uptime) + + if ( + not last_uptime + or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION + ): + return delta_uptime + + return last_uptime + + +def get_refoss_channel_name(device: RpcDevice, key: str) -> str: + """Get name based on device and channel name.""" + device_name = device.name + entity_name: str | None = None + if key in device.config: + entity_name = device.config[key].get("name") + + if entity_name is None: + channel = key.split(":")[0] + channel_id = key.split(":")[-1] + if key.startswith(("input:", "switch:", "cover:", "em:")): + return f"{device_name} {channel.title()} {channel_id}" + + if key.startswith(("emmerge:")): + return f"{device_name} {channel.title()}" + + return device_name + + return entity_name + + +def get_refoss_entity_name( + device: RpcDevice, key: str, description: str | None = None +) -> str: + """Naming for refoss entity.""" + channel_name = get_refoss_channel_name(device, key) + + if description: + return f"{channel_name} {description.lower()}" + + return channel_name + + +def get_refoss_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: + """Return list of key instances for device from a dict.""" + if key in keys_dict: + return [key] + + if key == "switch" and "cover:1" in keys_dict: + key = "cover" + + return [k for k in keys_dict if k.startswith(f"{key}:")] + + +def get_refoss_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: + """Return list of key ids for device from a dict.""" + return [int(k.split(":")[1]) for k in keys_dict if k.startswith(f"{key}:")] + + +def is_refoss_input_button( + config: dict[str, Any], status: dict[str, Any], key: str +) -> bool: + """Return true if input's type is set to button.""" + return cast(bool, config[key]["type"] == "button") + + +def get_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: + """Return list of input triggers for device.""" + triggers = [] + + key_ids = get_refoss_key_ids(device.config, "input") + + for id_ in key_ids: + key = f"input:{id_}" + if not is_refoss_input_button(device.config, device.status, key): + continue + + for trigger_type in INPUTS_EVENTS_TYPES: + subtype = f"button{id_}" + triggers.append((trigger_type, subtype)) + + return triggers + + +@callback +def update_device_fw_info( + hass: HomeAssistant, refossdevice: RpcDevice, entry: ConfigEntry +) -> None: + """Update the firmware version information in the device registry.""" + assert entry.unique_id + + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, + ): + if device.sw_version == refossdevice.firmware_version: + return + + LOGGER.debug("Updating device registry info for %s", entry.title) + + dev_reg.async_update_device(device.id, sw_version=refossdevice.firmware_version) + + +def is_refoss_wifi_stations_disabled( + config: dict[str, Any], _status: dict[str, Any], key: str +) -> bool: + """Return true if all WiFi stations are disabled.""" + if ( + config[key]["sta_1"]["enable"] is False + and config[key]["sta_2"]["enable"] is False + ): + return True + + return False + + +def merge_channel_get_status(_status: dict[str, Any], key: str, attr: str) -> Any: + """ + Merge channel attributes. If the key starts with 'emmerge:', sum the attribute values of the corresponding bits. + + :param _status: Device status dictionary + :param key: Key name + :param attr: Attribute name + :return: Merged attribute value or None + """ + if not key.startswith("emmerge:"): + return None + + try: + # Extract and convert the number + num = int(key.split(":")[1]) + except (IndexError, ValueError): + LOGGER.error("Failed to extract or convert number from key: %s", key) + return None + + # Find the indices of bits with a value of 1 in the binary representation + bit_positions = [i for i in range(num.bit_length()) if num & (1 << i)] + + val = 0 + for bit in bit_positions: + status_key = f"em:{bit+1}" + if status_key in _status and attr in _status[status_key]: + val += _status[status_key][attr] + else: + LOGGER.warning("Missing key %s or attribute %s in status", status_key, attr) + + return val + + +def get_host(host: str) -> str: + """Get the device IP address.""" + try: + ip_object = ip_address(host) + except ValueError: + # host contains hostname + return host + + if isinstance(ip_object, IPv6Address): + return f"[{host}]" + + return host From 14325cc86b9b24705207e42c61811b37ccae5d75 Mon Sep 17 00:00:00 2001 From: ashionky <495519020@qq.com> Date: Mon, 21 Jul 2025 14:05:44 +0800 Subject: [PATCH 5/6] cover --- .../refoss_rpc/refoss_rpc/__init__.py | 91 ---- .../refoss_rpc/refoss_rpc/binary_sensor.py | 129 ------ .../refoss_rpc/refoss_rpc/button.py | 123 ------ .../refoss_rpc/refoss_rpc/config_flow.py | 310 -------------- .../refoss_rpc/refoss_rpc/const.py | 47 -- .../refoss_rpc/refoss_rpc/coordinator.py | 403 ------------------ .../refoss_rpc/refoss_rpc/cover.py | 88 ---- .../refoss_rpc/refoss_rpc/device_trigger.py | 120 ------ .../refoss_rpc/refoss_rpc/entity.py | 230 ---------- .../refoss_rpc/refoss_rpc/event.py | 109 ----- .../refoss_rpc/refoss_rpc/logbook.py | 51 --- .../refoss_rpc/refoss_rpc/manifest.json | 20 - .../refoss_rpc/refoss_rpc/quality_scale.yaml | 26 -- .../refoss_rpc/refoss_rpc/sensor.py | 331 -------------- .../refoss_rpc/refoss_rpc/strings.json | 90 ---- .../refoss_rpc/refoss_rpc/switch.py | 53 --- .../refoss_rpc/translations/en.json | 90 ---- .../refoss_rpc/refoss_rpc/update.py | 159 ------- .../refoss_rpc/refoss_rpc/utils.py | 196 --------- 19 files changed, 2666 deletions(-) delete mode 100644 custom_components/refoss_rpc/refoss_rpc/__init__.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/binary_sensor.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/button.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/config_flow.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/const.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/coordinator.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/cover.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/device_trigger.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/entity.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/event.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/logbook.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/manifest.json delete mode 100644 custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml delete mode 100644 custom_components/refoss_rpc/refoss_rpc/sensor.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/strings.json delete mode 100644 custom_components/refoss_rpc/refoss_rpc/switch.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/translations/en.json delete mode 100644 custom_components/refoss_rpc/refoss_rpc/update.py delete mode 100644 custom_components/refoss_rpc/refoss_rpc/utils.py diff --git a/custom_components/refoss_rpc/refoss_rpc/__init__.py b/custom_components/refoss_rpc/refoss_rpc/__init__.py deleted file mode 100644 index 43e70e8..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/__init__.py +++ /dev/null @@ -1,91 +0,0 @@ -"""The Refoss RPC integration.""" - -from __future__ import annotations - -from typing import Final - -from aiorefoss.common import ConnectionOptions -from aiorefoss.exceptions import ( - DeviceConnectionError, - InvalidAuthError, - MacAddressMismatchError, -) -from aiorefoss.rpc_device import RpcDevice - -from homeassistant.const import ( - CONF_HOST, - CONF_MAC, - CONF_PASSWORD, - CONF_USERNAME, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .coordinator import ( - RefossConfigEntry, - RefossCoordinator, - RefossEntryData, -) - -PLATFORMS: Final = [ - Platform.BINARY_SENSOR, - Platform.BUTTON, - Platform.COVER, - Platform.EVENT, - Platform.SENSOR, - Platform.SWITCH, - Platform.UPDATE, -] - - -async def async_setup_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: - """Set up Refoss RPC from a config entry.""" - if not entry.data.get(CONF_HOST): - raise ConfigEntryError("Invalid Host, please try again") - - options = ConnectionOptions( - entry.data.get(CONF_HOST), - entry.data.get(CONF_USERNAME), - entry.data.get(CONF_PASSWORD), - entry.data.get(CONF_MAC), - ) - - device = await RpcDevice.create( - async_get_clientsession(hass), - options, - ) - runtime_data = entry.runtime_data = RefossEntryData(PLATFORMS) - - try: - await device.initialize() - except (DeviceConnectionError, MacAddressMismatchError) as err: - await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err - except InvalidAuthError as err: - await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err - - runtime_data.coordinator = RefossCoordinator(hass, entry, device) - runtime_data.coordinator.async_setup() - await hass.config_entries.async_forward_entry_setups(entry, runtime_data.platforms) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: RefossConfigEntry) -> bool: - """Unload a config entry.""" - - runtime_data = entry.runtime_data - - if runtime_data.coordinator: - await runtime_data.coordinator.shutdown() - - return await hass.config_entries.async_unload_platforms( - entry, runtime_data.platforms - ) diff --git a/custom_components/refoss_rpc/refoss_rpc/binary_sensor.py b/custom_components/refoss_rpc/refoss_rpc/binary_sensor.py deleted file mode 100644 index 6eecb5c..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/binary_sensor.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Binary sensor entities for Refoss.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Final - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .coordinator import RefossConfigEntry -from .entity import ( - RefossAttributeEntity, - RefossEntityDescription, - async_setup_entry_refoss, -) -from .utils import is_refoss_input_button - - -@dataclass(frozen=True, kw_only=True) -class RefossBinarySensorDescription( - RefossEntityDescription, BinarySensorEntityDescription -): - """Class to describe a binary sensor.""" - - -REFOSS_BINARY_SENSORS: Final = { - "input": RefossBinarySensorDescription( - key="input", - sub_key="state", - name="Input", - device_class=BinarySensorDeviceClass.POWER, - removal_condition=is_refoss_input_button, - ), - "cloud": RefossBinarySensorDescription( - key="cloud", - sub_key="connected", - name="Cloud", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "overtemp": RefossBinarySensorDescription( - key="sys", - sub_key="errors", - name="Overheating", - device_class=BinarySensorDeviceClass.PROBLEM, - value=lambda status, _: False if status is None else "overtemp" in status, - entity_category=EntityCategory.DIAGNOSTIC, - supported=lambda status: status.get("temperature") is not None, - ), - "overpower": RefossBinarySensorDescription( - key="switch", - sub_key="errors", - name="Overpowering", - device_class=BinarySensorDeviceClass.PROBLEM, - value=lambda status, _: False if status is None else "overpower" in status, - entity_category=EntityCategory.DIAGNOSTIC, - supported=lambda status: status.get("apower") is not None, - ), - "overvoltage": RefossBinarySensorDescription( - key="switch", - sub_key="errors", - name="Overvoltage", - device_class=BinarySensorDeviceClass.PROBLEM, - value=lambda status, _: False if status is None else "overvoltage" in status, - entity_category=EntityCategory.DIAGNOSTIC, - supported=lambda status: status.get("apower") is not None, - ), - "overcurrent": RefossBinarySensorDescription( - key="switch", - sub_key="errors", - name="Overcurrent", - device_class=BinarySensorDeviceClass.PROBLEM, - value=lambda status, _: False if status is None else "overcurrent" in status, - entity_category=EntityCategory.DIAGNOSTIC, - supported=lambda status: status.get("apower") is not None, - ), - "undervoltage": RefossBinarySensorDescription( - key="switch", - sub_key="errors", - name="Undervoltage", - device_class=BinarySensorDeviceClass.PROBLEM, - value=lambda status, _: False if status is None else "undervoltage" in status, - entity_category=EntityCategory.DIAGNOSTIC, - supported=lambda status: status.get("apower") is not None, - ), - "restart": RefossBinarySensorDescription( - key="sys", - sub_key="restart_required", - name="Restart required", - device_class=BinarySensorDeviceClass.PROBLEM, - entity_category=EntityCategory.DIAGNOSTIC, - ), -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up sensors for device.""" - coordinator = config_entry.runtime_data.coordinator - assert coordinator - - async_setup_entry_refoss( - hass, - config_entry, - async_add_entities, - REFOSS_BINARY_SENSORS, - RefossBinarySensor, - ) - - -class RefossBinarySensor(RefossAttributeEntity, BinarySensorEntity): - """Refoss binary sensor entity.""" - - entity_description: RefossBinarySensorDescription - - @property - def is_on(self) -> bool: - """Return true if sensor state is on.""" - return bool(self.attribute_value) diff --git a/custom_components/refoss_rpc/refoss_rpc/button.py b/custom_components/refoss_rpc/refoss_rpc/button.py deleted file mode 100644 index dbad781..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/button.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Button entities for Refoss.""" - -from __future__ import annotations - -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -from functools import partial -from typing import TYPE_CHECKING, Any, Final - -from homeassistant.components.button import ( - ButtonDeviceClass, - ButtonEntity, - ButtonEntityDescription, -) -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import slugify - -from .const import LOGGER -from .coordinator import RefossConfigEntry, RefossCoordinator - - -@dataclass(frozen=True, kw_only=True) -class RefossButtonDescription(ButtonEntityDescription): - """Class to describe a Button entity.""" - - press_action: Callable[[RefossCoordinator], Coroutine[Any, Any, None]] - - -REFOSS_BUTTONS: Final[list] = [ - RefossButtonDescription( - key="reboot", - name="Reboot", - device_class=ButtonDeviceClass.RESTART, - entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_reboot(), - ), - RefossButtonDescription( - key="fwcheck", - name="Check latest firmware", - entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_check_latest_firmware(), - ), -] - - -@callback -def async_migrate_unique_ids( - coordinator: RefossCoordinator, - entity_entry: er.RegistryEntry, -) -> dict[str, Any] | None: - """Migrate button unique IDs.""" - if not entity_entry.entity_id.startswith("button"): - return None - - device_name = slugify(coordinator.device.name) - - for key in ("reboot", "fwcheck"): - old_unique_id = f"{device_name}_{key}" - if entity_entry.unique_id == old_unique_id: - new_unique_id = f"{coordinator.mac}_{key}" - LOGGER.debug( - "Migrating unique_id for %s entity from [%s] to [%s]", - entity_entry.entity_id, - old_unique_id, - new_unique_id, - ) - return { - "new_unique_id": entity_entry.unique_id.replace( - old_unique_id, new_unique_id - ) - } - - return None - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set buttons for device.""" - entry_data = config_entry.runtime_data - coordinator: RefossCoordinator | None - coordinator = entry_data.coordinator - - if TYPE_CHECKING: - assert coordinator is not None - - await er.async_migrate_entries( - hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) - ) - - async_add_entities(RefossButton(coordinator, button) for button in REFOSS_BUTTONS) - - -class RefossButton(CoordinatorEntity[RefossCoordinator], ButtonEntity): - """Refoss button entity.""" - - entity_description: RefossButtonDescription - - def __init__( - self, - coordinator: RefossCoordinator, - description: RefossButtonDescription, - ) -> None: - """Initialize Refoss button.""" - super().__init__(coordinator) - self.entity_description = description - - self._attr_name = f"{coordinator.device.name} {description.name}" - self._attr_unique_id = f"{coordinator.mac}_{description.key}" - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} - ) - - async def async_press(self) -> None: - """Triggers the Refoss button press service.""" - await self.entity_description.press_action(self.coordinator) diff --git a/custom_components/refoss_rpc/refoss_rpc/config_flow.py b/custom_components/refoss_rpc/refoss_rpc/config_flow.py deleted file mode 100644 index dcd7074..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/config_flow.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Config flow for Refoss RPC integration.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from aiorefoss.common import ( - ConnectionOptions, - fmt_macaddress, - get_info, - get_info_auth, - mac_address_from_name, -) -from aiorefoss.exceptions import ( - DeviceConnectionError, - InvalidAuthError, - MacAddressMismatchError, -) -from aiorefoss.rpc_device import RpcDevice -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo - -from .const import DOMAIN, LOGGER -from .coordinator import async_reconnect_soon - -INTERNAL_WIFI_AP_IP = "10.10.10.1" - - -async def async_validate_input( - hass: HomeAssistant, - host: str, - info: dict[str, Any], - data: dict[str, Any], -) -> dict[str, Any]: - """Validate the user input allows us to connect.""" - options = ConnectionOptions( - ip_address=host, - username=data.get(CONF_USERNAME), - password=data.get(CONF_PASSWORD), - device_mac=info[CONF_MAC], - ) - - device = await RpcDevice.create( - async_get_clientsession(hass), - options, - ) - try: - await device.initialize() - finally: - await device.shutdown() - - return { - "name": device.name, - "model": device.model, - } - - -class RefossConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for refoss rpc.""" - - VERSION = 1 - MINOR_VERSION = 1 - - host: str = "" - info: dict[str, Any] = {} - device_info: dict[str, Any] = {} - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - if user_input is not None: - host = user_input[CONF_HOST] - try: - self.info = await self._async_get_info(host) - except DeviceConnectionError: - errors["base"] = "cannot_connect" - else: - mac = fmt_macaddress(self.info[CONF_MAC]) - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured({CONF_HOST: host}) - self.host = host - if get_info_auth(self.info): - return await self.async_step_credentials() - - try: - device_info = await async_validate_input( - self.hass, host, self.info, {} - ) - except DeviceConnectionError: - errors["base"] = "cannot_connect" - except MacAddressMismatchError: - errors["base"] = "mac_address_mismatch" - else: - if device_info["model"]: - return self.async_create_entry( - title=device_info["name"], - data={ - CONF_MAC: self.info[CONF_MAC], - CONF_HOST: self.host, - "model": device_info["model"], - }, - ) - errors["base"] = "firmware_not_fully_supported" - - schema = { - vol.Required(CONF_HOST): str, - } - return self.async_show_form( - step_id="user", data_schema=vol.Schema(schema), errors=errors - ) - - async def async_step_credentials( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the credentials step.""" - errors: dict[str, str] = {} - if user_input is not None: - user_input[CONF_USERNAME] = "admin" - try: - device_info = await async_validate_input( - self.hass, self.host, self.info, user_input - ) - except InvalidAuthError: - errors["base"] = "invalid_auth" - except DeviceConnectionError: - errors["base"] = "cannot_connect" - except MacAddressMismatchError: - errors["base"] = "mac_address_mismatch" - else: - if device_info["model"]: - return self.async_create_entry( - title=device_info["name"], - data={ - **user_input, - CONF_MAC: self.info[CONF_MAC], - CONF_HOST: self.host, - "model": device_info["model"], - }, - ) - errors["base"] = "firmware_not_fully_supported" - else: - user_input = {} - - schema = { - vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, - } - return self.async_show_form( - step_id="credentials", data_schema=vol.Schema(schema), errors=errors - ) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle configuration by re-auth.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required.""" - - errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() - host = reauth_entry.data[CONF_HOST] - - if user_input is not None: - try: - info = await self._async_get_info(host) - except (DeviceConnectionError, InvalidAuthError): - return self.async_abort(reason="reauth_unsuccessful") - - user_input[CONF_USERNAME] = "admin" - try: - await async_validate_input(self.hass, host, info, user_input) - except (DeviceConnectionError, InvalidAuthError): - return self.async_abort(reason="reauth_unsuccessful") - except MacAddressMismatchError: - return self.async_abort(reason="mac_address_mismatch") - - return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input - ) - - schema = { - vol.Required(CONF_PASSWORD): str, - } - - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema(schema), - errors=errors, - ) - - async def async_step_zeroconf( - self, discovery_info: ZeroconfServiceInfo - ) -> ConfigFlowResult: - """Handle a flow initialized by zeroconf discovery.""" - host = discovery_info.host - if mac := mac_address_from_name(discovery_info.name): - await self._async_discovered_mac(mac, host) - try: - self.info = await self._async_get_info(host) - except DeviceConnectionError: - return self.async_abort(reason="cannot_connect") - if not mac: - mac = fmt_macaddress(self.info[CONF_MAC]) - await self._async_discovered_mac(mac, host) - - self.host = host - self.context.update( - { - "title_placeholders": {"name": self.info["name"]}, - "configuration_url": f"http://{host}", - } - ) - - if get_info_auth(self.info): - return await self.async_step_credentials() - try: - self.device_info = await async_validate_input( - self.hass, self.host, self.info, {} - ) - except DeviceConnectionError: - return self.async_abort(reason="cannot_connect") - - return await self.async_step_confirm_discovery() - - async def async_step_confirm_discovery( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle discovery confirm.""" - errors: dict[str, str] = {} - - if not self.device_info["model"]: - errors["base"] = "firmware_not_fully_supported" - model = "Refoss" - else: - model = self.device_info["model"] - if user_input is not None: - return self.async_create_entry( - title=self.device_info["name"], - data={ - CONF_MAC: self.info[CONF_MAC], - CONF_HOST: self.host, - "model": model, - }, - ) - self._set_confirm_only() - return self.async_show_form( - step_id="confirm_discovery", - description_placeholders={ - "model": model, - "host": self.host, - }, - errors=errors, - ) - - async def _async_discovered_mac(self, mac: str, host: str) -> None: - """Abort and reconnect soon if the device with the mac address is already configured.""" - if ( - current_entry := await self.async_set_unique_id(mac) - ) and current_entry.data.get(CONF_HOST) == host: - LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac) - await async_reconnect_soon(self.hass, current_entry) - if host == INTERNAL_WIFI_AP_IP: - self._abort_if_unique_id_configured() - else: - self._abort_if_unique_id_configured({CONF_HOST: host}) - - async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a reconfiguration flow initialized by the user.""" - errors = {} - reconfigure_entry = self._get_reconfigure_entry() - self.host = reconfigure_entry.data[CONF_HOST] - if user_input is not None: - host = user_input[CONF_HOST] - try: - info = await self._async_get_info(host) - except DeviceConnectionError: - errors["base"] = "cannot_connect" - else: - mac = fmt_macaddress(info[CONF_MAC]) - await self.async_set_unique_id(mac) - self._abort_if_unique_id_mismatch(reason="another_device") - - return self.async_update_reload_and_abort( - reconfigure_entry, - data_updates={CONF_HOST: host}, - ) - - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=self.host): str}), - description_placeholders={"device_name": reconfigure_entry.title}, - errors=errors, - ) - - async def _async_get_info(self, host: str) -> dict[str, Any]: - """Get info from refoss device.""" - return await get_info(async_get_clientsession(self.hass), host) diff --git a/custom_components/refoss_rpc/refoss_rpc/const.py b/custom_components/refoss_rpc/refoss_rpc/const.py deleted file mode 100644 index 9138b28..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/const.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Constants for the Refoss RPC integration.""" - -from __future__ import annotations - -from logging import Logger, getLogger -from typing import Final - -DOMAIN: Final = "refoss_rpc" - -LOGGER: Logger = getLogger(__package__) - -#Check interval for devices -REFOSS_CHECK_INTERVAL = 60 - - -# Button Click events for devices -EVENT_REFOSS_CLICK: Final = "refoss.click" - -ATTR_CLICK_TYPE: Final = "click_type" -ATTR_CHANNEL: Final = "channel" -ATTR_DEVICE: Final = "device" -CONF_SUBTYPE: Final = "subtype" - -INPUTS_EVENTS_TYPES: Final = { - "button_down", - "button_up", - "button_single_push", - "button_double_push", - "button_triple_push", - "button_long_push", -} - -INPUTS_EVENTS_SUBTYPES: Final = { - "button1": 1, - "button2": 2, -} - -UPTIME_DEVIATION: Final = 5 - -# Time to wait before reloading entry when device config change -ENTRY_RELOAD_COOLDOWN = 30 - - -OTA_BEGIN = "ota_begin" -OTA_ERROR = "ota_error" -OTA_PROGRESS = "ota_progress" -OTA_SUCCESS = "ota_success" diff --git a/custom_components/refoss_rpc/refoss_rpc/coordinator.py b/custom_components/refoss_rpc/refoss_rpc/coordinator.py deleted file mode 100644 index 226167f..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/coordinator.py +++ /dev/null @@ -1,403 +0,0 @@ -"""Coordinators for the Refoss RPC integration.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -from datetime import timedelta -from typing import Any, cast - -from aiorefoss.exceptions import ( - DeviceConnectionError, - InvalidAuthError, - MacAddressMismatchError, - RpcCallError, -) -from aiorefoss.rpc_device import RpcDevice, RpcUpdateType -from propcache.api import cached_property - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_HOST, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import ( - ATTR_CHANNEL, - ATTR_CLICK_TYPE, - ATTR_DEVICE, - ENTRY_RELOAD_COOLDOWN, - EVENT_REFOSS_CLICK, - INPUTS_EVENTS_TYPES, - LOGGER, - OTA_BEGIN, - OTA_ERROR, - OTA_PROGRESS, - OTA_SUCCESS, - REFOSS_CHECK_INTERVAL, -) -from .utils import get_host, update_device_fw_info - - -@dataclass -class RefossEntryData: - """Class for sharing data within a given config entry.""" - - platforms: list[Platform] - coordinator: RefossCoordinator | None = None - - -RefossConfigEntry = ConfigEntry[RefossEntryData] - - -class RefossCoordinatorBase(DataUpdateCoordinator[None]): - """Coordinator for a Refoss device.""" - - def __init__( - self, - hass: HomeAssistant, - entry: RefossConfigEntry, - device: RpcDevice, - update_interval: float, - ) -> None: - """Initialize the Refoss device coordinator.""" - self.entry = entry - self.device = device - self.device_id: str | None = None - device_name = device.name if device.initialized else entry.title - interval_td = timedelta(seconds=update_interval) - self._came_online_once = False - super().__init__(hass, LOGGER, name=device_name, update_interval=interval_td) - - self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( - hass, - LOGGER, - cooldown=ENTRY_RELOAD_COOLDOWN, - immediate=False, - function=self._async_reload_entry, - ) - entry.async_on_unload(self._debounced_reload.async_shutdown) - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) - ) - - @cached_property - def model(self) -> str: - """Model of the device.""" - return cast(str, self.entry.data["model"]) - - @cached_property - def mac(self) -> str: - """Mac address of the device.""" - return cast(str, self.entry.unique_id) - - @property - def sw_version(self) -> str: - """Firmware version of the device.""" - return self.device.firmware_version if self.device.initialized else "" - - @property - def hw_version(self) -> str: - """Hardware version of the device.""" - return self.device.hw_version if self.device.initialized else "" - - def async_setup(self) -> None: - """Set up the coordinator.""" - dev_reg = dr.async_get(self.hass) - device_entry = dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, - name=self.device.name, - connections={(CONNECTION_NETWORK_MAC, self.mac)}, - manufacturer="Refoss", - model=self.model, - sw_version=self.sw_version, - hw_version=self.hw_version, - configuration_url=f"http://{get_host(self.entry.data[CONF_HOST])}", - ) - self.device_id = device_entry.id - self.remove_old_entity() - - def remove_old_entity(self) -> None: - """Remove old entity when reload.""" - entity_reg = er.async_get(self.hass) - entities = er.async_entries_for_device( - registry=entity_reg, device_id=self.device_id - ) - for entity in entities: - LOGGER.debug("Removing old entity: %s", entity.entity_id) - entity_reg.async_remove(entity.entity_id) - - async def shutdown(self) -> None: - """Shutdown the coordinator.""" - await self.device.shutdown() - - async def _handle_ha_stop(self, _event: Event) -> None: - """Handle Home Assistant stopping.""" - LOGGER.debug("Stopping device coordinator for %s", self.name) - await self.shutdown() - - async def _async_device_connect_task(self) -> bool: - """Connect to a Refoss device task.""" - LOGGER.debug("Connecting to Refoss Device - %s", self.name) - try: - await self.device.initialize() - update_device_fw_info(self.hass, self.device, self.entry) - except (DeviceConnectionError, MacAddressMismatchError) as err: - LOGGER.debug( - "Error connecting to Refoss device %s, error: %r", self.name, err - ) - return False - except InvalidAuthError: - self.entry.async_start_reauth(self.hass) - return False - - return True - - async def _async_reload_entry(self) -> None: - """Reload entry.""" - self._debounced_reload.async_cancel() - LOGGER.debug("Reloading entry %s", self.name) - await self.hass.config_entries.async_reload(self.entry.entry_id) - - async def async_shutdown_device_and_start_reauth(self) -> None: - """Shutdown Refoss device and start reauth flow.""" - self.last_update_success = False - await self.shutdown() - self.entry.async_start_reauth(self.hass) - - -class RefossCoordinator(RefossCoordinatorBase): - """Coordinator for a Refoss device.""" - - def __init__( - self, hass: HomeAssistant, entry: RefossConfigEntry, device: RpcDevice - ) -> None: - """Initialize the Refoss coordinator.""" - self.entry = entry - super().__init__(hass, entry, device, REFOSS_CHECK_INTERVAL) - - self.connected = False - self._connection_lock = asyncio.Lock() - self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] - self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] - self._connect_task: asyncio.Task | None = None - - async def async_device_online(self, source: str) -> None: - """Handle device going online.""" - - if not self._came_online_once or not self.device.initialized: - LOGGER.debug( - "device %s is online (source: %s), trying to poll and configure", - self.name, - source, - ) - self._async_handle_refoss_device_online() - - @callback - def async_subscribe_ota_events( - self, ota_event_callback: Callable[[dict[str, Any]], None] - ) -> CALLBACK_TYPE: - """Subscribe to OTA events.""" - - def _unsubscribe() -> None: - self._ota_event_listeners.remove(ota_event_callback) - - self._ota_event_listeners.append(ota_event_callback) - - return _unsubscribe - - @callback - def async_subscribe_input_events( - self, input_event_callback: Callable[[dict[str, Any]], None] - ) -> CALLBACK_TYPE: - """Subscribe to input events.""" - - def _unsubscribe() -> None: - self._input_event_listeners.remove(input_event_callback) - - self._input_event_listeners.append(input_event_callback) - - return _unsubscribe - - @callback - def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: - """Handle device events.""" - events: list[dict[str, Any]] = event_data["events"] - for event in events: - event_type = event.get("event") - if event_type is None: - continue - - RELOAD_EVENTS = {"config_changed", "emmerge_change", "cfg_change"} - if event_type in RELOAD_EVENTS: - LOGGER.info( - "Config for %s changed, reloading entry in %s seconds", - self.name, - ENTRY_RELOAD_COOLDOWN, - ) - self._debounced_reload.async_schedule_call() - elif event_type in INPUTS_EVENTS_TYPES: - for event_callback in self._input_event_listeners: - event_callback(event) - self.hass.bus.async_fire( - EVENT_REFOSS_CLICK, - { - ATTR_DEVICE_ID: self.device_id, - ATTR_DEVICE: self.device.name, - ATTR_CHANNEL: event["id"], - ATTR_CLICK_TYPE: event["event"], - }, - ) - elif event_type in (OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS): - for event_callback in self._ota_event_listeners: - event_callback(event) - - async def _async_update_data(self) -> None: - """Fetch data.""" - if self.hass.is_stopping: - return - - async with self._connection_lock: - if not self.device.connected: - if not await self._async_device_connect_task(): - raise UpdateFailed("Device reconnect error") - return - try: - LOGGER.debug("Polling Refoss Device - %s", self.name) - await self.device.poll() - except DeviceConnectionError as err: - raise UpdateFailed(f"Device disconnected: {err!r}") from err - except RpcCallError as err: - raise UpdateFailed(f"RPC call failed: {err!r}") from err - except InvalidAuthError: - await self.async_shutdown_device_and_start_reauth() - return - - async def _async_disconnected(self, reconnect: bool) -> None: - """Handle device disconnected.""" - async with self._connection_lock: - if not self.connected: # Already disconnected - return - self.connected = False - - # Try to reconnect right away if triggered by disconnect event - if reconnect: - await self.async_request_refresh() - - async def _async_connected(self) -> None: - """Handle device connected.""" - async with self._connection_lock: - if self.connected: # Already connected - return - self.connected = True - - @callback - def _async_handle_refoss_device_online(self) -> None: - """Handle device going online.""" - if self.device.connected or ( - self._connect_task and not self._connect_task.done() - ): - LOGGER.debug("Device %s already connected/connecting", self.name) - return - self._connect_task = self.entry.async_create_background_task( - self.hass, - self._async_device_connect_task(), - "device online", - eager_start=True, - ) - - @callback - def _async_handle_update( - self, device: RpcDevice, update_type: RpcUpdateType - ) -> None: - """Handle device update.""" - LOGGER.debug("Refoss %s handle update, type: %s", self.name, update_type) - if update_type is RpcUpdateType.ONLINE: - self._came_online_once = True - self._async_handle_refoss_device_online() - elif update_type is RpcUpdateType.INITIALIZED: - self.entry.async_create_background_task( - self.hass, self._async_connected(), "device init", eager_start=True - ) - self.async_set_updated_data(None) - elif update_type is RpcUpdateType.DISCONNECTED: - self.entry.async_create_background_task( - self.hass, - self._async_disconnected(True), - "device disconnected", - eager_start=True, - ) - self.async_set_updated_data(None) - elif update_type is RpcUpdateType.STATUS: - self.async_set_updated_data(None) - - elif update_type is RpcUpdateType.EVENT and (event := self.device.event): - self._async_device_event_handler(event) - - def async_setup(self) -> None: - """Set up the coordinator.""" - super().async_setup() - self.device.subscribe_updates(self._async_handle_update) - if self.device.initialized: - # If we are already initialized, we are connected - self.entry.async_create_task( - self.hass, self._async_connected(), eager_start=True - ) - - async def shutdown(self) -> None: - """Shutdown the coordinator.""" - if self.device.connected: - try: - await super().shutdown() - except InvalidAuthError: - self.entry.async_start_reauth(self.hass) - return - except DeviceConnectionError as err: - LOGGER.debug("Error during shutdown for device %s: %s", self.name, err) - return - await self._async_disconnected(False) - - -def get_refoss_coordinator_by_device_id( - hass: HomeAssistant, device_id: str -) -> RefossCoordinator | None: - """Get a Refoss device coordinator for the given device id.""" - dev_reg = dr.async_get(hass) - if device := dev_reg.async_get(device_id): - for config_entry in device.config_entries: - entry = hass.config_entries.async_get_entry(config_entry) - if ( - entry - and entry.state is ConfigEntryState.LOADED - and hasattr(entry, "runtime_data") - and isinstance(entry.runtime_data, RefossEntryData) - and (coordinator := entry.runtime_data.coordinator) - ): - return coordinator - - return None - - -async def async_reconnect_soon(hass: HomeAssistant, entry: RefossConfigEntry) -> None: - """Try to reconnect soon.""" - if ( - not hass.is_stopping - and entry.state is ConfigEntryState.LOADED - and (coordinator := entry.runtime_data.coordinator) - ): - entry.async_create_background_task( - hass, - coordinator.async_device_online("zeroconf"), - "reconnect soon", - eager_start=True, - ) diff --git a/custom_components/refoss_rpc/refoss_rpc/cover.py b/custom_components/refoss_rpc/refoss_rpc/cover.py deleted file mode 100644 index d54397e..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/cover.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Cover for refoss.""" - -from __future__ import annotations -from typing import Any, cast - - -from homeassistant.components.cover import ( - ATTR_POSITION, - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .coordinator import RefossConfigEntry, RefossCoordinator -from .entity import RefossEntity -from .utils import get_refoss_key_ids - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up cover for device.""" - coordinator = config_entry.runtime_data.coordinator - assert coordinator - - cover_key_ids = get_refoss_key_ids(coordinator.device.status, "cover") - - async_add_entities(RefossCover(coordinator, _id) for _id in cover_key_ids) - - -class RefossCover(RefossEntity, CoverEntity): - """Refoss cover entity.""" - - _attr_device_class = CoverDeviceClass.SHUTTER - _attr_supported_features: CoverEntityFeature = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) - - def __init__(self, coordinator: RefossCoordinator, _id: int) -> None: - """Initialize cover.""" - super().__init__(coordinator, f"cover:{_id}") - self._id = _id - if self.status["cali_state"] == "success": - self._attr_supported_features |= CoverEntityFeature.SET_POSITION - - @property - def current_cover_position(self) -> int | None: - """Position of the cover.""" - if not self.status["cali_state"] or self.status["cali_state"] != "success": - return None - return cast(int, self.status["current_pos"]) - - @property - def is_closed(self) -> bool | None: - """If cover is closed.""" - return cast(bool, self.status["state"] == "closed") - - @property - def is_closing(self) -> bool: - """Return if the cover is closing.""" - return cast(bool, self.status["state"] == "closing") - - @property - def is_opening(self) -> bool: - """Return if the cover is opening.""" - return cast(bool, self.status["state"] == "opening") - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close cover.""" - await self.call_rpc("Cover.Action.Set", {"id": self._id, "action": "close"}) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open cover.""" - await self.call_rpc("Cover.Action.Set", {"id": self._id, "action": "open"}) - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - await self.call_rpc( - "Cover.Pos.Set", {"id": self._id, "pos": kwargs[ATTR_POSITION]} - ) - - async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the cover.""" - await self.call_rpc("Cover.Action.Set", {"id": self._id, "action": "stop"}) diff --git a/custom_components/refoss_rpc/refoss_rpc/device_trigger.py b/custom_components/refoss_rpc/refoss_rpc/device_trigger.py deleted file mode 100644 index 2f20552..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/device_trigger.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Provides device triggers for Refoss.""" - -from __future__ import annotations - -from typing import Final - -import voluptuous as vol - -from homeassistant.components.device_automation import ( - DEVICE_TRIGGER_BASE_SCHEMA, - InvalidDeviceAutomationConfig, -) -from homeassistant.components.homeassistant.triggers import event as event_trigger -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_EVENT, - CONF_PLATFORM, - CONF_TYPE, -) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType - -from .const import ( - ATTR_CHANNEL, - ATTR_CLICK_TYPE, - CONF_SUBTYPE, - DOMAIN, - EVENT_REFOSS_CLICK, - INPUTS_EVENTS_SUBTYPES, - INPUTS_EVENTS_TYPES, -) -from .coordinator import get_refoss_coordinator_by_device_id -from .utils import get_input_triggers - -TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In(INPUTS_EVENTS_TYPES), - vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), - } -) - - -def append_input_triggers( - triggers: list[dict[str, str]], - input_triggers: list[tuple[str, str]], - device_id: str, -) -> None: - """Add trigger to triggers list.""" - for trigger, subtype in input_triggers: - triggers.append( - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) - - -async def async_validate_trigger_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate config.""" - config = TRIGGER_SCHEMA(config) - - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - - if config[CONF_TYPE] in INPUTS_EVENTS_TYPES: - coordinator = get_refoss_coordinator_by_device_id(hass, config[CONF_DEVICE_ID]) - if not coordinator or not coordinator.device.initialized: - return config - - input_triggers = get_input_triggers(coordinator.device) - if trigger in input_triggers: - return config - - raise InvalidDeviceAutomationConfig( - f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" - ) - - -async def async_get_triggers( - hass: HomeAssistant, device_id: str -) -> list[dict[str, str]]: - """List device triggers for Refoss devices.""" - triggers: list[dict[str, str]] = [] - - if coordinator := get_refoss_coordinator_by_device_id(hass, device_id): - input_triggers = get_input_triggers(coordinator.device) - append_input_triggers(triggers, input_triggers, device_id) - return triggers - - raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") - - -async def async_attach_trigger( - hass: HomeAssistant, - config: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, -) -> CALLBACK_TYPE: - """Attach a trigger.""" - event_config = { - event_trigger.CONF_PLATFORM: CONF_EVENT, - event_trigger.CONF_EVENT_TYPE: EVENT_REFOSS_CLICK, - event_trigger.CONF_EVENT_DATA: { - ATTR_DEVICE_ID: config[CONF_DEVICE_ID], - ATTR_CHANNEL: INPUTS_EVENTS_SUBTYPES[config[CONF_SUBTYPE]], - ATTR_CLICK_TYPE: config[CONF_TYPE], - }, - } - - event_config = event_trigger.TRIGGER_SCHEMA(event_config) - return await event_trigger.async_attach_trigger( - hass, event_config, action, trigger_info, platform_type="device" - ) diff --git a/custom_components/refoss_rpc/refoss_rpc/entity.py b/custom_components/refoss_rpc/refoss_rpc/entity.py deleted file mode 100644 index c1e2941..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/entity.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Refoss entity helper.""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import Any, cast - -from aiorefoss.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError - -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import LOGGER -from .coordinator import RefossConfigEntry, RefossCoordinator -from .utils import ( - async_remove_refoss_entity, - get_refoss_entity_name, - get_refoss_key_instances, - merge_channel_get_status, -) - - -@callback -def async_setup_entry_refoss( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, - sensors: Mapping[str, RefossEntityDescription], - sensor_class: Callable, -) -> None: - """Set up entities for Refoss.""" - coordinator = config_entry.runtime_data.coordinator - # If the device is not initialized, return directly - if not coordinator or not coordinator.device.initialized: - return - - device_status = coordinator.device.status - device_config = coordinator.device.config - mac = coordinator.mac - entities: list[Any] = [] - - for sensor_id, description in sensors.items(): - key_instances = get_refoss_key_instances(device_status, description.key) - - for key in key_instances: - key_status = device_status.get(key) - if key_status is None: - continue - - # Filter out sensors that are not supported or do not match the configuration - if ( - not key.startswith("emmerge:") - and description.sub_key not in key_status - and not description.supported(key_status) - ): - continue - - # Filter and remove entities that should not be created according to the configuration/status - if description.removal_condition and description.removal_condition( - device_config, device_status, key - ): - try: - domain = sensor_class.__module__.split(".")[-1] - except AttributeError: - LOGGER.error( - "Failed to get module name from sensor_class for sensor_id %s and key %s", - sensor_id, - key, - ) - continue - unique_id = f"{mac}-{key}-{sensor_id}" - async_remove_refoss_entity(hass, domain, unique_id) - else: - entities.append(sensor_class(coordinator, key, sensor_id, description)) - - if entities: - async_add_entities(entities) - - -@dataclass(frozen=True, kw_only=True) -class RefossEntityDescription(EntityDescription): - """Class to describe a entity.""" - - name: str = "" - sub_key: str - - value: Callable[[Any, Any], Any] | None = None - removal_condition: Callable[[dict, dict, str], bool] | None = None - supported: Callable = lambda _: False - - -class RefossEntity(CoordinatorEntity[RefossCoordinator]): - """Helper class to represent a entity.""" - - def __init__(self, coordinator: RefossCoordinator, key: str) -> None: - """Initialize Refoss entity.""" - super().__init__(coordinator) - self.key = key - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} - ) - self._attr_unique_id = f"{coordinator.mac}-{key}" - self._attr_name = get_refoss_entity_name(coordinator.device, key) - - @property - def available(self) -> bool: - """Check if device is available and initialized.""" - coordinator = self.coordinator - return super().available and (coordinator.device.initialized) - - @property - def status(self) -> dict | None: - """Device status by entity key.""" - device_status = self.coordinator.device.status.get(self.key) - if device_status is None: - LOGGER.debug("Device status not found for key: %s", self.key) - return device_status - - async def async_added_to_hass(self) -> None: - """When entity is added to HASS.""" - self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) - - @callback - def _update_callback(self) -> None: - """Handle device update.""" - self.async_write_ha_state() - - async def call_rpc(self, method: str, params: Any) -> Any: - """Call RPC method.""" - LOGGER.debug( - "Call RPC for entity %s, method: %s, params: %s", - self.name, - method, - params, - ) - try: - return await self.coordinator.device.call_rpc(method, params) - except DeviceConnectionError as err: - self.coordinator.last_update_success = False - raise HomeAssistantError( - f"Call RPC for {self.name} connection error, method: {method}, params:" - f" {params}, error: {err!r}" - ) from err - except RpcCallError as err: - raise HomeAssistantError( - f"Call RPC for {self.name} request error, method: {method}, params:" - f" {params}, error: {err!r}" - ) from err - except InvalidAuthError: - await self.coordinator.async_shutdown_device_and_start_reauth() - - -class RefossAttributeEntity(RefossEntity, Entity): - """Helper class to represent a attribute.""" - - entity_description: RefossEntityDescription - - def __init__( - self, - coordinator: RefossCoordinator, - key: str, - attribute: str, - description: RefossEntityDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator, key) - self.attribute = attribute - self.entity_description = description - - self._attr_unique_id = f"{super().unique_id}-{attribute}" - self._attr_name = get_refoss_entity_name( - device=coordinator.device, key=key, description=description.name - ) - self._last_value = None - - @property - def sub_status(self) -> Any | None: - """Get the sub - status of the device by entity key. - - Returns the value corresponding to the sub - key in the device status. - If the device status is None or the sub - key does not exist, returns None. - """ - device_status = self.status - if device_status is None: - LOGGER.debug("Device status is None for entity %s", self.name) - return None - sub_key = self.entity_description.sub_key - sub_status = device_status.get(sub_key) - return sub_status - - @property - def attribute_value(self) -> StateType: - """Value of sensor.""" - try: - if self.key.startswith("emmerge:"): - # Call the merge channel attributes function - return merge_channel_get_status( - self.coordinator.device.status, - self.key, - self.entity_description.sub_key, - ) - - # Reduce repeated calls and get the sub-status - sub_status = self.sub_status - - if self.entity_description.value is not None: - # Call the custom value processing function - self._last_value = self.entity_description.value( - sub_status, self._last_value - ) - else: - self._last_value = sub_status - - return self._last_value - except Exception as e: - # Log the exception - LOGGER.error( - "Error getting attribute value for entity %s, key %s, attribute %s: %s", - self.name, - self.key, - self.attribute, - str(e), - ) - return None diff --git a/custom_components/refoss_rpc/refoss_rpc/event.py b/custom_components/refoss_rpc/refoss_rpc/event.py deleted file mode 100644 index e399021..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/event.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Event entities for Refoss.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final - -from homeassistant.components.event import ( - DOMAIN as EVENT_DOMAIN, - EventDeviceClass, - EventEntity, - EventEntityDescription, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import INPUTS_EVENTS_TYPES -from .coordinator import RefossConfigEntry, RefossCoordinator -from .utils import ( - async_remove_refoss_entity, - get_refoss_entity_name, - get_refoss_key_instances, - is_refoss_input_button, -) - - -@dataclass(frozen=True, kw_only=True) -class RefossEventDescription(EventEntityDescription): - """Class to describe Refoss event.""" - - removal_condition: Callable[[dict, dict, str], bool] | None = None - - -REFOSS_EVENT: Final = RefossEventDescription( - key="input", - translation_key="input", - device_class=EventDeviceClass.BUTTON, - event_types=list(INPUTS_EVENTS_TYPES), - removal_condition=lambda config, status, key: not is_refoss_input_button( - config, status, key - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up event for device.""" - entities: list[RefossEvent] = [] - - coordinator = config_entry.runtime_data.coordinator - if TYPE_CHECKING: - assert coordinator - - key_instances = get_refoss_key_instances( - coordinator.device.status, REFOSS_EVENT.key - ) - - for key in key_instances: - if REFOSS_EVENT.removal_condition and REFOSS_EVENT.removal_condition( - coordinator.device.config, coordinator.device.status, key - ): - unique_id = f"{coordinator.mac}-{key}" - async_remove_refoss_entity(hass, EVENT_DOMAIN, unique_id) - else: - entities.append(RefossEvent(coordinator, key, REFOSS_EVENT)) - - async_add_entities(entities) - - -class RefossEvent(CoordinatorEntity[RefossCoordinator], EventEntity): - """Refoss event entity.""" - - entity_description: RefossEventDescription - - def __init__( - self, - coordinator: RefossCoordinator, - key: str, - description: RefossEventDescription, - ) -> None: - """Initialize event entity.""" - super().__init__(coordinator) - self.input_index = int(key.split(":")[-1]) - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} - ) - self._attr_unique_id = f"{coordinator.mac}-{key}" - self._attr_name = get_refoss_entity_name(coordinator.device, key) - self.entity_description = description - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - self.coordinator.async_subscribe_input_events(self._async_handle_event) - ) - - @callback - def _async_handle_event(self, event: dict[str, Any]) -> None: - """Handle the button event.""" - if event["id"] == self.input_index: - self._trigger_event(event["event"]) - self.async_write_ha_state() diff --git a/custom_components/refoss_rpc/refoss_rpc/logbook.py b/custom_components/refoss_rpc/refoss_rpc/logbook.py deleted file mode 100644 index 71b9d5c..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/logbook.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Describe Refoss logbook events.""" - -from __future__ import annotations - -from collections.abc import Callable - -from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME -from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import Event, HomeAssistant, callback - -from .const import ( - ATTR_CHANNEL, - ATTR_CLICK_TYPE, - ATTR_DEVICE, - DOMAIN, - EVENT_REFOSS_CLICK, - INPUTS_EVENTS_TYPES, -) -from .coordinator import get_refoss_coordinator_by_device_id -from .utils import get_refoss_entity_name - - -@callback -def async_describe_events( - hass: HomeAssistant, - async_describe_event: Callable[[str, str, Callable[[Event], dict]], None], -) -> None: - """Describe logbook events.""" - - @callback - def async_describe_refoss_click_event(event: Event) -> dict[str, str]: - """Describe refoss.click logbook event.""" - device_id = event.data[ATTR_DEVICE_ID] - click_type = event.data[ATTR_CLICK_TYPE] - channel = event.data[ATTR_CHANNEL] - input_name = f"{event.data[ATTR_DEVICE]} channel {channel}" - - if click_type in INPUTS_EVENTS_TYPES: - coordinator = get_refoss_coordinator_by_device_id(hass, device_id) - if coordinator and coordinator.device.initialized: - key = f"input:{channel}" - input_name = get_refoss_entity_name(coordinator.device, key) - - return { - LOGBOOK_ENTRY_NAME: "Refoss", - LOGBOOK_ENTRY_MESSAGE: ( - f"'{click_type}' click event for {input_name} Input was fired" - ), - } - - async_describe_event(DOMAIN, EVENT_REFOSS_CLICK, async_describe_refoss_click_event) diff --git a/custom_components/refoss_rpc/refoss_rpc/manifest.json b/custom_components/refoss_rpc/refoss_rpc/manifest.json deleted file mode 100644 index c5af008..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/manifest.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "domain": "refoss_rpc", - "name": "Refoss RPC", - "codeowners": ["@ashionky"], - "config_flow": true, - "documentation": "https://github.com/Refoss/refoss_rpc/blob/main/README.md", - "integration_type": "device", - "iot_class": "local_push", - "issue_tracker": "https://github.com/Refoss/refoss_rpc/issues", - "loggers": ["aiorefoss"], - "quality_scale": "bronze", - "requirements": ["aiorefoss==1.0.1"], - "version": "1.0.4", - "zeroconf": [ - { - "type": "_http._tcp.local.", - "name": "refoss*" - } - ] -} diff --git a/custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml b/custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml deleted file mode 100644 index 4109198..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/quality_scale.yaml +++ /dev/null @@ -1,26 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: | - This integration does not provide additional actions. - appropriate-polling: done - brands: done - common-modules: done - config-flow-test-coverage: done - config-flow: done - dependency-transparency: done - docs-actions: - status: exempt - comment: | - This integration does not provide additional actions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - entity-event-setup: done - entity-unique-id: done - has-entity-name: done - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done diff --git a/custom_components/refoss_rpc/refoss_rpc/sensor.py b/custom_components/refoss_rpc/refoss_rpc/sensor.py deleted file mode 100644 index ce701cb..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/sensor.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Sensor entities for Refoss.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Final - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType - -from .coordinator import RefossConfigEntry, RefossCoordinator -from .entity import ( - RefossAttributeEntity, - RefossEntityDescription, - async_setup_entry_refoss, -) -from .utils import get_device_uptime, is_refoss_wifi_stations_disabled - - -@dataclass(frozen=True, kw_only=True) -class RefossSensorDescription(RefossEntityDescription, SensorEntityDescription): - """Class to describe a sensor.""" - - -REFOSS_SENSORS: Final = { - "power": RefossSensorDescription( - key="switch", - sub_key="apower", - name="Power", - native_unit_of_measurement=UnitOfPower.MILLIWATT, - value=lambda status, _: None if status is None else float(status), - suggested_unit_of_measurement=UnitOfPower.WATT, - suggested_display_precision=2, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - "voltage": RefossSensorDescription( - key="switch", - sub_key="voltage", - name="Voltage", - native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, - value=lambda status, _: None if status is None else float(status), - suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, - suggested_display_precision=2, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "current": RefossSensorDescription( - key="switch", - sub_key="current", - name="Current", - native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, - value=lambda status, _: None if status is None else float(status), - suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - suggested_display_precision=2, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - ), - "energy": RefossSensorDescription( - key="switch", - sub_key="month_consumption", - name="This Month Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "cover_energy": RefossSensorDescription( - key="cover", - sub_key="aenergy", - name="Energy", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - value=lambda status, _: status["total"], - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - ), - "temperature": RefossSensorDescription( - key="sys", - sub_key="temperature", - name="Device temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda status, _: status["tc"], - suggested_display_precision=1, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "rssi": RefossSensorDescription( - key="wifi", - sub_key="rssi", - name="RSSI", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - removal_condition=is_refoss_wifi_stations_disabled, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "uptime": RefossSensorDescription( - key="sys", - sub_key="uptime", - name="Uptime", - value=get_device_uptime, - device_class=SensorDeviceClass.TIMESTAMP, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "em_power": RefossSensorDescription( - key="em", - sub_key="power", - name="Power", - native_unit_of_measurement=UnitOfPower.WATT, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - "em_voltage": RefossSensorDescription( - key="em", - sub_key="voltage", - name="Voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "em_current": RefossSensorDescription( - key="em", - sub_key="current", - name="Current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - ), - "em_month_energy": RefossSensorDescription( - key="em", - sub_key="month_energy", - name="This Month Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "em_month_ret_energy": RefossSensorDescription( - key="em", - sub_key="month_ret_energy", - name="This Month Return Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "em_week_energy": RefossSensorDescription( - key="em", - sub_key="week_energy", - name="This Week Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "em_week_ret_energy": RefossSensorDescription( - key="em", - sub_key="week_ret_energy", - name="This Week Retrun Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "em_day_energy": RefossSensorDescription( - key="em", - sub_key="day_energy", - name="Today Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "em_day_ret_energy": RefossSensorDescription( - key="em", - sub_key="day_ret_energy", - name="Today Return Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "em_pf": RefossSensorDescription( - key="em", - sub_key="pf", - name="Power factor", - value=lambda status, _: None if status is None else float(status), - suggested_display_precision=2, - device_class=SensorDeviceClass.POWER_FACTOR, - state_class=SensorStateClass.MEASUREMENT, - ), - "emmerge_power": RefossSensorDescription( - key="emmerge", - sub_key="power", - name="Power", - native_unit_of_measurement=UnitOfPower.WATT, - suggested_display_precision=2, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - "emmerge_current": RefossSensorDescription( - key="emmerge", - sub_key="current", - name="Current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - suggested_display_precision=2, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - ), - "emmerge_month_energy": RefossSensorDescription( - key="emmerge", - sub_key="month_energy", - name="This Month Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "emmerge_month_ret_energy": RefossSensorDescription( - key="emmerge", - sub_key="month_ret_energy", - name="This Month Return Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "emmerge_week_energy": RefossSensorDescription( - key="emmerge", - sub_key="week_energy", - name="This Week Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "emmerge_week_ret_energy": RefossSensorDescription( - key="emmerge", - sub_key="week_ret_energy", - name="This Week Retrun Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "emmerge_day_energy": RefossSensorDescription( - key="emmerge", - sub_key="day_energy", - name="Today Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - "emmerge_day_ret_energy": RefossSensorDescription( - key="emmerge", - sub_key="day_ret_energy", - name="Today Return Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=2, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up sensors for device.""" - coordinator = config_entry.runtime_data.coordinator - assert coordinator - - async_setup_entry_refoss( - hass, config_entry, async_add_entities, REFOSS_SENSORS, RefossSensor - ) - - -class RefossSensor(RefossAttributeEntity, SensorEntity): - """Refoss sensor entity.""" - - entity_description: RefossSensorDescription - - def __init__( - self, - coordinator: RefossCoordinator, - key: str, - attribute: str, - description: RefossSensorDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator, key, attribute, description) - - @property - def native_value(self) -> StateType: - """Return value of sensor.""" - return self.attribute_value diff --git a/custom_components/refoss_rpc/refoss_rpc/strings.json b/custom_components/refoss_rpc/refoss_rpc/strings.json deleted file mode 100644 index f0e74a1..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/strings.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "config": { - "flow_title": "{name}", - "step": { - "user": { - "description": "Before setup, devices must be connected to the network.\n\nThis path can be configured for Refoss product models including R11,P11s, etc. \n\nFor more information, please refer to 'Help'.", - "data": { - "host": "[%key:common::config_flow::data::host%]" - }, - "data_description": { - "host": "The hostname or IP address of the Refoss device to connect to." - } - }, - "credentials": { - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "Password for access to the device." - } - }, - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "Password for access to the device." - } - }, - "confirm_discovery": { - "description": "Do you want to set up the {model} at {host}?\n\ndevices that are not password protected will be added." - }, - "reconfigure": { - "description": "Update configuration for {device_name}.\n\nBefore setup, devices must be connected to the network.", - "data": { - "host": "[%key:common::config_flow::data::host%]" - }, - "data_description": { - "host": "[%key:component::refoss_rpc::config::step::user::data_description::host%]" - } - } - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Refoss device was used.", - "mac_address_mismatch": "[%key:component::refoss_rpc::config::error::mac_address_mismatch%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "firmware_not_fully_supported": "Device not fully supported. Please contact Refoss support", - "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." - } - }, - "device_automation": { - "trigger_subtype": { - "button1": "First button", - "button2": "Second button" - }, - "trigger_type": { - "button_down": "{subtype} button down", - "button_up": "{subtype} button up", - "button_single_push": "{subtype} single push", - "button_double_push": "{subtype} double push", - "button_triple_push": "{subtype} triple push", - "button_long_push": "{subtype} long push" - } - }, - "entity": { - "event": { - "input": { - "state_attributes": { - "event_type": { - "state": { - "button_down": "Button down", - "button_up": "Button up", - "button_single_push": "Single push", - "button_double_push": "Double push", - "button_triple_push": "Triple push", - "button_long_push": "Long push" - } - } - } - } - } - } -} diff --git a/custom_components/refoss_rpc/refoss_rpc/switch.py b/custom_components/refoss_rpc/refoss_rpc/switch.py deleted file mode 100644 index 1b38acc..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/switch.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Switch entities for refoss.""" - -from __future__ import annotations - -from typing import Any - -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .coordinator import RefossConfigEntry, RefossCoordinator -from .entity import RefossEntity -from .utils import get_refoss_key_ids - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up switch for device.""" - coordinator = config_entry.runtime_data.coordinator - assert coordinator - - switch_key_ids = get_refoss_key_ids(coordinator.device.status, "switch") - - async_add_entities(RefossSwitch(coordinator, _id) for _id in switch_key_ids) - - -class RefossSwitch(RefossEntity, SwitchEntity): - """Refoss switch entity.""" - - def __init__(self, coordinator: RefossCoordinator, _id: int) -> None: - """Initialize switch.""" - super().__init__(coordinator, f"switch:{_id}") - self._id = _id - - @property - def is_on(self) -> bool: - """Return true if switch is on.""" - return bool(self.status["output"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on.""" - await self.call_rpc("Switch.Action.Set", {"id": self._id, "action": "on"}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off.""" - await self.call_rpc("Switch.Action.Set", {"id": self._id, "action": "off"}) - - async def async_toggle(self, **kwargs: Any) -> None: - """Toggle.""" - await self.call_rpc("Switch.Action.Set", {"id": self._id, "action": "toggle"}) diff --git a/custom_components/refoss_rpc/refoss_rpc/translations/en.json b/custom_components/refoss_rpc/refoss_rpc/translations/en.json deleted file mode 100644 index c7ab056..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/translations/en.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured", - "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Refoss device was used.", - "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again.", - "reauth_successful": "Re-authentication was successful", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", - "reconfigure_successful": "Re-configuration was successful" - }, - "error": { - "cannot_connect": "Failed to connect", - "firmware_not_fully_supported": "Device not fully supported. Please contact Refoss support", - "invalid_auth": "Invalid authentication", - "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." - }, - "flow_title": "{name}", - "step": { - "confirm_discovery": { - "description": "Do you want to set up the {model} at {host}?\n\ndevices that are not password protected will be added." - }, - "credentials": { - "data": { - "password": "Password" - }, - "data_description": { - "password": "Password for access to the device." - } - }, - "reauth_confirm": { - "data": { - "password": "Password" - }, - "data_description": { - "password": "Password for access to the device." - } - }, - "reconfigure": { - "data": { - "host": "Host" - }, - "data_description": { - "host": "The hostname or IP address of the Refoss device to connect to." - }, - "description": "Update configuration for {device_name}.\n\nBefore setup, devices must be connected to the network." - }, - "user": { - "data": { - "host": "Host" - }, - "data_description": { - "host": "The hostname or IP address of the Refoss device to connect to." - }, - "description": "Before setup, devices must be connected to the network.\n\nThis path can be configured for Refoss product models including R11,P11s, etc. \n\nFor more information, please refer to 'Help'." - } - } - }, - "device_automation": { - "trigger_subtype": { - "button1": "First button", - "button2": "Second button" - }, - "trigger_type": { - "button_double_push": "{subtype} double push", - "button_down": "{subtype} button down", - "button_long_push": "{subtype} long push", - "button_single_push": "{subtype} single push", - "button_triple_push": "{subtype} triple push", - "button_up": "{subtype} button up" - } - }, - "entity": { - "event": { - "input": { - "state_attributes": { - "event_type": { - "state": { - "button_double_push": "Double push", - "button_down": "Button down", - "button_long_push": "Long push", - "button_single_push": "Single push", - "button_triple_push": "Triple push", - "button_up": "Button up" - } - } - } - } - } - } -} \ No newline at end of file diff --git a/custom_components/refoss_rpc/refoss_rpc/update.py b/custom_components/refoss_rpc/refoss_rpc/update.py deleted file mode 100644 index f96121b..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/update.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Update entities for Refoss.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -import logging -from typing import Any, Final, cast - -from aiorefoss.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError - -from homeassistant.components.update import ( - UpdateDeviceClass, - UpdateEntity, - UpdateEntityDescription, - UpdateEntityFeature, -) -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS -from .coordinator import RefossConfigEntry, RefossCoordinator -from .entity import ( - RefossAttributeEntity, - RefossEntityDescription, - async_setup_entry_refoss, -) - -LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class RefossUpdateDescription(RefossEntityDescription, UpdateEntityDescription): - """Class to describe a update.""" - - latest_version: Callable[[dict], Any] - - -REFOSS_UPDATES: Final = { - "fwupdate": RefossUpdateDescription( - key="sys", - sub_key="available_updates", - name="Firmware", - latest_version=lambda status: status.get("version", None), - device_class=UpdateDeviceClass.FIRMWARE, - entity_category=EntityCategory.CONFIG, - ), -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RefossConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up update for device.""" - async_setup_entry_refoss( - hass, config_entry, async_add_entities, REFOSS_UPDATES, RefossUpdateEntity - ) - - -class RefossUpdateEntity(RefossAttributeEntity, UpdateEntity): - """Refoss update entity.""" - - _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS - ) - entity_description: RefossUpdateDescription - - def __init__( - self, - coordinator: RefossCoordinator, - key: str, - attribute: str, - description: RefossUpdateDescription, - ) -> None: - """Initialize update entity.""" - super().__init__(coordinator, key, attribute, description) - self._ota_in_progress = False - self._ota_progress_percentage: int | None = None - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - self.coordinator.async_subscribe_ota_events(self.firmware_upgrade_callback) - ) - - @callback - def firmware_upgrade_callback(self, event: dict[str, Any]) -> None: - """Handle device firmware upgrade progress.""" - if self.in_progress is not False: - event_type = event["event"] - if event_type == OTA_BEGIN: - self._ota_progress_percentage = 0 - elif event_type == OTA_PROGRESS: - self._ota_progress_percentage = event["progress_percent"] - elif event_type in (OTA_ERROR, OTA_SUCCESS): - self._ota_in_progress = False - self._ota_progress_percentage = None - self.async_write_ha_state() - - @property - def installed_version(self) -> str | None: - """Version currently in use.""" - return cast(str, self.coordinator.device.firmware_version) - - @property - def latest_version(self) -> str | None: - """Latest version available for install.""" - new_version = self.entity_description.latest_version(self.sub_status) - if new_version: - return cast(str, new_version) - - return self.installed_version - - @property - def in_progress(self) -> bool: - """Update installation in progress.""" - return self._ota_in_progress - - @property - def update_percentage(self) -> int | None: - """Update installation progress.""" - return self._ota_progress_percentage - - async def async_install( - self, version: str | None, backup: bool, **kwargs: Any - ) -> None: - """Install the latest firmware version.""" - update_data = self.coordinator.device.status["sys"]["available_updates"] - LOGGER.debug("firmware update service - update_data: %s", update_data) - - new_version = update_data.get("version") - - LOGGER.info( - "Starting firmware update of device %s from '%s' to '%s'", - self.coordinator.name, - self.coordinator.device.firmware_version, - new_version, - ) - try: - await self.coordinator.device.trigger_firmware_update() - except DeviceConnectionError as err: - raise HomeAssistantError( - f"firmware update connection error: {err!r}" - ) from err - except RpcCallError as err: - raise HomeAssistantError(f"firmware update request error: {err!r}") from err - except InvalidAuthError: - await self.coordinator.async_shutdown_device_and_start_reauth() - else: - self._ota_in_progress = True - self._ota_progress_percentage = None - LOGGER.debug( - "firmware update call for %s successful", self.coordinator.name - ) diff --git a/custom_components/refoss_rpc/refoss_rpc/utils.py b/custom_components/refoss_rpc/refoss_rpc/utils.py deleted file mode 100644 index 50c7e30..0000000 --- a/custom_components/refoss_rpc/refoss_rpc/utils.py +++ /dev/null @@ -1,196 +0,0 @@ -"""refoss helpers functions.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from ipaddress import IPv6Address, ip_address -from typing import Any, cast - -from aiorefoss.rpc_device import RpcDevice - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.util.dt import utcnow - -from .const import DOMAIN, INPUTS_EVENTS_TYPES, LOGGER, UPTIME_DEVIATION - - -@callback -def async_remove_refoss_entity( - hass: HomeAssistant, domain: str, unique_id: str -) -> None: - """Remove a refoss entity.""" - entity_reg = er.async_get(hass) - entity_id = entity_reg.async_get_entity_id(domain, DOMAIN, unique_id) - if entity_id: - LOGGER.debug("Removing entity: %s", entity_id) - entity_reg.async_remove(entity_id) - - -def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: - """Return device uptime string, tolerate up to 5 seconds deviation.""" - delta_uptime = utcnow() - timedelta(seconds=uptime) - - if ( - not last_uptime - or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION - ): - return delta_uptime - - return last_uptime - - -def get_refoss_channel_name(device: RpcDevice, key: str) -> str: - """Get name based on device and channel name.""" - device_name = device.name - entity_name: str | None = None - if key in device.config: - entity_name = device.config[key].get("name") - - if entity_name is None: - channel = key.split(":")[0] - channel_id = key.split(":")[-1] - if key.startswith(("input:", "switch:", "cover:", "em:")): - return f"{device_name} {channel.title()} {channel_id}" - - if key.startswith(("emmerge:")): - return f"{device_name} {channel.title()}" - - return device_name - - return entity_name - - -def get_refoss_entity_name( - device: RpcDevice, key: str, description: str | None = None -) -> str: - """Naming for refoss entity.""" - channel_name = get_refoss_channel_name(device, key) - - if description: - return f"{channel_name} {description.lower()}" - - return channel_name - - -def get_refoss_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: - """Return list of key instances for device from a dict.""" - if key in keys_dict: - return [key] - - if key == "switch" and "cover:1" in keys_dict: - key = "cover" - - return [k for k in keys_dict if k.startswith(f"{key}:")] - - -def get_refoss_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: - """Return list of key ids for device from a dict.""" - return [int(k.split(":")[1]) for k in keys_dict if k.startswith(f"{key}:")] - - -def is_refoss_input_button( - config: dict[str, Any], status: dict[str, Any], key: str -) -> bool: - """Return true if input's type is set to button.""" - return cast(bool, config[key]["type"] == "button") - - -def get_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: - """Return list of input triggers for device.""" - triggers = [] - - key_ids = get_refoss_key_ids(device.config, "input") - - for id_ in key_ids: - key = f"input:{id_}" - if not is_refoss_input_button(device.config, device.status, key): - continue - - for trigger_type in INPUTS_EVENTS_TYPES: - subtype = f"button{id_}" - triggers.append((trigger_type, subtype)) - - return triggers - - -@callback -def update_device_fw_info( - hass: HomeAssistant, refossdevice: RpcDevice, entry: ConfigEntry -) -> None: - """Update the firmware version information in the device registry.""" - assert entry.unique_id - - dev_reg = dr.async_get(hass) - if device := dev_reg.async_get_device( - identifiers={(DOMAIN, entry.entry_id)}, - connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, - ): - if device.sw_version == refossdevice.firmware_version: - return - - LOGGER.debug("Updating device registry info for %s", entry.title) - - dev_reg.async_update_device(device.id, sw_version=refossdevice.firmware_version) - - -def is_refoss_wifi_stations_disabled( - config: dict[str, Any], _status: dict[str, Any], key: str -) -> bool: - """Return true if all WiFi stations are disabled.""" - if ( - config[key]["sta_1"]["enable"] is False - and config[key]["sta_2"]["enable"] is False - ): - return True - - return False - - -def merge_channel_get_status(_status: dict[str, Any], key: str, attr: str) -> Any: - """ - Merge channel attributes. If the key starts with 'emmerge:', sum the attribute values of the corresponding bits. - - :param _status: Device status dictionary - :param key: Key name - :param attr: Attribute name - :return: Merged attribute value or None - """ - if not key.startswith("emmerge:"): - return None - - try: - # Extract and convert the number - num = int(key.split(":")[1]) - except (IndexError, ValueError): - LOGGER.error("Failed to extract or convert number from key: %s", key) - return None - - # Find the indices of bits with a value of 1 in the binary representation - bit_positions = [i for i in range(num.bit_length()) if num & (1 << i)] - - val = 0 - for bit in bit_positions: - status_key = f"em:{bit+1}" - if status_key in _status and attr in _status[status_key]: - val += _status[status_key][attr] - else: - LOGGER.warning("Missing key %s or attribute %s in status", status_key, attr) - - return val - - -def get_host(host: str) -> str: - """Get the device IP address.""" - try: - ip_object = ip_address(host) - except ValueError: - # host contains hostname - return host - - if isinstance(ip_object, IPv6Address): - return f"[{host}]" - - return host From 590ea72bd5ac89cda9dd1f257a5b812310609ba8 Mon Sep 17 00:00:00 2001 From: ashionky <495519020@qq.com> Date: Mon, 21 Jul 2025 14:13:39 +0800 Subject: [PATCH 6/6] cover --- custom_components/refoss_rpc/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/refoss_rpc/manifest.json b/custom_components/refoss_rpc/manifest.json index c5af008..a9c17e9 100644 --- a/custom_components/refoss_rpc/manifest.json +++ b/custom_components/refoss_rpc/manifest.json @@ -10,7 +10,7 @@ "loggers": ["aiorefoss"], "quality_scale": "bronze", "requirements": ["aiorefoss==1.0.1"], - "version": "1.0.4", + "version": "1.0.5", "zeroconf": [ { "type": "_http._tcp.local.",