diff --git a/README.md b/README.md index 523271b18..d792ae239 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ Our main usage for this module is supporting [Home Assistant](https://www.home-a [![Generic badge](https://img.shields.io/github/v/release/plugwise/plugwise-beta)](https://github.com/plugwise/plugwise-beta) [![HASSfest](https://img.shields.io/badge/HASSfest%3F-view-blue.svg)](https://github.com/plugwise/plugwise-beta/actions) -A fully asynchronous approach to supporting Plugwise devices. This repository is **meant** for use of beta-testing. +A fully asynchronous approach to supporting Plugwise devices. This repository is **meant** for use of beta-testing. -## NEW Sept 2020 ## +## NEW Oct 2020 ## +The developer of the Plugwise Stick integration, @brefra, has joined the team. As a result we have added support for the Plugwise Stick. + +## Sept 2020 ## - Add a service: plugwise.delete_notification, this allows you to dismiss a Plugwise Notification from HA Core. - Support for switching groups created on the Plugwise App has been added, these are available on the Adam with Plugs and on the Stretch. - Support for the Plugwise Stretch v2 and v3 has been added. @@ -27,14 +30,18 @@ PLEASE NOTE: ~~at the moment you will need to remove the existing Core Plugwise [![Build Status](https://travis-ci.org/plugwise/Plugwise-Smile.svg?branch=master)](https://travis-ci.org/plugwise/Plugwise-Smile) [![codecov](https://codecov.io/gh/plugwise/Plugwise-Smile/branch/master/graph/badge.svg)](https://codecov.io/gh/plugwise/Plugwise-Smile) [![PyPI version fury.io](https://badge.fury.io/py/Plugwise-Smile.svg)](https://pypi.python.org/pypi/Plugwise-Smile/) + [![PyPI version fury.io](https://badge.fury.io/py/python-plugwise.svg)](https://pypi.org/pypi/python-plugwise/) ## What do we support (in short)? - - Adam (firmware 2.3 + 3.0) and the accompanying Lisa's, Tom's, Floor's, Koen's and Plugs. - - Smile & Anna (firmware 1.8, 3.1 and 4.0) - - Plugwise Notifications from the Adam and the Anna - - Smile P1 (firmware 2.1, 2.5, 3.3 and 4.0) - - Stretch (firmware 2.3 and 3.1) + - Thermostats + - Adam (firmware 2.x and 3.x) and the accompanying Lisa's, Tom's, Floor's, Koen's and Plugs. + - Anna (firmware 1.x, 3.x and 4.x) + - Notifications for both types + - Power-related + - Smile P1 (firmware 2.x, 3.x and 4.x) + - Stretch (firmware 2.x and 3.x, legacy Circle's and Stealth's) + - Stick (legacy Circle's, Stealth's and Scan's) ## What can I expect in HA Core from this component @@ -52,25 +59,36 @@ The `water_heater`-device present in previous releases has been replaced by an A ## How to add the integration to HA Core -For each Plugwise Smile (i.e. gateway) you will have to add it as an integration. For instance if you have an Adam and a Smile P1, you have to add them individually. If you have an Anna and an Adam, do not add the Anna, only add the Adam. +For each Plugwise Smile (i.e. gateway) you will have to add it as an integration. For instance if you have an Adam and a Smile P1, you have to add them individually. If you have an Anna and an Adam, **do not add the Anna**, only add the Adam. + - [ ] In Home Assitant click on `Configuration` - [ ] Click on `Integrations` - [ ] You should see one or more discovered Smiles - [ ] Click the `Configure` button and enter the Smile ID - [ ] Click Add to see the magic happens - If there is no discovered Smile present: + If there is no discovered Smile present or you are using the USB stick: + - [ ] Hit the `+` button in the right lower corner - [ ] Search or browse for 'Plugwise beta' and click it - - [ ] Enter your Smile IP-address and the 8 character ID of the smile - - [ ] Click Add and hopefully the magic happens - - [ ] Repeat this process to add more Smiles + - [ ] Select the type of integration: Network or USB + + - For the Network-selection: + + - [ ] Enter your Smile IP-address and the 8 character ID of the smile + - [ ] Click SUBMIT and FINISH and hopefully the magic happens + - [ ] Repeat this process to add more Smiles -HA Core wil continue to ask you if you want to put your Smile and detected other devices in area's and presto, things should be available to configure in lovelace. + - For the USB-selection: + + - [ ] Select or enter the USB-path + - [ ] Click SUBMIT and FINISH + +The config flow will then continue to ask you if you want to put your Smile and detected other devices in area's and presto, things should be available to configure in lovelace. ## Options ## -Using the OPTIONS-button, the default Smile-data refresh-interval can be modified. +Using the OPTIONS-button, the default Smile-data refresh-interval can be modified. There are no OPTIONS available for the Stick. # I don't like the name of the sensor or the icon @@ -78,12 +96,15 @@ You can adjust these in `Configuration`, `Integration` -> `Entities` (e.g. `http Just click on the device and adjust accordingly! +Please note that you can also click the cogwheel right top corner to rename all entities of a device at once. + # It doesn't work -If you notice issuess, we are on discord and on the Community. You can also create an Issue in these repos: +If you notice issuess, we are on Discord and on the (Community forums)[https://community.home-assistant.io/t/plugwise-smile-custom-component-beta/183560]. You can also create an Issue in these repos: - [plugwise-beta](https://github.com/plugwise/plugwise-beta) - the `custom_component` for HA Core - [Plugwise-Smile](https://github.com/plugwise/Plugwise-Smile) - the python module interfacing between the plugwise component and your Smile + - [python-plugwise](https://github.com/plugwise/python-plugwise) - the python module interfacing with the plugwise USB-stick # Smile? @@ -97,8 +118,13 @@ Results of our tests are checked by Travis, click the left button (the one that # ~~There is Anna support in HA Core already~~ Replaced by the new Plugwise component, based on this beta-version. -And from the original sources by @laetificat it was improved upon and upstreamed by @CoMPaTech and improved and maintained by @bouwew +From the original sources by @laetificat it was improved upon and upstreamed by @CoMPaTech. Right after that @bouwew joined to improve and help maintain the code. As of 2020 @brefra joined so we have a full range of Plugwise products supported. As things like async were in high demand from HA Core, desired by the original author and a great challenge for us we rewrote it largely. The Plugwise Smile Beta repository (accompanying the Plugwise-Smile python module) is intended for development purposes, just as `anna-ha` was for `haanna` (respectively the original before upstreaming and original python module). +With the three combined forces we now support, maintain and improve on: + - `plugwise-beta` (this repository) for beta-testing new features to go into the `plugwise`-integration for HA + - `Plugwise-Smile` for connectivity with the Smile (Adam, Anna and P1) and Stretch products (i.e. the new Plugs) + - `python-plugwise` for connectivity with the Stick (USB-device) (i.e. the old Plugs: Circle(+), Stealths and Scans) + And yes anna-ha with haanna (to some degree) support Anna v1.8 - but they don't support Adam nor the Smile P1 diff --git a/custom_components/plugwise/__init__.py b/custom_components/plugwise/__init__.py index bcbd161c8..9e46054fe 100644 --- a/custom_components/plugwise/__init__.py +++ b/custom_components/plugwise/__init__.py @@ -1,6 +1,5 @@ """Plugwise platform for Home Assistant Core.""" -import asyncio import logging import voluptuous as vol @@ -9,8 +8,13 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from .const import ALL_PLATFORMS, DOMAIN, UNDO_UPDATE_LISTENER -from .gateway import async_setup_entry_gw +from .const import ( + CONF_USB_PATH, + DOMAIN, +) + +from .gateway import async_setup_entry_gw, async_unload_entry_gw +from .usb import async_setup_entry_usb, async_unload_entry_usb CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) @@ -21,27 +25,20 @@ async def async_setup(hass: HomeAssistant, config: dict): """Set up the Plugwise platform.""" return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Plugwise components from a config entry.""" if entry.data.get(CONF_HOST): return await async_setup_entry_gw(hass, entry) - # PLACEHOLDER USB entry setup + if entry.data.get(CONF_USB_PATH): + return await async_setup_entry_usb(hass, entry) return False + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in ALL_PLATFORMS - ] - ) - ) - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok \ No newline at end of file + """Unload the Plugwise components.""" + if entry.data.get(CONF_HOST): + return await async_unload_entry_gw(hass, entry) + if entry.data.get(CONF_USB_PATH): + return await async_unload_entry_usb(hass, entry) + return False diff --git a/custom_components/plugwise/binary_sensor.py b/custom_components/plugwise/binary_sensor.py index 04d424d26..57465eabb 100644 --- a/custom_components/plugwise/binary_sensor.py +++ b/custom_components/plugwise/binary_sensor.py @@ -2,30 +2,120 @@ import logging +import voluptuous as vol + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( API, + ATTR_SCAN_DAYLIGHT_MODE, + ATTR_SCAN_SENSITIVITY_MODE, + ATTR_SCAN_RESET_TIMER, + ATTR_SED_STAY_ACTIVE, + ATTR_SED_SLEEP_FOR, + ATTR_SED_MAINTENANCE_INTERVAL, + ATTR_SED_CLOCK_SYNC, + ATTR_SED_CLOCK_INTERVAL, + AVAILABLE_SENSOR_ID, + BINARY_SENSORS, BINARY_SENSOR_MAP, + CB_NEW_NODE, COORDINATOR, DOMAIN, FLAME_ICON, FLOW_OFF_ICON, FLOW_ON_ICON, IDLE_ICON, + MOTION_SENSOR_ID, NO_NOTIFICATION_ICON, NOTIFICATION_ICON, - COORDINATOR, + PW_TYPE, + SCAN_SENSITIVITY_MODES, + SERVICE_CONFIGURE_BATTERY, + SERVICE_CONFIGURE_SCAN, + STICK, + USB, ) from .sensor import SmileSensor +from .usb import NodeEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Smile switches from a config entry.""" + if hass.data[DOMAIN][config_entry.entry_id][PW_TYPE] == USB: + return await async_setup_entry_usb(hass, config_entry, async_add_entities) + # Considered default and for earlier setups without usb/network config_flow + return await async_setup_entry_gateway(hass, config_entry, async_add_entities) + + +async def async_setup_entry_usb(hass, config_entry, async_add_entities): + """Set up Plugwise binary sensor based on config_entry.""" + stick = hass.data[DOMAIN][config_entry.entry_id][STICK] + platform = entity_platform.current_platform.get() + + async def async_add_sensor(mac): + """Add plugwise sensor.""" + _LOGGER.debug("Add binary_sensors for %s", mac) + + node = stick.node(mac) + for sensor_type in node.get_sensors(): + if sensor_type in BINARY_SENSORS: + async_add_entities([USBBinarySensor(node, mac, sensor_type)]) + ## TODO: two strings in debug, wont work and doesnt display what you want + _LOGGER.debug("Added %s as binary_sensors for %s", mac) + + if node.get_node_type() == "Scan" and sensor_type == MOTION_SENSOR_ID: + platform.async_register_entity_service( + SERVICE_CONFIGURE_SCAN, + { + vol.Required(ATTR_SCAN_SENSITIVITY_MODE): vol.In( + SCAN_SENSITIVITY_MODES + ), + vol.Required(ATTR_SCAN_RESET_TIMER): vol.All( + vol.Coerce(int), vol.Range(min=1, max=240) + ), + vol.Required(ATTR_SCAN_DAYLIGHT_MODE): cv.boolean, + }, + "_service_configure_scan", + ) + platform.async_register_entity_service( + SERVICE_CONFIGURE_BATTERY, + { + vol.Required(ATTR_SED_STAY_ACTIVE): vol.All( + vol.Coerce(int), vol.Range(min=1, max=120) + ), + vol.Required(ATTR_SED_SLEEP_FOR): vol.All( + vol.Coerce(int), vol.Range(min=10, max=60) + ), + vol.Required(ATTR_SED_MAINTENANCE_INTERVAL): vol.All( + vol.Coerce(int), vol.Range(min=5, max=1440) + ), + vol.Required(ATTR_SED_CLOCK_SYNC): cv.boolean, + vol.Required(ATTR_SED_CLOCK_INTERVAL): vol.All( + vol.Coerce(int), vol.Range(min=60, max=10080) + ), + }, + "_service_configure_battery_savings", + ) + + for mac in hass.data[DOMAIN][config_entry.entry_id]["binary_sensor"]: + hass.async_create_task(async_add_sensor(mac)) + + def discoved_binary_sensor(mac): + """Add newly discovered binary sensor.""" + hass.async_create_task(async_add_sensor(mac)) + + # Listen for discovered nodes + stick.subscribe_stick_callback(discoved_binary_sensor, CB_NEW_NODE) + + +async def async_setup_entry_gateway(hass, config_entry, async_add_entities): """Set up the Smile binary_sensors from a config entry.""" api = hass.data[DOMAIN][config_entry.entry_id][API] coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] @@ -38,7 +128,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device_properties["class"] == "heater_central": _LOGGER.debug("Plugwise device_class %s found", device_properties["class"]) data = api.get_device_data(dev_id) - for binary_sensor, b_s_type in BINARY_SENSOR_MAP.items(): + for binary_sensor, dummy in BINARY_SENSOR_MAP.items(): _LOGGER.debug("Binary_sensor: %s", binary_sensor) if binary_sensor not in data: continue @@ -47,7 +137,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "Plugwise binary_sensor Dev %s", device_properties["name"] ) entities.append( - PwBinarySensor( + GwBinarySensor( api, coordinator, device_properties["name"], @@ -63,7 +153,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device_properties["class"] == "gateway" and is_thermostat is not None: _LOGGER.debug("Plugwise device_class %s found", device_properties["class"]) entities.append( - PwNotifySensor( + GwNotifySensor( hass, api, coordinator, @@ -81,7 +171,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(entities, True) -class PwBinarySensor(SmileSensor, BinarySensorEntity): +class GwBinarySensor(SmileSensor, BinarySensorEntity): """Representation of a Plugwise binary_sensor.""" def __init__(self, api, coordinator, name, dev_id, binary_sensor, model): @@ -92,6 +182,7 @@ def __init__(self, api, coordinator, name, dev_id, binary_sensor, model): self._is_on = False self._icon = None + self._state = None self._unique_id = f"{dev_id}-{binary_sensor}" @@ -121,7 +212,7 @@ def _async_process_data(self): self.async_write_ha_state() -class PwNotifySensor(PwBinarySensor, BinarySensorEntity): +class GwNotifySensor(GwBinarySensor, BinarySensorEntity): """Representation of a Plugwise Notification binary_sensor.""" def __init__(self, hass, api, coordinator, name, dev_id, binary_sensor, model): @@ -133,6 +224,7 @@ def __init__(self, hass, api, coordinator, name, dev_id, binary_sensor, model): self._is_on = False self._icon = None + self._attributes = {} self._unique_id = f"{dev_id}-{binary_sensor}" @@ -152,13 +244,92 @@ def _async_process_data(self): self._icon = NO_NOTIFICATION_ICON if notify == {} else NOTIFICATION_ICON self._attributes = {} if notify != {}: - for id, details in notify.items(): + for notify_id, details in notify.items(): for msg_type, msg in details.items(): self._attributes[msg_type.upper()] = msg self._hass.components.persistent_notification.async_create( f"{msg_type.upper()}: {msg}", "Plugwise Notification:", - f"{DOMAIN}.{id}", + f"{DOMAIN}.{notify_id}", ) self.async_write_ha_state() + + +class USBBinarySensor(NodeEntity, BinarySensorEntity): + """Representation of a Plugwise Binary Sensor.""" + + def __init__(self, node, mac, sensor_id): + """Initialize a Node entity.""" + super().__init__(node, mac) + self.sensor_id = sensor_id + self.sensor_type = BINARY_SENSORS[sensor_id] + self.node_callbacks = (AVAILABLE_SENSOR_ID, sensor_id) + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self.sensor_type["class"] + + @property + def entity_registry_enabled_default(self): + """Return the sensor registration state.""" + return self.sensor_type["enabled_default"] + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.sensor_type["icon"] + + @property + def name(self): + """Return the display name of this sensor.""" + return f"{self.sensor_type['name']} ({self._mac[-5:]})" + + @property + def is_on(self): + """Return true if the binary_sensor is on.""" + return getattr(self._node, self.sensor_type["state"])() + + @property + def unique_id(self): + """Get unique ID.""" + return f"{self._mac}-{self.sensor_id}" + + def _service_configure_scan(self, **kwargs): + """Service call to configure motion sensor of Scan device.""" + sensitivity_mode = kwargs.get(ATTR_SCAN_SENSITIVITY_MODE) + reset_timer = kwargs.get(ATTR_SCAN_RESET_TIMER) + daylight_mode = kwargs.get(ATTR_SCAN_DAYLIGHT_MODE) + _LOGGER.debug( + "Configure Scan device '%s': sensitivity='%s', reset timer='%s', daylight mode='%s'", + self.name, + sensitivity_mode, + str(reset_timer), + str(daylight_mode), + ) + self._node.Configure_scan(reset_timer, sensitivity_mode, daylight_mode) + + def _service_configure_battery_savings(self, **kwargs): + """Configure battery powered (sed) device service call.""" + stay_active = kwargs.get(ATTR_SED_STAY_ACTIVE) + sleep_for = kwargs.get(ATTR_SED_SLEEP_FOR) + maintenance_interval = kwargs.get(ATTR_SED_MAINTENANCE_INTERVAL) + clock_sync = kwargs.get(ATTR_SED_CLOCK_SYNC) + clock_interval = kwargs.get(ATTR_SED_CLOCK_INTERVAL) + _LOGGER.debug( + "Configure SED device '%s': stay active='%s', sleep for='%s', maintenance interval='%s', clock sync='%s', clock interval='%s'", + self.name, + str(stay_active), + str(sleep_for), + str(maintenance_interval), + str(clock_sync), + str(clock_interval), + ) + self._node.Configure_SED( + stay_active, + maintenance_interval, + sleep_for, + clock_sync, + clock_interval, + ) diff --git a/custom_components/plugwise/climate.py b/custom_components/plugwise/climate.py index 7edd27fb5..a327ade82 100644 --- a/custom_components/plugwise/climate.py +++ b/custom_components/plugwise/climate.py @@ -28,7 +28,7 @@ DOMAIN, SCHEDULE_OFF, SCHEDULE_ON, - THERMOSTAT_CLASSES + THERMOSTAT_CLASSES, ) HVAC_MODES_HEAT_ONLY = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF] @@ -289,4 +289,5 @@ def _async_process_data(self): if self._mode_off: self._hvac_mode = HVAC_MODE_OFF - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() + \ No newline at end of file diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index 468f09f62..20906aa86 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Plugwise integration.""" import logging +import os +import serial.tools.list_ports +from typing import Dict import voluptuous as vol @@ -16,20 +19,96 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.core import callback + +import plugwise from Plugwise_Smile.Smile import Smile from .const import ( API, + CONF_USB_PATH, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_USERNAME, DOMAIN, + FLOW_NET, + FLOW_SMILE, + FLOW_STRETCH, + FLOW_TYPE, + FLOW_USB, + PW_TYPE, + SMILE, + STICK, + STRETCH, STRETCH_USERNAME, ZEROCONF_MAP, - ) +) # pylint:disable=unused-import + +from plugwise.exceptions import NetworkDown, PortError, StickInitError, TimeoutException _LOGGER = logging.getLogger(__name__) +CONF_MANUAL_PATH = "Enter Manually" + +CONNECTION_SCHEMA = vol.Schema( + { + vol.Required(FLOW_TYPE, default=FLOW_NET): vol.In( + { + FLOW_NET: f"Network: {SMILE} / {STRETCH}", + FLOW_USB: "USB: Stick", + } + ), + }, +) + + +@callback +def plugwise_stick_entries(hass): + """Return existing connections for Plugwise USB-stick domain.""" + sticks = [] + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data.get(PW_TYPE) == STICK: + sticks.append(entry.data.get(CONF_USB_PATH)) + return sticks + + +async def validate_usb_connection(self, device_path=None) -> Dict[str, str]: + """Test if device_path is a real Plugwise USB-Stick.""" + errors = {} + if device_path is None: + errors[CONF_BASE] = "connection_failed" + return errors, None + + # Avoid creating a 2nd connection to an already configured stick + if device_path in plugwise_stick_entries(self): + errors[CONF_BASE] = "already_configured" + return errors, None + + stick = await self.async_add_executor_job(plugwise.stick, device_path) + try: + await self.async_add_executor_job(stick.connect) + await self.async_add_executor_job(stick.initialize_stick) + await self.async_add_executor_job(stick.disconnect) + except PortError: + errors[CONF_BASE] = "cannot_connect" + except StickInitError: + errors[CONF_BASE] = "stick_init" + except NetworkDown: + errors[CONF_BASE] = "network_down" + except TimeoutException: + errors[CONF_BASE] = "network_timeout" + return errors, stick + + +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path + def _base_gw_schema(discovery_info): """Generate base schema for gateways.""" @@ -38,7 +117,12 @@ def _base_gw_schema(discovery_info): if not discovery_info: base_gw_schema[vol.Required(CONF_HOST)] = str base_gw_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int - base_gw_schema[vol.Required(CONF_USERNAME, description={"suggested_value": "smile"})] = str, + base_gw_schema[vol.Required(CONF_USERNAME, default=SMILE)] = vol.In( + { + SMILE: FLOW_SMILE, + STRETCH: FLOW_STRETCH, + } + ) base_gw_schema.update({vol.Required(CONF_PASSWORD): str}) @@ -72,9 +156,6 @@ async def validate_gw_input(hass: core.HomeAssistant, data): return api -# PLACEHOLDER USB connection validation - - class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Plugwise Smile.""" @@ -111,23 +192,85 @@ async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): CONF_PORT: self.discovery_info[CONF_PORT], CONF_USERNAME: self.discovery_info[CONF_USERNAME], } - return await self.async_step_user() + return await self.async_step_user_gateway() + + async def async_step_user_usb(self, user_input=None): + """Step when user initializes a integration.""" + errors = {} + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = [ + f"{p}, s/n: {p.serial_number or 'n/a'}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + list_of_ports.append(CONF_MANUAL_PATH) + + if user_input is not None: + user_input.pop(FLOW_TYPE, None) + user_selection = user_input[CONF_USB_PATH] + if user_selection == CONF_MANUAL_PATH: + return await self.async_step_manual_path() + if user_selection in list_of_ports: + port = ports[list_of_ports.index(user_selection)] + device_path = await self.hass.async_add_executor_job( + get_serial_by_id, port.device + ) + else: + device_path = await self.hass.async_add_executor_job( + get_serial_by_id, user_selection + ) + errors, stick = await validate_usb_connection(self.hass, device_path) + if not errors: + await self.async_set_unique_id(stick.get_mac_stick()) + return self.async_create_entry( + title="Stick", data={CONF_USB_PATH: device_path, PW_TYPE: STICK} + ) + return self.async_show_form( + step_id="user_usb", + data_schema=vol.Schema( + {vol.Required(CONF_USB_PATH): vol.In(list_of_ports)} + ), + errors=errors, + ) + + async def async_step_manual_path(self, user_input=None): + """Step when manual path to device.""" + errors = {} + if user_input is not None: + user_input.pop(FLOW_TYPE, None) + device_path = await self.hass.async_add_executor_job( + get_serial_by_id, user_input.get(CONF_USB_PATH) + ) + errors, stick = await validate_usb_connection(self.hass, device_path) + if not errors: + await self.async_set_unique_id(stick.get_mac_stick()) + return self.async_create_entry( + title="Stick", data={CONF_USB_PATH: device_path} + ) + return self.async_show_form( + step_id="manual_path", + data_schema=vol.Schema( + { + vol.Required( + CONF_USB_PATH, default="/dev/ttyUSB0" or vol.UNDEFINED + ): str + } + ), + errors=errors, + ) async def async_step_user_gateway(self, user_input=None): - """Handle the initial step for gateways.""" + """Handle the initial step when using network/gateway setups.""" errors = {} if user_input is not None: + user_input.pop(FLOW_TYPE, None) if self.discovery_info: user_input[CONF_HOST] = self.discovery_info[CONF_HOST] user_input[CONF_PORT] = self.discovery_info[CONF_PORT] user_input[CONF_USERNAME] = self.discovery_info[CONF_USERNAME] - for entry in self._async_current_entries(): - if entry.data.get(CONF_HOST) == user_input[CONF_HOST]: - return self.async_abort(reason="already_configured") - try: api = await validate_gw_input(self.hass, user_input) @@ -145,20 +288,30 @@ async def async_step_user_gateway(self, user_input=None): ) self._abort_if_unique_id_configured() + user_input[PW_TYPE] = API return self.async_create_entry(title=api.smile_name, data=user_input) return self.async_show_form( step_id="user_gateway", data_schema=_base_gw_schema(self.discovery_info), - errors=errors or {}, + errors=errors, ) - # PLACEHOLDER USB async_step_user_usb and async_step_user_usb_manual_paht - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - # PLACEHOLDER USB vs Gateway Logic - return await self.async_step_user_gateway() + """Handle the initial step when using network/gateway setups.""" + errors = {} + if user_input is not None: + if user_input[FLOW_TYPE] == FLOW_NET: + return await self.async_step_user_gateway() + + if user_input[FLOW_TYPE] == FLOW_USB: + return await self.async_step_user_usb() + + return self.async_show_form( + step_id="user", + data_schema=CONNECTION_SCHEMA, + errors=errors, + ) @staticmethod @callback @@ -174,8 +327,19 @@ def __init__(self, config_entry): """Initialize options flow.""" self.config_entry = config_entry + async def async_step_none(self, user_input=None): + """No options available.""" + if user_input is not None: + # Apparently not possible to abort an options flow at the moment + return self.async_create_entry(title="", data=self.config_entry.options) + + return self.async_show_form(step_id="none") + async def async_step_init(self, user_input=None): """Manage the Plugwise options.""" + if not self.config_entry.data.get(CONF_HOST): + return await self.async_step_none(user_input) + if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/custom_components/plugwise/const.py b/custom_components/plugwise/const.py index 5441b891d..a0e4aaec2 100644 --- a/custom_components/plugwise/const.py +++ b/custom_components/plugwise/const.py @@ -1,10 +1,13 @@ """Constants for Plugwise beta component.""" +from homeassistant.components.binary_sensor import DEVICE_CLASS_MOTION +from homeassistant.components.switch import DEVICE_CLASS_OUTLET from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, @@ -12,12 +15,25 @@ POWER_WATT, PRESSURE_BAR, TEMP_CELSIUS, + TIME_MILLISECONDS, VOLUME_CUBIC_METERS, ) API = "api" DOMAIN = "plugwise" COORDINATOR = "coordinator" +GATEWAY = "gateway" +PW_TYPE = "plugwise_type" +SMILE = "smile" +STICK = "stick" +STRETCH = "stretch" +USB = "usb" + +FLOW_NET = "flow_network" +FLOW_TYPE = "flow_type" +FLOW_USB = "flow_usb" +FLOW_SMILE = "smile (Adam/Anna/P1)" +FLOW_STRETCH = "stretch (Stretch)" UNDO_UPDATE_LISTENER = "undo_update_listener" @@ -228,3 +244,165 @@ # switch const: SWITCH_CLASSES = ["plug", "switch_group"] + +# --- Const for Plugwise USB-stick. + +CONF_USB_PATH = "usb_path" + +# Callback types +CB_NEW_NODE = "NEW_NODE" + +# Sensor IDs +AVAILABLE_SENSOR_ID = "available" +CURRENT_POWER_SENSOR_ID = "power_1s" +TODAY_ENERGY_SENSOR_ID = "power_con_today" +MOTION_SENSOR_ID = "motion" + +# Sensor types +SENSORS = { + AVAILABLE_SENSOR_ID: { + "class": None, + "enabled_default": False, + "icon": "mdi:signal-off", + "name": "Available", + "state": "get_available", + "unit": None, + }, + "ping": { + "class": None, + "enabled_default": False, + "icon": "mdi:speedometer", + "name": "Ping roundtrip", + "state": "get_ping", + "unit": TIME_MILLISECONDS, + }, + CURRENT_POWER_SENSOR_ID: { + "class": DEVICE_CLASS_POWER, + "enabled_default": True, + "icon": None, + "name": "Power usage", + "state": "get_power_usage", + "unit": POWER_WATT, + }, + "power_8s": { + "class": DEVICE_CLASS_POWER, + "enabled_default": False, + "icon": None, + "name": "Power usage 8 seconds", + "state": "get_power_usage_8_sec", + "unit": POWER_WATT, + }, + "power_con_cur_hour": { + "class": DEVICE_CLASS_POWER, + "enabled_default": True, + "icon": None, + "name": "Power consumption current hour", + "state": "get_power_consumption_current_hour", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "power_con_prev_hour": { + "class": DEVICE_CLASS_POWER, + "enabled_default": True, + "icon": None, + "name": "Power consumption previous hour", + "state": "get_power_consumption_prev_hour", + "unit": ENERGY_KILO_WATT_HOUR, + }, + TODAY_ENERGY_SENSOR_ID: { + "class": DEVICE_CLASS_POWER, + "enabled_default": True, + "icon": None, + "name": "Power consumption today", + "state": "get_power_consumption_today", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "power_con_yesterday": { + "class": DEVICE_CLASS_POWER, + "enabled_default": True, + "icon": None, + "name": "Power consumption yesterday", + "state": "get_power_consumption_yesterday", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "power_prod_cur_hour": { + "class": DEVICE_CLASS_POWER, + "enabled_default": False, + "icon": None, + "name": "Power production current hour", + "state": "get_power_production_current_hour", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "power_prod_prev_hour": { + "class": DEVICE_CLASS_POWER, + "enabled_default": False, + "icon": None, + "name": "Power production previous hour", + "state": "get_power_production_previous_hour", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "RSSI_in": { + "class": DEVICE_CLASS_SIGNAL_STRENGTH, + "enabled_default": False, + "icon": None, + "name": "Inbound RSSI", + "state": "get_in_RSSI", + "unit": "dBm", + }, + "RSSI_out": { + "class": DEVICE_CLASS_SIGNAL_STRENGTH, + "enabled_default": False, + "icon": None, + "name": "Outbound RSSI", + "state": "get_out_RSSI", + "unit": "dBm", + }, +} +BINARY_SENSORS = { + MOTION_SENSOR_ID: { + "class": DEVICE_CLASS_MOTION, + "enabled_default": True, + "icon": None, + "name": "Motion", + "state": "get_motion", + "unit": None, + } +} + +# Switch types +SWITCHES = { + "relay": { + "class": DEVICE_CLASS_OUTLET, + "enabled_default": True, + "icon": None, + "name": "Relay state", + "state": "get_relay_state", + "switch": "set_relay_state", + "unit": "state", + } +} + +ATTR_MAC_ADDRESS = "mac" + +ATTR_SCAN_DAYLIGHT_MODE = "day_light" +ATTR_SCAN_SENSITIVITY_MODE = "sensitivity_mode" +ATTR_SCAN_RESET_TIMER = "reset_timer" + +ATTR_SED_STAY_ACTIVE = "stay_active" +ATTR_SED_SLEEP_FOR = "sleep_for" +ATTR_SED_MAINTENANCE_INTERVAL = "maintenance_interval" +ATTR_SED_CLOCK_SYNC = "clock_sync" +ATTR_SED_CLOCK_INTERVAL = "clock_interval" + +SCAN_SENSITIVITY_HIGH = "high" +SCAN_SENSITIVITY_MEDIUM = "medium" +SCAN_SENSITIVITY_OFF = "off" +SCAN_SENSITIVITY_MODES = [ + SCAN_SENSITIVITY_HIGH, + SCAN_SENSITIVITY_MEDIUM, + SCAN_SENSITIVITY_OFF, +] + +SERVICE_CONFIGURE_BATTERY = "configure_battery_savings" +SERVICE_CONFIGURE_SCAN = "configure_scan" +SERVICE_DEVICE_ADD = "device_add" +SERVICE_DEVICE_REMOVE = "device_remove" diff --git a/custom_components/plugwise/gateway.py b/custom_components/plugwise/gateway.py index bec827bb9..e6f33d4d7 100644 --- a/custom_components/plugwise/gateway.py +++ b/custom_components/plugwise/gateway.py @@ -1,4 +1,4 @@ -"""Plugwise platform for Home Assistant Core.""" +"""Plugwise network/gateway platform.""" import asyncio import logging @@ -33,7 +33,10 @@ COORDINATOR, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, + DEFAULT_USERNAME, DOMAIN, + GATEWAY, + PW_TYPE, SENSOR_PLATFORMS, SERVICE_DELETE, UNDO_UPDATE_LISTENER, @@ -74,7 +77,7 @@ async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: connected = await api.connect() if not connected: - _LOGGER.error("Unable to connect to Smile %s", smile_name) + _LOGGER.error("Unable to connect to Smile %s", api.smile_name) raise ConfigEntryNotReady except Smile.InvalidAuthentication: @@ -95,7 +98,7 @@ async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def async_update_data(): + async def async_update_data_gw(): """Update data via API endpoint.""" _LOGGER.debug("Updating Smile %s", api.smile_name) try: @@ -118,7 +121,7 @@ async def async_update_data(): hass, _LOGGER, name=f"Smile {api.smile_name}", - update_method=async_update_data, + update_method=async_update_data_gw, update_interval=update_interval, ) @@ -141,6 +144,7 @@ async def async_update_data(): hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { API: api, COORDINATOR: coordinator, + PW_TYPE: GATEWAY, UNDO_UPDATE_LISTENER: undo_listener, } @@ -191,6 +195,25 @@ async def async_delete_notification(self): return True +async def async_unload_entry_gw(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in ALL_PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): """Handle options update.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] @@ -204,8 +227,8 @@ class SmileGateway(CoordinatorEntity): def __init__(self, api, coordinator, name, dev_id): """Initialise the gateway.""" - super().__init__(coordinator) + self._api = api self._name = name self._dev_id = dev_id @@ -252,4 +275,4 @@ async def async_added_to_hass(self): @callback def _async_process_data(self): """Interpret and process API data.""" - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/custom_components/plugwise/manifest.json b/custom_components/plugwise/manifest.json index 114694793..22582395c 100644 --- a/custom_components/plugwise/manifest.json +++ b/custom_components/plugwise/manifest.json @@ -2,7 +2,7 @@ "domain": "plugwise", "name": "Plugwise Beta", "documentation": "https://github.com/plugwise/plugwise-beta", - "requirements": ["Plugwise_Smile==1.6.0"], - "codeowners": ["@CoMPaTech","@bouwew"], + "requirements": ["Plugwise_Smile==1.6.0","python-plugwise==2.0.2"], + "codeowners": ["@CoMPaTech","@bouwew","@brefra"], "config_flow": true } diff --git a/custom_components/plugwise/sensor.py b/custom_components/plugwise/sensor.py index 7a1c06b07..886de8b73 100644 --- a/custom_components/plugwise/sensor.py +++ b/custom_components/plugwise/sensor.py @@ -12,9 +12,12 @@ from homeassistant.helpers.entity import Entity from .gateway import SmileGateway +from .usb import NodeEntity from .const import ( API, + AVAILABLE_SENSOR_ID, AUX_DEV_SENSOR_MAP, + CB_NEW_NODE, COOL_ICON, COORDINATOR, CUSTOM_ICONS, @@ -23,16 +26,50 @@ ENERGY_SENSOR_MAP, FLAME_ICON, IDLE_ICON, + PW_TYPE, SENSOR_MAP_DEVICE_CLASS, SENSOR_MAP_MODEL, SENSOR_MAP_UOM, + SENSORS, + STICK, THERMOSTAT_SENSOR_MAP, + USB, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Smile switches from a config entry.""" + if hass.data[DOMAIN][config_entry.entry_id][PW_TYPE] == USB: + return await async_setup_entry_usb(hass, config_entry, async_add_entities) + # Considered default and for earlier setups without usb/network config_flow + return await async_setup_entry_gateway(hass, config_entry, async_add_entities) + + +async def async_setup_entry_usb(hass, config_entry, async_add_entities): + """Set up Plugwise sensor based on config_entry.""" + stick = hass.data[DOMAIN][config_entry.entry_id][STICK] + + async def async_add_sensor(mac): + """Add plugwise sensor.""" + node = stick.node(mac) + for sensor_type in node.get_sensors(): + if sensor_type in SENSORS and sensor_type != AVAILABLE_SENSOR_ID: + async_add_entities([USBSensor(node, mac, sensor_type)]) + + for mac in hass.data[DOMAIN][config_entry.entry_id]["sensor"]: + hass.async_create_task(async_add_sensor(mac)) + + def discoved_sensor(mac): + """Add newly discovered sensor.""" + hass.async_create_task(async_add_sensor(mac)) + + # Listen for discovered nodes + stick.subscribe_stick_callback(discoved_sensor, CB_NEW_NODE) + + +async def async_setup_entry_gateway(hass, config_entry, async_add_entities): """Set up the Smile sensors from a config entry.""" _LOGGER.debug("Plugwise hass data %s", hass.data[DOMAIN]) api = hass.data[DOMAIN][config_entry.entry_id][API] @@ -57,7 +94,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = "Metered Switch" entities.append( - PwPowerSensor( + GwPowerSensor( api, coordinator, device_properties["name"], @@ -74,7 +111,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): continue entities.append( - PwThermostatSensor( + GwThermostatSensor( api, coordinator, device_properties["name"], @@ -90,7 +127,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): continue entities.append( - PwThermostatSensor( + GwThermostatSensor( api, coordinator, device_properties["name"], @@ -106,7 +143,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device_properties["class"] == "heater_central": _LOGGER.debug("Plugwise aux sensor Dev %s", device_properties["name"]) entities.append( - PwAuxDeviceSensor( + GwAuxDeviceSensor( api, coordinator, device_properties["name"], @@ -165,7 +202,7 @@ def unit_of_measurement(self): return self._unit_of_measurement -class PwThermostatSensor(SmileSensor, Entity): +class GwThermostatSensor(SmileSensor, Entity): """Thermostat (or generic) sensor devices.""" def __init__(self, api, coordinator, name, dev_id, sensor, sensor_type): @@ -196,7 +233,7 @@ def _async_process_data(self): self.async_write_ha_state() -class PwAuxDeviceSensor(SmileSensor, Entity): +class GwAuxDeviceSensor(SmileSensor, Entity): """Auxiliary Device sensors.""" def __init__(self, api, coordinator, name, dev_id, sensor): @@ -230,7 +267,7 @@ def _async_process_data(self): self.async_write_ha_state() -class PwPowerSensor(SmileSensor, Entity): +class GwPowerSensor(SmileSensor, Entity): """Power sensor devices.""" def __init__(self, api, coordinator, name, dev_id, sensor, sensor_type, model): @@ -265,3 +302,52 @@ def _async_process_data(self): self._icon = CUSTOM_ICONS.get(self._sensor, self._icon) self.async_write_ha_state() + + +class USBSensor(NodeEntity): + """Representation of a Plugwise sensor.""" + + def __init__(self, node, mac, sensor_id): + """Initialize a Node entity.""" + super().__init__(node, mac) + self.sensor_id = sensor_id + self.sensor_type = SENSORS[sensor_id] + self.node_callbacks = (AVAILABLE_SENSOR_ID, sensor_id) + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self.sensor_type["class"] + + @property + def entity_registry_enabled_default(self): + """Return the sensor registration state.""" + return self.sensor_type["enabled_default"] + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.sensor_type["icon"] + + @property + def name(self): + """Return the display name of this sensor.""" + return f"{self.sensor_type['name']} ({self._mac[-5:]})" + + @property + def state(self): + """Return the state of the sensor.""" + state_value = getattr(self._node, self.sensor_type["state"])() + if state_value is not None: + return float(round(state_value, 3)) + return None + + @property + def unique_id(self): + """Get unique ID.""" + return f"{self._mac}-{self.sensor_id}" + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self.sensor_type["unit"] diff --git a/custom_components/plugwise/services.yaml b/custom_components/plugwise/services.yaml index 6d46775c7..58ea942a7 100644 --- a/custom_components/plugwise/services.yaml +++ b/custom_components/plugwise/services.yaml @@ -1,2 +1,56 @@ delete_notification: - description: Delete the Plugwise Notification(s). \ No newline at end of file + description: Delete the Plugwise Notification(s). +device_add: + description: Manually add a new plugwise device + fields: + mac: + description: The full 16 character MAC address of the plugwise device. + example: 0123456789ABCDEF +device_remove: + description: Remove and unregister specified device (MAC) from plugwise network + fields: + mac: + description: The full 16 character MAC address of the plugwise device. + example: 0123456789ABCDEF +configure_scan: + description: > + Configure the motion settings for a Plugwise Scan device + The new configuratie will be send soon as the Scan devices is awake to receive configuration changes. For quick activation press the local button to awake the device. + fields: + entity_id: + description: Entity id of the Plugwise Scan motion sensor. + example: binary_sensor.motion_AB123 + sensitivity_mode: + description: Scan motion sensitivity mode (high/medium/off). + example: medium + reset_timer: + description: > + Number of minutes the Scan waits after no motion detected to set state back to off. + Valid range is 1 minute up to 240 minutes (4 hours) + example: 5 + day_light: + description: Daylight override to only report motion when lightlevel is below calibrated level + example: False +configure_battery_savings: + description: > + Configure the battery saving settings for battery powered Plugwise devices. + The new configuratie will be sent soon as the Plugwise device notifies Home Assistant it is awake to receive configuration changes. For quick reception of the configuration press the local button to wake up the device manually. + fields: + entity_id: + description: Entity id of the battery powered Plugwise device. + example: binary_sensor.motion_AB123 + stay_active: + description: Duration in seconds the device will be awake. A high value will drain the battery + example: 10 + sleep_for: + description: Duration in minutes the device will be in sleeping mode and not able to respond any command. + example: 60 + maintenance_interval: + description: Interval in minutes the node will wake up and notify it is able to receive (maintenance) commands. + example: 1440 + clock_sync: + description: Enable or disable the synchronisation of the internal clock. + example: False + clock_interval: + description: Interval the device will synchronize its internal clock. Only usefull if clock_sync is set to True. + example: 10080 diff --git a/custom_components/plugwise/strings.json b/custom_components/plugwise/strings.json index cb5baa599..85ca8be2d 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -1,6 +1,10 @@ { "options": { "step": { + "none": { + "title": "No Options available", + "description": "This Integration does not provide any Options" + }, "init": { "description": "Adjust Plugwise Options", "data": { @@ -23,20 +27,36 @@ "title": "Connect to the Smile", "description": "Please enter:", "data": { - "host": "Smile IP-address", - "username" : "Smile Username", "password": "Smile ID", + "username" : "Smile Username", + "host": "Smile IP-address", "port": "Smile port number" } + }, + "user_usb": { + "title": "Setup direct connection to legacy Plugwise USB-stick", + "description": "Please enter:", + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + } + }, + "manual_path": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + } } }, "error": { - "cannot_connect": "Failed to connect, please try again", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_auth": "Invalid authentication, check the 8 characters of your Smile ID", - "unknown": "Unexpected error" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "network_down": "Plugwise Zigbee network is down", + "network_timeout": "Network communication timeout", + "stick_init": "Initialization of Plugwise USB-stick failed", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This Smile is already configured" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } diff --git a/custom_components/plugwise/switch.py b/custom_components/plugwise/switch.py index ff0532813..c8dc9ba2b 100644 --- a/custom_components/plugwise/switch.py +++ b/custom_components/plugwise/switch.py @@ -9,12 +9,58 @@ from homeassistant.core import callback from .gateway import SmileGateway -from .const import API, COORDINATOR, DOMAIN, SWITCH_CLASSES, SWITCH_ICON +from .usb import NodeEntity +from .const import ( + API, + AVAILABLE_SENSOR_ID, + CB_NEW_NODE, + COORDINATOR, + CURRENT_POWER_SENSOR_ID, + DOMAIN, + PW_TYPE, + SENSORS, + STICK, + SWITCH_CLASSES, + SWITCH_ICON, + SWITCHES, + TODAY_ENERGY_SENSOR_ID, + USB, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Smile switches from a config entry.""" + if hass.data[DOMAIN][config_entry.entry_id][PW_TYPE] == USB: + return await async_setup_entry_usb(hass, config_entry, async_add_entities) + # Considered default and for earlier setups without usb/network config_flow + return await async_setup_entry_gateway(hass, config_entry, async_add_entities) + + +async def async_setup_entry_usb(hass, config_entry, async_add_entities): + """Set up the USB switches from a config entry.""" + stick = hass.data[DOMAIN][config_entry.entry_id][STICK] + + async def async_add_switch(mac): + """Add plugwise switch.""" + node = stick.node(mac) + for switch_type in node.get_switches(): + if switch_type in SWITCHES: + async_add_entities([USBSwitch(node, mac, switch_type)]) + + for mac in hass.data[DOMAIN][config_entry.entry_id]["switch"]: + hass.async_create_task(async_add_switch(mac)) + + def discoved_switch(mac): + """Add newly discovered switch.""" + hass.async_create_task(async_add_switch(mac)) + + # Listen for discovered nodes + stick.subscribe_stick_callback(discoved_switch, CB_NEW_NODE) + + +async def async_setup_entry_gateway(hass, config_entry, async_add_entities): """Set up the Smile switches from a config entry.""" api = hass.data[DOMAIN][config_entry.entry_id][API] coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] @@ -32,7 +78,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): model = "Switch Group" _LOGGER.debug("Plugwise switch Dev %s", device_properties["name"]) entities.append( - PwSwitch( + GwSwitch( api, coordinator, device_properties["name"], @@ -46,7 +92,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(entities, True) -class PwSwitch(SmileGateway, SwitchEntity): +class GwSwitch(SmileGateway, SwitchEntity): """Representation of a Plugwise plug.""" def __init__(self, api, coordinator, name, dev_id, members, model): @@ -111,3 +157,73 @@ def _async_process_data(self): _LOGGER.debug("Switch is ON is %s.", self._is_on) self.async_write_ha_state() + + +class USBSwitch(NodeEntity, SwitchEntity): + """Representation of a switch.""" + + def __init__(self, node, mac, switch_id): + """Initialize a Node entity.""" + super().__init__(node, mac) + self.switch_id = switch_id + self.switch_type = SWITCHES[self.switch_id] + if (CURRENT_POWER_SENSOR_ID in node.get_sensors()) and ( + TODAY_ENERGY_SENSOR_ID in node.get_sensors() + ): + self.node_callbacks = ( + AVAILABLE_SENSOR_ID, + switch_id, + CURRENT_POWER_SENSOR_ID, + TODAY_ENERGY_SENSOR_ID, + ) + else: + self.node_callbacks = (AVAILABLE_SENSOR_ID, self.switch_id) + + @property + def current_power_w(self): + """Return the current power usage in W.""" + current_power = getattr(self._node, SENSORS[CURRENT_POWER_SENSOR_ID]["state"])() + if current_power: + return float(round(current_power, 2)) + return None + + @property + def device_class(self): + """Return the device class of this switch.""" + return self.switch_type["class"] + + @property + def entity_registry_enabled_default(self): + """Return the switch registration state.""" + return self.switch_type["enabled_default"] + + @property + def icon(self): + """Return the icon.""" + return None if self.switch_type["class"] else self.switch_type["icon"] + + @property + def is_on(self): + """Return true if the switch is on.""" + return getattr(self._node, self.switch_type["state"])() + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + today_energy = getattr(self._node, SENSORS[TODAY_ENERGY_SENSOR_ID]["state"])() + if today_energy: + return float(round(today_energy, 3)) + return None + + def turn_off(self, **kwargs): + """Instruct the switch to turn off.""" + getattr(self._node, self.switch_type["switch"])(False) + + def turn_on(self, **kwargs): + """Instruct the switch to turn on.""" + getattr(self._node, self.switch_type["switch"])(True) + + @property + def unique_id(self): + """Get unique ID.""" + return f"{self._mac}-{self.switch_id}" diff --git a/custom_components/plugwise/translations/en.json b/custom_components/plugwise/translations/en.json index cb5baa599..63917c0e1 100644 --- a/custom_components/plugwise/translations/en.json +++ b/custom_components/plugwise/translations/en.json @@ -1,6 +1,10 @@ { "options": { "step": { + "none": { + "title": "No Options available", + "description": "This Integration does not provide any Options" + }, "init": { "description": "Adjust Plugwise Options", "data": { @@ -23,20 +27,36 @@ "title": "Connect to the Smile", "description": "Please enter:", "data": { - "host": "Smile IP-address", - "username" : "Smile Username", "password": "Smile ID", + "username" : "Smile Username", + "host": "Smile IP-address", "port": "Smile port number" } + }, + "user_usb": { + "title": "Setup direct connection to legacy Plugwise USB-stick", + "description": "Please enter:", + "data": { + "usb_path": "USB Path" + } + }, + "manual_path": { + "data": { + "usb_path": "USB Path" + } } }, "error": { - "cannot_connect": "Failed to connect, please try again", + "already_configured": "This device is already configured", "invalid_auth": "Invalid authentication, check the 8 characters of your Smile ID", - "unknown": "Unexpected error" + "cannot_connect": "Cannot connect", + "network_down": "Plugwise Zigbee network is down", + "network_timeout": "Network communication timeout", + "stick_init": "Initialization of Plugwise USB-stick failed", + "unknown": "Unknown error!" }, "abort": { - "already_configured": "This Smile is already configured" + "already_configured": "This device is already configured" } } } diff --git a/custom_components/plugwise/translations/nl.json b/custom_components/plugwise/translations/nl.json index dd149b908..6de8b010e 100644 --- a/custom_components/plugwise/translations/nl.json +++ b/custom_components/plugwise/translations/nl.json @@ -1,6 +1,10 @@ { "options": { "step": { + "none": { + "title": "Geen Opties beschikbaar", + "description": "Deze Integratie heeft geen Opties" + }, "init": { "description": "Plugwise Opties aanpassen", "data": { @@ -23,20 +27,36 @@ "title": "Verbinden met de Plugwise Smile", "description": "Aub invoeren:", "data": { - "host": "Smile IP-adres", - "username": "Smile gebruikersnaam", "password": "Smile ID", + "username" : "Smile gebruikersnaam", + "host": "Smile IP-adres", "port": "Smile poort nummer" } + }, + "user_usb": { + "title": "Verbinden met een legacy USB-stick", + "description": "Aub invoeren:", + "data": { + "usb_path": "USB pad" + } + }, + "manual_path": { + "data": { + "usb_path": "USB pad" + } } }, "error": { - "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "already_configured": "Dit apparaat is al geconfigureerd", "invalid_auth": "Authenticatie mislukt, voer de 8 karakters van je Smile goed in", - "unknown": "Overwachte fout" + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "network_down": "Plugwise Zigbee netwerk is down", + "network_timeout": "Network communicatie timeout", + "stick_init": "Initaliseren van USB-stick mislukt", + "unknown": "Onbekende fout" }, "abort": { - "already_configured": "Deze Smile is al geconfigureerd" + "already_configured": "Dit apparaat is al geconfigureerd" } } } diff --git a/custom_components/plugwise/usb.py b/custom_components/plugwise/usb.py new file mode 100644 index 000000000..45b6b030b --- /dev/null +++ b/custom_components/plugwise/usb.py @@ -0,0 +1,202 @@ +"""Support for Plugwise devices connected to a Plugwise USB-stick.""" +import asyncio +import logging +import voluptuous as vol + +import plugwise +from plugwise.exceptions import ( + CirclePlusError, + NetworkDown, + PortError, + StickInitError, + TimeoutException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_MAC_ADDRESS, + AVAILABLE_SENSOR_ID, + CONF_USB_PATH, + DOMAIN, + PW_TYPE, + SENSORS, + SERVICE_DEVICE_ADD, + SERVICE_DEVICE_REMOVE, + UNDO_UPDATE_LISTENER, + STICK, + USB, +) + +_LOGGER = logging.getLogger(__name__) +CB_TYPE_NEW_NODE = "NEW_NODE" +PLUGWISE_STICK_PLATFORMS = ["binary_sensor", "sensor", "switch"] + + +async def async_setup_entry_usb(hass: HomeAssistant, config_entry: ConfigEntry): + """Establish connection with plugwise USB-stick.""" + hass.data.setdefault(DOMAIN, {}) + + def discover_finished(): + """Create entities for all discovered nodes.""" + nodes = stick.nodes() + _LOGGER.debug( + "Successfully discovered %s out of %s registered nodes", + str(len(nodes)), + str(stick.registered_nodes()), + ) + for component in PLUGWISE_STICK_PLATFORMS: + hass.data[DOMAIN][config_entry.entry_id][component] = [] + for mac in nodes: + if component in stick.node(mac).get_categories(): + hass.data[DOMAIN][config_entry.entry_id][component].append(mac) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + stick.auto_update() + # Enable reception of join request and automatically accept new node join requests + stick.allow_join_requests(True, True) + + def shutdown(event): + hass.async_add_executor_job(stick.disconnect) + + stick = plugwise.stick(config_entry.data[CONF_USB_PATH]) + hass.data[DOMAIN][config_entry.entry_id] = {PW_TYPE: USB, STICK: stick} + try: + _LOGGER.debug("Connect to USB-Stick") + await hass.async_add_executor_job(stick.connect) + _LOGGER.debug("Initialize USB-stick") + await hass.async_add_executor_job(stick.initialize_stick) + _LOGGER.debug("Discover Circle+ node") + await hass.async_add_executor_job(stick.initialize_circle_plus) + except PortError: + _LOGGER.error("Connecting to Plugwise USBstick communication failed") + raise ConfigEntryNotReady + except StickInitError: + _LOGGER.error("Initializing of Plugwise USBstick communication failed") + await hass.async_add_executor_job(stick.disconnect) + raise ConfigEntryNotReady + except NetworkDown: + _LOGGER.warning("Plugwise zigbee network down") + await hass.async_add_executor_job(stick.disconnect) + raise ConfigEntryNotReady + except CirclePlusError: + _LOGGER.warning("Failed to connect to Circle+ node") + await hass.async_add_executor_job(stick.disconnect) + raise ConfigEntryNotReady + except TimeoutException: + _LOGGER.warning("Timeout") + await hass.async_add_executor_job(stick.disconnect) + raise ConfigEntryNotReady + _LOGGER.debug("Start discovery of registered nodes") + stick.scan(discover_finished) + + # Listen when EVENT_HOMEASSISTANT_STOP is fired + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + # Listen for entry updates + hass.data[DOMAIN][config_entry.entry_id][ + UNDO_UPDATE_LISTENER + ] = config_entry.add_update_listener(_async_update_listener) + + async def device_add(service): + """Manually add device to Plugwise zigbee network.""" + stick.node_join(service.data[ATTR_MAC_ADDRESS]) + + async def device_remove(service): + """Manually remove device from Plugwise zigbee network.""" + stick.node_unjoin(service.data[ATTR_MAC_ADDRESS]) + + service_device_schema = vol.Schema({vol.Required(ATTR_MAC_ADDRESS): cv.string}) + + hass.services.async_register( + DOMAIN, SERVICE_DEVICE_ADD, device_add, service_device_schema + ) + hass.services.async_register( + DOMAIN, SERVICE_DEVICE_REMOVE, device_remove, service_device_schema + ) + + return True + + +async def async_unload_entry_usb(hass: HomeAssistant, config_entry: ConfigEntry): + """Unload the Plugwise stick connection.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLUGWISE_STICK_PLATFORMS + ] + ) + ) + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + if unload_ok: + stick = hass.data[DOMAIN][config_entry.entry_id]["stick"] + await hass.async_add_executor_job(stick.disconnect) + hass.data[DOMAIN].pop(config_entry.entry_id) + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +class NodeEntity(Entity): + """Base class for a Plugwise entities.""" + + def __init__(self, node, mac): + """Initialize a Node entity.""" + self._node = node + self._mac = mac + self.node_callbacks = (AVAILABLE_SENSOR_ID,) + + async def async_added_to_hass(self): + """Subscribe to updates.""" + for node_callback in self.node_callbacks: + self._node.subscribe_callback(self.sensor_update, node_callback) + + async def async_will_remove_from_hass(self): + """Unsubscribe to updates.""" + for node_callback in self.node_callbacks: + self._node.unsubscribe_callback(self.sensor_update, node_callback) + + @property + def available(self): + """Return the availability of this entity.""" + return getattr(self._node, SENSORS[AVAILABLE_SENSOR_ID]["state"])() + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._mac)}, + "name": f"{self._node.get_node_type()} ({self._mac})", + "manufacturer": "Plugwise", + "model": self._node.get_node_type(), + "sw_version": f"{self._node.get_firmware_version()}", + } + + @property + def name(self): + """Return the display name of this entity.""" + return f"{self._node.get_node_type()} {self._mac[-5:]}" + + def sensor_update(self, state): + """Handle status update of Entity.""" + self.schedule_update_ha_state() + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self): + """Get unique ID.""" + return f"{self._mac}-{self._node.get_node_type()}" diff --git a/hacs.json b/hacs.json index d32ddf76b..b224264ab 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "name": "Plugwise beta custom component", "domains": ["binary_sensor","climate","sensor","switch"], - "homeassistant": "0.110.0", + "homeassistant": "0.115.0", "render_readme": true }