Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
f573ebb
Add initial code Plugwise_stick integration
brefra May 16, 2020
bbd67e2
Some code cleanup and formating
brefra May 16, 2020
15f0967
Apply black formatting
brefra May 16, 2020
99e61c0
Fix formating of string.json
brefra May 16, 2020
9171783
Apply flake8 suggestions
brefra May 16, 2020
0b92388
Apply missed black format const.py
brefra May 16, 2020
0f202f6
add python-plugwise to requirements_test_all
brefra May 16, 2020
a98bb39
Apply flake8 suggestions
brefra May 16, 2020
bf1087c
Correct docstrings
brefra May 16, 2020
f39f860
Correct isort
brefra May 17, 2020
fd5fcb3
Correct device info format
brefra May 17, 2020
6ea3018
Remove unnecessary else in config_flow.py
brefra May 17, 2020
29d321d
Add missing __init__.py for testing config flow
brefra May 17, 2020
863a3ca
Fix config_flow tests
brefra May 17, 2020
5cd0584
Black formatting test_config_flow.py
brefra May 17, 2020
6c15bd9
Update config_flow.py
brefra May 17, 2020
a1e7691
Remove sensor platform
brefra May 17, 2020
31d32be
Fix pylint in config_flow.py
brefra May 17, 2020
97133db
Extend tests of config flow
brefra May 17, 2020
40d3835
Remove unused import
brefra May 17, 2020
57fa08b
Apply black formatting
brefra May 17, 2020
4f0e31c
Apply isort
brefra May 17, 2020
d1d464a
Remove unused logging from switch.py
brefra May 17, 2020
e6e79da
remove unused logging config_flow.py
brefra May 17, 2020
42a608c
Remove unused variables
brefra May 17, 2020
8405040
Code cleanup
brefra May 17, 2020
e9a624e
Remove empty lines
brefra May 17, 2020
0f60284
Update config flow tests
brefra May 17, 2020
9e4e123
Improve validaton of user selection
brefra May 18, 2020
82303c9
Update test_config_flow.py
brefra May 18, 2020
5a9d8d1
Change logging level
brefra May 18, 2020
321f873
Change to AsyncMock
brefra May 18, 2020
3456793
Replaced SwitchDevice into SwichEntity
brefra May 24, 2020
2213d77
Cleanup config_flow.py
brefra May 26, 2020
2c17daa
Improve config_flow test coverage
brefra May 26, 2020
fad33b3
Apply black formatting
brefra May 26, 2020
b214a13
Remove unused import
brefra May 26, 2020
ab56529
Remove unused import from test_config_flow
brefra May 27, 2020
5d70748
Apply isort
brefra May 27, 2020
78f900e
Change AsyncMock into MagicMock
brefra May 27, 2020
4495327
Fixed issue closing port
brefra Jun 4, 2020
4089d95
Bump to version 0.8
brefra Jun 4, 2020
1afeeb3
Bump requirements to python-plugwise 0.8
brefra Jun 4, 2020
7b7536e
Don't return icon if device_class is specified
brefra Jun 5, 2020
2030262
Refactor switch.py
brefra Jun 5, 2020
65b8b39
Code cleanup switch.py
brefra Jun 19, 2020
591d5cd
Disconnect at HA shutdown event
brefra Jun 19, 2020
533110f
Handle Circle+ init exception
brefra Jun 19, 2020
db45d7d
Do disconnect when exception happens
brefra Jun 19, 2020
d846155
Add const for new node
brefra Jun 19, 2020
81e47e6
Add missing test for disconnect
brefra Jun 19, 2020
587ddb2
Bump python-plugwise to 1.0.0
brefra Jun 19, 2020
810b90c
Correct disconnect
brefra Jun 19, 2020
4d52854
Disconnect from async job
brefra Jun 20, 2020
4e4caeb
Bump python-plugwise to 1.0.1
brefra Jun 20, 2020
813a695
Combine config_flow tests
brefra Jun 20, 2020
e904753
Fix incorect sorting after rebase
brefra Jul 3, 2020
0836dee
Add config update listener
brefra Aug 17, 2020
f8af7b0
Use energy device class for energy sensor
brefra Aug 17, 2020
82bec88
Add listener for newly discovered switches
brefra Aug 17, 2020
7b39950
Set variable types
brefra Aug 17, 2020
d767990
Reorganize shutdown
brefra Aug 17, 2020
f5c8136
Fix unload
brefra Aug 17, 2020
a16021c
Rewrite node discovery
brefra Aug 17, 2020
de7b7c5
Bump python-plugwise to 1.2.1
brefra Aug 17, 2020
0bc2d15
Rename to add_switch
brefra Aug 17, 2020
9104275
bump python-plugwise to 1.2.2
brefra Aug 22, 2020
c1f726f
Remove calculation for today_energy_kwh attribute
brefra Aug 22, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@ omit =
homeassistant/components/plugwise/climate.py
homeassistant/components/plugwise/sensor.py
homeassistant/components/plugwise/switch.py
homeassistant/components/plugwise_stick/__init__.py
homeassistant/components/plugwise_stick/switch.py
homeassistant/components/plum_lightpad/light.py
homeassistant/components/pocketcasts/sensor.py
homeassistant/components/point/*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ homeassistant/components/plaato/* @JohNan
homeassistant/components/plant/* @ChristianKuehnel
homeassistant/components/plex/* @jjlawren
homeassistant/components/plugwise/* @CoMPaTech @bouwew
homeassistant/components/plugwise_stick/* @brefra
homeassistant/components/plum_lightpad/* @ColinHarrington @prystupa
homeassistant/components/point/* @fredrike
homeassistant/components/poolsense/* @haemishkyd
Expand Down
180 changes: 180 additions & 0 deletions homeassistant/components/plugwise_stick/__init__.py
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()}"
131 changes: 131 additions & 0 deletions homeassistant/components/plugwise_stick/config_flow.py
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
)
Copy link
Copy Markdown
Contributor

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:

            for entry in self._async_current_entries():
                if entry.data.get("parameter") == user_input["parameter"]:
                    return self.async_abort(reason="already_configured")

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.

Copy link
Copy Markdown
Contributor Author

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:
image

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.

Copy link
Copy Markdown
Contributor

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.

errors = await validate_connection(self.hass, device_path)
if not errors:
return self.async_create_entry(
Copy link
Copy Markdown
Contributor

@bouwew bouwew Sep 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add add this before return self.async_create_entry():

                await self.async_set_unique_id("unique_id", raise_on_progress=False)
                self._abort_if_unique_id_configured()

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 :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'll add an unique_id to the the config entry.

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
62 changes: 62 additions & 0 deletions homeassistant/components/plugwise_stick/const.py
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",
}
}
Loading