Skip to content
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

Add SenseME integration #62909

Merged
merged 20 commits into from
Jan 8, 2022
1 change: 1 addition & 0 deletions .strict-typing
Expand Up @@ -118,6 +118,7 @@ homeassistant.components.samsungtv.*
homeassistant.components.scene.*
homeassistant.components.select.*
homeassistant.components.sensor.*
homeassistant.components.senseme.*
homeassistant.components.shelly.*
homeassistant.components.simplisafe.*
homeassistant.components.slack.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -796,6 +796,8 @@ homeassistant/components/select/* @home-assistant/core
tests/components/select/* @home-assistant/core
homeassistant/components/sense/* @kbickar
tests/components/sense/* @kbickar
homeassistant/components/senseme/* @mikelawrence @bdraco
tests/components/senseme/* @mikelawrence @bdraco
homeassistant/components/sensibo/* @andrey-git @gjohansson-ST
tests/components/sensibo/* @andrey-git @gjohansson-ST
homeassistant/components/sentry/* @dcramer @frenck
Expand Down
36 changes: 36 additions & 0 deletions homeassistant/components/senseme/__init__.py
@@ -0,0 +1,36 @@
"""The SenseME integration."""
from __future__ import annotations

from aiosenseme import async_get_device_by_device_info

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

from .const import CONF_INFO, DOMAIN, PLATFORMS, UPDATE_RATE
from .discovery import async_start_discovery


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SenseME from a config entry."""
async_start_discovery(hass)

status, device = await async_get_device_by_device_info(
info=entry.data[CONF_INFO], start_first=True, refresh_minutes=UPDATE_RATE
)
if not status:
device.stop()
raise ConfigEntryNotReady(f"Connect to address {device.address} failed")

await device.async_update(not status)

hass.data[DOMAIN][entry.entry_id] = device
hass.config_entries.async_setup_platforms(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN][entry.entry_id].stop()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
139 changes: 139 additions & 0 deletions homeassistant/components/senseme/config_flow.py
@@ -0,0 +1,139 @@
"""Config flow for SenseME."""
from __future__ import annotations

import ipaddress
from typing import Any

from aiosenseme import SensemeDevice, async_get_device_by_ip_address
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_ID
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.typing import DiscoveryInfoType

from .const import CONF_HOST_MANUAL, CONF_INFO, DOMAIN
from .discovery import async_discover, async_get_discovered_device

DISCOVER_TIMEOUT = 5


class SensemeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle SenseME discovery config flow."""

VERSION = 1

def __init__(self) -> None:
"""Initialize the SenseME config flow."""
self._discovered_devices: list[SensemeDevice] | None = None
self._discovered_device: SensemeDevice | None = None

async def async_step_discovery(
self, discovery_info: DiscoveryInfoType
) -> FlowResult:
"""Handle discovery."""
uuid = discovery_info[CONF_ID]
device = async_get_discovered_device(self.hass, discovery_info[CONF_ID])
host = device.address
await self.async_set_unique_id(uuid)
for entry in self._async_current_entries(include_ignore=False):
if entry.data[CONF_INFO]["address"] == host:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
return self.async_abort(reason="already_configured")
if entry.unique_id != uuid:
continue
if entry.data[CONF_INFO]["address"] != host:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
self.hass.config_entries.async_update_entry(
entry, data={CONF_INFO: {**entry.data[CONF_INFO], "address": host}}
)
return self.async_abort(reason="already_configured")
self._discovered_device = device
return await self.async_step_discovery_confirm()

async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
device = self._discovered_device
assert device is not None

if user_input is not None:
return await self._async_entry_for_device(device)
placeholders = {
"name": device.name,
"model": device.model,
"host": device.address,
}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="discovery_confirm", description_placeholders=placeholders
)

async def _async_entry_for_device(self, device: SensemeDevice) -> FlowResult:
"""Create a config entry for a device."""
await self.async_set_unique_id(device.uuid)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device.name,
data={CONF_INFO: device.get_device_info},
)

async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle manual entry of an ip address."""
errors = {}
if user_input is not None:
host = user_input[CONF_HOST]
try:
ipaddress.ip_address(host)
except ValueError:
errors[CONF_HOST] = "invalid_host"
else:
if device := await async_get_device_by_ip_address(host):
device.stop()
return await self._async_entry_for_device(device)

errors[CONF_HOST] = "cannot_connect"

return self.async_show_form(
step_id="manual",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
if self._discovered_devices is None:
self._discovered_devices = await async_discover(self.hass, DISCOVER_TIMEOUT)
current_ids = self._async_current_ids()
device_selection = [
device.name
for device in self._discovered_devices
if device.uuid not in current_ids
]

if not device_selection:
return await self.async_step_manual(user_input=None)

device_selection.append(CONF_HOST_MANUAL)

if user_input is not None:
if user_input[CONF_HOST] == CONF_HOST_MANUAL:
return await self.async_step_manual()

for device in self._discovered_devices:
if device == user_input[CONF_HOST]:
return await self._async_entry_for_device(device)

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_HOST, default=device_selection[0]): vol.In(
device_selection
)
}
),
)
23 changes: 23 additions & 0 deletions homeassistant/components/senseme/const.py
@@ -0,0 +1,23 @@
"""Constants for the SenseME integration."""


from homeassistant.const import Platform

DOMAIN = "senseme"

# Periodic fan update rate in minutes
UPDATE_RATE = 1

# data storage
CONF_INFO = "info"
CONF_HOST_MANUAL = "IP Address"
DISCOVERY = "discovery"

# Fan Preset Modes
PRESET_MODE_WHOOSH = "Whoosh"

# Fan Directions
SENSEME_DIRECTION_FORWARD = "FWD"
SENSEME_DIRECTION_REVERSE = "REV"

PLATFORMS = [Platform.FAN]
63 changes: 63 additions & 0 deletions homeassistant/components/senseme/discovery.py
@@ -0,0 +1,63 @@
"""The SenseME integration discovery."""
from __future__ import annotations

import asyncio

from aiosenseme import SensemeDevice, SensemeDiscovery

from homeassistant import config_entries
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback

from .const import DISCOVERY, DOMAIN


@callback
def async_start_discovery(hass: HomeAssistant) -> bool:
"""Start discovery if its not already running."""
domain_data = hass.data.setdefault(DOMAIN, {})
if DISCOVERY in domain_data:
return False # already running
discovery = domain_data[DISCOVERY] = SensemeDiscovery(False)
discovery.add_callback(lambda devices: async_trigger_discovery(hass, devices))
discovery.start()
return True # started


@callback
def async_get_discovered_device(hass: HomeAssistant, uuid: str) -> SensemeDevice:
"""Return a discovered device."""
discovery: SensemeDiscovery = hass.data[DOMAIN][DISCOVERY]
devices: list[SensemeDevice] = discovery.devices
for discovered_device in devices:
if discovered_device.uuid == uuid:
return discovered_device
raise RuntimeError("Discovered device unexpectedly disappeared")


async def async_discover(hass: HomeAssistant, timeout: float) -> list[SensemeDevice]:
"""Discover devices or restart it if its already running."""
started = async_start_discovery(hass)
discovery: SensemeDiscovery = hass.data[DOMAIN][DISCOVERY]
if not started: # already running
discovery.stop()
discovery.start()
await asyncio.sleep(timeout)
devices: list[SensemeDevice] = discovery.devices
return devices


@callback
def async_trigger_discovery(
hass: HomeAssistant,
discovered_devices: list[SensemeDevice],
) -> None:
"""Trigger config flows for discovered devices."""
for device in discovered_devices:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DISCOVERY},
data={CONF_ID: device.uuid},
)
)
54 changes: 54 additions & 0 deletions homeassistant/components/senseme/entity.py
@@ -0,0 +1,54 @@
"""The SenseME integration entities."""
from __future__ import annotations

from abc import abstractmethod
from typing import cast

from aiosenseme import SensemeDevice

from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo, Entity


class SensemeEntity(Entity):
"""Base class for senseme entities."""

_attr_should_poll = False

def __init__(self, device: SensemeDevice, name: str) -> None:
"""Initialize the entity."""
self._device = device
self._attr_name = name
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)},
name=self._device.name,
manufacturer="Big Ass Fans",
model=self._device.model,
sw_version=self._device.fw_version,
suggested_area=self._device.room_name,
)

@property
def extra_state_attributes(self) -> dict:
"""Get the current device state attributes."""
return {
"room_name": self._device.room_name,
"room_type": self._device.room_type,
bdraco marked this conversation as resolved.
Show resolved Hide resolved
}

@property
def available(self) -> bool:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"""Return True if available/operational."""
return cast(bool, self._device.available)

@abstractmethod
def _async_update_from_device(self) -> None:
"""Process an update from the device."""

async def async_added_to_hass(self) -> None:
"""Add data updated listener after this object has been initialized."""
self._device.add_callback(self._async_update_from_device)

async def async_will_remove_from_hass(self) -> None:
"""Remove data updated listener after this object has been initialized."""
self._device.remove_callback(self._async_update_from_device)