-
-
Notifications
You must be signed in to change notification settings - Fork 37.1k
Add Plugwise USB-stick integration #35713
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f573ebb
bbd67e2
15f0967
99e61c0
9171783
0b92388
0f202f6
a98bb39
bf1087c
f39f860
fd5fcb3
6ea3018
29d321d
863a3ca
5cd0584
6c15bd9
a1e7691
31d32be
97133db
40d3835
57fa08b
4f0e31c
d1d464a
e6e79da
42a608c
8405040
e9a624e
0f60284
9e4e123
82303c9
5a9d8d1
321f873
3456793
2213d77
2c17daa
fad33b3
b214a13
ab56529
5d70748
78f900e
4495327
4089d95
1afeeb3
7b7536e
2030262
65b8b39
591d5cd
533110f
db45d7d
d846155
81e47e6
587ddb2
810b90c
4d52854
4e4caeb
813a695
e904753
0836dee
f8af7b0
82bec88
7b39950
d767990
f5c8136
a16021c
de7b7c5
0bc2d15
9104275
c1f726f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| """Support for Plugwise devices connected to a Plugwise USB-stick.""" | ||
| import asyncio | ||
| import logging | ||
|
|
||
| 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.entity import Entity | ||
|
|
||
| from .const import ( | ||
| AVAILABLE_SENSOR_ID, | ||
| CONF_USB_PATH, | ||
| DOMAIN, | ||
| SENSORS, | ||
| UNDO_UPDATE_LISTENER, | ||
| ) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
| CB_TYPE_NEW_NODE = "NEW_NODE" | ||
| PLUGWISE_STICK_PLATFORMS = ["switch"] | ||
|
|
||
|
|
||
| async def async_setup(hass, config): | ||
| """Set up the Plugwise stick platform.""" | ||
| return True | ||
|
|
||
|
|
||
| async def async_setup_entry(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() | ||
|
|
||
| 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] = {"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) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(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 PlugwiseNodeEntity(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()}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| """Config flow for the Plugwise_stick platform.""" | ||
| import os | ||
| from typing import Dict | ||
|
|
||
| import plugwise | ||
| from plugwise.exceptions import NetworkDown, PortError, StickInitError, TimeoutException | ||
| import serial.tools.list_ports | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.core import HomeAssistant, callback | ||
|
|
||
| from .const import CONF_USB_PATH, DOMAIN | ||
|
|
||
| CONF_MANUAL_PATH = "Enter Manually" | ||
|
|
||
|
|
||
| @callback | ||
| def plugwise_stick_entries(hass: HomeAssistant): | ||
| """Return existing connections for Plugwise USB-stick domain.""" | ||
| return { | ||
| (entry.data[CONF_USB_PATH]) | ||
| for entry in hass.config_entries.async_entries(DOMAIN) | ||
| } | ||
|
|
||
|
|
||
| class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow.""" | ||
|
|
||
| VERSION = 1 | ||
| CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH | ||
|
|
||
| async def async_step_user(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_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 = await validate_connection(self.hass, device_path) | ||
| if not errors: | ||
| return self.async_create_entry( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should add add this before The config_flow also needs a unique_id, otherwise you will be able to add the integration more than once. Also, there will be issues when adding a 2nd Stick. I know, unlikely, but there's always someone that will do this :)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, I'll add an |
||
| title=device_path, data={CONF_USB_PATH: device_path} | ||
| ) | ||
| return self.async_show_form( | ||
| step_id="user", | ||
| 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: | ||
| device_path = await self.hass.async_add_executor_job( | ||
| get_serial_by_id, user_input.get(CONF_USB_PATH) | ||
| ) | ||
| errors = await validate_connection(self.hass, device_path) | ||
| if not errors: | ||
| return self.async_create_entry( | ||
| title=device_path, 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 if errors else {}, | ||
| ) | ||
|
|
||
|
|
||
| async def validate_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["base"] = "connection_failed" | ||
| return errors | ||
|
|
||
| if device_path in plugwise_stick_entries(self): | ||
| errors["base"] = "connection_exists" | ||
| return errors | ||
|
|
||
| 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["base"] = "cannot_connect" | ||
| except StickInitError: | ||
| errors["base"] = "stick_init" | ||
| except NetworkDown: | ||
| errors["base"] = "network_down" | ||
| except TimeoutException: | ||
| errors["base"] = "network_timeout" | ||
| return errors | ||
|
|
||
|
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| """Const for Plugwise USB-stick.""" | ||
|
|
||
| from homeassistant.components.switch import DEVICE_CLASS_OUTLET | ||
| from homeassistant.const import ( | ||
| DEVICE_CLASS_ENERGY, | ||
| DEVICE_CLASS_POWER, | ||
| ENERGY_KILO_WATT_HOUR, | ||
| POWER_WATT, | ||
| ) | ||
|
|
||
| DOMAIN = "plugwise_stick" | ||
| CONF_USB_PATH = "usb_path" | ||
| UNDO_UPDATE_LISTENER = "undo_update_listener" | ||
|
|
||
| # 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" | ||
|
|
||
| # Sensor types | ||
| SENSORS = { | ||
| AVAILABLE_SENSOR_ID: { | ||
| "class": None, | ||
| "enabled_default": False, | ||
| "icon": "mdi:signal-off", | ||
| "name": "Available", | ||
| "state": "get_available", | ||
| "unit": None, | ||
| }, | ||
| CURRENT_POWER_SENSOR_ID: { | ||
| "class": DEVICE_CLASS_POWER, | ||
| "enabled_default": True, | ||
| "icon": None, | ||
| "name": "Power usage", | ||
| "state": "get_power_usage", | ||
| "unit": POWER_WATT, | ||
| }, | ||
| TODAY_ENERGY_SENSOR_ID: { | ||
| "class": DEVICE_CLASS_ENERGY, | ||
| "enabled_default": True, | ||
| "icon": None, | ||
| "name": "Power consumption today", | ||
| "state": "get_power_consumption_today", | ||
| "unit": ENERGY_KILO_WATT_HOUR, | ||
| }, | ||
| } | ||
|
|
||
| # 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", | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: here you could add something like:
This, together with the unique_id, see below, stops the possibility of adding a configuration 2 times.
This requires also a few addition lines in string.json.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do prevent adding the same USB-stick in a manually started second config flow at L103-L105 together with L18-L24 which gives a nice error message:

I'm not sure it is user friendly to abort the config flow instead. To me aborting the configflow seems to be only usefull when automatic discovery triggers the config flow, which is not the case for serial devices.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very good! Then please ignore my comment.