Home Assistant Unofficial Reference 2024.12.1
config.py
Go to the documentation of this file.
1 """Support for local control of entities by emulating a Philips Hue bridge."""
2 
3 from __future__ import annotations
4 
5 from functools import cache
6 import logging
7 
8 from homeassistant.components import (
9  climate,
10  cover,
11  fan,
12  humidifier,
13  light,
14  media_player,
15  scene,
16  script,
17 )
18 from homeassistant.const import CONF_ENTITIES, CONF_TYPE
19 from homeassistant.core import (
20  Event,
21  EventStateChangedData,
22  HomeAssistant,
23  State,
24  callback,
25  split_entity_id,
26 )
27 from homeassistant.helpers import storage
28 from homeassistant.helpers.event import (
29  async_track_state_added_domain,
30  async_track_state_removed_domain,
31 )
32 from homeassistant.helpers.typing import ConfigType
33 
34 SUPPORTED_DOMAINS = {
35  climate.DOMAIN,
36  cover.DOMAIN,
37  fan.DOMAIN,
38  humidifier.DOMAIN,
39  light.DOMAIN,
40  media_player.DOMAIN,
41  scene.DOMAIN,
42  script.DOMAIN,
43 }
44 
45 
46 TYPE_ALEXA = "alexa"
47 TYPE_GOOGLE = "google_home"
48 
49 
50 NUMBERS_FILE = "emulated_hue_ids.json"
51 DATA_KEY = "emulated_hue.ids"
52 DATA_VERSION = "1"
53 SAVE_DELAY = 60
54 
55 CONF_ADVERTISE_IP = "advertise_ip"
56 CONF_ADVERTISE_PORT = "advertise_port"
57 CONF_ENTITY_HIDDEN = "hidden"
58 CONF_ENTITY_NAME = "name"
59 CONF_EXPOSE_BY_DEFAULT = "expose_by_default"
60 CONF_EXPOSED_DOMAINS = "exposed_domains"
61 CONF_HOST_IP = "host_ip"
62 CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable"
63 CONF_LISTEN_PORT = "listen_port"
64 CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains"
65 CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast"
66 
67 
68 DEFAULT_LIGHTS_ALL_DIMMABLE = False
69 DEFAULT_LISTEN_PORT = 8300
70 DEFAULT_UPNP_BIND_MULTICAST = True
71 DEFAULT_OFF_MAPS_TO_ON_DOMAINS = {"script", "scene"}
72 DEFAULT_EXPOSE_BY_DEFAULT = True
73 DEFAULT_EXPOSED_DOMAINS = [
74  "switch",
75  "light",
76  "group",
77  "input_boolean",
78  "media_player",
79  "fan",
80 ]
81 DEFAULT_TYPE = TYPE_GOOGLE
82 
83 ATTR_EMULATED_HUE_NAME = "emulated_hue_name"
84 
85 
86 _LOGGER = logging.getLogger(__name__)
87 
88 
89 class Config:
90  """Hold configuration variables for the emulated hue bridge."""
91 
92  def __init__(self, hass: HomeAssistant, conf: ConfigType, local_ip: str) -> None:
93  """Initialize the instance."""
94  self.hasshass = hass
95  self.typetype = conf.get(CONF_TYPE)
96  self.numbersnumbers: dict[str, str] = {}
97  self.storestore: storage.Store | None = None
98  self.cached_states: dict[str, list] = {}
99  self._exposed_cache: dict[str, bool] = {}
100 
101  if self.typetype == TYPE_ALEXA:
102  _LOGGER.warning(
103  "Emulated Hue running in legacy mode because type has been "
104  "specified. More info at https://goo.gl/M6tgz8"
105  )
106 
107  # Get the IP address that will be passed to the Echo during discovery
108  self.host_ip_addr: str = conf.get(CONF_HOST_IP) or local_ip
109 
110  # Get the port that the Hue bridge will listen on
111  self.listen_port: int = conf.get(CONF_LISTEN_PORT) or DEFAULT_LISTEN_PORT
112 
113  # Get whether or not UPNP binds to multicast address (239.255.255.250)
114  # or to the unicast address (host_ip_addr)
115  self.upnp_bind_multicast: bool = conf.get(
116  CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST
117  )
118 
119  # Get domains that cause both "on" and "off" commands to map to "on"
120  # This is primarily useful for things like scenes or scripts, which
121  # don't really have a concept of being off
122  off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
123  if isinstance(off_maps_to_on_domains, list):
124  self.off_maps_to_on_domainsoff_maps_to_on_domains = set(off_maps_to_on_domains)
125  else:
126  self.off_maps_to_on_domainsoff_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
127 
128  # Get whether or not entities should be exposed by default, or if only
129  # explicitly marked ones will be exposed
130  self.expose_by_default: bool = conf.get(
131  CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT
132  )
133 
134  # Get domains that are exposed by default when expose_by_default is
135  # True
136  self.exposed_domainsexposed_domains = set(
137  conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
138  )
139 
140  # Calculated effective advertised IP and port for network isolation
141  self.advertise_ip: str = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
142 
143  self.advertise_port: int = conf.get(CONF_ADVERTISE_PORT) or self.listen_port
144 
145  self.entities: dict[str, dict[str, str]] = conf.get(CONF_ENTITIES, {})
146 
147  self._entities_with_hidden_attr_in_config_entities_with_hidden_attr_in_config = {}
148  for entity_id in self.entities:
149  hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN)
150  if hidden_value is not None:
151  self._entities_with_hidden_attr_in_config_entities_with_hidden_attr_in_config[entity_id] = hidden_value
152 
153  # Get whether all non-dimmable lights should be reported as dimmable
154  # for compatibility with older installations.
155  self.lights_all_dimmable: bool = conf.get(CONF_LIGHTS_ALL_DIMMABLE) or False
156 
157  if self.expose_by_default:
158  self.track_domainstrack_domains = set(self.exposed_domainsexposed_domains) or SUPPORTED_DOMAINS
159  else:
160  self.track_domainstrack_domains = {
161  split_entity_id(entity_id)[0] for entity_id in self.entities
162  }
163 
164  async def async_setup(self) -> None:
165  """Set up tracking and migrate to storage."""
166  hass = self.hasshass
167  self.storestore = storage.Store(hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type]
168  numbers_path = hass.config.path(NUMBERS_FILE)
169  self.numbersnumbers = (
170  await storage.async_migrator(hass, numbers_path, self.storestore) or {}
171  )
173  hass, self.track_domainstrack_domains, self._clear_exposed_cache_clear_exposed_cache
174  )
176  hass, self.track_domainstrack_domains, self._clear_exposed_cache_clear_exposed_cache
177  )
178 
179  @cache # pylint: disable=method-cache-max-size-none
180  def entity_id_to_number(self, entity_id: str) -> str:
181  """Get a unique number for the entity id."""
182  if self.typetype == TYPE_ALEXA:
183  return entity_id
184 
185  # Google Home
186  for number, ent_id in self.numbersnumbers.items():
187  if entity_id == ent_id:
188  return number
189 
190  number = "1"
191  if self.numbersnumbers:
192  number = str(max(int(k) for k in self.numbersnumbers) + 1)
193  self.numbersnumbers[number] = entity_id
194  assert self.storestore is not None
195  self.storestore.async_delay_save(lambda: self.numbersnumbers, SAVE_DELAY)
196  return number
197 
198  def number_to_entity_id(self, number: str) -> str | None:
199  """Convert unique number to entity id."""
200  if self.typetype == TYPE_ALEXA:
201  return number
202 
203  # Google Home
204  return self.numbersnumbers.get(number)
205 
206  def get_entity_name(self, state: State) -> str:
207  """Get the name of an entity."""
208  if (
209  state.entity_id in self.entities
210  and CONF_ENTITY_NAME in self.entities[state.entity_id]
211  ):
212  return self.entities[state.entity_id][CONF_ENTITY_NAME]
213 
214  return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) # type: ignore[no-any-return]
215 
216  @cache # pylint: disable=method-cache-max-size-none
217  def get_exposed_entity_ids(self) -> list[str]:
218  """Return a list of exposed states."""
219  state_machine = self.hasshass.states
220  if self.expose_by_default:
221  return [
222  state.entity_id
223  for state in state_machine.async_all()
224  if self.is_state_exposedis_state_exposed(state)
225  ]
226  return [
227  entity_id
228  for entity_id in self.entities
229  if (state := state_machine.get(entity_id)) and self.is_state_exposedis_state_exposed(state)
230  ]
231 
232  @callback
233  def _clear_exposed_cache(self, event: Event[EventStateChangedData]) -> None:
234  """Clear the cache of exposed entity ids."""
235  self.get_exposed_entity_idsget_exposed_entity_ids.cache_clear()
236 
237  def is_state_exposed(self, state: State) -> bool:
238  """Cache determine if an entity should be exposed on the emulated bridge."""
239  if (exposed := self._exposed_cache.get(state.entity_id)) is not None:
240  return exposed
241  exposed = self._is_state_exposed_is_state_exposed(state)
242  self._exposed_cache[state.entity_id] = exposed
243  return exposed
244 
245  def _is_state_exposed(self, state: State) -> bool:
246  """Determine if an entity state should be exposed on the emulated bridge.
247 
248  Async friendly.
249  """
250  if state.attributes.get("view") is not None:
251  # Ignore entities that are views
252  return False
253 
254  if state.entity_id in self._entities_with_hidden_attr_in_config_entities_with_hidden_attr_in_config:
255  return not self._entities_with_hidden_attr_in_config_entities_with_hidden_attr_in_config[state.entity_id]
256 
257  if not self.expose_by_default:
258  return False
259  # Expose an entity if the entity's domain is exposed by default and
260  # the configuration doesn't explicitly exclude it from being
261  # exposed, or if the entity is explicitly exposed
262  if state.domain in self.exposed_domainsexposed_domains:
263  return True
264 
265  return False
str|None number_to_entity_id(self, str number)
Definition: config.py:198
None __init__(self, HomeAssistant hass, ConfigType conf, str local_ip)
Definition: config.py:92
str entity_id_to_number(self, str entity_id)
Definition: config.py:180
None _clear_exposed_cache(self, Event[EventStateChangedData] event)
Definition: config.py:233
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
CALLBACK_TYPE async_track_state_added_domain(HomeAssistant hass, str|Iterable[str] domains, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:648
CALLBACK_TYPE async_track_state_removed_domain(HomeAssistant hass, str|Iterable[str] domains, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:706
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)
Definition: storage.py:444