mirror of
https://github.com/home-assistant/core.git
synced 2025-09-23 20:09:35 +00:00
Compare commits
77 Commits
sensor_num
...
2023.2.0b8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0bae47c992 | ||
![]() |
0d3a368a1f | ||
![]() |
c7871d13cf | ||
![]() |
3d6ced2a16 | ||
![]() |
2f403b712c | ||
![]() |
1caca91174 | ||
![]() |
1859dcf99b | ||
![]() |
c34eb1ad9d | ||
![]() |
be69e9579c | ||
![]() |
ac6fa3275b | ||
![]() |
2f896c5df8 | ||
![]() |
c9e86ccd38 | ||
![]() |
8760227296 | ||
![]() |
edf02b70ea | ||
![]() |
32a7ae6129 | ||
![]() |
29056f1bd7 | ||
![]() |
01dea7773a | ||
![]() |
688bba15ac | ||
![]() |
f6230e2d71 | ||
![]() |
6a9f06d36e | ||
![]() |
dc50a6899a | ||
![]() |
d39d4d6b7f | ||
![]() |
6a1710063a | ||
![]() |
565a9735fc | ||
![]() |
ba966bd0f7 | ||
![]() |
c7b944ca75 | ||
![]() |
0702314dcb | ||
![]() |
81de0bba22 | ||
![]() |
0b015d46c3 | ||
![]() |
0713e034b9 | ||
![]() |
171acc22ca | ||
![]() |
2e26a40bba | ||
![]() |
3f717ae854 | ||
![]() |
a491bfe84c | ||
![]() |
423acfa93b | ||
![]() |
f14771ccf2 | ||
![]() |
07e9b0e98b | ||
![]() |
0d27ee4fd8 | ||
![]() |
71b13d8f3e | ||
![]() |
63c218060b | ||
![]() |
8a9de2671b | ||
![]() |
85d5ea2eca | ||
![]() |
55b5b36c47 | ||
![]() |
c9cf3c29f8 | ||
![]() |
6db9653a87 | ||
![]() |
9adaf27064 | ||
![]() |
69ed30f743 | ||
![]() |
d33373f6ee | ||
![]() |
bedf5fe6cd | ||
![]() |
29eb7e8f9e | ||
![]() |
60b96f19b7 | ||
![]() |
0a6ce35e30 | ||
![]() |
6397cc5d04 | ||
![]() |
b7311dc655 | ||
![]() |
e20c7491c1 | ||
![]() |
8cbefd5f97 | ||
![]() |
c7665b479a | ||
![]() |
4f2966674a | ||
![]() |
b464179eac | ||
![]() |
cd59705c4b | ||
![]() |
77bd23899f | ||
![]() |
d211603ba7 | ||
![]() |
1dc3bb6eb1 | ||
![]() |
22afc7c7fb | ||
![]() |
ba82f13821 | ||
![]() |
41add96bab | ||
![]() |
c8c3f4bef6 | ||
![]() |
8cb8ecdae9 | ||
![]() |
bd1371680f | ||
![]() |
8f684e962a | ||
![]() |
07a1259db9 | ||
![]() |
ea2bf34647 | ||
![]() |
a6fdf1d09a | ||
![]() |
9ca04dbfa1 | ||
![]() |
e1c8dff536 | ||
![]() |
a1416b9044 | ||
![]() |
123aafd772 |
@@ -128,7 +128,6 @@ SENSOR_DESCRIPTIONS = (
|
||||
key=TYPE_AQI_PM25_24H,
|
||||
name="AQI PM2.5 24h avg",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_AQI_PM25_IN,
|
||||
@@ -140,7 +139,6 @@ SENSOR_DESCRIPTIONS = (
|
||||
key=TYPE_AQI_PM25_IN_24H,
|
||||
name="AQI PM2.5 indoor 24h avg",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_BAROMABSIN,
|
||||
@@ -182,7 +180,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Event rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_FEELSLIKE,
|
||||
@@ -287,7 +285,6 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Last rain",
|
||||
icon="mdi:water",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_DAY,
|
||||
@@ -315,7 +312,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Monthly rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_PM25_24H,
|
||||
@@ -586,7 +583,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Lifetime rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_UV,
|
||||
@@ -599,7 +596,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Weekly rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR,
|
||||
|
@@ -6,6 +6,13 @@ import os
|
||||
from typing import Any
|
||||
|
||||
from adb_shell.auth.keygen import keygen
|
||||
from adb_shell.exceptions import (
|
||||
AdbTimeoutError,
|
||||
InvalidChecksumError,
|
||||
InvalidCommandError,
|
||||
InvalidResponseError,
|
||||
TcpTimeoutException,
|
||||
)
|
||||
from androidtv.adb_manager.adb_manager_sync import ADBPythonSync, PythonRSASigner
|
||||
from androidtv.setup_async import (
|
||||
AndroidTVAsync,
|
||||
@@ -43,6 +50,18 @@ from .const import (
|
||||
SIGNAL_CONFIG_ENTITY,
|
||||
)
|
||||
|
||||
ADB_PYTHON_EXCEPTIONS: tuple = (
|
||||
AdbTimeoutError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
ValueError,
|
||||
InvalidChecksumError,
|
||||
InvalidCommandError,
|
||||
InvalidResponseError,
|
||||
TcpTimeoutException,
|
||||
)
|
||||
ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
|
||||
|
||||
@@ -132,9 +151,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Android TV platform."""
|
||||
|
||||
state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES)
|
||||
aftv, error_message = await async_connect_androidtv(
|
||||
hass, entry.data, state_detection_rules=state_det_rules
|
||||
)
|
||||
if CONF_ADB_SERVER_IP not in entry.data:
|
||||
exceptions = ADB_PYTHON_EXCEPTIONS
|
||||
else:
|
||||
exceptions = ADB_TCP_EXCEPTIONS
|
||||
|
||||
try:
|
||||
aftv, error_message = await async_connect_androidtv(
|
||||
hass, entry.data, state_detection_rules=state_det_rules
|
||||
)
|
||||
except exceptions as exc:
|
||||
raise ConfigEntryNotReady(exc) from exc
|
||||
|
||||
if not aftv:
|
||||
raise ConfigEntryNotReady(error_message)
|
||||
|
||||
|
@@ -7,13 +7,6 @@ import functools
|
||||
import logging
|
||||
from typing import Any, Concatenate, ParamSpec, TypeVar
|
||||
|
||||
from adb_shell.exceptions import (
|
||||
AdbTimeoutError,
|
||||
InvalidChecksumError,
|
||||
InvalidCommandError,
|
||||
InvalidResponseError,
|
||||
TcpTimeoutException,
|
||||
)
|
||||
from androidtv.constants import APPS, KEYS
|
||||
from androidtv.exceptions import LockNotAcquiredException
|
||||
import voluptuous as vol
|
||||
@@ -42,7 +35,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import get_androidtv_mac
|
||||
from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac
|
||||
from .const import (
|
||||
ANDROID_DEV,
|
||||
ANDROID_DEV_OPT,
|
||||
@@ -252,19 +245,10 @@ class ADBDevice(MediaPlayerEntity):
|
||||
# ADB exceptions to catch
|
||||
if not aftv.adb_server_ip:
|
||||
# Using "adb_shell" (Python ADB implementation)
|
||||
self.exceptions = (
|
||||
AdbTimeoutError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
ValueError,
|
||||
InvalidChecksumError,
|
||||
InvalidCommandError,
|
||||
InvalidResponseError,
|
||||
TcpTimeoutException,
|
||||
)
|
||||
self.exceptions = ADB_PYTHON_EXCEPTIONS
|
||||
else:
|
||||
# Using "pure-python-adb" (communicate with ADB server)
|
||||
self.exceptions = (ConnectionResetError, RuntimeError)
|
||||
self.exceptions = ADB_TCP_EXCEPTIONS
|
||||
|
||||
# Property attributes
|
||||
self._attr_extra_state_attributes = {
|
||||
|
@@ -209,6 +209,20 @@ class BluetoothManager:
|
||||
self._bluetooth_adapters, self.storage
|
||||
)
|
||||
self.async_setup_unavailable_tracking()
|
||||
seen: set[str] = set()
|
||||
for address, service_info in itertools.chain(
|
||||
self._connectable_history.items(), self._all_history.items()
|
||||
):
|
||||
if address in seen:
|
||||
continue
|
||||
seen.add(address)
|
||||
for domain in self._integration_matcher.match_domains(service_info):
|
||||
discovery_flow.async_create_flow(
|
||||
self.hass,
|
||||
domain,
|
||||
{"source": config_entries.SOURCE_BLUETOOTH},
|
||||
service_info,
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def async_stop(self, event: Event) -> None:
|
||||
|
@@ -14,11 +14,14 @@
|
||||
}
|
||||
},
|
||||
"confirm_discovery": {
|
||||
"description": "Please press the Bosch Smart Home Controller's front-side button until LED starts flashing.\nReady to continue to set up {model} @ {host} with Home Assistant?"
|
||||
"description": "Smart Home Controller I: Please press the front-side button until LED starts flashing.\nSmart Home Controller II: Press the function button shortly. Cloud and network lights start blinking orange.\nDevice is now ready to be paired.\n\nReady to continue to set up {model} @ {host} with Home Assistant?"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The bosch_shc integration needs to re-authenticate your account"
|
||||
"description": "The bosch_shc integration needs to re-authenticate your account",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"flow_title": "Bosch SHC: {name}",
|
||||
"step": {
|
||||
"confirm_discovery": {
|
||||
"description": "Bitte dr\u00fccke die frontseitige Taste des Bosch Smart Home Controllers, bis die LED zu blinken beginnt.\nBist du bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?"
|
||||
"description": "Smart Home Controller I: Bitte dr\u00fccke die frontseitige Taste, bis die LED zu blinken beginnt.\nSmart Home Controller II: Dr\u00fccke kurz die Funktionstaste. Die Cloud- und Netzwerkleuchten beginnen orange zu blinken.\nDas Ger\u00e4t ist nun f\u00fcr die Kopplung bereit.\n\nBist du bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?"
|
||||
},
|
||||
"credentials": {
|
||||
"data": {
|
||||
@@ -23,7 +23,10 @@
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Die bosch_shc Integration muss dein Konto neu authentifizieren",
|
||||
"title": "Integration erneut authentifizieren"
|
||||
"title": "Integration erneut authentifizieren",
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"flow_title": "Bosch SHC: {name}",
|
||||
"step": {
|
||||
"confirm_discovery": {
|
||||
"description": "Please press the Bosch Smart Home Controller's front-side button until LED starts flashing.\nReady to continue to set up {model} @ {host} with Home Assistant?"
|
||||
"description": "Smart Home Controller I: Please press the front-side button until LED starts flashing.\nSmart Home Controller II: Press the function button shortly. Cloud and network lights start blinking orange.\nDevice is now ready to be paired.\n\nReady to continue to set up {model} @ {host} with Home Assistant?"
|
||||
},
|
||||
"credentials": {
|
||||
"data": {
|
||||
@@ -23,7 +23,10 @@
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The bosch_shc integration needs to re-authenticate your account",
|
||||
"title": "Reauthenticate Integration"
|
||||
"title": "Reauthenticate Integration",
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
|
@@ -11,7 +11,7 @@ import re
|
||||
from typing import IO, Any
|
||||
|
||||
from hassil.intents import Intents, ResponseType, SlotList, TextSlotList
|
||||
from hassil.recognize import recognize
|
||||
from hassil.recognize import RecognizeResult, recognize_all
|
||||
from hassil.util import merge_dict
|
||||
from home_assistant_intents import get_intents
|
||||
import yaml
|
||||
@@ -71,6 +71,8 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = {}
|
||||
self._areas_list: TextSlotList | None = None
|
||||
self._names_list: TextSlotList | None = None
|
||||
|
||||
async def async_initialize(self, config_intents):
|
||||
"""Initialize the default agent."""
|
||||
@@ -81,6 +83,22 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
if config_intents:
|
||||
self._config_intents = config_intents
|
||||
|
||||
self.hass.bus.async_listen(
|
||||
area_registry.EVENT_AREA_REGISTRY_UPDATED,
|
||||
self._async_handle_area_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
self._async_handle_entity_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
core.EVENT_STATE_CHANGED,
|
||||
self._async_handle_state_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
|
||||
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
||||
"""Process a sentence."""
|
||||
language = user_input.language or self.hass.config.language
|
||||
@@ -109,7 +127,12 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
"name": self._make_names_list(),
|
||||
}
|
||||
|
||||
result = recognize(user_input.text, lang_intents.intents, slot_lists=slot_lists)
|
||||
result = await self.hass.async_add_executor_job(
|
||||
self._recognize,
|
||||
user_input,
|
||||
lang_intents,
|
||||
slot_lists,
|
||||
)
|
||||
if result is None:
|
||||
_LOGGER.debug("No intent was matched for '%s'", user_input.text)
|
||||
return _make_error_result(
|
||||
@@ -160,21 +183,43 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
).get(response_key)
|
||||
if response_str:
|
||||
response_template = template.Template(response_str, self.hass)
|
||||
intent_response.async_set_speech(
|
||||
response_template.async_render(
|
||||
{
|
||||
"slots": {
|
||||
entity_name: entity_value.text or entity_value.value
|
||||
for entity_name, entity_value in result.entities.items()
|
||||
}
|
||||
speech = response_template.async_render(
|
||||
{
|
||||
"slots": {
|
||||
entity_name: entity_value.text or entity_value.value
|
||||
for entity_name, entity_value in result.entities.items()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Normalize whitespace
|
||||
speech = " ".join(speech.strip().split())
|
||||
intent_response.async_set_speech(speech)
|
||||
|
||||
return ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
def _recognize(
|
||||
self,
|
||||
user_input: ConversationInput,
|
||||
lang_intents: LanguageIntents,
|
||||
slot_lists: dict[str, SlotList],
|
||||
) -> RecognizeResult | None:
|
||||
"""Search intents for a match to user input."""
|
||||
# Prioritize matches with entity names above area names
|
||||
maybe_result: RecognizeResult | None = None
|
||||
for result in recognize_all(
|
||||
user_input.text, lang_intents.intents, slot_lists=slot_lists
|
||||
):
|
||||
if "name" in result.entities:
|
||||
return result
|
||||
|
||||
# Keep looking in case an entity has the same name
|
||||
maybe_result = result
|
||||
|
||||
return maybe_result
|
||||
|
||||
async def async_reload(self, language: str | None = None):
|
||||
"""Clear cached intents for a language."""
|
||||
if language is None:
|
||||
@@ -196,13 +241,15 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
|
||||
async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None:
|
||||
"""Load all intents of a language with lock."""
|
||||
hass_components = set(self.hass.config.components)
|
||||
async with self._lang_lock[language]:
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._get_or_load_intents,
|
||||
language,
|
||||
self._get_or_load_intents, language, hass_components
|
||||
)
|
||||
|
||||
def _get_or_load_intents(self, language: str) -> LanguageIntents | None:
|
||||
def _get_or_load_intents(
|
||||
self, language: str, hass_components: set[str]
|
||||
) -> LanguageIntents | None:
|
||||
"""Load all intents for language (run inside executor)."""
|
||||
lang_intents = self._lang_intents.get(language)
|
||||
|
||||
@@ -215,7 +262,7 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
|
||||
# Check if any new components have been loaded
|
||||
intents_changed = False
|
||||
for component in self.hass.config.components:
|
||||
for component in hass_components:
|
||||
if component in loaded_components:
|
||||
continue
|
||||
|
||||
@@ -310,8 +357,29 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
|
||||
return lang_intents
|
||||
|
||||
@core.callback
|
||||
def _async_handle_area_registry_changed(self, event: core.Event) -> None:
|
||||
"""Clear area area cache when the area registry has changed."""
|
||||
self._areas_list = None
|
||||
|
||||
@core.callback
|
||||
def _async_handle_entity_registry_changed(self, event: core.Event) -> None:
|
||||
"""Clear names list cache when an entity changes aliases."""
|
||||
if event.data["action"] == "update" and "aliases" not in event.data["changes"]:
|
||||
return
|
||||
self._names_list = None
|
||||
|
||||
@core.callback
|
||||
def _async_handle_state_changed(self, event: core.Event) -> None:
|
||||
"""Clear names list cache when a state is added or removed from the state machine."""
|
||||
if event.data.get("old_state") and event.data.get("new_state"):
|
||||
return
|
||||
self._names_list = None
|
||||
|
||||
def _make_areas_list(self) -> TextSlotList:
|
||||
"""Create slot list mapping area names/aliases to area ids."""
|
||||
if self._areas_list is not None:
|
||||
return self._areas_list
|
||||
registry = area_registry.async_get(self.hass)
|
||||
areas = []
|
||||
for entry in registry.async_list_areas():
|
||||
@@ -320,31 +388,34 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
for alias in entry.aliases:
|
||||
areas.append((alias, entry.id))
|
||||
|
||||
return TextSlotList.from_tuples(areas)
|
||||
self._areas_list = TextSlotList.from_tuples(areas, allow_template=False)
|
||||
return self._areas_list
|
||||
|
||||
def _make_names_list(self) -> TextSlotList:
|
||||
"""Create slot list mapping entity names/aliases to entity ids."""
|
||||
if self._names_list is not None:
|
||||
return self._names_list
|
||||
states = self.hass.states.async_all()
|
||||
registry = entity_registry.async_get(self.hass)
|
||||
entities = entity_registry.async_get(self.hass)
|
||||
names = []
|
||||
for state in states:
|
||||
domain = state.entity_id.split(".", maxsplit=1)[0]
|
||||
context = {"domain": domain}
|
||||
context = {"domain": state.domain}
|
||||
|
||||
entry = registry.async_get(state.entity_id)
|
||||
if entry is not None:
|
||||
if entry.entity_category:
|
||||
entity = entities.async_get(state.entity_id)
|
||||
if entity is not None:
|
||||
if entity.entity_category:
|
||||
# Skip configuration/diagnostic entities
|
||||
continue
|
||||
|
||||
if entry.aliases:
|
||||
for alias in entry.aliases:
|
||||
if entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
names.append((alias, state.entity_id, context))
|
||||
|
||||
# Default name
|
||||
names.append((state.name, state.entity_id, context))
|
||||
|
||||
return TextSlotList.from_tuples(names)
|
||||
self._names_list = TextSlotList.from_tuples(names, allow_template=False)
|
||||
return self._names_list
|
||||
|
||||
def _get_error_text(
|
||||
self, response_type: ResponseType, lang_intents: LanguageIntents
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "conversation",
|
||||
"name": "Conversation",
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"requirements": ["hassil==0.2.5", "home-assistant-intents==2023.1.25"],
|
||||
"requirements": ["hassil==0.2.6", "home-assistant-intents==2023.1.31"],
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"quality_scale": "internal",
|
||||
|
@@ -17,6 +17,10 @@ from .device_trigger import (
|
||||
CONF_BUTTON_2,
|
||||
CONF_BUTTON_3,
|
||||
CONF_BUTTON_4,
|
||||
CONF_BUTTON_5,
|
||||
CONF_BUTTON_6,
|
||||
CONF_BUTTON_7,
|
||||
CONF_BUTTON_8,
|
||||
CONF_CLOSE,
|
||||
CONF_DIM_DOWN,
|
||||
CONF_DIM_UP,
|
||||
@@ -95,6 +99,10 @@ INTERFACES = {
|
||||
CONF_BUTTON_2: "Button 2",
|
||||
CONF_BUTTON_3: "Button 3",
|
||||
CONF_BUTTON_4: "Button 4",
|
||||
CONF_BUTTON_5: "Button 5",
|
||||
CONF_BUTTON_6: "Button 6",
|
||||
CONF_BUTTON_7: "Button 7",
|
||||
CONF_BUTTON_8: "Button 8",
|
||||
CONF_SIDE_1: "Side 1",
|
||||
CONF_SIDE_2: "Side 2",
|
||||
CONF_SIDE_3: "Side 3",
|
||||
|
@@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_USE_LEGACY_PROTOCOL],
|
||||
)
|
||||
if not smartplug.authenticated and entry.data[CONF_USE_LEGACY_PROTOCOL]:
|
||||
if not smartplug.authenticated and smartplug.use_legacy_protocol:
|
||||
raise ConfigEntryNotReady("Cannot connect/authenticate")
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SmartPlugData(smartplug)
|
||||
|
@@ -131,6 +131,6 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception: %s", ex)
|
||||
return "unknown"
|
||||
if smartplug.authenticated:
|
||||
return None
|
||||
return "cannot_connect"
|
||||
if not smartplug.authenticated and smartplug.use_legacy_protocol:
|
||||
return "cannot_connect"
|
||||
return None
|
||||
|
@@ -19,9 +19,9 @@ class SmartPlugData:
|
||||
"""Initialize the data object."""
|
||||
self.smartplug = smartplug
|
||||
self.state: str | None = None
|
||||
self.temperature: str | None = None
|
||||
self.current_consumption = None
|
||||
self.total_consumption: str | None = None
|
||||
self.temperature: str = ""
|
||||
self.current_consumption: str = ""
|
||||
self.total_consumption: str = ""
|
||||
self.available = False
|
||||
self._n_tried = 0
|
||||
self._last_tried: datetime | None = None
|
||||
|
@@ -94,17 +94,22 @@ class SmartPlugSwitch(DLinkEntity, SwitchEntity):
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of the device."""
|
||||
attrs: dict[str, Any] = {}
|
||||
if self.data.temperature and self.data.temperature.isnumeric():
|
||||
attrs[ATTR_TEMPERATURE] = self.hass.config.units.temperature(
|
||||
try:
|
||||
temperature = self.hass.config.units.temperature(
|
||||
int(self.data.temperature), UnitOfTemperature.CELSIUS
|
||||
)
|
||||
else:
|
||||
attrs[ATTR_TEMPERATURE] = None
|
||||
if self.data.total_consumption and self.data.total_consumption.isnumeric():
|
||||
attrs[ATTR_TOTAL_CONSUMPTION] = float(self.data.total_consumption)
|
||||
else:
|
||||
attrs[ATTR_TOTAL_CONSUMPTION] = None
|
||||
except ValueError:
|
||||
temperature = None
|
||||
|
||||
try:
|
||||
total_consumption = float(self.data.total_consumption)
|
||||
except ValueError:
|
||||
total_consumption = None
|
||||
|
||||
attrs = {
|
||||
ATTR_TOTAL_CONSUMPTION: total_consumption,
|
||||
ATTR_TEMPERATURE: temperature,
|
||||
}
|
||||
|
||||
return attrs
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "DLNA Digital Media Renderer",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"requirements": ["async-upnp-client==0.33.0", "getmac==0.8.2"],
|
||||
"requirements": ["async-upnp-client==0.33.1", "getmac==0.8.2"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"ssdp": [
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "DLNA Digital Media Server",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"requirements": ["async-upnp-client==0.33.0"],
|
||||
"requirements": ["async-upnp-client==0.33.1"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["media_source"],
|
||||
"ssdp": [
|
||||
|
@@ -209,7 +209,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/consumption/gas/currently_delivered",
|
||||
name="Current gas usage",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
|
@@ -69,7 +69,10 @@ class DSMRSensor(SensorEntity):
|
||||
@callback
|
||||
def message_received(message):
|
||||
"""Handle new MQTT messages."""
|
||||
if self.entity_description.state is not None:
|
||||
if message.payload == "":
|
||||
self._attr_native_value = None
|
||||
elif self.entity_description.state is not None:
|
||||
# Perform optional additional parsing
|
||||
self._attr_native_value = self.entity_description.state(message.payload)
|
||||
else:
|
||||
self._attr_native_value = message.payload
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "environment_canada",
|
||||
"name": "Environment Canada",
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"requirements": ["env_canada==0.5.22"],
|
||||
"requirements": ["env_canada==0.5.27"],
|
||||
"codeowners": ["@gwww", "@michaeldavie"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
|
@@ -17,6 +17,7 @@ from aioesphomeapi import (
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
HomeassistantServiceCall,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
ReconnectLogic,
|
||||
RequiresEncryptionAPIError,
|
||||
@@ -347,7 +348,14 @@ async def async_setup_entry( # noqa: C901
|
||||
|
||||
async def on_connect_error(err: Exception) -> None:
|
||||
"""Start reauth flow if appropriate connect error type."""
|
||||
if isinstance(err, (RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError)):
|
||||
if isinstance(
|
||||
err,
|
||||
(
|
||||
RequiresEncryptionAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
InvalidAuthAPIError,
|
||||
),
|
||||
):
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
reconnect_logic = ReconnectLogic(
|
||||
|
@@ -92,10 +92,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._name = entry.title
|
||||
self._device_name = entry.data.get(CONF_DEVICE_NAME)
|
||||
|
||||
if await self._retrieve_encryption_key_from_dashboard():
|
||||
error = await self.fetch_device_info()
|
||||
if error is None:
|
||||
return await self._async_authenticate_or_add()
|
||||
# Device without encryption allows fetching device info. We can then check
|
||||
# if the device is no longer using a password. If we did try with a password,
|
||||
# we know setting password to empty will allow us to authenticate.
|
||||
error = await self.fetch_device_info()
|
||||
if (
|
||||
error is None
|
||||
and self._password
|
||||
and self._device_info
|
||||
and not self._device_info.uses_password
|
||||
):
|
||||
self._password = ""
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -105,6 +113,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle reauthorization flow."""
|
||||
errors = {}
|
||||
|
||||
if await self._retrieve_encryption_key_from_dashboard():
|
||||
error = await self.fetch_device_info()
|
||||
if error is None:
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
if user_input is not None:
|
||||
self._noise_psk = user_input[CONF_NOISE_PSK]
|
||||
error = await self.fetch_device_info()
|
||||
@@ -153,6 +166,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if self._device_info.uses_password:
|
||||
return await self.async_step_authenticate()
|
||||
|
||||
self._password = ""
|
||||
return self._async_get_entry()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
|
@@ -8,7 +8,7 @@ import logging
|
||||
import aiohttp
|
||||
from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -52,8 +52,14 @@ async def async_set_dashboard_info(
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if reloads:
|
||||
await asyncio.gather(*reloads)
|
||||
# Re-auth flows will check the dashboard for encryption key when the form is requested
|
||||
reauths = [
|
||||
hass.config_entries.flow.async_configure(flow["flow_id"])
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["handler"] == DOMAIN and flow["context"]["source"] == SOURCE_REAUTH
|
||||
]
|
||||
if reloads or reauths:
|
||||
await asyncio.gather(*reloads, *reauths)
|
||||
|
||||
|
||||
class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "ESPHome",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/esphome",
|
||||
"requirements": ["aioesphomeapi==13.1.0", "esphome-dashboard-api==1.2.1"],
|
||||
"requirements": ["aioesphomeapi==13.1.0", "esphome-dashboard-api==1.2.3"],
|
||||
"zeroconf": ["_esphomelib._tcp.local."],
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"codeowners": ["@OttoWinter", "@jesserockz"],
|
||||
|
@@ -72,15 +72,13 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
||||
_attr_title = "ESPHome"
|
||||
_attr_name = "Firmware"
|
||||
|
||||
_device_info: ESPHomeDeviceInfo
|
||||
|
||||
def __init__(
|
||||
self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard
|
||||
) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
assert entry_data.device_info is not None
|
||||
self._device_info = entry_data.device_info
|
||||
self._entry_data = entry_data
|
||||
self._attr_unique_id = entry_data.device_info.mac_address
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={
|
||||
@@ -88,6 +86,12 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def _device_info(self) -> ESPHomeDeviceInfo:
|
||||
"""Return the device info."""
|
||||
assert self._entry_data.device_info is not None
|
||||
return self._entry_data.device_info
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if update is available."""
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20230125.0"],
|
||||
"requirements": ["home-assistant-frontend==20230130.0"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
@@ -98,6 +98,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
for service_name in hass.services.async_services()[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
if entry.options.get(CONF_ENABLE_CONVERSATION_AGENT, False):
|
||||
conversation.async_unset_agent(hass, entry)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
@@ -13,14 +13,22 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DATA_AUTH, DOMAIN
|
||||
from .const import DATA_AUTH, DATA_HASS_CONFIG, DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Google Mail platform."""
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_HASS_CONFIG] = config
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Google Mail from a config entry."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
@@ -36,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady from err
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth
|
||||
hass.data[DOMAIN][entry.entry_id] = auth
|
||||
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
@@ -44,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
Platform.NOTIFY,
|
||||
DOMAIN,
|
||||
{DATA_AUTH: auth, CONF_NAME: entry.title},
|
||||
{},
|
||||
hass.data[DOMAIN][DATA_HASS_CONFIG],
|
||||
)
|
||||
)
|
||||
|
||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
@@ -57,23 +57,29 @@ class OAuth2FlowHandler(
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
if self.reauth_entry:
|
||||
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
|
||||
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||
|
||||
def _get_profile() -> dict[str, Any]:
|
||||
def _get_profile() -> str:
|
||||
"""Get profile from inside the executor."""
|
||||
users = build( # pylint: disable=no-member
|
||||
"gmail", "v1", credentials=credentials
|
||||
).users()
|
||||
return users.getProfile(userId="me").execute()
|
||||
return users.getProfile(userId="me").execute()["emailAddress"]
|
||||
|
||||
email = (await self.hass.async_add_executor_job(_get_profile))["emailAddress"]
|
||||
credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||
email = await self.hass.async_add_executor_job(_get_profile)
|
||||
|
||||
await self.async_set_unique_id(email)
|
||||
self._abort_if_unique_id_configured()
|
||||
if not self.reauth_entry:
|
||||
await self.async_set_unique_id(email)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=email, data=data)
|
||||
return self.async_create_entry(title=email, data=data)
|
||||
|
||||
if self.reauth_entry.unique_id == email:
|
||||
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
|
||||
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_abort(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"email": cast(str, self.reauth_entry.unique_id)},
|
||||
)
|
||||
|
@@ -16,6 +16,7 @@ ATTR_START = "start"
|
||||
ATTR_TITLE = "title"
|
||||
|
||||
DATA_AUTH = "auth"
|
||||
DATA_HASS_CONFIG = "hass_config"
|
||||
DEFAULT_ACCESS = [
|
||||
"https://www.googleapis.com/auth/gmail.compose",
|
||||
"https://www.googleapis.com/auth/gmail.settings.basic",
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Entity representing a Google Mail account."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
|
||||
from .api import AsyncConfigEntryAuth
|
||||
@@ -24,6 +25,7 @@ class GoogleMailEntity(Entity):
|
||||
f"{auth.oauth_session.config_entry.entry_id}_{description.key}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, auth.oauth_session.config_entry.entry_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=auth.oauth_session.config_entry.unique_id,
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"requirements": ["google-api-python-client==2.71.0"],
|
||||
"codeowners": ["@tkdrob"],
|
||||
"iot_class": "cloud_polling",
|
||||
"integration_type": "device"
|
||||
"integration_type": "service"
|
||||
}
|
||||
|
@@ -3,10 +3,9 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from googleapiclient.http import HttpRequest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
@@ -27,9 +26,9 @@ async def async_get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> GMailNotificationService:
|
||||
) -> GMailNotificationService | None:
|
||||
"""Get the notification service."""
|
||||
return GMailNotificationService(cast(DiscoveryInfoType, discovery_info))
|
||||
return GMailNotificationService(discovery_info) if discovery_info else None
|
||||
|
||||
|
||||
class GMailNotificationService(BaseNotificationService):
|
||||
@@ -61,6 +60,6 @@ class GMailNotificationService(BaseNotificationService):
|
||||
msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body})
|
||||
else:
|
||||
if not to_addrs:
|
||||
raise vol.Invalid("recipient address required")
|
||||
raise ValueError("recipient address required")
|
||||
msg = users.messages().send(userId=email["From"], body=body)
|
||||
await self.hass.async_add_executor_job(msg.execute)
|
||||
|
@@ -21,7 +21,8 @@
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with {email}."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
|
@@ -12,7 +12,8 @@
|
||||
"oauth_error": "Received invalid token data.",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"timeout_connect": "Timeout establishing connection",
|
||||
"unknown": "Unexpected error"
|
||||
"unknown": "Unexpected error",
|
||||
"wrong_account": "Wrong account: Please authenticate with {email}."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "Successfully authenticated"
|
||||
|
@@ -169,10 +169,17 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
if self.hvac_mode in [HVACMode.COOL, HVACMode.HEAT_COOL]:
|
||||
if self.hvac_mode == HVACMode.COOL:
|
||||
return self._device.raw_ui_data["CoolLowerSetptLimit"]
|
||||
if self.hvac_mode == HVACMode.HEAT:
|
||||
return self._device.raw_ui_data["HeatLowerSetptLimit"]
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return min(
|
||||
[
|
||||
self._device.raw_ui_data["CoolLowerSetptLimit"],
|
||||
self._device.raw_ui_data["HeatLowerSetptLimit"],
|
||||
]
|
||||
)
|
||||
return DEFAULT_MIN_TEMP
|
||||
|
||||
@property
|
||||
@@ -180,8 +187,15 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
"""Return the maximum temperature."""
|
||||
if self.hvac_mode == HVACMode.COOL:
|
||||
return self._device.raw_ui_data["CoolUpperSetptLimit"]
|
||||
if self.hvac_mode in [HVACMode.HEAT, HVACMode.HEAT_COOL]:
|
||||
if self.hvac_mode == HVACMode.HEAT:
|
||||
return self._device.raw_ui_data["HeatUpperSetptLimit"]
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return max(
|
||||
[
|
||||
self._device.raw_ui_data["CoolUpperSetptLimit"],
|
||||
self._device.raw_ui_data["HeatUpperSetptLimit"],
|
||||
]
|
||||
)
|
||||
return DEFAULT_MAX_TEMP
|
||||
|
||||
@property
|
||||
@@ -257,41 +271,45 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
# Get current mode
|
||||
mode = self._device.system_mode
|
||||
# Set hold if this is not the case
|
||||
if getattr(self._device, f"hold_{mode}", None) is False:
|
||||
# Get next period key
|
||||
next_period_key = f"{mode.capitalize()}NextPeriod"
|
||||
# Get next period raw value
|
||||
next_period = self._device.raw_ui_data.get(next_period_key)
|
||||
if self._device.hold_heat is False and self._device.hold_cool is False:
|
||||
# Get next period time
|
||||
hour, minute = divmod(next_period * 15, 60)
|
||||
hour_heat, minute_heat = divmod(
|
||||
self._device.raw_ui_data["HeatNextPeriod"] * 15, 60
|
||||
)
|
||||
hour_cool, minute_cool = divmod(
|
||||
self._device.raw_ui_data["CoolNextPeriod"] * 15, 60
|
||||
)
|
||||
# Set hold time
|
||||
if mode in COOLING_MODES:
|
||||
await self._device.set_hold_cool(datetime.time(hour, minute))
|
||||
elif mode in HEATING_MODES:
|
||||
await self._device.set_hold_heat(datetime.time(hour, minute))
|
||||
await self._device.set_hold_cool(
|
||||
datetime.time(hour_cool, minute_cool)
|
||||
)
|
||||
if mode in HEATING_MODES:
|
||||
await self._device.set_hold_heat(
|
||||
datetime.time(hour_heat, minute_heat)
|
||||
)
|
||||
|
||||
# Set temperature
|
||||
if mode in COOLING_MODES:
|
||||
# Set temperature if not in auto
|
||||
if mode == "cool":
|
||||
await self._device.set_setpoint_cool(temperature)
|
||||
elif mode in HEATING_MODES:
|
||||
if mode == "heat":
|
||||
await self._device.set_setpoint_heat(temperature)
|
||||
|
||||
except AIOSomecomfort.SomeComfortError:
|
||||
_LOGGER.error("Temperature %.1f out of range", temperature)
|
||||
except AIOSomecomfort.SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %.1f: %s", temperature, err)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if {HVACMode.COOL, HVACMode.HEAT} & set(self._hvac_mode_map):
|
||||
await self._set_temperature(**kwargs)
|
||||
|
||||
try:
|
||||
if HVACMode.HEAT_COOL in self._hvac_mode_map:
|
||||
try:
|
||||
if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH):
|
||||
await self._device.set_setpoint_cool(temperature)
|
||||
if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW):
|
||||
await self._device.set_setpoint_heat(temperature)
|
||||
except AIOSomecomfort.SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %s: %s", temperature, err)
|
||||
|
||||
except AIOSomecomfort.SomeComfortError as err:
|
||||
_LOGGER.error("Invalid temperature %.1f: %s", temperature, err)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
@@ -322,7 +340,7 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
if mode in COOLING_MODES:
|
||||
await self._device.set_hold_cool(True)
|
||||
await self._device.set_setpoint_cool(self._cool_away_temp)
|
||||
elif mode in HEATING_MODES:
|
||||
if mode in HEATING_MODES:
|
||||
await self._device.set_hold_heat(True)
|
||||
await self._device.set_setpoint_heat(self._heat_away_temp)
|
||||
|
||||
@@ -349,7 +367,7 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
# Set permanent hold
|
||||
if mode in COOLING_MODES:
|
||||
await self._device.set_hold_cool(True)
|
||||
elif mode in HEATING_MODES:
|
||||
if mode in HEATING_MODES:
|
||||
await self._device.set_hold_heat(True)
|
||||
|
||||
except AIOSomecomfort.SomeComfortError:
|
||||
|
@@ -20,7 +20,11 @@ import voluptuous as vol
|
||||
from homeassistant import exceptions
|
||||
from homeassistant.const import CONTENT_TYPE_JSON
|
||||
from homeassistant.core import Context, is_callback
|
||||
from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS, json_bytes
|
||||
from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS, json_bytes, json_dumps
|
||||
from homeassistant.util.json import (
|
||||
find_paths_unserializable_data,
|
||||
format_unserializable_data,
|
||||
)
|
||||
|
||||
from .const import KEY_AUTHENTICATED, KEY_HASS
|
||||
|
||||
@@ -54,7 +58,12 @@ class HomeAssistantView:
|
||||
try:
|
||||
msg = json_bytes(result)
|
||||
except JSON_ENCODE_EXCEPTIONS as err:
|
||||
_LOGGER.error("Unable to serialize to JSON: %s\n%s", err, result)
|
||||
_LOGGER.error(
|
||||
"Unable to serialize to JSON. Bad data found at %s",
|
||||
format_unserializable_data(
|
||||
find_paths_unserializable_data(result, dump=json_dumps)
|
||||
),
|
||||
)
|
||||
raise HTTPInternalServerError from err
|
||||
response = web.Response(
|
||||
body=msg,
|
||||
|
@@ -313,7 +313,11 @@ def _generate_device_info(node: Node) -> DeviceInfo:
|
||||
model += f" ({node.type})"
|
||||
|
||||
# Get extra information for Z-Wave Devices
|
||||
if node.protocol == PROTO_ZWAVE and node.zwave_props.mfr_id != "0":
|
||||
if (
|
||||
node.protocol == PROTO_ZWAVE
|
||||
and node.zwave_props
|
||||
and node.zwave_props.mfr_id != "0"
|
||||
):
|
||||
device_info[
|
||||
ATTR_MANUFACTURER
|
||||
] = f"Z-Wave MfrID:{int(node.zwave_props.mfr_id):#0{6}x}"
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "Universal Devices ISY/IoX",
|
||||
"integration_type": "hub",
|
||||
"documentation": "https://www.home-assistant.io/integrations/isy994",
|
||||
"requirements": ["pyisy==3.1.9"],
|
||||
"requirements": ["pyisy==3.1.11"],
|
||||
"codeowners": ["@bdraco", "@shbatm"],
|
||||
"config_flow": true,
|
||||
"ssdp": [
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "Keenetic NDMS2 Router",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2",
|
||||
"requirements": ["ndms2_client==0.1.1"],
|
||||
"requirements": ["ndms2_client==0.1.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1",
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "Matter (BETA)",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"requirements": ["python-matter-server==2.0.1"],
|
||||
"requirements": ["python-matter-server==2.0.2"],
|
||||
"dependencies": ["websocket_api"],
|
||||
"codeowners": ["@home-assistant/matter"],
|
||||
"iot_class": "local_push"
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Sensor platform for mobile_app."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass
|
||||
@@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
@@ -99,7 +101,7 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor):
|
||||
self._config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
def native_value(self) -> StateType | date | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
if (state := self._config[ATTR_SENSOR_STATE]) in (None, STATE_UNKNOWN):
|
||||
return None
|
||||
@@ -122,7 +124,7 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor):
|
||||
return state
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement this sensor expresses itself in."""
|
||||
return self._config.get(ATTR_SENSOR_UOM)
|
||||
|
||||
|
@@ -22,9 +22,7 @@ from homeassistant.components import (
|
||||
notify as hass_notify,
|
||||
tag,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES as BINARY_SENSOR_CLASSES,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.camera import CameraEntityFeature
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_BATTERY,
|
||||
@@ -33,10 +31,7 @@ from homeassistant.components.device_tracker import (
|
||||
ATTR_LOCATION_NAME,
|
||||
)
|
||||
from homeassistant.components.frontend import MANIFEST_JSON
|
||||
from homeassistant.components.sensor import (
|
||||
DEVICE_CLASSES as SENSOR_CLASSES,
|
||||
STATE_CLASSES as SENSOSR_STATE_CLASSES,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -58,7 +53,7 @@ from homeassistant.helpers import (
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from .const import (
|
||||
@@ -131,8 +126,7 @@ WEBHOOK_COMMANDS: Registry[
|
||||
str, Callable[[HomeAssistant, ConfigEntry, Any], Coroutine[Any, Any, Response]]
|
||||
] = Registry()
|
||||
|
||||
COMBINED_CLASSES = set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES)
|
||||
SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR]
|
||||
SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR)
|
||||
|
||||
WEBHOOK_PAYLOAD_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -507,19 +501,27 @@ def _extract_sensor_unique_id(webhook_id: str, unique_id: str) -> str:
|
||||
vol.All(
|
||||
{
|
||||
vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict,
|
||||
vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.All(
|
||||
vol.Lower, vol.In(COMBINED_CLASSES)
|
||||
vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.Any(
|
||||
None,
|
||||
vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)),
|
||||
vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)),
|
||||
),
|
||||
vol.Required(ATTR_SENSOR_NAME): cv.string,
|
||||
vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES),
|
||||
vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string,
|
||||
vol.Optional(ATTR_SENSOR_UOM): cv.string,
|
||||
vol.Optional(ATTR_SENSOR_UOM): vol.Any(None, cv.string),
|
||||
vol.Optional(ATTR_SENSOR_STATE, default=None): vol.Any(
|
||||
None, bool, str, int, float
|
||||
None, bool, int, float, str
|
||||
),
|
||||
vol.Optional(ATTR_SENSOR_ENTITY_CATEGORY): vol.Any(
|
||||
None, vol.Coerce(EntityCategory)
|
||||
),
|
||||
vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): vol.Any(
|
||||
None, cv.icon
|
||||
),
|
||||
vol.Optional(ATTR_SENSOR_STATE_CLASS): vol.Any(
|
||||
None, vol.Coerce(SensorStateClass)
|
||||
),
|
||||
vol.Optional(ATTR_SENSOR_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): cv.icon,
|
||||
vol.Optional(ATTR_SENSOR_STATE_CLASS): vol.In(SENSOSR_STATE_CLASSES),
|
||||
vol.Optional(ATTR_SENSOR_DISABLED): bool,
|
||||
},
|
||||
_validate_state_class_sensor,
|
||||
@@ -619,8 +621,10 @@ async def webhook_update_sensor_states(
|
||||
sensor_schema_full = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict,
|
||||
vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): cv.icon,
|
||||
vol.Required(ATTR_SENSOR_STATE): vol.Any(None, bool, str, int, float),
|
||||
vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): vol.Any(
|
||||
None, cv.icon
|
||||
),
|
||||
vol.Required(ATTR_SENSOR_STATE): vol.Any(None, bool, int, float, str),
|
||||
vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES),
|
||||
vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string,
|
||||
}
|
||||
|
@@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfLength,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
@@ -41,7 +42,7 @@ SENSOR_DESCRIPTIONS = {
|
||||
"battery_voltage": SensorEntityDescription(
|
||||
key="battery_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
|
@@ -744,8 +744,12 @@ class MqttDiscoveryDeviceUpdate(ABC):
|
||||
self.log_name,
|
||||
discovery_hash,
|
||||
)
|
||||
await self.async_update(discovery_payload)
|
||||
if not discovery_payload:
|
||||
try:
|
||||
await self.async_update(discovery_payload)
|
||||
finally:
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
self._discovery_data[ATTR_DISCOVERY_PAYLOAD] = discovery_payload
|
||||
elif not discovery_payload:
|
||||
# Unregister and clean up the current discovery instance
|
||||
stop_discovery_updates(
|
||||
self.hass, self._discovery_data, self._remove_discovery_updated
|
||||
@@ -869,15 +873,19 @@ class MqttDiscoveryUpdate(Entity):
|
||||
_LOGGER.info("Removing component: %s", self.entity_id)
|
||||
self._cleanup_discovery_on_remove()
|
||||
await _async_remove_state_and_registry_entry(self)
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
elif self._discovery_update:
|
||||
if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]:
|
||||
# Non-empty, changed payload: Notify component
|
||||
_LOGGER.info("Updating component: %s", self.entity_id)
|
||||
await self._discovery_update(payload)
|
||||
try:
|
||||
await self._discovery_update(payload)
|
||||
finally:
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
else:
|
||||
# Non-empty, unchanged payload: Ignore to avoid changing states
|
||||
_LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id)
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
send_discovery_done(self.hass, self._discovery_data)
|
||||
|
||||
if discovery_hash:
|
||||
assert self._discovery_data is not None
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant import exceptions
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry, entity_registry
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
@@ -41,37 +41,6 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp
|
||||
return bridge.locks, bridge.openers
|
||||
|
||||
|
||||
def _update_devices(devices: list[NukiDevice]) -> dict[str, set[str]]:
|
||||
"""
|
||||
Update the Nuki devices.
|
||||
|
||||
Returns:
|
||||
A dict with the events to be fired. The event type is the key and the device ids are the value
|
||||
"""
|
||||
|
||||
events: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for device in devices:
|
||||
for level in (False, True):
|
||||
try:
|
||||
if isinstance(device, NukiOpener):
|
||||
last_ring_action_state = device.ring_action_state
|
||||
|
||||
device.update(level)
|
||||
|
||||
if not last_ring_action_state and device.ring_action_state:
|
||||
events["ring"].add(device.nuki_id)
|
||||
else:
|
||||
device.update(level)
|
||||
except RequestException:
|
||||
continue
|
||||
|
||||
if device.state not in ERROR_STATES:
|
||||
break
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the Nuki entry."""
|
||||
|
||||
@@ -101,42 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except RequestException as err:
|
||||
raise exceptions.ConfigEntryNotReady from err
|
||||
|
||||
async def async_update_data() -> None:
|
||||
"""Fetch data from Nuki bridge."""
|
||||
try:
|
||||
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||
# handled by the data update coordinator.
|
||||
async with async_timeout.timeout(10):
|
||||
events = await hass.async_add_executor_job(
|
||||
_update_devices, locks + openers
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
|
||||
except RequestException as err:
|
||||
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
for event, device_ids in events.items():
|
||||
for device_id in device_ids:
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
Platform.LOCK, DOMAIN, device_id
|
||||
)
|
||||
event_data = {
|
||||
"entity_id": entity_id,
|
||||
"type": event,
|
||||
}
|
||||
hass.bus.async_fire("nuki_event", event_data)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
# Name of the data. For logging purposes.
|
||||
name="nuki devices",
|
||||
update_method=async_update_data,
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
# Device registration for the bridge
|
||||
info = bridge.info()
|
||||
bridge_id = parse_id(info["ids"]["hardwareId"])
|
||||
dev_reg = device_registry.async_get(hass)
|
||||
dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, bridge_id)},
|
||||
manufacturer="Nuki Home Solutions GmbH",
|
||||
name=f"Nuki Bridge {bridge_id}",
|
||||
model="Hardware Bridge",
|
||||
sw_version=info["versions"]["firmwareVersion"],
|
||||
)
|
||||
|
||||
coordinator = NukiCoordinator(hass, bridge, locks, openers)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
DATA_COORDINATOR: coordinator,
|
||||
DATA_BRIDGE: bridge,
|
||||
@@ -178,3 +126,94 @@ class NukiEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(coordinator)
|
||||
self._nuki_device = nuki_device
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Device info for Nuki entities."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))},
|
||||
"name": self._nuki_device.name,
|
||||
"manufacturer": "Nuki Home Solutions GmbH",
|
||||
"model": self._nuki_device.device_type_str.capitalize(),
|
||||
"sw_version": self._nuki_device.firmware_version,
|
||||
"via_device": (DOMAIN, self.coordinator.bridge_id),
|
||||
}
|
||||
|
||||
|
||||
class NukiCoordinator(DataUpdateCoordinator):
|
||||
"""Data Update Coordinator for the Nuki integration."""
|
||||
|
||||
def __init__(self, hass, bridge, locks, openers):
|
||||
"""Initialize my coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
# Name of the data. For logging purposes.
|
||||
name="nuki devices",
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.bridge = bridge
|
||||
self.locks = locks
|
||||
self.openers = openers
|
||||
|
||||
@property
|
||||
def bridge_id(self):
|
||||
"""Return the parsed id of the Nuki bridge."""
|
||||
return parse_id(self.bridge.info()["ids"]["hardwareId"])
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from Nuki bridge."""
|
||||
try:
|
||||
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||
# handled by the data update coordinator.
|
||||
async with async_timeout.timeout(10):
|
||||
events = await self.hass.async_add_executor_job(
|
||||
self.update_devices, self.locks + self.openers
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
|
||||
except RequestException as err:
|
||||
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
|
||||
|
||||
ent_reg = entity_registry.async_get(self.hass)
|
||||
for event, device_ids in events.items():
|
||||
for device_id in device_ids:
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
Platform.LOCK, DOMAIN, device_id
|
||||
)
|
||||
event_data = {
|
||||
"entity_id": entity_id,
|
||||
"type": event,
|
||||
}
|
||||
self.hass.bus.async_fire("nuki_event", event_data)
|
||||
|
||||
def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]:
|
||||
"""
|
||||
Update the Nuki devices.
|
||||
|
||||
Returns:
|
||||
A dict with the events to be fired. The event type is the key and the device ids are the value
|
||||
"""
|
||||
|
||||
events: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for device in devices:
|
||||
for level in (False, True):
|
||||
try:
|
||||
if isinstance(device, NukiOpener):
|
||||
last_ring_action_state = device.ring_action_state
|
||||
|
||||
device.update(level)
|
||||
|
||||
if not last_ring_action_state and device.ring_action_state:
|
||||
events["ring"].add(device.nuki_id)
|
||||
else:
|
||||
device.update(level)
|
||||
except RequestException:
|
||||
continue
|
||||
|
||||
if device.state not in ERROR_STATES:
|
||||
break
|
||||
|
||||
return events
|
||||
|
@@ -34,13 +34,10 @@ async def async_setup_entry(
|
||||
class NukiDoorsensorEntity(NukiEntity, BinarySensorEntity):
|
||||
"""Representation of a Nuki Lock Doorsensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Door sensor"
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the lock."""
|
||||
return self._nuki_device.name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
|
@@ -67,13 +67,9 @@ async def async_setup_entry(
|
||||
class NukiDeviceEntity(NukiEntity, LockEntity, ABC):
|
||||
"""Representation of a Nuki device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = LockEntityFeature.OPEN
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the lock."""
|
||||
return self._nuki_device.name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return a unique ID."""
|
||||
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import openai
|
||||
from openai import error
|
||||
@@ -13,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, TemplateError
|
||||
from homeassistant.helpers import area_registry, device_registry, intent, template
|
||||
from homeassistant.helpers import area_registry, intent, template
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from .const import DEFAULT_MODEL, DEFAULT_PROMPT
|
||||
@@ -74,6 +73,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
try:
|
||||
prompt = self._async_generate_prompt()
|
||||
except TemplateError as err:
|
||||
_LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
@@ -97,15 +97,26 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
|
||||
_LOGGER.debug("Prompt for %s: %s", model, prompt)
|
||||
|
||||
result = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
openai.Completion.create,
|
||||
engine=model,
|
||||
prompt=prompt,
|
||||
max_tokens=150,
|
||||
user=conversation_id,
|
||||
try:
|
||||
result = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
openai.Completion.create,
|
||||
engine=model,
|
||||
prompt=prompt,
|
||||
max_tokens=150,
|
||||
user=conversation_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
except error.OpenAIError as err:
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
f"Sorry, I had a problem talking to OpenAI: {err}",
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
_LOGGER.debug("Response %s", result)
|
||||
response = result["choices"][0]["text"].strip()
|
||||
self.history[conversation_id] = prompt + response
|
||||
@@ -122,20 +133,9 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
|
||||
def _async_generate_prompt(self) -> str:
|
||||
"""Generate a prompt for the user."""
|
||||
dev_reg = device_registry.async_get(self.hass)
|
||||
return template.Template(DEFAULT_PROMPT, self.hass).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"areas": [
|
||||
area
|
||||
for area in area_registry.async_get(self.hass).areas.values()
|
||||
# Filter out areas without devices
|
||||
if any(
|
||||
not dev.disabled_by
|
||||
for dev in device_registry.async_entries_for_area(
|
||||
dev_reg, cast(str, area.id)
|
||||
)
|
||||
)
|
||||
],
|
||||
"areas": list(area_registry.async_get(self.hass).areas.values()),
|
||||
}
|
||||
)
|
||||
|
@@ -3,19 +3,26 @@
|
||||
DOMAIN = "openai_conversation"
|
||||
CONF_PROMPT = "prompt"
|
||||
DEFAULT_MODEL = "text-davinci-003"
|
||||
DEFAULT_PROMPT = """
|
||||
You are a conversational AI for a smart home named {{ ha_name }}.
|
||||
If a user wants to control a device, reject the request and suggest using the Home Assistant UI.
|
||||
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
|
||||
|
||||
An overview of the areas and the devices in this smart home:
|
||||
{% for area in areas %}
|
||||
{%- for area in areas %}
|
||||
{%- set area_info = namespace(printed=false) %}
|
||||
{%- for device in area_devices(area.name) -%}
|
||||
{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %}
|
||||
{%- if not area_info.printed %}
|
||||
|
||||
{{ area.name }}:
|
||||
{% for device in area_devices(area.name) -%}
|
||||
{%- if not device_attr(device, "disabled_by") %}
|
||||
- {{ device_attr(device, "name") }} ({{ device_attr(device, "model") }} by {{ device_attr(device, "manufacturer") }})
|
||||
{%- endif %}
|
||||
{%- set area_info.printed = true %}
|
||||
{%- endif %}
|
||||
- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
Answer the users questions about the world truthfully.
|
||||
|
||||
If the user wants to control a device, reject the request and suggest using the Home Assistant app.
|
||||
|
||||
Now finish this conversation:
|
||||
|
||||
|
@@ -386,7 +386,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
|
||||
key=OverkizState.CORE_THREE_WAY_HANDLE_DIRECTION,
|
||||
name="Three way handle direction",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["open", "tilt", "close"],
|
||||
options=["open", "tilt", "closed"],
|
||||
translation_key="three_way_handle_direction",
|
||||
),
|
||||
]
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "plugwise",
|
||||
"name": "Plugwise",
|
||||
"documentation": "https://www.home-assistant.io/integrations/plugwise",
|
||||
"requirements": ["plugwise==0.27.1"],
|
||||
"requirements": ["plugwise==0.27.5"],
|
||||
"codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"],
|
||||
"zeroconf": ["_plugwise._tcp.local."],
|
||||
"config_flow": true,
|
||||
|
@@ -836,7 +836,9 @@ class Recorder(threading.Thread):
|
||||
return
|
||||
|
||||
try:
|
||||
shared_data_bytes = EventData.shared_data_bytes_from_event(event)
|
||||
shared_data_bytes = EventData.shared_data_bytes_from_event(
|
||||
event, self.dialect_name
|
||||
)
|
||||
except JSON_ENCODE_EXCEPTIONS as ex:
|
||||
_LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex)
|
||||
return
|
||||
@@ -869,7 +871,7 @@ class Recorder(threading.Thread):
|
||||
try:
|
||||
dbstate = States.from_event(event)
|
||||
shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event(
|
||||
event, self._exclude_attributes_by_domain
|
||||
event, self._exclude_attributes_by_domain, self.dialect_name
|
||||
)
|
||||
except JSON_ENCODE_EXCEPTIONS as ex:
|
||||
_LOGGER.warning(
|
||||
@@ -1024,7 +1026,9 @@ class Recorder(threading.Thread):
|
||||
|
||||
def _post_schema_migration(self, old_version: int, new_version: int) -> None:
|
||||
"""Run post schema migration tasks."""
|
||||
migration.post_schema_migration(self.event_session, old_version, new_version)
|
||||
migration.post_schema_migration(
|
||||
self.engine, self.event_session, old_version, new_version
|
||||
)
|
||||
|
||||
def _send_keep_alive(self) -> None:
|
||||
"""Send a keep alive to keep the db connection open."""
|
||||
@@ -1040,6 +1044,8 @@ class Recorder(threading.Thread):
|
||||
|
||||
async def async_block_till_done(self) -> None:
|
||||
"""Async version of block_till_done."""
|
||||
if self._queue.empty() and not self._event_session_has_pending_writes():
|
||||
return
|
||||
event = asyncio.Event()
|
||||
self.queue_task(SynchronizeTask(event))
|
||||
await event.wait()
|
||||
|
@@ -43,18 +43,19 @@ from homeassistant.helpers.json import (
|
||||
JSON_DECODE_EXCEPTIONS,
|
||||
JSON_DUMP,
|
||||
json_bytes,
|
||||
json_bytes_strip_null,
|
||||
json_loads,
|
||||
)
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import ALL_DOMAIN_EXCLUDE_ATTRS
|
||||
from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
|
||||
from .models import StatisticData, StatisticMetaData, process_timestamp
|
||||
|
||||
# SQLAlchemy Schema
|
||||
# pylint: disable=invalid-name
|
||||
Base = declarative_base()
|
||||
|
||||
SCHEMA_VERSION = 32
|
||||
SCHEMA_VERSION = 33
|
||||
|
||||
_StatisticsBaseSelfT = TypeVar("_StatisticsBaseSelfT", bound="StatisticsBase")
|
||||
|
||||
@@ -251,8 +252,12 @@ class EventData(Base): # type: ignore[misc,valid-type]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def shared_data_bytes_from_event(event: Event) -> bytes:
|
||||
def shared_data_bytes_from_event(
|
||||
event: Event, dialect: SupportedDialect | None
|
||||
) -> bytes:
|
||||
"""Create shared_data from an event."""
|
||||
if dialect == SupportedDialect.POSTGRESQL:
|
||||
return json_bytes_strip_null(event.data)
|
||||
return json_bytes(event.data)
|
||||
|
||||
@staticmethod
|
||||
@@ -416,7 +421,9 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
|
||||
|
||||
@staticmethod
|
||||
def shared_attrs_bytes_from_event(
|
||||
event: Event, exclude_attrs_by_domain: dict[str, set[str]]
|
||||
event: Event,
|
||||
exclude_attrs_by_domain: dict[str, set[str]],
|
||||
dialect: SupportedDialect | None,
|
||||
) -> bytes:
|
||||
"""Create shared_attrs from a state_changed event."""
|
||||
state: State | None = event.data.get("new_state")
|
||||
@@ -427,6 +434,10 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
|
||||
exclude_attrs = (
|
||||
exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS
|
||||
)
|
||||
if dialect == SupportedDialect.POSTGRESQL:
|
||||
return json_bytes_strip_null(
|
||||
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
|
||||
)
|
||||
return json_bytes(
|
||||
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
|
||||
)
|
||||
|
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.engine import CursorResult, Engine
|
||||
from sqlalchemy.exc import (
|
||||
DatabaseError,
|
||||
InternalError,
|
||||
@@ -43,7 +43,7 @@ from .statistics import (
|
||||
get_start_time,
|
||||
validate_db_schema as statistics_validate_db_schema,
|
||||
)
|
||||
from .tasks import PostSchemaMigrationTask
|
||||
from .tasks import CommitTask, PostSchemaMigrationTask
|
||||
from .util import session_scope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -166,6 +166,9 @@ def migrate_schema(
|
||||
|
||||
if current_version != SCHEMA_VERSION:
|
||||
instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION))
|
||||
# Make sure the post schema migration task is committed in case
|
||||
# the next task does not have commit_before = True
|
||||
instance.queue_task(CommitTask())
|
||||
|
||||
|
||||
def _create_index(
|
||||
@@ -271,9 +274,13 @@ def _drop_index(
|
||||
"Finished dropping index %s from table %s", index_name, table_name
|
||||
)
|
||||
else:
|
||||
if index_name == "ix_states_context_parent_id":
|
||||
# Was only there on nightly so we do not want
|
||||
if index_name in ("ix_states_entity_id", "ix_states_context_parent_id"):
|
||||
# ix_states_context_parent_id was only there on nightly so we do not want
|
||||
# to generate log noise or issues about it.
|
||||
#
|
||||
# ix_states_entity_id was only there for users who upgraded from schema
|
||||
# version 8 or earlier. Newer installs will not have it so we do not
|
||||
# want to generate log noise or issues about it.
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
@@ -502,12 +509,12 @@ def _apply_update( # noqa: C901
|
||||
timestamp_type = "FLOAT"
|
||||
|
||||
if new_version == 1:
|
||||
_create_index(session_maker, "events", "ix_events_time_fired")
|
||||
# This used to create ix_events_time_fired, but it was removed in version 32
|
||||
pass
|
||||
elif new_version == 2:
|
||||
# Create compound start/end index for recorder_runs
|
||||
_create_index(session_maker, "recorder_runs", "ix_recorder_runs_start_end")
|
||||
# Create indexes for states
|
||||
_create_index(session_maker, "states", "ix_states_last_updated")
|
||||
# This used to create ix_states_last_updated bit it was removed in version 32
|
||||
elif new_version == 3:
|
||||
# There used to be a new index here, but it was removed in version 4.
|
||||
pass
|
||||
@@ -526,8 +533,7 @@ def _apply_update( # noqa: C901
|
||||
_drop_index(session_maker, "states", "states__state_changes")
|
||||
_drop_index(session_maker, "states", "states__significant_changes")
|
||||
_drop_index(session_maker, "states", "ix_states_entity_id_created")
|
||||
|
||||
_create_index(session_maker, "states", "ix_states_entity_id_last_updated")
|
||||
# This used to create ix_states_entity_id_last_updated, but it was removed in version 32
|
||||
elif new_version == 5:
|
||||
# Create supporting index for States.event_id foreign key
|
||||
_create_index(session_maker, "states", "ix_states_event_id")
|
||||
@@ -538,20 +544,21 @@ def _apply_update( # noqa: C901
|
||||
["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"],
|
||||
)
|
||||
_create_index(session_maker, "events", "ix_events_context_id")
|
||||
_create_index(session_maker, "events", "ix_events_context_user_id")
|
||||
# This used to create ix_events_context_user_id, but it was removed in version 28
|
||||
_add_columns(
|
||||
session_maker,
|
||||
"states",
|
||||
["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"],
|
||||
)
|
||||
_create_index(session_maker, "states", "ix_states_context_id")
|
||||
_create_index(session_maker, "states", "ix_states_context_user_id")
|
||||
# This used to create ix_states_context_user_id, but it was removed in version 28
|
||||
elif new_version == 7:
|
||||
_create_index(session_maker, "states", "ix_states_entity_id")
|
||||
# There used to be a ix_states_entity_id index here, but it was removed in later schema
|
||||
pass
|
||||
elif new_version == 8:
|
||||
_add_columns(session_maker, "events", ["context_parent_id CHARACTER(36)"])
|
||||
_add_columns(session_maker, "states", ["old_state_id INTEGER"])
|
||||
_create_index(session_maker, "events", "ix_events_context_parent_id")
|
||||
# This used to create ix_events_context_parent_id, but it was removed in version 28
|
||||
elif new_version == 9:
|
||||
# We now get the context from events with a join
|
||||
# since its always there on state_changed events
|
||||
@@ -569,7 +576,7 @@ def _apply_update( # noqa: C901
|
||||
# Redundant keys on composite index:
|
||||
# We already have ix_states_entity_id_last_updated
|
||||
_drop_index(session_maker, "states", "ix_states_entity_id")
|
||||
_create_index(session_maker, "events", "ix_events_event_type_time_fired")
|
||||
# This used to create ix_events_event_type_time_fired, but it was removed in version 32
|
||||
_drop_index(session_maker, "events", "ix_events_event_type")
|
||||
elif new_version == 10:
|
||||
# Now done in step 11
|
||||
@@ -846,8 +853,7 @@ def _apply_update( # noqa: C901
|
||||
_create_index(session_maker, "events", "ix_events_event_type_time_fired_ts")
|
||||
_create_index(session_maker, "states", "ix_states_entity_id_last_updated_ts")
|
||||
_create_index(session_maker, "states", "ix_states_last_updated_ts")
|
||||
with session_scope(session=session_maker()) as session:
|
||||
_migrate_columns_to_timestamp(hass, session, engine)
|
||||
_migrate_columns_to_timestamp(session_maker, engine)
|
||||
elif new_version == 32:
|
||||
# Migration is done in two steps to ensure we can start using
|
||||
# the new columns before we wipe the old ones.
|
||||
@@ -855,11 +861,17 @@ def _apply_update( # noqa: C901
|
||||
_drop_index(session_maker, "events", "ix_events_event_type_time_fired")
|
||||
_drop_index(session_maker, "states", "ix_states_last_updated")
|
||||
_drop_index(session_maker, "events", "ix_events_time_fired")
|
||||
elif new_version == 33:
|
||||
# This index is no longer used and can cause MySQL to use the wrong index
|
||||
# when querying the states table.
|
||||
# https://github.com/home-assistant/core/issues/83787
|
||||
_drop_index(session_maker, "states", "ix_states_entity_id")
|
||||
else:
|
||||
raise ValueError(f"No schema migration defined for version {new_version}")
|
||||
|
||||
|
||||
def post_schema_migration(
|
||||
engine: Engine,
|
||||
session: Session,
|
||||
old_version: int,
|
||||
new_version: int,
|
||||
@@ -878,62 +890,147 @@ def post_schema_migration(
|
||||
# In version 31 we migrated all the time_fired, last_updated, and last_changed
|
||||
# columns to be timestamps. In version 32 we need to wipe the old columns
|
||||
# since they are no longer used and take up a significant amount of space.
|
||||
_wipe_old_string_time_columns(session)
|
||||
_wipe_old_string_time_columns(engine, session)
|
||||
|
||||
|
||||
def _wipe_old_string_time_columns(session: Session) -> None:
|
||||
def _wipe_old_string_time_columns(engine: Engine, session: Session) -> None:
|
||||
"""Wipe old string time columns to save space."""
|
||||
# Wipe Events.time_fired since its been replaced by Events.time_fired_ts
|
||||
# Wipe States.last_updated since its been replaced by States.last_updated_ts
|
||||
# Wipe States.last_changed since its been replaced by States.last_changed_ts
|
||||
session.execute(text("UPDATE events set time_fired=NULL;"))
|
||||
session.execute(text("UPDATE states set last_updated=NULL, last_changed=NULL;"))
|
||||
session.commit()
|
||||
#
|
||||
if engine.dialect.name == SupportedDialect.SQLITE:
|
||||
session.execute(text("UPDATE events set time_fired=NULL;"))
|
||||
session.commit()
|
||||
session.execute(text("UPDATE states set last_updated=NULL, last_changed=NULL;"))
|
||||
session.commit()
|
||||
elif engine.dialect.name == SupportedDialect.MYSQL:
|
||||
#
|
||||
# Since this is only to save space we limit the number of rows we update
|
||||
# to 10,000,000 per table since we do not want to block the database for too long
|
||||
# or run out of innodb_buffer_pool_size on MySQL. The old data will eventually
|
||||
# be cleaned up by the recorder purge if we do not do it now.
|
||||
#
|
||||
session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;"))
|
||||
session.commit()
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE states set last_updated=NULL, last_changed=NULL "
|
||||
" LIMIT 10000000;"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
|
||||
#
|
||||
# Since this is only to save space we limit the number of rows we update
|
||||
# to 250,000 per table since we do not want to block the database for too long
|
||||
# or run out ram with postgresql. The old data will eventually
|
||||
# be cleaned up by the recorder purge if we do not do it now.
|
||||
#
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE events set time_fired=NULL "
|
||||
"where event_id in "
|
||||
"(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE states set last_updated=NULL, last_changed=NULL "
|
||||
"where state_id in "
|
||||
"(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _migrate_columns_to_timestamp(
|
||||
hass: HomeAssistant, session: Session, engine: Engine
|
||||
session_maker: Callable[[], Session], engine: Engine
|
||||
) -> None:
|
||||
"""Migrate columns to use timestamp."""
|
||||
# Migrate all data in Events.time_fired to Events.time_fired_ts
|
||||
# Migrate all data in States.last_updated to States.last_updated_ts
|
||||
# Migrate all data in States.last_changed to States.last_changed_ts
|
||||
connection = session.connection()
|
||||
result: CursorResult | None = None
|
||||
if engine.dialect.name == SupportedDialect.SQLITE:
|
||||
connection.execute(
|
||||
text(
|
||||
'UPDATE events set time_fired_ts=strftime("%s",time_fired) + '
|
||||
"cast(substr(time_fired,-7) AS FLOAT);"
|
||||
# With SQLite we do this in one go since it is faster
|
||||
with session_scope(session=session_maker()) as session:
|
||||
connection = session.connection()
|
||||
connection.execute(
|
||||
text(
|
||||
'UPDATE events set time_fired_ts=strftime("%s",time_fired) + '
|
||||
"cast(substr(time_fired,-7) AS FLOAT);"
|
||||
)
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
'UPDATE states set last_updated_ts=strftime("%s",last_updated) + '
|
||||
"cast(substr(last_updated,-7) AS FLOAT), "
|
||||
'last_changed_ts=strftime("%s",last_changed) + '
|
||||
"cast(substr(last_changed,-7) AS FLOAT);"
|
||||
connection.execute(
|
||||
text(
|
||||
'UPDATE states set last_updated_ts=strftime("%s",last_updated) + '
|
||||
"cast(substr(last_updated,-7) AS FLOAT), "
|
||||
'last_changed_ts=strftime("%s",last_changed) + '
|
||||
"cast(substr(last_changed,-7) AS FLOAT);"
|
||||
)
|
||||
)
|
||||
)
|
||||
elif engine.dialect.name == SupportedDialect.MYSQL:
|
||||
connection.execute(
|
||||
text("UPDATE events set time_fired_ts=UNIX_TIMESTAMP(time_fired);")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE states set last_updated_ts=UNIX_TIMESTAMP(last_updated), "
|
||||
"last_changed_ts=UNIX_TIMESTAMP(last_changed);"
|
||||
)
|
||||
)
|
||||
# With MySQL we do this in chunks to avoid hitting the `innodb_buffer_pool_size` limit
|
||||
# We also need to do this in a loop since we can't be sure that we have
|
||||
# updated all rows in the table until the rowcount is 0
|
||||
while result is None or result.rowcount > 0:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
result = session.connection().execute(
|
||||
text(
|
||||
"UPDATE events set time_fired_ts="
|
||||
"IF(time_fired is NULL,0,"
|
||||
"UNIX_TIMESTAMP(CONVERT_TZ(time_fired,'+00:00',@@global.time_zone))"
|
||||
") "
|
||||
"where time_fired_ts is NULL "
|
||||
"LIMIT 250000;"
|
||||
)
|
||||
)
|
||||
result = None
|
||||
while result is None or result.rowcount > 0:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
result = session.connection().execute(
|
||||
text(
|
||||
"UPDATE states set last_updated_ts="
|
||||
"IF(last_updated is NULL,0,"
|
||||
"UNIX_TIMESTAMP(CONVERT_TZ(last_updated,'+00:00',@@global.time_zone)) "
|
||||
"), "
|
||||
"last_changed_ts="
|
||||
"UNIX_TIMESTAMP(CONVERT_TZ(last_changed,'+00:00',@@global.time_zone)) "
|
||||
"where last_updated_ts is NULL "
|
||||
"LIMIT 250000;"
|
||||
)
|
||||
)
|
||||
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
|
||||
connection.execute(
|
||||
text("UPDATE events set time_fired_ts=EXTRACT(EPOCH FROM time_fired);")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE states set last_updated_ts=EXTRACT(EPOCH FROM last_updated), "
|
||||
"last_changed_ts=EXTRACT(EPOCH FROM last_changed);"
|
||||
)
|
||||
)
|
||||
# With Postgresql we do this in chunks to avoid using too much memory
|
||||
# We also need to do this in a loop since we can't be sure that we have
|
||||
# updated all rows in the table until the rowcount is 0
|
||||
while result is None or result.rowcount > 0:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
result = session.connection().execute(
|
||||
text(
|
||||
"UPDATE events SET "
|
||||
"time_fired_ts= "
|
||||
"(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired) end) "
|
||||
"WHERE event_id IN ( "
|
||||
"SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 "
|
||||
" );"
|
||||
)
|
||||
)
|
||||
result = None
|
||||
while result is None or result.rowcount > 0:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
result = session.connection().execute(
|
||||
text(
|
||||
"UPDATE states set last_updated_ts="
|
||||
"(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated) end), "
|
||||
"last_changed_ts=EXTRACT(EPOCH FROM last_changed) "
|
||||
"where state_id IN ( "
|
||||
"SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 "
|
||||
" );"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _initialize_database(session: Session) -> bool:
|
||||
|
@@ -7,5 +7,11 @@
|
||||
"database_engine": "Database Engine",
|
||||
"database_version": "Database Version"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"maria_db_range_index_regression": {
|
||||
"title": "Update MariaDB to {min_version} or later resolve a significant performance issue",
|
||||
"description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"issues": {
|
||||
"maria_db_range_index_regression": {
|
||||
"description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version.",
|
||||
"title": "Update MariaDB to {min_version} or later resolve a significant performance issue"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"current_recorder_run": "Current Run Start Time",
|
||||
|
@@ -25,11 +25,11 @@ from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DATA_INSTANCE, SQLITE_URL_PREFIX, SupportedDialect
|
||||
from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect
|
||||
from .db_schema import (
|
||||
TABLE_RECORDER_RUNS,
|
||||
TABLE_SCHEMA_CHANGES,
|
||||
@@ -51,9 +51,35 @@ QUERY_RETRY_WAIT = 0.1
|
||||
SQLITE3_POSTFIXES = ["", "-wal", "-shm"]
|
||||
DEFAULT_YIELD_STATES_ROWS = 32768
|
||||
|
||||
# Our minimum versions for each database
|
||||
#
|
||||
# Older MariaDB suffers https://jira.mariadb.org/browse/MDEV-25020
|
||||
# which is fixed in 10.5.17, 10.6.9, 10.7.5, 10.8.4
|
||||
#
|
||||
MIN_VERSION_MARIA_DB = AwesomeVersion(
|
||||
"10.3.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
RECOMMENDED_MIN_VERSION_MARIA_DB = AwesomeVersion(
|
||||
"10.5.17", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
MARIA_DB_106 = AwesomeVersion(
|
||||
"10.6.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
RECOMMENDED_MIN_VERSION_MARIA_DB_106 = AwesomeVersion(
|
||||
"10.6.9", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
MARIA_DB_107 = AwesomeVersion(
|
||||
"10.7.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
RECOMMENDED_MIN_VERSION_MARIA_DB_107 = AwesomeVersion(
|
||||
"10.7.5", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
MARIA_DB_108 = AwesomeVersion(
|
||||
"10.8.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
RECOMMENDED_MIN_VERSION_MARIA_DB_108 = AwesomeVersion(
|
||||
"10.8.4", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
MIN_VERSION_MYSQL = AwesomeVersion(
|
||||
"8.0.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
|
||||
)
|
||||
@@ -408,6 +434,34 @@ def build_mysqldb_conv() -> dict:
|
||||
return {**conversions, FIELD_TYPE.DATETIME: _datetime_or_none}
|
||||
|
||||
|
||||
@callback
|
||||
def _async_create_mariadb_range_index_regression_issue(
|
||||
hass: HomeAssistant, version: AwesomeVersion
|
||||
) -> None:
|
||||
"""Create an issue for the index range regression in older MariaDB.
|
||||
|
||||
The range scan issue was fixed in MariaDB 10.5.17, 10.6.9, 10.7.5, 10.8.4 and later.
|
||||
"""
|
||||
if version >= MARIA_DB_108:
|
||||
min_version = RECOMMENDED_MIN_VERSION_MARIA_DB_108
|
||||
elif version >= MARIA_DB_107:
|
||||
min_version = RECOMMENDED_MIN_VERSION_MARIA_DB_107
|
||||
elif version >= MARIA_DB_106:
|
||||
min_version = RECOMMENDED_MIN_VERSION_MARIA_DB_106
|
||||
else:
|
||||
min_version = RECOMMENDED_MIN_VERSION_MARIA_DB
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"maria_db_range_index_regression",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.CRITICAL,
|
||||
learn_more_url="https://jira.mariadb.org/browse/MDEV-25020",
|
||||
translation_key="maria_db_range_index_regression",
|
||||
translation_placeholders={"min_version": str(min_version)},
|
||||
)
|
||||
|
||||
|
||||
def setup_connection_for_dialect(
|
||||
instance: Recorder,
|
||||
dialect_name: str,
|
||||
@@ -464,6 +518,18 @@ def setup_connection_for_dialect(
|
||||
_fail_unsupported_version(
|
||||
version or version_string, "MariaDB", MIN_VERSION_MARIA_DB
|
||||
)
|
||||
if version and (
|
||||
(version < RECOMMENDED_MIN_VERSION_MARIA_DB)
|
||||
or (MARIA_DB_106 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_106)
|
||||
or (MARIA_DB_107 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_107)
|
||||
or (MARIA_DB_108 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_108)
|
||||
):
|
||||
instance.hass.add_job(
|
||||
_async_create_mariadb_range_index_regression_issue,
|
||||
instance.hass,
|
||||
version,
|
||||
)
|
||||
|
||||
else:
|
||||
if not version or version < MIN_VERSION_MYSQL:
|
||||
_fail_unsupported_version(
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.5.0",
|
||||
"wakeonlan==2.1.0",
|
||||
"async-upnp-client==0.33.0"
|
||||
"async-upnp-client==0.33.1"
|
||||
],
|
||||
"ssdp": [
|
||||
{
|
||||
|
@@ -9,6 +9,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
|
||||
import logging
|
||||
from math import ceil, floor, log10
|
||||
import re
|
||||
from typing import Any, Final, cast, final
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -84,6 +85,8 @@ _LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||
|
||||
NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$")
|
||||
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=30)
|
||||
|
||||
__all__ = [
|
||||
@@ -596,21 +599,22 @@ class SensorEntity(Entity):
|
||||
f"({type(value)})"
|
||||
) from err
|
||||
# This should raise in Home Assistant Core 2023.4
|
||||
self._invalid_numeric_value_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
"Sensor %s has device class %s, state class %s and unit %s "
|
||||
"thus indicating it has a numeric value; however, it has the "
|
||||
"non-numeric value: %s (%s); Please update your configuration "
|
||||
"if your entity is manually configured, otherwise %s",
|
||||
self.entity_id,
|
||||
device_class,
|
||||
state_class,
|
||||
unit_of_measurement,
|
||||
value,
|
||||
type(value),
|
||||
report_issue,
|
||||
)
|
||||
if not self._invalid_numeric_value_reported:
|
||||
self._invalid_numeric_value_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
"Sensor %s has device class %s, state class %s and unit %s "
|
||||
"thus indicating it has a numeric value; however, it has the "
|
||||
"non-numeric value: %s (%s); Please update your configuration "
|
||||
"if your entity is manually configured, otherwise %s",
|
||||
self.entity_id,
|
||||
device_class,
|
||||
state_class,
|
||||
unit_of_measurement,
|
||||
value,
|
||||
type(value),
|
||||
report_issue,
|
||||
)
|
||||
return value
|
||||
else:
|
||||
numerical_value = value
|
||||
@@ -647,8 +651,14 @@ class SensorEntity(Entity):
|
||||
unit_of_measurement,
|
||||
)
|
||||
value = f"{converted_numerical_value:.{precision}f}"
|
||||
# This can be replaced with adding the z option when we drop support for
|
||||
# Python 3.10
|
||||
value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value)
|
||||
elif precision is not None:
|
||||
value = f"{numerical_value:.{precision}f}"
|
||||
# This can be replaced with adding the z option when we drop support for
|
||||
# Python 3.10
|
||||
value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value)
|
||||
|
||||
# Validate unit of measurement used for sensors with a device class
|
||||
if (
|
||||
@@ -665,6 +675,7 @@ class SensorEntity(Entity):
|
||||
(
|
||||
"Entity %s (%s) is using native unit of measurement '%s' which "
|
||||
"is not a valid unit for the device class ('%s') it is using; "
|
||||
"expected one of %s; "
|
||||
"Please update your configuration if your entity is manually "
|
||||
"configured, otherwise %s"
|
||||
),
|
||||
@@ -672,6 +683,7 @@ class SensorEntity(Entity):
|
||||
type(self),
|
||||
native_unit_of_measurement,
|
||||
device_class,
|
||||
[str(unit) if unit else "no unit of measurement" for unit in units],
|
||||
report_issue,
|
||||
)
|
||||
|
||||
|
@@ -513,7 +513,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]
|
||||
SensorDeviceClass.DATA_SIZE: set(SensorStateClass),
|
||||
SensorDeviceClass.DATE: set(),
|
||||
SensorDeviceClass.DISTANCE: set(SensorStateClass),
|
||||
SensorDeviceClass.DURATION: set(),
|
||||
SensorDeviceClass.DURATION: set(SensorStateClass),
|
||||
SensorDeviceClass.ENERGY: {
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -535,10 +535,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]
|
||||
SensorDeviceClass.PM25: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.POWER_FACTOR: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.POWER: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.PRECIPITATION: {
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
},
|
||||
SensorDeviceClass.PRECIPITATION: set(SensorStateClass),
|
||||
SensorDeviceClass.PRECIPITATION_INTENSITY: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.PRESSURE: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.REACTIVE_POWER: {SensorStateClass.MEASUREMENT},
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "Shelly",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||
"requirements": ["aioshelly==5.3.0"],
|
||||
"requirements": ["aioshelly==5.3.1"],
|
||||
"dependencies": ["bluetooth", "http"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
@@ -202,7 +202,9 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A
|
||||
return {
|
||||
source_ip
|
||||
for source_ip in await network.async_get_enabled_source_ips(hass)
|
||||
if not source_ip.is_loopback and not source_ip.is_global
|
||||
if not source_ip.is_loopback
|
||||
and not source_ip.is_global
|
||||
and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4)
|
||||
}
|
||||
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "ssdp",
|
||||
"name": "Simple Service Discovery Protocol (SSDP)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ssdp",
|
||||
"requirements": ["async-upnp-client==0.33.0"],
|
||||
"requirements": ["async-upnp-client==0.33.1"],
|
||||
"dependencies": ["network"],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": [],
|
||||
|
@@ -89,9 +89,9 @@ SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = (
|
||||
TradfriSensorEntityDescription(
|
||||
key="aqi",
|
||||
name="air quality",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon="mdi:air-filter",
|
||||
value=_get_air_quality,
|
||||
),
|
||||
TradfriSensorEntityDescription(
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "UPnP/IGD",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/upnp",
|
||||
"requirements": ["async-upnp-client==0.33.0", "getmac==0.8.2"],
|
||||
"requirements": ["async-upnp-client==0.33.1", "getmac==0.8.2"],
|
||||
"dependencies": ["network", "ssdp"],
|
||||
"codeowners": ["@StevenLooman"],
|
||||
"ssdp": [
|
||||
|
@@ -17,7 +17,7 @@ button:
|
||||
description: >-
|
||||
Name of the button to press. Known possible values are
|
||||
LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT,
|
||||
MUTE, RED, GREEN, BLUE, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
|
||||
MUTE, RED, GREEN, BLUE, YELLOW, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
|
||||
PLAY, PAUSE, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
|
||||
required: true
|
||||
example: "LEFT"
|
||||
|
@@ -161,12 +161,14 @@ def _state_diff(
|
||||
additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state.context.id
|
||||
else:
|
||||
additions[COMPRESSED_STATE_CONTEXT] = new_state.context.id
|
||||
old_attributes = old_state.attributes
|
||||
for key, value in new_state.attributes.items():
|
||||
if old_attributes.get(key) != value:
|
||||
additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value
|
||||
if removed := set(old_attributes).difference(new_state.attributes):
|
||||
diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: removed}
|
||||
if (old_attributes := old_state.attributes) != (
|
||||
new_attributes := new_state.attributes
|
||||
):
|
||||
for key, value in new_attributes.items():
|
||||
if old_attributes.get(key) != value:
|
||||
additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value
|
||||
if removed := set(old_attributes).difference(new_attributes):
|
||||
diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: removed}
|
||||
return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}}
|
||||
|
||||
|
||||
|
@@ -55,12 +55,3 @@ TRANSLATION_KEY_MAPPING = {
|
||||
"zone_LINK_CONTROL": "zone_link_control",
|
||||
"zone_LINK_AUDIO_DELAY": "zone_link_audio_delay",
|
||||
}
|
||||
|
||||
ZONE_SLEEP_STATE_MAPPING = {
|
||||
"off": "off",
|
||||
"30 min": "30_min",
|
||||
"60 min": "60_min",
|
||||
"90 min": "90_min",
|
||||
"120 min": "120_min",
|
||||
}
|
||||
STATE_ZONE_SLEEP_MAPPING = {val: key for key, val in ZONE_SLEEP_STATE_MAPPING.items()}
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"name": "MusicCast",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
|
||||
"requirements": ["aiomusiccast==0.14.4"],
|
||||
"requirements": ["aiomusiccast==0.14.7"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Yamaha Corporation"
|
||||
|
@@ -9,11 +9,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator
|
||||
from .const import (
|
||||
STATE_ZONE_SLEEP_MAPPING,
|
||||
TRANSLATION_KEY_MAPPING,
|
||||
ZONE_SLEEP_STATE_MAPPING,
|
||||
)
|
||||
from .const import TRANSLATION_KEY_MAPPING
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -48,10 +44,6 @@ class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity):
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select the given option."""
|
||||
value = {val: key for key, val in self.capability.options.items()}[option]
|
||||
# If the translation key is "zone_sleep", we need to translate
|
||||
# Home Assistant state back to the MusicCast value
|
||||
if self.translation_key == "zone_sleep":
|
||||
value = STATE_ZONE_SLEEP_MAPPING[value]
|
||||
await self.capability.set(value)
|
||||
|
||||
@property
|
||||
@@ -62,20 +54,9 @@ class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity):
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return the list possible options."""
|
||||
# If the translation key is "zone_sleep", we need to translate
|
||||
# the options to make them compatible with Home Assistant
|
||||
if self.translation_key == "zone_sleep":
|
||||
return list(STATE_ZONE_SLEEP_MAPPING)
|
||||
return list(self.capability.options.values())
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected option."""
|
||||
# If the translation key is "zone_sleep", we need to translate
|
||||
# the value to make it compatible with Home Assistant
|
||||
if (
|
||||
value := self.capability.current
|
||||
) is not None and self.translation_key == "zone_sleep":
|
||||
return ZONE_SLEEP_STATE_MAPPING[value]
|
||||
|
||||
return value
|
||||
return self.capability.options.get(self.capability.current)
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "yeelight",
|
||||
"name": "Yeelight",
|
||||
"documentation": "https://www.home-assistant.io/integrations/yeelight",
|
||||
"requirements": ["yeelight==0.7.10", "async-upnp-client==0.33.0"],
|
||||
"requirements": ["yeelight==0.7.10", "async-upnp-client==0.33.1"],
|
||||
"codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
|
@@ -37,6 +37,7 @@ DECONZ_DOMAIN = "deconz"
|
||||
|
||||
FORMATION_STRATEGY = "formation_strategy"
|
||||
FORMATION_FORM_NEW_NETWORK = "form_new_network"
|
||||
FORMATION_FORM_INITIAL_NETWORK = "form_initial_network"
|
||||
FORMATION_REUSE_SETTINGS = "reuse_settings"
|
||||
FORMATION_CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup"
|
||||
FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup"
|
||||
@@ -270,8 +271,21 @@ class BaseZhaFlow(FlowHandler):
|
||||
strategies.append(FORMATION_REUSE_SETTINGS)
|
||||
|
||||
strategies.append(FORMATION_UPLOAD_MANUAL_BACKUP)
|
||||
strategies.append(FORMATION_FORM_NEW_NETWORK)
|
||||
|
||||
# Do not show "erase network settings" if there are none to erase
|
||||
if self._radio_mgr.current_settings is None:
|
||||
strategies.append(FORMATION_FORM_INITIAL_NETWORK)
|
||||
else:
|
||||
strategies.append(FORMATION_FORM_NEW_NETWORK)
|
||||
|
||||
# Automatically form a new network if we're onboarding with a brand new radio
|
||||
if not onboarding.async_is_onboarded(self.hass) and set(strategies) == {
|
||||
FORMATION_UPLOAD_MANUAL_BACKUP,
|
||||
FORMATION_FORM_INITIAL_NETWORK,
|
||||
}:
|
||||
return await self.async_step_form_initial_network()
|
||||
|
||||
# Otherwise, let the user choose
|
||||
return self.async_show_menu(
|
||||
step_id="choose_formation_strategy",
|
||||
menu_options=strategies,
|
||||
@@ -283,6 +297,13 @@ class BaseZhaFlow(FlowHandler):
|
||||
"""Reuse the existing network settings on the stick."""
|
||||
return await self._async_create_radio_entry()
|
||||
|
||||
async def async_step_form_initial_network(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Form an initial network."""
|
||||
# This step exists only for translations, it does nothing new
|
||||
return await self.async_step_form_new_network(user_input)
|
||||
|
||||
async def async_step_form_new_network(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -439,7 +460,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# Without confirmation, discovery can automatically progress into parts of the
|
||||
# config flow logic that interacts with hardware!
|
||||
# config flow logic that interacts with hardware.
|
||||
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
|
||||
# Probe the radio type if we don't have one yet
|
||||
if (
|
||||
|
@@ -224,7 +224,8 @@ class InovelliConfigEntityChannel(ZigbeeChannel):
|
||||
"switch_type": False,
|
||||
"button_delay": False,
|
||||
"smart_bulb_mode": False,
|
||||
"double_tap_up_for_full_brightness": True,
|
||||
"double_tap_up_for_max_brightness": True,
|
||||
"double_tap_down_for_min_brightness": True,
|
||||
"led_color_when_on": True,
|
||||
"led_color_when_off": True,
|
||||
"led_intensity_when_on": True,
|
||||
|
@@ -4,10 +4,10 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zha",
|
||||
"requirements": [
|
||||
"bellows==0.34.6",
|
||||
"bellows==0.34.7",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.91",
|
||||
"zha-quirks==0.0.92",
|
||||
"zigpy-deconz==0.19.2",
|
||||
"zigpy==0.53.0",
|
||||
"zigpy-xbee==0.16.2",
|
||||
|
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
from zigpy.application import ControllerApplication
|
||||
import zigpy.backups
|
||||
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED
|
||||
from zigpy.exceptions import NetworkNotFormed
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -126,6 +126,7 @@ class ZhaRadioManager:
|
||||
|
||||
app_config[CONF_DATABASE] = database_path
|
||||
app_config[CONF_DEVICE] = self.device_settings
|
||||
app_config[CONF_NWK_BACKUP_ENABLED] = False
|
||||
app_config = self.radio_type.controller.SCHEMA(app_config)
|
||||
|
||||
app = await self.radio_type.controller.new(
|
||||
@@ -206,6 +207,7 @@ class ZhaRadioManager:
|
||||
|
||||
# The list of backups will always exist
|
||||
self.backups = app.backups.backups.copy()
|
||||
self.backups.sort(reverse=True, key=lambda b: b.backup_time)
|
||||
|
||||
return backup
|
||||
|
||||
|
@@ -511,7 +511,7 @@ class PolledSmartEnergySummation(SmartEnergySummation):
|
||||
models={"ZLinky_TIC"},
|
||||
)
|
||||
class Tier1SmartEnergySummation(
|
||||
SmartEnergySummation, id_suffix="tier1_summation_delivered"
|
||||
PolledSmartEnergySummation, id_suffix="tier1_summation_delivered"
|
||||
):
|
||||
"""Tier 1 Smart Energy Metering summation sensor."""
|
||||
|
||||
@@ -524,7 +524,7 @@ class Tier1SmartEnergySummation(
|
||||
models={"ZLinky_TIC"},
|
||||
)
|
||||
class Tier2SmartEnergySummation(
|
||||
SmartEnergySummation, id_suffix="tier2_summation_delivered"
|
||||
PolledSmartEnergySummation, id_suffix="tier2_summation_delivered"
|
||||
):
|
||||
"""Tier 2 Smart Energy Metering summation sensor."""
|
||||
|
||||
@@ -537,7 +537,7 @@ class Tier2SmartEnergySummation(
|
||||
models={"ZLinky_TIC"},
|
||||
)
|
||||
class Tier3SmartEnergySummation(
|
||||
SmartEnergySummation, id_suffix="tier3_summation_delivered"
|
||||
PolledSmartEnergySummation, id_suffix="tier3_summation_delivered"
|
||||
):
|
||||
"""Tier 3 Smart Energy Metering summation sensor."""
|
||||
|
||||
@@ -550,7 +550,7 @@ class Tier3SmartEnergySummation(
|
||||
models={"ZLinky_TIC"},
|
||||
)
|
||||
class Tier4SmartEnergySummation(
|
||||
SmartEnergySummation, id_suffix="tier4_summation_delivered"
|
||||
PolledSmartEnergySummation, id_suffix="tier4_summation_delivered"
|
||||
):
|
||||
"""Tier 4 Smart Energy Metering summation sensor."""
|
||||
|
||||
@@ -563,7 +563,7 @@ class Tier4SmartEnergySummation(
|
||||
models={"ZLinky_TIC"},
|
||||
)
|
||||
class Tier5SmartEnergySummation(
|
||||
SmartEnergySummation, id_suffix="tier5_summation_delivered"
|
||||
PolledSmartEnergySummation, id_suffix="tier5_summation_delivered"
|
||||
):
|
||||
"""Tier 5 Smart Energy Metering summation sensor."""
|
||||
|
||||
@@ -576,7 +576,7 @@ class Tier5SmartEnergySummation(
|
||||
models={"ZLinky_TIC"},
|
||||
)
|
||||
class Tier6SmartEnergySummation(
|
||||
SmartEnergySummation, id_suffix="tier6_summation_delivered"
|
||||
PolledSmartEnergySummation, id_suffix="tier6_summation_delivered"
|
||||
):
|
||||
"""Tier 6 Smart Energy Metering summation sensor."""
|
||||
|
||||
|
@@ -31,7 +31,8 @@
|
||||
"title": "Network Formation",
|
||||
"description": "Choose the network settings for your radio.",
|
||||
"menu_options": {
|
||||
"form_new_network": "Erase network settings and form a new network",
|
||||
"form_new_network": "Erase network settings and create a new network",
|
||||
"form_initial_network": "Create a network",
|
||||
"reuse_settings": "Keep radio network settings",
|
||||
"choose_automatic_backup": "Restore an automatic backup",
|
||||
"upload_manual_backup": "Upload a manual backup"
|
||||
@@ -86,11 +87,11 @@
|
||||
},
|
||||
"intent_migrate": {
|
||||
"title": "Migrate to a new radio",
|
||||
"description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?"
|
||||
"description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?"
|
||||
},
|
||||
"instruct_unplug": {
|
||||
"title": "Unplug your old radio",
|
||||
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it."
|
||||
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio."
|
||||
},
|
||||
"choose_serial_port": {
|
||||
"title": "[%key:component::zha::config::step::choose_serial_port::title%]",
|
||||
@@ -120,6 +121,7 @@
|
||||
"description": "[%key:component::zha::config::step::choose_formation_strategy::description%]",
|
||||
"menu_options": {
|
||||
"form_new_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::form_new_network%]",
|
||||
"form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::form_initial_network%]",
|
||||
"reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::reuse_settings%]",
|
||||
"choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::choose_automatic_backup%]",
|
||||
"upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::upload_manual_backup%]"
|
||||
|
@@ -372,14 +372,26 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_
|
||||
channel_names=CHANNEL_INOVELLI,
|
||||
)
|
||||
class InovelliDoubleTapForFullBrightness(
|
||||
ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_full_brightness"
|
||||
ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_max_brightness"
|
||||
):
|
||||
"""Inovelli double tap for full brightness control."""
|
||||
|
||||
_zcl_attribute: str = "double_tap_up_for_full_brightness"
|
||||
_zcl_attribute: str = "double_tap_up_for_max_brightness"
|
||||
_attr_name: str = "Double tap full brightness"
|
||||
|
||||
|
||||
@CONFIG_DIAGNOSTIC_MATCH(
|
||||
channel_names=CHANNEL_INOVELLI,
|
||||
)
|
||||
class InovelliDoubleTapForMinBrightness(
|
||||
ZHASwitchConfigurationEntity, id_suffix="double_tap_down_for_min_brightness"
|
||||
):
|
||||
"""Inovelli double tap down for minimum brightness control."""
|
||||
|
||||
_zcl_attribute: str = "double_tap_down_for_min_brightness"
|
||||
_attr_name: str = "Double tap minimum brightness"
|
||||
|
||||
|
||||
@CONFIG_DIAGNOSTIC_MATCH(
|
||||
channel_names=CHANNEL_INOVELLI,
|
||||
)
|
||||
|
@@ -22,7 +22,8 @@
|
||||
"description": "Choose the network settings for your radio.",
|
||||
"menu_options": {
|
||||
"choose_automatic_backup": "Restore an automatic backup",
|
||||
"form_new_network": "Erase network settings and form a new network",
|
||||
"form_initial_network": "Create a network",
|
||||
"form_new_network": "Erase network settings and create a new network",
|
||||
"reuse_settings": "Keep radio network settings",
|
||||
"upload_manual_backup": "Upload a manual backup"
|
||||
},
|
||||
@@ -174,7 +175,8 @@
|
||||
"description": "Choose the network settings for your radio.",
|
||||
"menu_options": {
|
||||
"choose_automatic_backup": "Restore an automatic backup",
|
||||
"form_new_network": "Erase network settings and form a new network",
|
||||
"form_initial_network": "Create a network",
|
||||
"form_new_network": "Erase network settings and create a new network",
|
||||
"reuse_settings": "Keep radio network settings",
|
||||
"upload_manual_backup": "Upload a manual backup"
|
||||
},
|
||||
@@ -192,11 +194,11 @@
|
||||
"title": "Reconfigure ZHA"
|
||||
},
|
||||
"instruct_unplug": {
|
||||
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.",
|
||||
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio.",
|
||||
"title": "Unplug your old radio"
|
||||
},
|
||||
"intent_migrate": {
|
||||
"description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?",
|
||||
"description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?",
|
||||
"title": "Migrate to a new radio"
|
||||
},
|
||||
"manual_pick_radio_type": {
|
||||
|
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
PATCH_VERSION: Final = "0b8"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
|
||||
|
@@ -2007,7 +2007,7 @@
|
||||
"name": "Google Domains"
|
||||
},
|
||||
"google_mail": {
|
||||
"integration_type": "device",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "Google Mail"
|
||||
|
@@ -13,7 +13,7 @@ from collections.abc import Awaitable, Callable
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, cast
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from aiohttp import client, web
|
||||
import async_timeout
|
||||
@@ -437,7 +437,10 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
|
||||
state = _decode_jwt(hass, request.query["state"])
|
||||
|
||||
if state is None:
|
||||
return web.Response(text="Invalid state")
|
||||
return web.Response(
|
||||
text="Invalid state. Is My Home Assistant configured to go to the right instance?",
|
||||
status=400,
|
||||
)
|
||||
|
||||
user_input: dict[str, Any] = {"state": state}
|
||||
|
||||
@@ -538,7 +541,10 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str:
|
||||
@callback
|
||||
def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None:
|
||||
"""JWT encode data."""
|
||||
secret = cast(str, hass.data.get(DATA_JWT_SECRET))
|
||||
secret = cast(Optional[str], hass.data.get(DATA_JWT_SECRET))
|
||||
|
||||
if secret is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return jwt.decode(encoded, secret, algorithms=["HS256"])
|
||||
|
@@ -20,7 +20,7 @@ from homeassistant.core import Context, HomeAssistant, State, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from . import area_registry, config_validation as cv, entity_registry
|
||||
from . import area_registry, config_validation as cv, device_registry, entity_registry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_SlotsType = dict[str, Any]
|
||||
@@ -138,15 +138,62 @@ def _has_name(
|
||||
if name in (state.entity_id, state.name.casefold()):
|
||||
return True
|
||||
|
||||
# Check aliases
|
||||
if (entity is not None) and entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
if name == alias.casefold():
|
||||
return True
|
||||
# Check name/aliases
|
||||
if (entity is None) or (not entity.aliases):
|
||||
return False
|
||||
|
||||
for alias in entity.aliases:
|
||||
if name == alias.casefold():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _find_area(
|
||||
id_or_name: str, areas: area_registry.AreaRegistry
|
||||
) -> area_registry.AreaEntry | None:
|
||||
"""Find an area by id or name, checking aliases too."""
|
||||
area = areas.async_get_area(id_or_name) or areas.async_get_area_by_name(id_or_name)
|
||||
if area is not None:
|
||||
return area
|
||||
|
||||
# Check area aliases
|
||||
for maybe_area in areas.areas.values():
|
||||
if not maybe_area.aliases:
|
||||
continue
|
||||
|
||||
for area_alias in maybe_area.aliases:
|
||||
if id_or_name == area_alias.casefold():
|
||||
return maybe_area
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _filter_by_area(
|
||||
states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]],
|
||||
area: area_registry.AreaEntry,
|
||||
devices: device_registry.DeviceRegistry,
|
||||
) -> Iterable[tuple[State, entity_registry.RegistryEntry | None]]:
|
||||
"""Filter state/entity pairs by an area."""
|
||||
entity_area_ids: dict[str, str | None] = {}
|
||||
for _state, entity in states_and_entities:
|
||||
if entity is None:
|
||||
continue
|
||||
|
||||
if entity.area_id:
|
||||
# Use entity's area id first
|
||||
entity_area_ids[entity.id] = entity.area_id
|
||||
elif entity.device_id:
|
||||
# Fall back to device area if not set on entity
|
||||
device = devices.async_get(entity.device_id)
|
||||
if device is not None:
|
||||
entity_area_ids[entity.id] = device.area_id
|
||||
|
||||
for state, entity in states_and_entities:
|
||||
if (entity is not None) and (entity_area_ids.get(entity.id) == area.id):
|
||||
yield (state, entity)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_match_states(
|
||||
@@ -159,6 +206,7 @@ def async_match_states(
|
||||
states: Iterable[State] | None = None,
|
||||
entities: entity_registry.EntityRegistry | None = None,
|
||||
areas: area_registry.AreaRegistry | None = None,
|
||||
devices: device_registry.DeviceRegistry | None = None,
|
||||
) -> Iterable[State]:
|
||||
"""Find states that match the constraints."""
|
||||
if states is None:
|
||||
@@ -199,28 +247,29 @@ def async_match_states(
|
||||
if areas is None:
|
||||
areas = area_registry.async_get(hass)
|
||||
|
||||
# id or name
|
||||
area = areas.async_get_area(area_name) or areas.async_get_area_by_name(
|
||||
area_name
|
||||
)
|
||||
area = _find_area(area_name, areas)
|
||||
assert area is not None, f"No area named {area_name}"
|
||||
|
||||
if area is not None:
|
||||
# Filter by area
|
||||
states_and_entities = [
|
||||
(state, entity)
|
||||
for state, entity in states_and_entities
|
||||
if (entity is not None) and (entity.area_id == area.id)
|
||||
]
|
||||
# Filter by states/entities by area
|
||||
if devices is None:
|
||||
devices = device_registry.async_get(hass)
|
||||
|
||||
states_and_entities = list(_filter_by_area(states_and_entities, area, devices))
|
||||
|
||||
if name is not None:
|
||||
if devices is None:
|
||||
devices = device_registry.async_get(hass)
|
||||
|
||||
# Filter by name
|
||||
name = name.casefold()
|
||||
|
||||
# Check states
|
||||
for state, entity in states_and_entities:
|
||||
if _has_name(state, entity, name):
|
||||
yield state
|
||||
break
|
||||
|
||||
else:
|
||||
# Not filtered by name
|
||||
for state, _entity in states_and_entities:
|
||||
|
@@ -71,6 +71,40 @@ def json_bytes(data: Any) -> bytes:
|
||||
)
|
||||
|
||||
|
||||
def json_bytes_strip_null(data: Any) -> bytes:
|
||||
"""Dump json bytes after terminating strings at the first NUL."""
|
||||
|
||||
def process_dict(_dict: dict[Any, Any]) -> dict[Any, Any]:
|
||||
"""Strip NUL from items in a dict."""
|
||||
return {key: strip_null(o) for key, o in _dict.items()}
|
||||
|
||||
def process_list(_list: list[Any]) -> list[Any]:
|
||||
"""Strip NUL from items in a list."""
|
||||
return [strip_null(o) for o in _list]
|
||||
|
||||
def strip_null(obj: Any) -> Any:
|
||||
"""Strip NUL from an object."""
|
||||
if isinstance(obj, str):
|
||||
return obj.split("\0", 1)[0]
|
||||
if isinstance(obj, dict):
|
||||
return process_dict(obj)
|
||||
if isinstance(obj, list):
|
||||
return process_list(obj)
|
||||
return obj
|
||||
|
||||
# We expect null-characters to be very rare, hence try encoding first and look
|
||||
# for an escaped null-character in the output.
|
||||
result = json_bytes(data)
|
||||
if b"\\u0000" in result:
|
||||
# We work on the processed result so we don't need to worry about
|
||||
# Home Assistant extensions which allows encoding sets, tuples, etc.
|
||||
data_processed = orjson.loads(result)
|
||||
data_processed = strip_null(data_processed)
|
||||
result = json_bytes(data_processed)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def json_dumps(data: Any) -> str:
|
||||
"""Dump json string.
|
||||
|
||||
|
@@ -4,7 +4,7 @@ aiodiscover==1.4.13
|
||||
aiohttp==3.8.1
|
||||
aiohttp_cors==0.7.0
|
||||
astral==2.2
|
||||
async-upnp-client==0.33.0
|
||||
async-upnp-client==0.33.1
|
||||
async_timeout==4.0.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==22.2.0
|
||||
@@ -21,10 +21,10 @@ cryptography==39.0.0
|
||||
dbus-fast==1.84.0
|
||||
fnvhash==0.1.0
|
||||
hass-nabucasa==0.61.0
|
||||
hassil==0.2.5
|
||||
hassil==0.2.6
|
||||
home-assistant-bluetooth==1.9.2
|
||||
home-assistant-frontend==20230125.0
|
||||
home-assistant-intents==2023.1.25
|
||||
home-assistant-frontend==20230130.0
|
||||
home-assistant-intents==2023.1.31
|
||||
httpx==0.23.3
|
||||
ifaddr==0.1.7
|
||||
janus==1.0.0
|
||||
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.2.0.dev0"
|
||||
version = "2023.2.0b8"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
@@ -214,7 +214,7 @@ aiolyric==1.0.9
|
||||
aiomodernforms==0.1.8
|
||||
|
||||
# homeassistant.components.yamaha_musiccast
|
||||
aiomusiccast==0.14.4
|
||||
aiomusiccast==0.14.7
|
||||
|
||||
# homeassistant.components.nanoleaf
|
||||
aionanoleaf==0.2.1
|
||||
@@ -267,7 +267,7 @@ aiosenseme==0.6.1
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==5.3.0
|
||||
aioshelly==5.3.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -371,7 +371,7 @@ asterisk_mbox==0.5.0
|
||||
# homeassistant.components.ssdp
|
||||
# homeassistant.components.upnp
|
||||
# homeassistant.components.yeelight
|
||||
async-upnp-client==0.33.0
|
||||
async-upnp-client==0.33.1
|
||||
|
||||
# homeassistant.components.supla
|
||||
asyncpysupla==0.0.5
|
||||
@@ -422,7 +422,7 @@ beautifulsoup4==4.11.1
|
||||
# beewi_smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.34.6
|
||||
bellows==0.34.7
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.12.0
|
||||
@@ -658,7 +658,7 @@ enocean==0.50
|
||||
enturclient==0.2.4
|
||||
|
||||
# homeassistant.components.environment_canada
|
||||
env_canada==0.5.22
|
||||
env_canada==0.5.27
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
envoy_reader==0.20.1
|
||||
@@ -673,7 +673,7 @@ epson-projector==0.5.0
|
||||
epsonprinter==0.0.9
|
||||
|
||||
# homeassistant.components.esphome
|
||||
esphome-dashboard-api==1.2.1
|
||||
esphome-dashboard-api==1.2.3
|
||||
|
||||
# homeassistant.components.netgear_lte
|
||||
eternalegypt==0.0.12
|
||||
@@ -874,7 +874,7 @@ hass-nabucasa==0.61.0
|
||||
hass_splunk==0.1.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
hassil==0.2.5
|
||||
hassil==0.2.6
|
||||
|
||||
# homeassistant.components.tasmota
|
||||
hatasmota==0.6.3
|
||||
@@ -907,10 +907,10 @@ hole==0.8.0
|
||||
holidays==0.18.0
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230125.0
|
||||
home-assistant-frontend==20230130.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.1.25
|
||||
home-assistant-intents==2023.1.31
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
@@ -1168,7 +1168,7 @@ mycroftapi==2.0
|
||||
nad_receiver==0.3.0
|
||||
|
||||
# homeassistant.components.keenetic_ndms2
|
||||
ndms2_client==0.1.1
|
||||
ndms2_client==0.1.2
|
||||
|
||||
# homeassistant.components.ness_alarm
|
||||
nessclient==0.10.0
|
||||
@@ -1373,7 +1373,7 @@ plexauth==0.0.6
|
||||
plexwebsocket==0.0.13
|
||||
|
||||
# homeassistant.components.plugwise
|
||||
plugwise==0.27.1
|
||||
plugwise==0.27.5
|
||||
|
||||
# homeassistant.components.plum_lightpad
|
||||
plumlightpad==0.0.11
|
||||
@@ -1702,7 +1702,7 @@ pyirishrail==0.0.2
|
||||
pyiss==1.0.1
|
||||
|
||||
# homeassistant.components.isy994
|
||||
pyisy==3.1.9
|
||||
pyisy==3.1.11
|
||||
|
||||
# homeassistant.components.itach
|
||||
pyitachip2ir==0.0.7
|
||||
@@ -2072,7 +2072,7 @@ python-kasa==0.5.0
|
||||
# python-lirc==1.2.3
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==2.0.1
|
||||
python-matter-server==2.0.2
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
@@ -2693,7 +2693,7 @@ zeroconf==0.47.1
|
||||
zeversolar==0.2.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.91
|
||||
zha-quirks==0.0.92
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
|
@@ -195,7 +195,7 @@ aiolyric==1.0.9
|
||||
aiomodernforms==0.1.8
|
||||
|
||||
# homeassistant.components.yamaha_musiccast
|
||||
aiomusiccast==0.14.4
|
||||
aiomusiccast==0.14.7
|
||||
|
||||
# homeassistant.components.nanoleaf
|
||||
aionanoleaf==0.2.1
|
||||
@@ -245,7 +245,7 @@ aiosenseme==0.6.1
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==5.3.0
|
||||
aioshelly==5.3.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -328,7 +328,7 @@ arcam-fmj==1.0.1
|
||||
# homeassistant.components.ssdp
|
||||
# homeassistant.components.upnp
|
||||
# homeassistant.components.yeelight
|
||||
async-upnp-client==0.33.0
|
||||
async-upnp-client==0.33.1
|
||||
|
||||
# homeassistant.components.sleepiq
|
||||
asyncsleepiq==1.2.3
|
||||
@@ -352,7 +352,7 @@ base36==0.1.1
|
||||
beautifulsoup4==4.11.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.34.6
|
||||
bellows==0.34.7
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.12.0
|
||||
@@ -511,7 +511,7 @@ energyzero==0.3.1
|
||||
enocean==0.50
|
||||
|
||||
# homeassistant.components.environment_canada
|
||||
env_canada==0.5.22
|
||||
env_canada==0.5.27
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
envoy_reader==0.20.1
|
||||
@@ -523,7 +523,7 @@ ephem==4.1.2
|
||||
epson-projector==0.5.0
|
||||
|
||||
# homeassistant.components.esphome
|
||||
esphome-dashboard-api==1.2.1
|
||||
esphome-dashboard-api==1.2.3
|
||||
|
||||
# homeassistant.components.eufylife_ble
|
||||
eufylife_ble_client==0.1.7
|
||||
@@ -666,7 +666,7 @@ habitipy==0.2.0
|
||||
hass-nabucasa==0.61.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
hassil==0.2.5
|
||||
hassil==0.2.6
|
||||
|
||||
# homeassistant.components.tasmota
|
||||
hatasmota==0.6.3
|
||||
@@ -690,10 +690,10 @@ hole==0.8.0
|
||||
holidays==0.18.0
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230125.0
|
||||
home-assistant-frontend==20230130.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.1.25
|
||||
home-assistant-intents==2023.1.31
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
@@ -867,7 +867,7 @@ mutagen==1.46.0
|
||||
mutesync==0.0.1
|
||||
|
||||
# homeassistant.components.keenetic_ndms2
|
||||
ndms2_client==0.1.1
|
||||
ndms2_client==0.1.2
|
||||
|
||||
# homeassistant.components.ness_alarm
|
||||
nessclient==0.10.0
|
||||
@@ -1003,7 +1003,7 @@ plexauth==0.0.6
|
||||
plexwebsocket==0.0.13
|
||||
|
||||
# homeassistant.components.plugwise
|
||||
plugwise==0.27.1
|
||||
plugwise==0.27.5
|
||||
|
||||
# homeassistant.components.plum_lightpad
|
||||
plumlightpad==0.0.11
|
||||
@@ -1221,7 +1221,7 @@ pyiqvia==2022.04.0
|
||||
pyiss==1.0.1
|
||||
|
||||
# homeassistant.components.isy994
|
||||
pyisy==3.1.9
|
||||
pyisy==3.1.11
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.1
|
||||
@@ -1468,7 +1468,7 @@ python-juicenet==1.1.0
|
||||
python-kasa==0.5.0
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==2.0.1
|
||||
python-matter-server==2.0.2
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
@@ -1906,7 +1906,7 @@ zeroconf==0.47.1
|
||||
zeversolar==0.2.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.91
|
||||
zha-quirks==0.0.92
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.19.2
|
||||
|
@@ -111,7 +111,7 @@ def patch_connect(success):
|
||||
}
|
||||
|
||||
|
||||
def patch_shell(response=None, error=False, mac_eth=False):
|
||||
def patch_shell(response=None, error=False, mac_eth=False, exc=None):
|
||||
"""Mock the `AdbDeviceTcpAsyncFake.shell` and `DeviceAsyncFake.shell` methods."""
|
||||
|
||||
async def shell_success(self, cmd, *args, **kwargs):
|
||||
@@ -128,7 +128,7 @@ def patch_shell(response=None, error=False, mac_eth=False):
|
||||
async def shell_fail_python(self, cmd, *args, **kwargs):
|
||||
"""Mock the `AdbDeviceTcpAsyncFake.shell` method when it fails."""
|
||||
self.shell_cmd = cmd
|
||||
raise ValueError
|
||||
raise exc or ValueError
|
||||
|
||||
async def shell_fail_server(self, cmd):
|
||||
"""Mock the `DeviceAsyncFake.shell` method when it fails."""
|
||||
|
@@ -2,6 +2,7 @@
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from adb_shell.exceptions import TcpTimeoutException as AdbShellTimeoutException
|
||||
from androidtv.constants import APPS as ANDROIDTV_APPS, KEYS
|
||||
from androidtv.exceptions import LockNotAcquiredException
|
||||
import pytest
|
||||
@@ -538,25 +539,28 @@ async def test_select_source_firetv(hass, source, expected_arg, method_patch):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
["config", "connect"],
|
||||
[
|
||||
CONFIG_ANDROIDTV_DEFAULT,
|
||||
CONFIG_FIRETV_DEFAULT,
|
||||
(CONFIG_ANDROIDTV_DEFAULT, False),
|
||||
(CONFIG_FIRETV_DEFAULT, False),
|
||||
(CONFIG_ANDROIDTV_DEFAULT, True),
|
||||
(CONFIG_FIRETV_DEFAULT, True),
|
||||
],
|
||||
)
|
||||
async def test_setup_fail(hass, config):
|
||||
async def test_setup_fail(hass, config, connect):
|
||||
"""Test that the entity is not created when the ADB connection is not established."""
|
||||
patch_key, entity_id, config_entry = _setup(config)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patchers.patch_connect(False)[patch_key], patchers.patch_shell(
|
||||
SHELL_RESPONSE_OFF
|
||||
with patchers.patch_connect(connect)[patch_key], patchers.patch_shell(
|
||||
SHELL_RESPONSE_OFF, error=True, exc=AdbShellTimeoutException
|
||||
)[patch_key]:
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id) is False
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_update_entity(hass, entity_id)
|
||||
state = hass.states.get(entity_id)
|
||||
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||
assert state is None
|
||||
|
||||
|
||||
|
@@ -186,3 +186,18 @@ def one_adapter_old_bluez():
|
||||
},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="disable_new_discovery_flows")
|
||||
def disable_new_discovery_flows_fixture():
|
||||
"""Fixture that disables new discovery flows.
|
||||
|
||||
We want to disable new discovery flows as we are testing the
|
||||
BluetoothManager and not the discovery flows. This fixture
|
||||
will patch the discovery_flow.async_create_flow method to
|
||||
ensure we do not load other integrations.
|
||||
"""
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow"
|
||||
) as mock_create_flow:
|
||||
yield mock_create_flow
|
||||
|
@@ -345,7 +345,9 @@ async def test_base_scanner_connecting_behavior(hass, enable_bluetooth):
|
||||
unsetup()
|
||||
|
||||
|
||||
async def test_restore_history_remote_adapter(hass, hass_storage):
|
||||
async def test_restore_history_remote_adapter(
|
||||
hass, hass_storage, disable_new_discovery_flows
|
||||
):
|
||||
"""Test we can restore history for a remote adapter."""
|
||||
|
||||
data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads(
|
||||
|
@@ -282,7 +282,9 @@ async def test_switching_adapters_based_on_stale(
|
||||
)
|
||||
|
||||
|
||||
async def test_restore_history_from_dbus(hass, one_adapter):
|
||||
async def test_restore_history_from_dbus(
|
||||
hass, one_adapter, disable_new_discovery_flows
|
||||
):
|
||||
"""Test we can restore history from dbus."""
|
||||
address = "AA:BB:CC:CC:CC:FF"
|
||||
|
||||
@@ -304,7 +306,7 @@ async def test_restore_history_from_dbus(hass, one_adapter):
|
||||
|
||||
|
||||
async def test_restore_history_from_dbus_and_remote_adapters(
|
||||
hass, one_adapter, hass_storage
|
||||
hass, one_adapter, hass_storage, disable_new_discovery_flows
|
||||
):
|
||||
"""Test we can restore history from dbus along with remote adapters."""
|
||||
address = "AA:BB:CC:CC:CC:FF"
|
||||
@@ -337,10 +339,11 @@ async def test_restore_history_from_dbus_and_remote_adapters(
|
||||
assert (
|
||||
bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is not None
|
||||
)
|
||||
assert disable_new_discovery_flows.call_count > 1
|
||||
|
||||
|
||||
async def test_restore_history_from_dbus_and_corrupted_remote_adapters(
|
||||
hass, one_adapter, hass_storage
|
||||
hass, one_adapter, hass_storage, disable_new_discovery_flows
|
||||
):
|
||||
"""Test we can restore history from dbus when the remote adapters data is corrupted."""
|
||||
address = "AA:BB:CC:CC:CC:FF"
|
||||
@@ -371,6 +374,7 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters(
|
||||
|
||||
assert bluetooth.async_ble_device_from_address(hass, address) is not None
|
||||
assert bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is None
|
||||
assert disable_new_discovery_flows.call_count >= 1
|
||||
|
||||
|
||||
async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
|
||||
|
@@ -6,11 +6,17 @@ import pytest
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.cover import SERVICE_OPEN_COVER
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context
|
||||
from homeassistant.helpers import entity_registry, intent
|
||||
from homeassistant.helpers import (
|
||||
area_registry,
|
||||
device_registry,
|
||||
entity_registry,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_mock_service
|
||||
from tests.common import MockConfigEntry, async_mock_service
|
||||
|
||||
|
||||
class OrderBeerIntentHandler(intent.IntentHandler):
|
||||
@@ -75,6 +81,143 @@ async def test_http_processing_intent(
|
||||
}
|
||||
|
||||
|
||||
async def test_http_processing_intent_entity_added(
|
||||
hass, init_components, hass_client, hass_admin_user
|
||||
):
|
||||
"""Test processing intent via HTTP API with entities added later.
|
||||
|
||||
We want to ensure that adding an entity later busts the cache
|
||||
so that the new entity is available as well as any aliases.
|
||||
"""
|
||||
er = entity_registry.async_get(hass)
|
||||
er.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen")
|
||||
er.async_update_entity("light.kitchen", aliases={"my cool light"})
|
||||
hass.states.async_set("light.kitchen", "off")
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "turn on my cool light"}
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"response": {
|
||||
"response_type": "action_done",
|
||||
"card": {},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Turned on my cool light",
|
||||
}
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {
|
||||
"targets": [],
|
||||
"success": [
|
||||
{"id": "light.kitchen", "name": "kitchen", "type": "entity"}
|
||||
],
|
||||
"failed": [],
|
||||
},
|
||||
},
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
# Add an alias
|
||||
er.async_get_or_create("light", "demo", "5678", suggested_object_id="late")
|
||||
hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"})
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "turn on friendly light"}
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"response": {
|
||||
"response_type": "action_done",
|
||||
"card": {},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Turned on friendly light",
|
||||
}
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {
|
||||
"targets": [],
|
||||
"success": [
|
||||
{"id": "light.late", "name": "friendly light", "type": "entity"}
|
||||
],
|
||||
"failed": [],
|
||||
},
|
||||
},
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
# Now add an alias
|
||||
er.async_update_entity("light.late", aliases={"late added light"})
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "turn on late added light"}
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"response": {
|
||||
"response_type": "action_done",
|
||||
"card": {},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Turned on late added light",
|
||||
}
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {
|
||||
"targets": [],
|
||||
"success": [
|
||||
{"id": "light.late", "name": "friendly light", "type": "entity"}
|
||||
],
|
||||
"failed": [],
|
||||
},
|
||||
},
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
# Now delete the entity
|
||||
er.async_remove("light.late")
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "turn on late added light"}
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
assert data == {
|
||||
"conversation_id": None,
|
||||
"response": {
|
||||
"card": {},
|
||||
"data": {"code": "no_intent_match"},
|
||||
"language": hass.config.language,
|
||||
"response_type": "error",
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Sorry, I couldn't understand " "that",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on"))
|
||||
async def test_turn_on_intent(hass, init_components, sentence):
|
||||
"""Test calling the turn on intent."""
|
||||
@@ -569,3 +712,117 @@ async def test_non_default_response(hass, init_components):
|
||||
)
|
||||
)
|
||||
assert result.response.speech["plain"]["speech"] == "Opened front door"
|
||||
|
||||
|
||||
async def test_turn_on_area(hass, init_components):
|
||||
"""Test turning on an area."""
|
||||
er = entity_registry.async_get(hass)
|
||||
dr = device_registry.async_get(hass)
|
||||
ar = area_registry.async_get(hass)
|
||||
entry = MockConfigEntry(domain="test")
|
||||
|
||||
device = dr.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
|
||||
)
|
||||
|
||||
kitchen_area = ar.async_create("kitchen")
|
||||
dr.async_update_device(device.id, area_id=kitchen_area.id)
|
||||
|
||||
er.async_get_or_create("light", "demo", "1234", suggested_object_id="stove")
|
||||
er.async_update_entity(
|
||||
"light.stove", aliases={"my stove light"}, area_id=kitchen_area.id
|
||||
)
|
||||
hass.states.async_set("light.stove", "off")
|
||||
|
||||
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on")
|
||||
|
||||
await hass.services.async_call(
|
||||
"conversation",
|
||||
"process",
|
||||
{conversation.ATTR_TEXT: "turn on lights in the kitchen"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.domain == HASS_DOMAIN
|
||||
assert call.service == "turn_on"
|
||||
assert call.data == {"entity_id": "light.stove"}
|
||||
|
||||
basement_area = ar.async_create("basement")
|
||||
dr.async_update_device(device.id, area_id=basement_area.id)
|
||||
er.async_update_entity("light.stove", area_id=basement_area.id)
|
||||
calls.clear()
|
||||
|
||||
# Test that the area is updated
|
||||
await hass.services.async_call(
|
||||
"conversation",
|
||||
"process",
|
||||
{conversation.ATTR_TEXT: "turn on lights in the kitchen"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(calls) == 0
|
||||
|
||||
# Test the new area works
|
||||
await hass.services.async_call(
|
||||
"conversation",
|
||||
"process",
|
||||
{conversation.ATTR_TEXT: "turn on lights in the basement"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.domain == HASS_DOMAIN
|
||||
assert call.service == "turn_on"
|
||||
assert call.data == {"entity_id": "light.stove"}
|
||||
|
||||
|
||||
async def test_light_area_same_name(hass, init_components):
|
||||
"""Test turning on a light with the same name as an area."""
|
||||
entities = entity_registry.async_get(hass)
|
||||
devices = device_registry.async_get(hass)
|
||||
areas = area_registry.async_get(hass)
|
||||
entry = MockConfigEntry(domain="test")
|
||||
|
||||
device = devices.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
|
||||
)
|
||||
|
||||
kitchen_area = areas.async_create("kitchen")
|
||||
devices.async_update_device(device.id, area_id=kitchen_area.id)
|
||||
|
||||
kitchen_light = entities.async_get_or_create(
|
||||
"light", "demo", "1234", original_name="kitchen light"
|
||||
)
|
||||
entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen_area.id)
|
||||
hass.states.async_set(
|
||||
kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
|
||||
)
|
||||
|
||||
ceiling_light = entities.async_get_or_create(
|
||||
"light", "demo", "5678", original_name="ceiling light"
|
||||
)
|
||||
entities.async_update_entity(ceiling_light.entity_id, area_id=kitchen_area.id)
|
||||
hass.states.async_set(
|
||||
ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"}
|
||||
)
|
||||
|
||||
calls = async_mock_service(hass, HASS_DOMAIN, "turn_on")
|
||||
|
||||
await hass.services.async_call(
|
||||
"conversation",
|
||||
"process",
|
||||
{conversation.ATTR_TEXT: "turn on kitchen light"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Should only turn on one light instead of all lights in the kitchen
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.domain == HASS_DOMAIN
|
||||
assert call.service == "turn_on"
|
||||
assert call.data == {"entity_id": kitchen_light.entity_id}
|
||||
|
@@ -3,3 +3,4 @@
|
||||
DASHBOARD_SLUG = "mock-slug"
|
||||
DASHBOARD_HOST = "mock-host"
|
||||
DASHBOARD_PORT = 1234
|
||||
VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
|
||||
|
@@ -7,7 +7,12 @@ from aioesphomeapi import APIClient, DeviceInfo
|
||||
import pytest
|
||||
from zeroconf import Zeroconf
|
||||
|
||||
from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard
|
||||
from homeassistant.components.esphome import (
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_NOISE_PSK,
|
||||
DOMAIN,
|
||||
dashboard,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -27,9 +32,9 @@ def esphome_mock_async_zeroconf(mock_async_zeroconf):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
def mock_config_entry(hass) -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
config_entry = MockConfigEntry(
|
||||
title="ESPHome Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
@@ -37,9 +42,12 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_PORT: 6053,
|
||||
CONF_PASSWORD: "pwd",
|
||||
CONF_NOISE_PSK: "12345678123456781234567812345678",
|
||||
CONF_DEVICE_NAME: "test",
|
||||
},
|
||||
unique_id="11:22:33:44:55:aa",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
return config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -59,8 +67,6 @@ async def init_integration(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the ESPHome integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
@@ -24,9 +24,10 @@ from homeassistant.components.hassio import HassioServiceInfo
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import VALID_NOISE_PSK
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
|
||||
INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM="
|
||||
|
||||
|
||||
@@ -518,6 +519,116 @@ async def test_reauth_fixed_via_dashboard(
|
||||
assert len(mock_get_encryption_key.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_fixed_via_dashboard_add_encryption_remove_password(
|
||||
hass, mock_client, mock_zeroconf, mock_dashboard, mock_config_entry
|
||||
):
|
||||
"""Test reauth fixed automatically via dashboard with password removed."""
|
||||
mock_client.device_info.side_effect = (
|
||||
InvalidAuthAPIError,
|
||||
DeviceInfo(uses_password=False, name="test"),
|
||||
)
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
"name": "test",
|
||||
"configuration": "test.yaml",
|
||||
}
|
||||
)
|
||||
|
||||
await dashboard.async_get_dashboard(hass).async_refresh()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||
return_value=VALID_NOISE_PSK,
|
||||
) as mock_get_encryption_key:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome",
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": mock_config_entry.entry_id,
|
||||
"unique_id": mock_config_entry.unique_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT, result
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == ""
|
||||
|
||||
assert len(mock_get_encryption_key.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_fixed_via_remove_password(hass, mock_client, mock_config_entry):
|
||||
"""Test reauth fixed automatically by seeing password removed."""
|
||||
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome",
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": mock_config_entry.entry_id,
|
||||
"unique_id": mock_config_entry.unique_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT, result
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == ""
|
||||
|
||||
|
||||
async def test_reauth_fixed_via_dashboard_at_confirm(
|
||||
hass, mock_client, mock_zeroconf, mock_dashboard
|
||||
):
|
||||
"""Test reauth fixed automatically via dashboard at confirm step."""
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: "127.0.0.1",
|
||||
CONF_PORT: 6053,
|
||||
CONF_PASSWORD: "",
|
||||
CONF_DEVICE_NAME: "test",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome",
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
"unique_id": entry.unique_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM, result
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
"name": "test",
|
||||
"configuration": "test.yaml",
|
||||
}
|
||||
)
|
||||
|
||||
await dashboard.async_get_dashboard(hass).async_refresh()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||
return_value=VALID_NOISE_PSK,
|
||||
) as mock_get_encryption_key:
|
||||
# We just fetch the form
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT, result
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||
|
||||
assert len(mock_get_encryption_key.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf):
|
||||
"""Test reauth initiation with invalid PSK."""
|
||||
entry = MockConfigEntry(
|
||||
|
@@ -1,8 +1,13 @@
|
||||
"""Test ESPHome dashboard features."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.esphome import dashboard
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from aioesphomeapi import DeviceInfo, InvalidAuthAPIError
|
||||
|
||||
from homeassistant.components.esphome import CONF_NOISE_PSK, dashboard
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import VALID_NOISE_PSK
|
||||
|
||||
|
||||
async def test_new_info_reload_config_entries(hass, init_integration, mock_dashboard):
|
||||
@@ -20,3 +25,51 @@ async def test_new_info_reload_config_entries(hass, init_integration, mock_dashb
|
||||
await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052)
|
||||
|
||||
assert len(mock_setup.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_new_dashboard_fix_reauth(
|
||||
hass, mock_client, mock_config_entry, mock_dashboard
|
||||
):
|
||||
"""Test config entries waiting for reauth are triggered."""
|
||||
mock_client.device_info.side_effect = (
|
||||
InvalidAuthAPIError,
|
||||
DeviceInfo(uses_password=False, name="test"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||
return_value=VALID_NOISE_PSK,
|
||||
) as mock_get_encryption_key:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome",
|
||||
context={
|
||||
"source": SOURCE_REAUTH,
|
||||
"entry_id": mock_config_entry.entry_id,
|
||||
"unique_id": mock_config_entry.unique_id,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert len(mock_get_encryption_key.mock_calls) == 0
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
"name": "test",
|
||||
"configuration": "test.yaml",
|
||||
}
|
||||
)
|
||||
|
||||
await dashboard.async_get_dashboard(hass).async_refresh()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||
return_value=VALID_NOISE_PSK,
|
||||
) as mock_get_encryption_key, patch(
|
||||
"homeassistant.components.esphome.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_get_encryption_key.mock_calls) == 1
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert mock_config_entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||
|
@@ -3,7 +3,7 @@
|
||||
from aiohttp import ClientSession
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.esphome import CONF_NOISE_PSK
|
||||
from homeassistant.components.esphome import CONF_DEVICE_NAME, CONF_NOISE_PSK
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -25,6 +25,7 @@ async def test_diagnostics(
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["config"]["data"] == {
|
||||
CONF_DEVICE_NAME: "test",
|
||||
CONF_HOST: "192.168.1.2",
|
||||
CONF_PORT: 6053,
|
||||
CONF_PASSWORD: "**REDACTED**",
|
||||
|
@@ -114,6 +114,6 @@ async def mock_setup_integration(
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
yield func
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user