Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 12 additions & 3 deletions homeassistant/components/hue/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

import voluptuous as vol

from homeassistant import config_entries
from homeassistant import config_entries, core
from homeassistant.const import CONF_FILENAME, CONF_HOST
from homeassistant.helpers import config_validation as cv, device_registry as dr

from .bridge import HueBridge
from .bridge import HueBridge, normalize_bridge_id
from .config_flow import ( # Loading the config flow file will register the flow
configured_hosts,
)
Expand Down Expand Up @@ -102,7 +102,9 @@ async def async_setup(hass, config):
return True


async def async_setup_entry(hass, entry):
async def async_setup_entry(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
"""Set up a bridge from a config entry."""
host = entry.data["host"]
config = hass.data[DATA_CONFIGS].get(host)
Expand All @@ -121,6 +123,13 @@ async def async_setup_entry(hass, entry):

hass.data[DOMAIN][host] = bridge
config = bridge.api.config

# For backwards compat
if entry.unique_id is None:
hass.config_entries.async_update_entry(
entry, unique_id=normalize_bridge_id(config.bridgeid)
)

device_registry = await dr.async_get_registry(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
Expand Down
22 changes: 22 additions & 0 deletions homeassistant/components/hue/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,25 @@ async def get_bridge(hass, host, username=None):
except aiohue.AiohueException:
LOGGER.exception("Unknown Hue linking error occurred")
raise AuthenticationRequired


def normalize_bridge_id(bridge_id: str):
"""Normalize a bridge identifier.

There are three sources where we receive bridge ID from:
- ssdp/upnp: <host>/description.xml, field root/device/serialNumber
- nupnp: "id" field
- Hue Bridge API: config.bridgeid

The SSDP/UPNP source does not contain the middle 4 characters compared
to the other sources. In all our tests the middle 4 characters are "fffe".
"""
if len(bridge_id) == 16:
return bridge_id[0:6] + bridge_id[-6:]

if len(bridge_id) == 12:
return bridge_id

LOGGER.warning("Unexpected bridge id number found: %s", bridge_id)

return bridge_id
31 changes: 12 additions & 19 deletions homeassistant/components/hue/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client

from .bridge import get_bridge
from .bridge import get_bridge, normalize_bridge_id
from .const import DOMAIN, LOGGER
from .errors import AuthenticationRequired, CannotConnect

Expand Down Expand Up @@ -154,17 +154,15 @@ async def async_step_ssdp(self, discovery_info):
if host in configured_hosts(self.hass):
return self.async_abort(reason="already_configured")

# This value is based off host/description.xml and is, weirdly, missing
# 4 characters in the middle of the serial compared to results returned
# from the NUPNP API or when querying the bridge API for bridgeid.
# (on first gen Hue hub)
serial = discovery_info.get("serial")
bridge_id = discovery_info.get("serial")

await self.async_set_unique_id(normalize_bridge_id(bridge_id))

return await self.async_step_import(
{
"host": host,
# This format is the legacy format that Hue used for discovery
"path": f"phue-{serial}.conf",
"path": f"phue-{bridge_id}.conf",
}
)

Expand All @@ -180,6 +178,10 @@ async def async_step_homekit(self, homekit_info):
if host in configured_hosts(self.hass):
return self.async_abort(reason="already_configured")

await self.async_set_unique_id(
normalize_bridge_id(homekit_info["properties"]["id"].replace(":", ""))
)

return await self.async_step_import({"host": host})

async def async_step_import(self, import_info):
Expand Down Expand Up @@ -234,18 +236,9 @@ async def _entry_from_bridge(self, bridge):
host = bridge.host
bridge_id = bridge.config.bridgeid

same_hub_entries = [
entry.entry_id
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.data["bridge_id"] == bridge_id or entry.data["host"] == host
]

if same_hub_entries:
await asyncio.wait(
[
self.hass.config_entries.async_remove(entry_id)
for entry_id in same_hub_entries
]
if self.unique_id is None:
await self.async_set_unique_id(
normalize_bridge_id(bridge_id), raise_on_progress=False
)

return self.async_create_entry(
Expand Down
61 changes: 60 additions & 1 deletion homeassistant/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import asyncio
import functools
import logging
from typing import Any, Callable, Dict, List, Optional, Set, cast
from typing import Any, Callable, Dict, List, Optional, Set, Union, cast
import uuid
import weakref

Expand Down Expand Up @@ -75,6 +75,10 @@ class OperationNotAllowed(ConfigError):
"""Raised when a config entry operation is not allowed."""


class UniqueIdInProgress(data_entry_flow.AbortFlow):
"""Error to indicate that the unique Id is in progress."""


class ConfigEntry:
"""Hold a configuration entry."""

Expand All @@ -85,6 +89,7 @@ class ConfigEntry:
"title",
"data",
"options",
"unique_id",
"system_options",
"source",
"connection_class",
Expand All @@ -104,6 +109,7 @@ def __init__(
connection_class: str,
system_options: dict,
options: Optional[dict] = None,
unique_id: Optional[str] = None,
entry_id: Optional[str] = None,
state: str = ENTRY_STATE_NOT_LOADED,
) -> None:
Expand Down Expand Up @@ -138,6 +144,9 @@ def __init__(
# State of the entry (LOADED, NOT_LOADED)
self.state = state

# Unique ID of this entry.
self.unique_id = unique_id

# Listeners to call on update
self.update_listeners: List = []

Expand Down Expand Up @@ -533,11 +542,15 @@ def async_update_entry(
self,
entry: ConfigEntry,
*,
unique_id: Union[str, dict, None] = _UNDEF,
data: dict = _UNDEF,
options: dict = _UNDEF,
system_options: dict = _UNDEF,
) -> None:
"""Update a config entry."""
if unique_id is not _UNDEF:
entry.unique_id = cast(Optional[str], unique_id)

if data is not _UNDEF:
entry.data = data

Expand Down Expand Up @@ -602,6 +615,25 @@ async def _async_finish_flow(
if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
return result

# Check if config entry exists with unique ID. Unload it.
existing_entry = None
unique_id = flow.context.get("unique_id")

if unique_id is not None:
for check_entry in self.async_entries(result["handler"]):
if check_entry.unique_id == unique_id:
existing_entry = check_entry
break

# Unload the entry before setting up the new one.
# We will remove it only after the other one is set up,
# so that device customizations are not getting lost.
if (
existing_entry is not None
and existing_entry.state not in UNRECOVERABLE_STATES
):
await self.async_unload(existing_entry.entry_id)

entry = ConfigEntry(
version=result["version"],
domain=result["handler"],
Expand All @@ -611,12 +643,16 @@ async def _async_finish_flow(
system_options={},
source=flow.context["source"],
connection_class=flow.CONNECTION_CLASS,
unique_id=unique_id,
)
self._entries.append(entry)
self._async_schedule_save()

await self.async_setup(entry.entry_id)

if existing_entry is not None:
await self.async_remove(existing_entry.entry_id)

result["result"] = entry
return result

Expand Down Expand Up @@ -687,6 +723,8 @@ async def _old_conf_migrator(old_config: Dict[str, Any]) -> Dict[str, Any]:
class ConfigFlow(data_entry_flow.FlowHandler):
"""Base class for config flows with some helpers."""

unique_id = None

def __init_subclass__(cls, domain: Optional[str] = None, **kwargs: Any) -> None:
"""Initialize a subclass, register if possible."""
super().__init_subclass__(**kwargs) # type: ignore
Expand All @@ -701,6 +739,27 @@ def async_get_options_flow(config_entry: ConfigEntry) -> "OptionsFlow":
"""Get the options flow for this handler."""
raise data_entry_flow.UnknownHandler

async def async_set_unique_id(
self, unique_id: str, *, raise_on_progress: bool = True
) -> Optional[ConfigEntry]:
"""Set a unique ID for the config flow.

Returns optionally existing config entry with same ID.
"""
if raise_on_progress:
for progress in self._async_in_progress():
if progress["context"].get("unique_id") == unique_id:
raise UniqueIdInProgress("already_in_progress")

# pylint: disable=no-member
self.context["unique_id"] = unique_id

for entry in self._async_current_entries():
if entry.unique_id == unique_id:
return entry

return None

@callback
def _async_current_entries(self) -> List[ConfigEntry]:
"""Return current entries."""
Expand Down
46 changes: 37 additions & 9 deletions homeassistant/data_entry_flow.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Classes to help gather user submissions."""
import logging
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, cast
import uuid

import voluptuous as vol
Expand Down Expand Up @@ -36,6 +36,16 @@ class UnknownStep(FlowError):
"""Unknown step specified."""


class AbortFlow(FlowError):
"""Exception to indicate a flow needs to be aborted."""

def __init__(self, reason: str, description_placeholders: Optional[Dict] = None):
"""Initialize an abort flow exception."""
super().__init__(f"Flow aborted: {reason}")
self.reason = reason
self.description_placeholders = description_placeholders


class FlowManager:
"""Manage all the flows that are in progress."""

Expand Down Expand Up @@ -131,7 +141,12 @@ async def _async_handle_step(
)
)

result: Dict = await getattr(flow, method)(user_input)
try:
result: Dict = await getattr(flow, method)(user_input)
except AbortFlow as err:
result = _create_abort_data(
flow.flow_id, flow.handler, err.reason, err.description_placeholders
)

if result["type"] not in (
RESULT_TYPE_FORM,
Expand Down Expand Up @@ -228,13 +243,9 @@ def async_abort(
self, *, reason: str, description_placeholders: Optional[Dict] = None
) -> Dict[str, Any]:
"""Abort the config flow."""
return {
"type": RESULT_TYPE_ABORT,
"flow_id": self.flow_id,
"handler": self.handler,
"reason": reason,
"description_placeholders": description_placeholders,
}
return _create_abort_data(
self.flow_id, cast(str, self.handler), reason, description_placeholders
)

@callback
def async_external_step(
Expand All @@ -259,3 +270,20 @@ def async_external_step_done(self, *, next_step_id: str) -> Dict[str, Any]:
"handler": self.handler,
"step_id": next_step_id,
}


@callback
def _create_abort_data(
flow_id: str,
handler: str,
reason: str,
description_placeholders: Optional[Dict] = None,
) -> Dict[str, Any]:
"""Return the definition of an external step for the user to take."""
return {
"type": RESULT_TYPE_ABORT,
"flow_id": flow_id,
"handler": handler,
"reason": reason,
"description_placeholders": description_placeholders,
}
2 changes: 2 additions & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ def __init__(
options={},
system_options={},
connection_class=config_entries.CONN_CLASS_UNKNOWN,
unique_id=None,
):
"""Initialize a mock config entry."""
kwargs = {
Expand All @@ -682,6 +683,7 @@ def __init__(
"version": version,
"title": title,
"connection_class": connection_class,
"unique_id": unique_id,
}
if source is not None:
kwargs["source"] = source
Expand Down
Loading