Add basic typing to emulated_hue (#72663)

* Add basic typing to emulated_hue

* type a few more places

* fixes

* numbers are always stringified

* numbers are always stringified

* coverage

* drop assert
This commit is contained in:
J. Nick Koston 2022-05-29 06:27:32 -10:00 committed by GitHub
parent d603952872
commit 237ef6419b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 324 additions and 249 deletions

View File

@ -1,4 +1,6 @@
"""Support for local control of entities by emulating a Philips Hue bridge.""" """Support for local control of entities by emulating a Philips Hue bridge."""
from __future__ import annotations
import logging import logging
from aiohttp import web from aiohttp import web
@ -12,10 +14,29 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .config import (
CONF_ADVERTISE_IP,
CONF_ADVERTISE_PORT,
CONF_ENTITY_HIDDEN,
CONF_ENTITY_NAME,
CONF_EXPOSE_BY_DEFAULT,
CONF_EXPOSED_DOMAINS,
CONF_HOST_IP,
CONF_LIGHTS_ALL_DIMMABLE,
CONF_LISTEN_PORT,
CONF_OFF_MAPS_TO_ON_DOMAINS,
CONF_UPNP_BIND_MULTICAST,
DEFAULT_LIGHTS_ALL_DIMMABLE,
DEFAULT_LISTEN_PORT,
DEFAULT_TYPE,
TYPE_ALEXA,
TYPE_GOOGLE,
Config,
)
from .const import DOMAIN
from .hue_api import ( from .hue_api import (
HueAllGroupsStateView, HueAllGroupsStateView,
HueAllLightsStateView, HueAllLightsStateView,
@ -27,46 +48,14 @@ from .hue_api import (
HueUnauthorizedUser, HueUnauthorizedUser,
HueUsernameView, HueUsernameView,
) )
from .upnp import DescriptionXmlView, create_upnp_datagram_endpoint from .upnp import (
DescriptionXmlView,
DOMAIN = "emulated_hue" UPNPResponderProtocol,
create_upnp_datagram_endpoint,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
NUMBERS_FILE = "emulated_hue_ids.json"
DATA_KEY = "emulated_hue.ids"
DATA_VERSION = "1"
SAVE_DELAY = 60
CONF_ADVERTISE_IP = "advertise_ip"
CONF_ADVERTISE_PORT = "advertise_port"
CONF_ENTITY_HIDDEN = "hidden"
CONF_ENTITY_NAME = "name"
CONF_EXPOSE_BY_DEFAULT = "expose_by_default"
CONF_EXPOSED_DOMAINS = "exposed_domains"
CONF_HOST_IP = "host_ip"
CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable"
CONF_LISTEN_PORT = "listen_port"
CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains"
CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast"
TYPE_ALEXA = "alexa"
TYPE_GOOGLE = "google_home"
DEFAULT_LIGHTS_ALL_DIMMABLE = False
DEFAULT_LISTEN_PORT = 8300
DEFAULT_UPNP_BIND_MULTICAST = True
DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ["script", "scene"]
DEFAULT_EXPOSE_BY_DEFAULT = True
DEFAULT_EXPOSED_DOMAINS = [
"switch",
"light",
"group",
"input_boolean",
"media_player",
"fan",
]
DEFAULT_TYPE = TYPE_GOOGLE
CONFIG_ENTITY_SCHEMA = vol.Schema( CONFIG_ENTITY_SCHEMA = vol.Schema(
{ {
@ -75,6 +64,7 @@ CONFIG_ENTITY_SCHEMA = vol.Schema(
} }
) )
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
@ -102,8 +92,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
ATTR_EMULATED_HUE_NAME = "emulated_hue_name"
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
"""Activate the emulated_hue component.""" """Activate the emulated_hue component."""
@ -140,7 +128,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
config.advertise_ip, config.advertise_ip,
config.advertise_port or config.listen_port, config.advertise_port or config.listen_port,
) )
protocol = None protocol: UPNPResponderProtocol | None = None
async def stop_emulated_hue_bridge(event): async def stop_emulated_hue_bridge(event):
"""Stop the emulated hue bridge.""" """Stop the emulated hue bridge."""
@ -161,7 +149,8 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
nonlocal site nonlocal site
nonlocal runner nonlocal runner
_, protocol = await listen transport_protocol = await listen
protocol = transport_protocol[1]
runner = web.AppRunner(app) runner = web.AppRunner(app)
await runner.setup() await runner.setup()
@ -184,163 +173,3 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
return True return True
class Config:
"""Hold configuration variables for the emulated hue bridge."""
def __init__(self, hass, conf, local_ip):
"""Initialize the instance."""
self.hass = hass
self.type = conf.get(CONF_TYPE)
self.numbers = None
self.store = None
self.cached_states = {}
self._exposed_cache = {}
if self.type == TYPE_ALEXA:
_LOGGER.warning(
"Emulated Hue running in legacy mode because type has been "
"specified. More info at https://goo.gl/M6tgz8"
)
# Get the IP address that will be passed to the Echo during discovery
self.host_ip_addr = conf.get(CONF_HOST_IP)
if self.host_ip_addr is None:
self.host_ip_addr = local_ip
# Get the port that the Hue bridge will listen on
self.listen_port = conf.get(CONF_LISTEN_PORT)
if not isinstance(self.listen_port, int):
self.listen_port = DEFAULT_LISTEN_PORT
_LOGGER.info(
"Listen port not specified, defaulting to %s", self.listen_port
)
# Get whether or not UPNP binds to multicast address (239.255.255.250)
# or to the unicast address (host_ip_addr)
self.upnp_bind_multicast = conf.get(
CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST
)
# Get domains that cause both "on" and "off" commands to map to "on"
# This is primarily useful for things like scenes or scripts, which
# don't really have a concept of being off
self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
if not isinstance(self.off_maps_to_on_domains, list):
self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
# Get whether or not entities should be exposed by default, or if only
# explicitly marked ones will be exposed
self.expose_by_default = conf.get(
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT
)
# Get domains that are exposed by default when expose_by_default is
# True
self.exposed_domains = set(
conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
)
# Calculated effective advertised IP and port for network isolation
self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port
self.entities = conf.get(CONF_ENTITIES, {})
self._entities_with_hidden_attr_in_config = {}
for entity_id in self.entities:
hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN)
if hidden_value is not None:
self._entities_with_hidden_attr_in_config[entity_id] = hidden_value
# Get whether all non-dimmable lights should be reported as dimmable
# for compatibility with older installations.
self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE)
async def async_setup(self):
"""Set up and migrate to storage."""
self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY)
self.numbers = (
await storage.async_migrator(
self.hass, self.hass.config.path(NUMBERS_FILE), self.store
)
or {}
)
def entity_id_to_number(self, entity_id):
"""Get a unique number for the entity id."""
if self.type == TYPE_ALEXA:
return entity_id
# Google Home
for number, ent_id in self.numbers.items():
if entity_id == ent_id:
return number
number = "1"
if self.numbers:
number = str(max(int(k) for k in self.numbers) + 1)
self.numbers[number] = entity_id
self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY)
return number
def number_to_entity_id(self, number):
"""Convert unique number to entity id."""
if self.type == TYPE_ALEXA:
return number
# Google Home
assert isinstance(number, str)
return self.numbers.get(number)
def get_entity_name(self, entity):
"""Get the name of an entity."""
if (
entity.entity_id in self.entities
and CONF_ENTITY_NAME in self.entities[entity.entity_id]
):
return self.entities[entity.entity_id][CONF_ENTITY_NAME]
return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
def is_entity_exposed(self, entity):
"""Cache determine if an entity should be exposed on the emulated bridge."""
entity_id = entity.entity_id
if entity_id not in self._exposed_cache:
self._exposed_cache[entity_id] = self._is_entity_exposed(entity)
return self._exposed_cache[entity_id]
def filter_exposed_entities(self, states):
"""Filter a list of all states down to exposed entities."""
exposed = []
for entity in states:
entity_id = entity.entity_id
if entity_id not in self._exposed_cache:
self._exposed_cache[entity_id] = self._is_entity_exposed(entity)
if self._exposed_cache[entity_id]:
exposed.append(entity)
return exposed
def _is_entity_exposed(self, entity):
"""Determine if an entity should be exposed on the emulated bridge.
Async friendly.
"""
if entity.attributes.get("view") is not None:
# Ignore entities that are views
return False
if entity.entity_id in self._entities_with_hidden_attr_in_config:
return not self._entities_with_hidden_attr_in_config[entity.entity_id]
if not self.expose_by_default:
return False
# Expose an entity if the entity's domain is exposed by default and
# the configuration doesn't explicitly exclude it from being
# exposed, or if the entity is explicitly exposed
if entity.domain in self.exposed_domains:
return True
return False

View File

@ -0,0 +1,213 @@
"""Support for local control of entities by emulating a Philips Hue bridge."""
from __future__ import annotations
from collections.abc import Iterable
import logging
from homeassistant.const import CONF_ENTITIES, CONF_TYPE
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import storage
from homeassistant.helpers.typing import ConfigType
TYPE_ALEXA = "alexa"
TYPE_GOOGLE = "google_home"
NUMBERS_FILE = "emulated_hue_ids.json"
DATA_KEY = "emulated_hue.ids"
DATA_VERSION = "1"
SAVE_DELAY = 60
CONF_ADVERTISE_IP = "advertise_ip"
CONF_ADVERTISE_PORT = "advertise_port"
CONF_ENTITY_HIDDEN = "hidden"
CONF_ENTITY_NAME = "name"
CONF_EXPOSE_BY_DEFAULT = "expose_by_default"
CONF_EXPOSED_DOMAINS = "exposed_domains"
CONF_HOST_IP = "host_ip"
CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable"
CONF_LISTEN_PORT = "listen_port"
CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains"
CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast"
DEFAULT_LIGHTS_ALL_DIMMABLE = False
DEFAULT_LISTEN_PORT = 8300
DEFAULT_UPNP_BIND_MULTICAST = True
DEFAULT_OFF_MAPS_TO_ON_DOMAINS = {"script", "scene"}
DEFAULT_EXPOSE_BY_DEFAULT = True
DEFAULT_EXPOSED_DOMAINS = [
"switch",
"light",
"group",
"input_boolean",
"media_player",
"fan",
]
DEFAULT_TYPE = TYPE_GOOGLE
ATTR_EMULATED_HUE_NAME = "emulated_hue_name"
_LOGGER = logging.getLogger(__name__)
class Config:
"""Hold configuration variables for the emulated hue bridge."""
def __init__(
self, hass: HomeAssistant, conf: ConfigType, local_ip: str | None
) -> None:
"""Initialize the instance."""
self.hass = hass
self.type = conf.get(CONF_TYPE)
self.numbers: dict[str, str] = {}
self.store: storage.Store | None = None
self.cached_states: dict[str, list] = {}
self._exposed_cache: dict[str, bool] = {}
if self.type == TYPE_ALEXA:
_LOGGER.warning(
"Emulated Hue running in legacy mode because type has been "
"specified. More info at https://goo.gl/M6tgz8"
)
# Get the IP address that will be passed to the Echo during discovery
self.host_ip_addr = conf.get(CONF_HOST_IP)
if self.host_ip_addr is None:
self.host_ip_addr = local_ip
# Get the port that the Hue bridge will listen on
self.listen_port = conf.get(CONF_LISTEN_PORT)
if not isinstance(self.listen_port, int):
self.listen_port = DEFAULT_LISTEN_PORT
_LOGGER.info(
"Listen port not specified, defaulting to %s", self.listen_port
)
# Get whether or not UPNP binds to multicast address (239.255.255.250)
# or to the unicast address (host_ip_addr)
self.upnp_bind_multicast = conf.get(
CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST
)
# Get domains that cause both "on" and "off" commands to map to "on"
# This is primarily useful for things like scenes or scripts, which
# don't really have a concept of being off
off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
if isinstance(off_maps_to_on_domains, list):
self.off_maps_to_on_domains = set(off_maps_to_on_domains)
else:
self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
# Get whether or not entities should be exposed by default, or if only
# explicitly marked ones will be exposed
self.expose_by_default = conf.get(
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT
)
# Get domains that are exposed by default when expose_by_default is
# True
self.exposed_domains = set(
conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
)
# Calculated effective advertised IP and port for network isolation
self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port
self.entities = conf.get(CONF_ENTITIES, {})
self._entities_with_hidden_attr_in_config = {}
for entity_id in self.entities:
hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN)
if hidden_value is not None:
self._entities_with_hidden_attr_in_config[entity_id] = hidden_value
# Get whether all non-dimmable lights should be reported as dimmable
# for compatibility with older installations.
self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE)
async def async_setup(self) -> None:
"""Set up and migrate to storage."""
self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type]
self.numbers = (
await storage.async_migrator(
self.hass, self.hass.config.path(NUMBERS_FILE), self.store
)
or {}
)
def entity_id_to_number(self, entity_id: str) -> str:
"""Get a unique number for the entity id."""
if self.type == TYPE_ALEXA:
return entity_id
# Google Home
for number, ent_id in self.numbers.items():
if entity_id == ent_id:
return number
number = "1"
if self.numbers:
number = str(max(int(k) for k in self.numbers) + 1)
self.numbers[number] = entity_id
assert self.store is not None
self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY)
return number
def number_to_entity_id(self, number: str) -> str | None:
"""Convert unique number to entity id."""
if self.type == TYPE_ALEXA:
return number
# Google Home
return self.numbers.get(number)
def get_entity_name(self, entity: State) -> str:
"""Get the name of an entity."""
if (
entity.entity_id in self.entities
and CONF_ENTITY_NAME in self.entities[entity.entity_id]
):
return self.entities[entity.entity_id][CONF_ENTITY_NAME]
return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
def is_entity_exposed(self, entity: State) -> bool:
"""Cache determine if an entity should be exposed on the emulated bridge."""
if (exposed := self._exposed_cache.get(entity.entity_id)) is not None:
return exposed
exposed = self._is_entity_exposed(entity)
self._exposed_cache[entity.entity_id] = exposed
return exposed
def filter_exposed_entities(self, states: Iterable[State]) -> list[State]:
"""Filter a list of all states down to exposed entities."""
exposed: list[State] = [
state for state in states if self.is_entity_exposed(state)
]
return exposed
def _is_entity_exposed(self, entity: State) -> bool:
"""Determine if an entity should be exposed on the emulated bridge.
Async friendly.
"""
if entity.attributes.get("view") is not None:
# Ignore entities that are views
return False
if entity.entity_id in self._entities_with_hidden_attr_in_config:
return not self._entities_with_hidden_attr_in_config[entity.entity_id]
if not self.expose_by_default:
return False
# Expose an entity if the entity's domain is exposed by default and
# the configuration doesn't explicitly exclude it from being
# exposed, or if the entity is explicitly exposed
if entity.domain in self.exposed_domains:
return True
return False

View File

@ -2,3 +2,5 @@
HUE_SERIAL_NUMBER = "001788FFFE23BFC2" HUE_SERIAL_NUMBER = "001788FFFE23BFC2"
HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc" HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc"
DOMAIN = "emulated_hue"

View File

@ -1,10 +1,15 @@
"""Support for a Hue API to control Home Assistant.""" """Support for a Hue API to control Home Assistant."""
from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
from http import HTTPStatus from http import HTTPStatus
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
import time import time
from typing import Any
from aiohttp import web
from homeassistant import core from homeassistant import core
from homeassistant.components import ( from homeassistant.components import (
@ -58,9 +63,12 @@ from homeassistant.const import (
STATE_ON, STATE_ON,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
) )
from homeassistant.core import State
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.network import is_local from homeassistant.util.network import is_local
from .config import Config
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# How long to wait for a state change to happen # How long to wait for a state change to happen
@ -111,7 +119,7 @@ class HueUnauthorizedUser(HomeAssistantView):
extra_urls = ["/api/"] extra_urls = ["/api/"]
requires_auth = False requires_auth = False
async def get(self, request): async def get(self, request: web.Request) -> web.Response:
"""Handle a GET request.""" """Handle a GET request."""
return self.json(UNAUTHORIZED_USER) return self.json(UNAUTHORIZED_USER)
@ -124,8 +132,9 @@ class HueUsernameView(HomeAssistantView):
extra_urls = ["/api/"] extra_urls = ["/api/"]
requires_auth = False requires_auth = False
async def post(self, request): async def post(self, request: web.Request) -> web.Response:
"""Handle a POST request.""" """Handle a POST request."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
@ -147,13 +156,14 @@ class HueAllGroupsStateView(HomeAssistantView):
name = "emulated_hue:all_groups:state" name = "emulated_hue:all_groups:state"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
@core.callback @core.callback
def get(self, request, username): def get(self, request: web.Request, username: str) -> web.Response:
"""Process a request to make the Brilliant Lightpad work.""" """Process a request to make the Brilliant Lightpad work."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
@ -167,13 +177,14 @@ class HueGroupView(HomeAssistantView):
name = "emulated_hue:groups:state" name = "emulated_hue:groups:state"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
@core.callback @core.callback
def put(self, request, username): def put(self, request: web.Request, username: str) -> web.Response:
"""Process a request to make the Logitech Pop working.""" """Process a request to make the Logitech Pop working."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
@ -197,13 +208,14 @@ class HueAllLightsStateView(HomeAssistantView):
name = "emulated_hue:lights:state" name = "emulated_hue:lights:state"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
@core.callback @core.callback
def get(self, request, username): def get(self, request: web.Request, username: str) -> web.Response:
"""Process a request to get the list of available lights.""" """Process a request to get the list of available lights."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
@ -217,13 +229,14 @@ class HueFullStateView(HomeAssistantView):
name = "emulated_hue:username:state" name = "emulated_hue:username:state"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
@core.callback @core.callback
def get(self, request, username): def get(self, request: web.Request, username: str) -> web.Response:
"""Process a request to get the list of available lights.""" """Process a request to get the list of available lights."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
if username != HUE_API_USERNAME: if username != HUE_API_USERNAME:
@ -245,13 +258,14 @@ class HueConfigView(HomeAssistantView):
name = "emulated_hue:username:config" name = "emulated_hue:username:config"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
@core.callback @core.callback
def get(self, request, username=""): def get(self, request: web.Request, username: str = "") -> web.Response:
"""Process a request to get the configuration.""" """Process a request to get the configuration."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED)
@ -267,17 +281,18 @@ class HueOneLightStateView(HomeAssistantView):
name = "emulated_hue:light:state" name = "emulated_hue:light:state"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
@core.callback @core.callback
def get(self, request, username, entity_id): def get(self, request: web.Request, username: str, entity_id: str) -> web.Response:
"""Process a request to get the state of an individual light.""" """Process a request to get the state of an individual light."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
hass = request.app["hass"] hass: core.HomeAssistant = request.app["hass"]
hass_entity_id = self.config.number_to_entity_id(entity_id) hass_entity_id = self.config.number_to_entity_id(entity_id)
if hass_entity_id is None: if hass_entity_id is None:
@ -307,17 +322,20 @@ class HueOneLightChangeView(HomeAssistantView):
name = "emulated_hue:light:state" name = "emulated_hue:light:state"
requires_auth = False requires_auth = False
def __init__(self, config): def __init__(self, config: Config) -> None:
"""Initialize the instance of the view.""" """Initialize the instance of the view."""
self.config = config self.config = config
async def put(self, request, username, entity_number): # noqa: C901 async def put( # noqa: C901
self, request: web.Request, username: str, entity_number: str
) -> web.Response:
"""Process a request to set the state of an individual light.""" """Process a request to set the state of an individual light."""
assert request.remote is not None
if not is_local(ip_address(request.remote)): if not is_local(ip_address(request.remote)):
return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED)
config = self.config config = self.config
hass = request.app["hass"] hass: core.HomeAssistant = request.app["hass"]
entity_id = config.number_to_entity_id(entity_number) entity_id = config.number_to_entity_id(entity_number)
if entity_id is None: if entity_id is None:
@ -344,7 +362,7 @@ class HueOneLightChangeView(HomeAssistantView):
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
# Parse the request # Parse the request
parsed = { parsed: dict[str, Any] = {
STATE_ON: False, STATE_ON: False,
STATE_BRIGHTNESS: None, STATE_BRIGHTNESS: None,
STATE_HUE: None, STATE_HUE: None,
@ -416,10 +434,10 @@ class HueOneLightChangeView(HomeAssistantView):
turn_on_needed = False turn_on_needed = False
# Convert the resulting "on" status into the service we need to call # Convert the resulting "on" status into the service we need to call
service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF service: str | None = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF
# Construct what we need to send to the service # Construct what we need to send to the service
data = {ATTR_ENTITY_ID: entity_id} data: dict[str, Any] = {ATTR_ENTITY_ID: entity_id}
# If the requested entity is a light, set the brightness, hue, # If the requested entity is a light, set the brightness, hue,
# saturation and color temp # saturation and color temp
@ -596,7 +614,7 @@ class HueOneLightChangeView(HomeAssistantView):
return self.json(json_response) return self.json(json_response)
def get_entity_state(config, entity): def get_entity_state(config: Config, entity: State) -> dict[str, Any]:
"""Retrieve and convert state and brightness values for an entity.""" """Retrieve and convert state and brightness values for an entity."""
cached_state_entry = config.cached_states.get(entity.entity_id, None) cached_state_entry = config.cached_states.get(entity.entity_id, None)
cached_state = None cached_state = None
@ -617,7 +635,7 @@ def get_entity_state(config, entity):
# Remove the now stale cached entry. # Remove the now stale cached entry.
config.cached_states.pop(entity.entity_id) config.cached_states.pop(entity.entity_id)
data = { data: dict[str, Any] = {
STATE_ON: False, STATE_ON: False,
STATE_BRIGHTNESS: None, STATE_BRIGHTNESS: None,
STATE_HUE: None, STATE_HUE: None,
@ -700,7 +718,7 @@ def get_entity_state(config, entity):
return data return data
def entity_to_json(config, entity): def entity_to_json(config: Config, entity: State) -> dict[str, Any]:
"""Convert an entity to its Hue bridge JSON representation.""" """Convert an entity to its Hue bridge JSON representation."""
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])
@ -709,7 +727,7 @@ def entity_to_json(config, entity):
state = get_entity_state(config, entity) state = get_entity_state(config, entity)
retval = { retval: dict[str, Any] = {
"state": { "state": {
HUE_API_STATE_ON: state[STATE_ON], HUE_API_STATE_ON: state[STATE_ON],
"reachable": entity.state != STATE_UNAVAILABLE, "reachable": entity.state != STATE_UNAVAILABLE,
@ -793,13 +811,15 @@ def entity_to_json(config, entity):
return retval return retval
def create_hue_success_response(entity_number, attr, value): def create_hue_success_response(
entity_number: str, attr: str, value: str
) -> dict[str, Any]:
"""Create a success response for an attribute set on a light.""" """Create a success response for an attribute set on a light."""
success_key = f"/lights/{entity_number}/state/{attr}" success_key = f"/lights/{entity_number}/state/{attr}"
return {"success": {success_key: value}} return {"success": {success_key: value}}
def create_config_model(config, request): def create_config_model(config: Config, request: web.Request) -> dict[str, Any]:
"""Create a config resource.""" """Create a config resource."""
return { return {
"mac": "00:00:00:00:00:00", "mac": "00:00:00:00:00:00",
@ -811,29 +831,29 @@ def create_config_model(config, request):
} }
def create_list_of_entities(config, request): def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]:
"""Create a list of all entities.""" """Create a list of all entities."""
hass = request.app["hass"] hass: core.HomeAssistant = request.app["hass"]
json_response = {} json_response: dict[str, Any] = {
config.entity_id_to_number(entity.entity_id): entity_to_json(config, entity)
for entity in config.filter_exposed_entities(hass.states.async_all()): for entity in config.filter_exposed_entities(hass.states.async_all())
number = config.entity_id_to_number(entity.entity_id) }
json_response[number] = entity_to_json(config, entity)
return json_response return json_response
def hue_brightness_to_hass(value): def hue_brightness_to_hass(value: int) -> int:
"""Convert hue brightness 1..254 to hass format 0..255.""" """Convert hue brightness 1..254 to hass format 0..255."""
return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255)) return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255))
def hass_to_hue_brightness(value): def hass_to_hue_brightness(value: int) -> int:
"""Convert hass brightness 0..255 to hue 1..254 scale.""" """Convert hass brightness 0..255 to hue 1..254 scale."""
return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX))
async def wait_for_state_change_or_timeout(hass, entity_id, timeout): async def wait_for_state_change_or_timeout(
hass: core.HomeAssistant, entity_id: str, timeout: float
) -> None:
"""Wait for an entity to change state.""" """Wait for an entity to change state."""
ev = asyncio.Event() ev = asyncio.Event()

View File

@ -1,13 +1,14 @@
"""Test the Emulated Hue component.""" """Test the Emulated Hue component."""
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from homeassistant.components.emulated_hue import ( from homeassistant.components.emulated_hue.config import (
DATA_KEY, DATA_KEY,
DATA_VERSION, DATA_VERSION,
SAVE_DELAY, SAVE_DELAY,
Config, Config,
) )
from homeassistant.const import EVENT_HOMEASSISTANT_START
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import utcnow from homeassistant.util import utcnow
@ -121,6 +122,13 @@ async def test_setup_works(hass):
"""Test setup works.""" """Test setup works."""
hass.config.components.add("network") hass.config.components.add("network")
with patch( with patch(
"homeassistant.components.emulated_hue.create_upnp_datagram_endpoint" "homeassistant.components.emulated_hue.create_upnp_datagram_endpoint",
), patch("homeassistant.components.emulated_hue.async_get_source_ip"): AsyncMock(),
) as mock_create_upnp_datagram_endpoint, patch(
"homeassistant.components.emulated_hue.async_get_source_ip"
):
assert await async_setup_component(hass, "emulated_hue", {}) assert await async_setup_component(hass, "emulated_hue", {})
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
assert len(mock_create_upnp_datagram_endpoint.mock_calls) == 2

View File

@ -2,6 +2,7 @@
from http import HTTPStatus from http import HTTPStatus
import json import json
import unittest import unittest
from unittest.mock import patch
from aiohttp import web from aiohttp import web
import defusedxml.ElementTree as ET import defusedxml.ElementTree as ET
@ -52,11 +53,13 @@ def hue_client(aiohttp_client):
async def setup_hue(hass): async def setup_hue(hass):
"""Set up the emulated_hue integration.""" """Set up the emulated_hue integration."""
assert await setup.async_setup_component( with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"):
hass, assert await setup.async_setup_component(
emulated_hue.DOMAIN, hass,
{emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, emulated_hue.DOMAIN,
) {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}},
)
await hass.async_block_till_done()
def test_upnp_discovery_basic(): def test_upnp_discovery_basic():