diff --git a/.coveragerc b/.coveragerc index 8bc20400dba..db20f089e4b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1410,6 +1410,16 @@ omit = homeassistant/components/zwave/util.py homeassistant/components/zwave_js/discovery.py homeassistant/components/zwave_js/sensor.py + homeassistant/components/zwave_me/__init__.py + homeassistant/components/zwave_me/binary_sensor.py + homeassistant/components/zwave_me/button.py + homeassistant/components/zwave_me/climate.py + homeassistant/components/zwave_me/helpers.py + homeassistant/components/zwave_me/light.py + homeassistant/components/zwave_me/lock.py + homeassistant/components/zwave_me/number.py + homeassistant/components/zwave_me/sensor.py + homeassistant/components/zwave_me/switch.py [report] # Regexes for lines to exclude from consideration diff --git a/CODEOWNERS b/CODEOWNERS index 6eec61b49e2..7e56031c2bf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1111,6 +1111,8 @@ homeassistant/components/zwave/* @home-assistant/z-wave tests/components/zwave/* @home-assistant/z-wave homeassistant/components/zwave_js/* @home-assistant/z-wave tests/components/zwave_js/* @home-assistant/z-wave +homeassistant/components/zwave_me/* @lawfulchaos @Z-Wave-Me +tests/components/zwave_me/* @lawfulchaos @Z-Wave-Me # Individual files homeassistant/components/demo/weather @fabaff diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py new file mode 100644 index 00000000000..42b510f9417 --- /dev/null +++ b/homeassistant/components/zwave_me/__init__.py @@ -0,0 +1,123 @@ +"""The Z-Wave-Me WS integration.""" +import asyncio +import logging + +from zwave_me_ws import ZWaveMe, ZWaveMeData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, PLATFORMS, ZWaveMePlatform + +_LOGGER = logging.getLogger(__name__) +ZWAVE_ME_PLATFORMS = [platform.value for platform in ZWaveMePlatform] + + +async def async_setup_entry(hass, entry): + """Set up Z-Wave-Me from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + controller = hass.data[DOMAIN][entry.entry_id] = ZWaveMeController(hass, entry) + if await controller.async_establish_connection(): + hass.async_create_task(async_setup_platforms(hass, entry, controller)) + return True + raise ConfigEntryNotReady() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + controller = hass.data[DOMAIN].pop(entry.entry_id) + await controller.zwave_api.close_ws() + return unload_ok + + +class ZWaveMeController: + """Main ZWave-Me API class.""" + + def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: + """Create the API instance.""" + self.device_ids: set = set() + self._hass = hass + self.config = config + self.zwave_api = ZWaveMe( + on_device_create=self.on_device_create, + on_device_update=self.on_device_update, + on_new_device=self.add_device, + token=self.config.data[CONF_TOKEN], + url=self.config.data[CONF_URL], + platforms=ZWAVE_ME_PLATFORMS, + ) + self.platforms_inited = False + + async def async_establish_connection(self): + """Get connection status.""" + is_connected = await self.zwave_api.get_connection() + return is_connected + + def add_device(self, device: ZWaveMeData) -> None: + """Send signal to create device.""" + if device.deviceType in ZWAVE_ME_PLATFORMS and self.platforms_inited: + if device.id in self.device_ids: + dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{device.id}", device) + else: + dispatcher_send( + self._hass, f"ZWAVE_ME_NEW_{device.deviceType.upper()}", device + ) + self.device_ids.add(device.id) + + def on_device_create(self, devices: list) -> None: + """Create multiple devices.""" + for device in devices: + self.add_device(device) + + def on_device_update(self, new_info: ZWaveMeData) -> None: + """Send signal to update device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) + + +async def async_setup_platforms( + hass: HomeAssistant, entry: ConfigEntry, controller: ZWaveMeController +) -> None: + """Set up platforms.""" + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in PLATFORMS + ] + ) + controller.platforms_inited = True + + await hass.async_add_executor_job(controller.zwave_api.get_devices) + + +class ZWaveMeEntity(Entity): + """Representation of a ZWaveMe device.""" + + def __init__(self, controller, device): + """Initialize the device.""" + self.controller = controller + self.device = device + self._attr_name = device.title + self._attr_unique_id = f"{self.controller.config.unique_id}-{self.device.id}" + self._attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """Connect to an updater.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data + ) + ) + + @callback + def get_new_data(self, new_data): + """Update info in the HAss.""" + self.device = new_data + self._attr_available = not new_data.isFailed + self.async_write_ha_state() diff --git a/homeassistant/components/zwave_me/binary_sensor.py b/homeassistant/components/zwave_me/binary_sensor.py new file mode 100644 index 00000000000..40d850b8483 --- /dev/null +++ b/homeassistant/components/zwave_me/binary_sensor.py @@ -0,0 +1,75 @@ +"""Representation of a sensorBinary.""" +from __future__ import annotations + +from zwave_me_ws import ZWaveMeData + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ZWaveMeController, ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +BINARY_SENSORS_MAP: dict[str, BinarySensorEntityDescription] = { + "generic": BinarySensorEntityDescription( + key="generic", + ), + "motion": BinarySensorEntityDescription( + key="motion", + device_class=DEVICE_CLASS_MOTION, + ), +} +DEVICE_NAME = ZWaveMePlatform.BINARY_SENSOR + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + + @callback + def add_new_device(new_device: ZWaveMeData) -> None: + controller: ZWaveMeController = hass.data[DOMAIN][config_entry.entry_id] + description = BINARY_SENSORS_MAP.get( + new_device.probeType, BINARY_SENSORS_MAP["generic"] + ) + sensor = ZWaveMeBinarySensor(controller, new_device, description) + + async_add_entities( + [ + sensor, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeBinarySensor(ZWaveMeEntity, BinarySensorEntity): + """Representation of a ZWaveMe binary sensor.""" + + def __init__( + self, + controller: ZWaveMeController, + device: ZWaveMeData, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the device.""" + super().__init__(controller=controller, device=device) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return self.device.level == "on" diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py new file mode 100644 index 00000000000..40105d100d4 --- /dev/null +++ b/homeassistant/components/zwave_me/button.py @@ -0,0 +1,40 @@ +"""Representation of a toggleButton.""" +from typing import Any + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +DEVICE_NAME = ZWaveMePlatform.BUTTON + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the number platform.""" + + @callback + def add_new_device(new_device): + controller = hass.data[DOMAIN][config_entry.entry_id] + button = ZWaveMeButton(controller, new_device) + + async_add_entities( + [ + button, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeButton(ZWaveMeEntity, ButtonEntity): + """Representation of a ZWaveMe button.""" + + def press(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.controller.zwave_api.send_command(self.device.id, "on") diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py new file mode 100644 index 00000000000..140c397ecde --- /dev/null +++ b/homeassistant/components/zwave_me/climate.py @@ -0,0 +1,101 @@ +"""Representation of a thermostat.""" +from __future__ import annotations + +from zwave_me_ws import ZWaveMeData + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +TEMPERATURE_DEFAULT_STEP = 0.5 + +DEVICE_NAME = ZWaveMePlatform.CLIMATE + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the climate platform.""" + + @callback + def add_new_device(new_device: ZWaveMeData) -> None: + """Add a new device.""" + controller = hass.data[DOMAIN][config_entry.entry_id] + climate = ZWaveMeClimate(controller, new_device) + + async_add_entities( + [ + climate, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeClimate(ZWaveMeEntity, ClimateEntity): + """Representation of a ZWaveMe sensor.""" + + def set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + self.controller.zwave_api.send_command( + self.device.id, f"exact?level={temperature}" + ) + + @property + def temperature_unit(self) -> str: + """Return the temperature_unit.""" + return self.device.scaleTitle + + @property + def target_temperature(self) -> float: + """Return the state of the sensor.""" + return self.device.level + + @property + def max_temp(self) -> float: + """Return min temperature for the device.""" + return self.device.max + + @property + def min_temp(self) -> float: + """Return max temperature for the device.""" + return self.device.min + + @property + def hvac_modes(self) -> list[str]: + """Return the list of available operation modes.""" + return [HVAC_MODE_HEAT] + + @property + def hvac_mode(self) -> str: + """Return the current mode.""" + return HVAC_MODE_HEAT + + @property + def supported_features(self) -> int: + """Return the supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def target_temperature_step(self) -> float: + """Return the supported step of target temperature.""" + return TEMPERATURE_DEFAULT_STEP diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py new file mode 100644 index 00000000000..a4b257bdab5 --- /dev/null +++ b/homeassistant/components/zwave_me/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow to configure ZWaveMe integration.""" + +import logging + +from url_normalize import url_normalize +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN, CONF_URL + +from . import helpers +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """ZWaveMe integration config flow.""" + + def __init__(self): + """Initialize flow.""" + self.url = None + self.token = None + self.uuid = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user or started with zeroconf.""" + errors = {} + if self.url is None: + schema = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_TOKEN): str, + } + ) + else: + schema = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } + ) + + if user_input is not None: + if self.url is None: + self.url = user_input[CONF_URL] + + self.token = user_input[CONF_TOKEN] + if not self.url.startswith(("ws://", "wss://")): + self.url = f"ws://{self.url}" + self.url = url_normalize(self.url, default_scheme="ws") + if self.uuid is None: + self.uuid = await helpers.get_uuid(self.url, self.token) + if self.uuid is not None: + await self.async_set_unique_id(self.uuid, raise_on_progress=False) + self._abort_if_unique_id_configured() + else: + errors["base"] = "no_valid_uuid_set" + + if not errors: + return self.async_create_entry( + title=self.url, + data={CONF_URL: self.url, CONF_TOKEN: self.token}, + ) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_zeroconf(self, discovery_info): + """ + Handle a discovered Z-Wave accessory - get url to pass into user step. + + This flow is triggered by the discovery component. + """ + self.url = discovery_info.host + self.uuid = await helpers.get_uuid(self.url) + if self.uuid is None: + return self.async_abort(reason="no_valid_uuid_set") + + await self.async_set_unique_id(self.uuid) + self._abort_if_unique_id_configured() + return await self.async_step_user() diff --git a/homeassistant/components/zwave_me/const.py b/homeassistant/components/zwave_me/const.py new file mode 100644 index 00000000000..ccbf6989f07 --- /dev/null +++ b/homeassistant/components/zwave_me/const.py @@ -0,0 +1,32 @@ +"""Constants for ZWaveMe.""" +from homeassistant.backports.enum import StrEnum +from homeassistant.const import Platform + +# Base component constants +DOMAIN = "zwave_me" + + +class ZWaveMePlatform(StrEnum): + """Included ZWaveMe platforms.""" + + BINARY_SENSOR = "sensorBinary" + BUTTON = "toggleButton" + CLIMATE = "thermostat" + LOCK = "doorlock" + NUMBER = "switchMultilevel" + SWITCH = "switchBinary" + SENSOR = "sensorMultilevel" + RGBW_LIGHT = "switchRGBW" + RGB_LIGHT = "switchRGB" + + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] diff --git a/homeassistant/components/zwave_me/helpers.py b/homeassistant/components/zwave_me/helpers.py new file mode 100644 index 00000000000..0d53512d1cb --- /dev/null +++ b/homeassistant/components/zwave_me/helpers.py @@ -0,0 +1,14 @@ +"""Helpers for zwave_me config flow.""" +from __future__ import annotations + +from zwave_me_ws import ZWaveMe + + +async def get_uuid(url: str, token: str | None = None) -> str | None: + """Get an uuid from Z-Wave-Me.""" + conn = ZWaveMe(url=url, token=token) + uuid = None + if await conn.get_connection(): + uuid = await conn.get_uuid() + await conn.close_ws() + return uuid diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py new file mode 100644 index 00000000000..df2a14d3bbd --- /dev/null +++ b/homeassistant/components/zwave_me/light.py @@ -0,0 +1,85 @@ +"""Representation of an RGB light.""" +from __future__ import annotations + +from typing import Any + +from zwave_me_ws import ZWaveMeData + +from homeassistant.components.light import ATTR_RGB_COLOR, COLOR_MODE_RGB, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the rgb platform.""" + + @callback + def add_new_device(new_device: ZWaveMeData) -> None: + """Add a new device.""" + controller = hass.data[DOMAIN][config_entry.entry_id] + rgb = ZWaveMeRGB(controller, new_device) + + async_add_entities( + [ + rgb, + ] + ) + + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{ZWaveMePlatform.RGB_LIGHT.upper()}", add_new_device + ) + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{ZWaveMePlatform.RGBW_LIGHT.upper()}", add_new_device + ) + + +class ZWaveMeRGB(ZWaveMeEntity, LightEntity): + """Representation of a ZWaveMe light.""" + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device on.""" + self.controller.zwave_api.send_command(self.device.id, "off") + + def turn_on(self, **kwargs: Any): + """Turn the device on.""" + color = kwargs.get(ATTR_RGB_COLOR) + + if color is None: + color = (122, 122, 122) + cmd = "exact?red={}&green={}&blue={}".format(*color) + self.controller.zwave_api.send_command(self.device.id, cmd) + + @property + def is_on(self) -> bool: + """Return true if the light is on.""" + return self.device.level == "on" + + @property + def brightness(self) -> int: + """Return the brightness of a device.""" + return max(self.device.color.values()) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Return the rgb color value [int, int, int].""" + rgb = self.device.color + return rgb["r"], rgb["g"], rgb["b"] + + @property + def supported_color_modes(self) -> set: + """Return all color modes.""" + return {COLOR_MODE_RGB} + + @property + def color_mode(self) -> str: + """Return current color mode.""" + return COLOR_MODE_RGB diff --git a/homeassistant/components/zwave_me/lock.py b/homeassistant/components/zwave_me/lock.py new file mode 100644 index 00000000000..17e64ff1602 --- /dev/null +++ b/homeassistant/components/zwave_me/lock.py @@ -0,0 +1,60 @@ +"""Representation of a doorlock.""" +from __future__ import annotations + +from typing import Any + +from zwave_me_ws import ZWaveMeData + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +DEVICE_NAME = ZWaveMePlatform.LOCK + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the lock platform.""" + + @callback + def add_new_device(new_device: ZWaveMeData) -> None: + """Add a new device.""" + controller = hass.data[DOMAIN][config_entry.entry_id] + lock = ZWaveMeLock(controller, new_device) + + async_add_entities( + [ + lock, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeLock(ZWaveMeEntity, LockEntity): + """Representation of a ZWaveMe lock.""" + + @property + def is_locked(self) -> bool: + """Return the state of the lock.""" + return self.device.level == "close" + + def unlock(self, **kwargs: Any) -> None: + """Send command to unlock the lock.""" + self.controller.zwave_api.send_command(self.device.id, "open") + + def lock(self, **kwargs: Any) -> None: + """Send command to lock the lock.""" + self.controller.zwave_api.send_command(self.device.id, "close") diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json new file mode 100644 index 00000000000..1a7177ca470 --- /dev/null +++ b/homeassistant/components/zwave_me/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "zwave_me", + "name": "Z-Wave.Me", + "documentation": "https://www.home-assistant.io/integrations/zwave_me", + "iot_class": "local_push", + "requirements": [ + "zwave_me_ws==0.1.23", + "url-normalize==1.4.1" + ], + "after_dependencies": ["zeroconf"], + "zeroconf": [{"type":"_hap._tcp.local.", "name": "*z.wave-me*"}], + "config_flow": true, + "codeowners": [ + "@lawfulchaos", + "@Z-Wave-Me" + ] +} diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py new file mode 100644 index 00000000000..b955ade21db --- /dev/null +++ b/homeassistant/components/zwave_me/number.py @@ -0,0 +1,45 @@ +"""Representation of a switchMultilevel.""" +from homeassistant.components.number import NumberEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +DEVICE_NAME = ZWaveMePlatform.NUMBER + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the number platform.""" + + @callback + def add_new_device(new_device): + controller = hass.data[DOMAIN][config_entry.entry_id] + switch = ZWaveMeNumber(controller, new_device) + + async_add_entities( + [ + switch, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeNumber(ZWaveMeEntity, NumberEntity): + """Representation of a ZWaveMe Multilevel Switch.""" + + @property + def value(self): + """Return the unit of measurement.""" + return self.device.level + + def set_value(self, value: float) -> None: + """Update the current value.""" + self.controller.zwave_api.send_command( + self.device.id, f"exact?level={str(round(value))}" + ) diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py new file mode 100644 index 00000000000..84c4f406495 --- /dev/null +++ b/homeassistant/components/zwave_me/sensor.py @@ -0,0 +1,120 @@ +"""Representation of a sensorMultilevel.""" +from __future__ import annotations + +from zwave_me_ws import ZWaveMeData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + LIGHT_LUX, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ZWaveMeController, ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +SENSORS_MAP: dict[str, SensorEntityDescription] = { + "meterElectric_watt": SensorEntityDescription( + key="meterElectric_watt", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "meterElectric_kilowatt_hour": SensorEntityDescription( + key="meterElectric_kilowatt_hour", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "meterElectric_voltage": SensorEntityDescription( + key="meterElectric_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + "light": SensorEntityDescription( + key="light", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + "noise": SensorEntityDescription( + key="noise", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + state_class=SensorStateClass.MEASUREMENT, + ), + "currentTemperature": SensorEntityDescription( + key="currentTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + "temperature": SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + "generic": SensorEntityDescription( + key="generic", + ), +} +DEVICE_NAME = ZWaveMePlatform.SENSOR + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + + @callback + def add_new_device(new_device: ZWaveMeData) -> None: + controller: ZWaveMeController = hass.data[DOMAIN][config_entry.entry_id] + description = SENSORS_MAP.get(new_device.probeType, SENSORS_MAP["generic"]) + sensor = ZWaveMeSensor(controller, new_device, description) + + async_add_entities( + [ + sensor, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeSensor(ZWaveMeEntity, SensorEntity): + """Representation of a ZWaveMe sensor.""" + + def __init__( + self, + controller: ZWaveMeController, + device: ZWaveMeData, + description: SensorEntityDescription, + ) -> None: + """Initialize the device.""" + super().__init__(controller=controller, device=device) + self.entity_description = description + + @property + def native_value(self) -> str: + """Return the state of the sensor.""" + return self.device.level diff --git a/homeassistant/components/zwave_me/strings.json b/homeassistant/components/zwave_me/strings.json new file mode 100644 index 00000000000..4986de744c0 --- /dev/null +++ b/homeassistant/components/zwave_me/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "description": "Input IP address of Z-Way server and Z-Way access token. IP address can be prefixed with wss:// if HTTPS should be used instead of HTTP. To get the token go to the Z-Way user interface > Menu > Settings > User > API token. It is suggested to create a new user for Home Assistant and grant access to devices you need to control from Home Assistant. It is also possible to use remote access via find.z-wave.me to connect a remote Z-Way. Input wss://find.z-wave.me in IP field and copy the token with Global scope (log-in to Z-Way via find.z-wave.me for this).", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "token": "Token" + } + } + }, + "error": { + "no_valid_uuid_set": "No valid UUID set" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_valid_uuid_set": "No valid UUID set" + } + } +} diff --git a/homeassistant/components/zwave_me/switch.py b/homeassistant/components/zwave_me/switch.py new file mode 100644 index 00000000000..c759809df15 --- /dev/null +++ b/homeassistant/components/zwave_me/switch.py @@ -0,0 +1,67 @@ +"""Representation of a switchBinary.""" +import logging +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +_LOGGER = logging.getLogger(__name__) +DEVICE_NAME = ZWaveMePlatform.SWITCH + +SWITCH_MAP: dict[str, SwitchEntityDescription] = { + "generic": SwitchEntityDescription( + key="generic", + device_class=SwitchDeviceClass.SWITCH, + ) +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the switch platform.""" + + @callback + def add_new_device(new_device): + controller = hass.data[DOMAIN][config_entry.entry_id] + switch = ZWaveMeSwitch(controller, new_device, SWITCH_MAP["generic"]) + + async_add_entities( + [ + switch, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeSwitch(ZWaveMeEntity, SwitchEntity): + """Representation of a ZWaveMe binary switch.""" + + def __init__(self, controller, device, description): + """Initialize the device.""" + super().__init__(controller, device) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.device.level == "on" + + def turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.controller.zwave_api.send_command(self.device.id, "on") + + def turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self.controller.zwave_api.send_command(self.device.id, "off") diff --git a/homeassistant/components/zwave_me/translations/en.json b/homeassistant/components/zwave_me/translations/en.json new file mode 100644 index 00000000000..81d09d5c350 --- /dev/null +++ b/homeassistant/components/zwave_me/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "no_valid_uuid_set": "No valid UUID set" + }, + "error": { + "no_valid_uuid_set": "No valid UUID set" + }, + "step": { + "user": { + "data": { + "token": "Token", + "url": "URL" + }, + "description": "Input IP address of Z-Way server and Z-Way access token. IP address can be prefixed with wss:// if HTTPS should be used instead of HTTP. To get the token go to the Z-Way user interface > Menu > Settings > User > API token. It is suggested to create a new user for Home Assistant and grant access to devices you need to control from Home Assistant. It is also possible to use remote access via find.z-wave.me to connect a remote Z-Way. Input wss://find.z-wave.me in IP field and copy the token with Global scope (log-in to Z-Way via find.z-wave.me for this)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c521bd85e3e..061dcd3a0ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -378,5 +378,6 @@ FLOWS = [ "zerproc", "zha", "zwave", - "zwave_js" + "zwave_js", + "zwave_me" ] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index da48577a146..06d1940f23d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -144,6 +144,10 @@ ZEROCONF = { "_hap._tcp.local.": [ { "domain": "homekit_controller" + }, + { + "domain": "zwave_me", + "name": "*z.wave-me*" } ], "_homekit._tcp.local.": [ diff --git a/requirements_all.txt b/requirements_all.txt index 8dc8a0f5bfd..3a5da629839 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2408,6 +2408,7 @@ upcloud-api==2.0.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru +# homeassistant.components.zwave_me url-normalize==1.4.1 # homeassistant.components.uscis @@ -2562,3 +2563,6 @@ zm-py==0.5.2 # homeassistant.components.zwave_js zwave-js-server-python==0.34.0 + +# homeassistant.components.zwave_me +zwave_me_ws==0.1.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bb1d6564e5..c86988eb62a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1475,6 +1475,7 @@ upcloud-api==2.0.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru +# homeassistant.components.zwave_me url-normalize==1.4.1 # homeassistant.components.uvc @@ -1578,3 +1579,6 @@ zigpy==0.43.0 # homeassistant.components.zwave_js zwave-js-server-python==0.34.0 + +# homeassistant.components.zwave_me +zwave_me_ws==0.1.23 diff --git a/tests/components/zwave_me/__init__.py b/tests/components/zwave_me/__init__.py new file mode 100644 index 00000000000..da2457db55e --- /dev/null +++ b/tests/components/zwave_me/__init__.py @@ -0,0 +1 @@ +"""Tests for the zwave_me integration.""" diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py new file mode 100644 index 00000000000..9c7c9f51e24 --- /dev/null +++ b/tests/components/zwave_me/test_config_flow.py @@ -0,0 +1,184 @@ +"""Test the zwave_me config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.zwave_me.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, + FlowResult, +) + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + host="ws://192.168.1.14", + hostname="mock_hostname", + name="mock_name", + port=1234, + properties={ + "deviceid": "aa:bb:cc:dd:ee:ff", + "manufacturer": "fake_manufacturer", + "model": "fake_model", + "serialNumber": "fake_serial", + }, + type="mock_type", +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + with patch( + "homeassistant.components.zwave_me.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.zwave_me.helpers.get_uuid", + return_value="test_uuid", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "192.168.1.14", + "token": "test-token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "ws://192.168.1.14" + assert result2["data"] == { + "url": "ws://192.168.1.14", + "token": "test-token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf(hass: HomeAssistant): + """Test starting a flow from zeroconf.""" + with patch( + "homeassistant.components.zwave_me.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.zwave_me.helpers.get_uuid", + return_value="test_uuid", + ): + result: FlowResult = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "token": "test-token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "ws://192.168.1.14" + assert result2["data"] == { + "url": "ws://192.168.1.14", + "token": "test-token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_error_handling_zeroconf(hass: HomeAssistant): + """Test getting proper errors from no uuid.""" + with patch("homeassistant.components.zwave_me.helpers.get_uuid", return_value=None): + result: FlowResult = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_valid_uuid_set" + + +async def test_handle_error_user(hass: HomeAssistant): + """Test getting proper errors from no uuid.""" + with patch("homeassistant.components.zwave_me.helpers.get_uuid", return_value=None): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "192.168.1.15", + "token": "test-token", + }, + ) + assert result2["errors"] == {"base": "no_valid_uuid_set"} + + +async def test_duplicate_user(hass: HomeAssistant): + """Test getting proper errors from duplicate uuid.""" + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + title="ZWave_me", + data={ + "url": "ws://192.168.1.15", + "token": "test-token", + }, + unique_id="test_uuid", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.zwave_me.helpers.get_uuid", + return_value="test_uuid", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "192.168.1.15", + "token": "test-token", + }, + ) + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_duplicate_zeroconf(hass: HomeAssistant): + """Test getting proper errors from duplicate uuid.""" + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + title="ZWave_me", + data={ + "url": "ws://192.168.1.14", + "token": "test-token", + }, + unique_id="test_uuid", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.zwave_me.helpers.get_uuid", + return_value="test_uuid", + ): + + result: FlowResult = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"