Compare commits

...

52 Commits

Author SHA1 Message Date
Paulus Schoutsen 0bae47c992 Bumped version to 2023.2.0b8 2023-01-31 15:16:21 -05:00
Paulus Schoutsen 0d3a368a1f Improve JSON errors from HTTP view (#87042) 2023-01-31 15:16:16 -05:00
Franck Nijhof c7871d13cf Fix Yamaha MusicCast zone sleep select entity (#87041) 2023-01-31 15:16:15 -05:00
J. Nick Koston 3d6ced2a16 Add a repair issue when using MariaDB is affected by MDEV-25020 (#87040)
closes https://github.com/home-assistant/core/issues/83787
2023-01-31 15:16:14 -05:00
Michael Hansen 2f403b712c Bump home-assistant-intents to 2023.1.31 (#87034) 2023-01-31 15:16:12 -05:00
mkmer 1caca91174 Honeywell Correct key name (#87018)
* Correct key name

* Logic error around setpoint and auto mode

* Set tempurature and setpoints correctly

* Only high/low in auto.
2023-01-31 15:16:11 -05:00
Franck Nijhof 1859dcf99b Only report invalid numeric value for sensors once (#87010) 2023-01-31 15:16:10 -05:00
Bouwe Westerdijk c34eb1ad9d Bump plugwise to v0.27.5 (#87001)
fixes undefined
2023-01-31 15:16:08 -05:00
Paulus Schoutsen be69e9579c Bump ESPHome Dashboard API 1.2.3 (#86997) 2023-01-31 15:16:07 -05:00
Michael Davie ac6fa3275b Bump env_canada to 0.5.27 (#86996)
fixes undefined
2023-01-31 15:16:06 -05:00
Paulus Schoutsen 2f896c5df8 Bumped version to 2023.2.0b7 2023-01-30 23:47:52 -05:00
Paulus Schoutsen c9e86ccd38 ESPHome handle remove password and no encryption (#86995)
* ESPHome handle remove password and no encryption

* Start reauth for invalid api password

---------

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2023-01-30 23:47:48 -05:00
Paulus Schoutsen 8760227296 ESPHome discovered dashboard checks reauth flows (#86993) 2023-01-30 23:47:47 -05:00
Michael Hansen edf02b70ea Prioritize entity names over area names in Assist matching (#86982)
* Refactor async_match_states

* Check entity name after state, before aliases

* Give entity name matches priority over area names

* Don't force result to have area

* Add area alias in tests

* Move name/area list creation back

* Clean up PR

* More clean up
2023-01-30 23:47:46 -05:00
shbatm 32a7ae6129 Bump pyisy to 3.1.11 (#86981)
* Bump pyisy to 3.1.10

* Bump pyisy to 3.1.11
2023-01-30 23:47:45 -05:00
Paulus Schoutsen 29056f1bd7 Check dashboard when showing reauth form (#86980) 2023-01-30 23:47:44 -05:00
puddly 01dea7773a Bump ZHA dependencies (#86979)
Bump ZHA dependency bellows from 0.34.6 to 0.34.7
2023-01-30 23:47:43 -05:00
Bram Kragten 688bba15ac Update frontend to 20230130.0 (#86978) 2023-01-30 23:47:42 -05:00
Franck Nijhof f6230e2d71 Allow any state class when using the precipitation device class (#86977) 2023-01-30 23:47:41 -05:00
Steven Looman 6a9f06d36e Ensure a proper scope_id is given for IPv6 addresses when initializing the SSDP component (#86975)
fixes undefined
2023-01-30 23:47:40 -05:00
Steven Looman dc50a6899a Fix error on empty location in ssdp messages (#86970) 2023-01-30 23:47:39 -05:00
Paul Bottein d39d4d6b7f Uses PolledSmartEnergySummation for ZLinky (#86960) 2023-01-30 23:47:38 -05:00
ollo69 6a1710063a Catch AndroidTV exception on setup (#86819)
fixes undefined
2023-01-30 23:47:37 -05:00
puddly 565a9735fc ZHA config flow cleanup (#86742)
fixes undefined
2023-01-30 23:47:36 -05:00
mkmer ba966bd0f7 Honeywell auto mode invalid attribute (#86728)
fixes undefined
2023-01-30 23:47:35 -05:00
Michael Hansen c7b944ca75 Use device area id in intent matching (#86678)
* Use device area id when matching

* Normalize whitespace in response

* Add extra test entity
2023-01-30 23:47:34 -05:00
Paulus Schoutsen 0702314dcb Bumped version to 2023.2.0b6 2023-01-30 14:39:37 -05:00
Michael Hansen 81de0bba22 Performance improvements for Assist (#86966)
* Move hassil recognize into executor

* Bump hassil to 0.2.6

* Disable template parsing in name/area lists

* Don't iterate over hass.config.components directly
2023-01-30 14:38:27 -05:00
Paulus Schoutsen 0b015d46c3 Fix some mobile app sensor registration/update issues (#86965)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2023-01-30 14:38:26 -05:00
J. Nick Koston 0713e034b9 Silence spurious warnings about removing ix_states_entity_id with newer installs (#86961)
* Silence spurious warnings about removing ix_states_entity_id with newer installs

https://ptb.discord.com/channels/330944238910963714/427516175237382144/1069648035459641465

* Silence spurious warnings about removing ix_states_entity_id with newer installs

https://ptb.discord.com/channels/330944238910963714/427516175237382144/1069648035459641465
2023-01-30 14:38:25 -05:00
Mick Vleeshouwer 171acc22ca Fix ThreeWayHandle sensor in Overkiz integration (#86953)
Fix typo in sensor.py

Fixes https://github.com/home-assistant/core/issues/85913
2023-01-30 14:38:24 -05:00
J. Nick Koston 2e26a40bba Speed up live history setup if there is no pending data to commit (#86942) 2023-01-30 14:38:23 -05:00
Jan Bouwhuis 3f717ae854 Fix MQTT discovery failing after bad config update (#86935)
* Fix MQTT discovery failing after bad config update

* Update last discovery payload after update success

* Improve test, correct update assignment

* send_discovery_done to finally-catch vol.Error

* Just use try..finally

* Remove extra line

* use elif to avoid log confusion
2023-01-30 14:38:21 -05:00
Paulus Schoutsen a491bfe84c Bumped version to 2023.2.0b5 2023-01-30 09:13:30 -05:00
Erik Montnemery 423acfa93b Drop minus sign on negative zero (#86939)
* Drop minus sign on negative zero

* Add tests
2023-01-30 09:13:26 -05:00
J. Nick Koston f14771ccf2 Fix old indices not being removed in schema migration leading to slow MySQL queries (#86917)
fixes #83787
2023-01-30 09:13:25 -05:00
Thomas Schamm 07e9b0e98b Add Bosch SHC description and host form strings (#86897)
* Add description to setup SHC II. Add missing host info in reauth_confirm

* Remove template value in en.json
2023-01-30 09:13:24 -05:00
J. Nick Koston 0d27ee4fd8 Cache the names and area lists in the default agent (#86874)
* Cache the names and area lists in the default agent

fixes #86803

* add coverage to make sure the entity cache busts

* add areas test

* cover the last line
2023-01-30 09:13:22 -05:00
Robert Hillis 71b13d8f3e Address Google mail late review (#86847) 2023-01-30 09:13:21 -05:00
Tom Puttemans 63c218060b Ignore empty payloads from DSMR Reader (#86841)
* Ignore empty payloads from DSMR Reader

* Simplify empty payload handling

If the native value hasn't changed, requesting to store it won't have a performance impact.

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

---------

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
2023-01-30 09:13:20 -05:00
Paulus Schoutsen 8a9de2671b Bumped version to 2023.2.0b4 2023-01-28 22:07:57 -05:00
J. Nick Koston 85d5ea2eca Fix v32 schema migration when MySQL global.time_zone is configured with non-UTC timezone (#86867)
* Fix v32 schema migration when MySQL timezone is not UTC

* tweak
2023-01-28 22:07:54 -05:00
Martin Hjelmare 55b5b36c47 Fix tradfri air quality device class (#86861) 2023-01-28 22:07:52 -05:00
J. Nick Koston c9cf3c29f8 Improve websocket throughput of state changes (#86855)
After the start event we tend to get an event storm of state
changes which can get the websocket behind. #86854 will
help with that a bit, but we can reduce the overhead
to build a state diff when the attributes have not
changed
2023-01-28 22:07:52 -05:00
Robert Hillis 6db9653a87 Fix D-Link attributes (#86842)
* Fix D-Link attributes

* fix blocking call
2023-01-28 22:07:51 -05:00
Paul Bottein 9adaf27064 Update frontend to 20230128.0 (#86838) 2023-01-28 22:07:50 -05:00
Paulus Schoutsen 69ed30f743 Bumped version to 2023.2.0b3 2023-01-27 22:54:05 -05:00
shbatm d33373f6ee Check for missing ISY994 Z-Wave Properties (#86829)
* Check for missing Z-Wave Properties

* Fix black from mobile
2023-01-27 22:54:01 -05:00
Robert Hillis bedf5fe6cd Fix D-Link config flow auth (#86824) 2023-01-27 22:54:00 -05:00
Bouwe Westerdijk 29eb7e8f9e Bump plugwise to v0.27.4 (#86812)
fixes undefined
2023-01-27 22:53:59 -05:00
J. Nick Koston 60b96f19b7 Fix Bluetooth discoveries missing between restarts (#86808)
* Fix Bluetooth discoveries missing between restarts

* do not load other integrations

* coverage
2023-01-27 22:53:58 -05:00
J. Nick Koston 0a6ce35e30 Chunk MariaDB and Postgresql data migration to avoid running out of buffer space (#86680)
* Chunk MariaDB data migration to avoid running out of buffer space

This will make the migration slower but since the innodb_buffer_pool_size
is using the defaul to 128M and not tuned to the db size there is a
risk of running out of buffer space for large databases

* Update homeassistant/components/recorder/migration.py

* hard code since bandit thinks its an injection

* Update homeassistant/components/recorder/migration.py

* guard against manually modified data/corrupt db

* adjust to 10k per chunk

* adjust to 50k per chunk

* memory still just fine at 250k

* but slower

* commit after each chunk to reduce lock pressure

* adjust

* set to 0 if null so we do not loop forever (this should only happen if the data is missing)

* set to 0 if null so we do not loop forever (this should only happen if the data is missing)

* tweak

* tweak

* limit cleanup

* lower limit to give some more buffer

* lower limit to give some more buffer

* where required for sqlite

* sqlite can wipe as many as needed with no limit

* limit on mysql only

* chunk postgres

* fix limit

* tweak

* fix reference

* fix

* tweak for ram

* postgres memory reduction

* defer cleanup

* fix

* same order
2023-01-27 22:53:57 -05:00
95 changed files with 1738 additions and 415 deletions
+31 -3
View File
@@ -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",
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+14 -9
View File
@@ -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": [
@@ -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",
+9 -1
View File
@@ -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()
@@ -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"],
@@ -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",
@@ -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"
+41 -23
View File
@@ -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:
+11 -2
View File
@@ -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,
+5 -1
View File
@@ -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": [
@@ -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)
+23 -19
View File
@@ -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,
}
+12 -4
View File
@@ -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
+1 -1
View File
@@ -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,
+5 -1
View File
@@ -1026,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."""
@@ -1042,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()
@@ -55,7 +55,7 @@ from .models import StatisticData, StatisticMetaData, process_timestamp
# pylint: disable=invalid-name
Base = declarative_base()
SCHEMA_VERSION = 32
SCHEMA_VERSION = 33
_StatisticsBaseSelfT = TypeVar("_StatisticsBaseSelfT", bound="StatisticsBase")
+150 -53
View File
@@ -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",
+69 -3
View File
@@ -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": [
{
+25 -15
View File
@@ -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 (
+1 -4
View File
@@ -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 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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": [],
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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": [
@@ -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"],
+23 -2
View File
@@ -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 (
+1 -1
View File
@@ -4,7 +4,7 @@
"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.92",
@@ -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
+6 -6
View File
@@ -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."""
+5 -3
View File
@@ -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%]"
@@ -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": {
+1 -1
View File
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023
MINOR_VERSION: Final = 2
PATCH_VERSION: Final = "0b2"
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)
+1 -1
View File
@@ -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"
+65 -16
View File
@@ -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:
+4 -4
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2023.2.0b2"
version = "2023.2.0b8"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
+10 -10
View File
@@ -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
@@ -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
@@ -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
+10 -10
View File
@@ -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
@@ -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
@@ -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
+2 -2
View File
@@ -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
+15
View File
@@ -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(
+7 -3
View File
@@ -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(
+259 -2
View File
@@ -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}
+1
View File
@@ -3,3 +3,4 @@
DASHBOARD_SLUG = "mock-slug"
DASHBOARD_HOST = "mock-host"
DASHBOARD_PORT = 1234
VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
+11 -5
View File
@@ -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()
+81 -18
View File
@@ -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,23 +519,14 @@ async def test_reauth_fixed_via_dashboard(
assert len(mock_get_encryption_key.mock_calls) == 1
async def test_reauth_fixed_via_dashboard_remove_password(
hass, mock_client, mock_zeroconf, mock_dashboard
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."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "hello",
CONF_DEVICE_NAME: "test",
},
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"),
)
entry.add_to_hass(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_dashboard["configured"].append(
{
@@ -553,15 +545,86 @@ async def test_reauth_fixed_via_dashboard_remove_password(
"esphome",
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": entry.entry_id,
"unique_id": entry.unique_id,
"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 entry.data[CONF_PASSWORD] == ""
assert len(mock_get_encryption_key.mock_calls) == 1
+55 -2
View File
@@ -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
+2 -1
View File
@@ -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**",
+1 -1
View File
@@ -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
@@ -0,0 +1,6 @@
{
"emailAddress": "example2@gmail.com",
"messagesTotal": 35308,
"threadsTotal": 33901,
"historyId": "4178212"
}
@@ -2,6 +2,7 @@
from unittest.mock import patch
from httplib2 import Response
import pytest
from homeassistant import config_entries
from homeassistant.components.google_mail.const import DOMAIN
@@ -68,14 +69,36 @@ async def test_full_flow(
)
@pytest.mark.parametrize(
"fixture,abort_reason,placeholders,calls,access_token",
[
("get_profile", "reauth_successful", None, 1, "updated-access-token"),
(
"get_profile_2",
"wrong_account",
{"email": "example@gmail.com"},
0,
"mock-access-token",
),
],
)
async def test_reauth(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock: AiohttpClientMocker,
current_request_with_host,
config_entry: MockConfigEntry,
fixture: str,
abort_reason: str,
placeholders: dict[str, str],
calls: int,
access_token: str,
) -> None:
"""Test the reauthentication case updates the existing config entry."""
"""Test the re-authentication case updates the correct config entry.
Make sure we abort if the user selects the
wrong account on the consent screen.
"""
config_entry.add_to_hass(hass)
config_entry.async_start_reauth(hass)
@@ -118,19 +141,26 @@ async def test_reauth(
with patch(
"homeassistant.components.google_mail.async_setup_entry", return_value=True
) as mock_setup:
) as mock_setup, patch(
"httplib2.Http.request",
return_value=(
Response({}),
bytes(load_fixture(f"google_mail/{fixture}.json"), encoding="UTF-8"),
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert result.get("type") == "abort"
assert result.get("reason") == "reauth_successful"
assert result["reason"] == abort_reason
assert result["description_placeholders"] == placeholders
assert len(mock_setup.mock_calls) == calls
assert config_entry.unique_id == TITLE
assert "token" in config_entry.data
# Verify access token is refreshed
assert config_entry.data["token"].get("access_token") == "updated-access-token"
assert config_entry.data["token"].get("access_token") == access_token
assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token"
+2 -1
View File
@@ -29,7 +29,7 @@ async def test_setup_success(
await hass.config_entries.async_unload(entries[0].entry_id)
await hass.async_block_till_done()
assert not len(hass.services.async_services().get(DOMAIN, {}))
assert not hass.services.async_services().get(DOMAIN)
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
@@ -125,6 +125,7 @@ async def test_device_info(
entry = hass.config_entries.async_entries(DOMAIN)[0]
device = device_registry.async_get_device({(DOMAIN, entry.entry_id)})
assert device.entry_type is dr.DeviceEntryType.SERVICE
assert device.identifiers == {(DOMAIN, entry.entry_id)}
assert device.manufacturer == "Google, Inc."
assert device.name == "example@gmail.com"
+1 -1
View File
@@ -52,7 +52,7 @@ async def test_notify_voluptuous_error(
"""Test voluptuous error thrown when drafting email."""
await setup_integration()
with pytest.raises(Invalid) as ex:
with pytest.raises(ValueError) as ex:
await hass.services.async_call(
NOTIFY_DOMAIN,
"example_gmail_com",
+8 -7
View File
@@ -1,4 +1,5 @@
"""Tests for Home Assistant View."""
from decimal import Decimal
from http import HTTPStatus
import json
from unittest.mock import AsyncMock, Mock
@@ -32,18 +33,18 @@ def mock_request_with_stopping():
async def test_invalid_json(caplog):
"""Test trying to return invalid JSON."""
view = HomeAssistantView()
with pytest.raises(HTTPInternalServerError):
view.json(rb"\ud800")
HomeAssistantView.json({"hello": Decimal("2.0")})
assert "Unable to serialize to JSON" in caplog.text
assert (
"Unable to serialize to JSON. Bad data found at $.hello=2.0(<class 'decimal.Decimal'>"
in caplog.text
)
async def test_nan_serialized_to_null(caplog):
async def test_nan_serialized_to_null():
"""Test nan serialized to null JSON."""
view = HomeAssistantView()
response = view.json(float("NaN"))
response = HomeAssistantView.json(float("NaN"))
assert json.loads(response.body.decode("utf-8")) is None
@@ -2303,6 +2303,11 @@ async def test_recorder_is_far_behind(recorder_mock, hass, hass_ws_client, caplo
hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "1"})
await hass.async_block_till_done()
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["id"] == 7
assert msg["type"] == "event"
assert msg["event"]["events"] == []
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["id"] == 7
assert msg["type"] == "event"
@@ -979,6 +979,32 @@ async def test_reregister_sensor(hass, create_registrations, webhook_client):
entry = ent_reg.async_get("sensor.test_1_battery_state")
assert entry.disabled_by is None
reg_resp = await webhook_client.post(
webhook_url,
json={
"type": "register_sensor",
"data": {
"name": "New Name 2",
"state": 100,
"type": "sensor",
"unique_id": "abcd",
"state_class": None,
"device_class": None,
"entity_category": None,
"icon": None,
"unit_of_measurement": None,
},
},
)
assert reg_resp.status == HTTPStatus.CREATED
entry = ent_reg.async_get("sensor.test_1_battery_state")
assert entry.original_name == "Test 1 New Name 2"
assert entry.device_class is None
assert entry.unit_of_measurement is None
assert entry.entity_category is None
assert entry.original_icon is None
async def test_webhook_handle_conversation_process(
hass, create_registrations, webhook_client, mock_agent
@@ -1017,3 +1043,57 @@ async def test_webhook_handle_conversation_process(
},
"conversation_id": None,
}
async def test_sending_sensor_state(hass, create_registrations, webhook_client, caplog):
"""Test that we can register and send sensor state as number and None."""
webhook_id = create_registrations[1]["webhook_id"]
webhook_url = f"/api/webhook/{webhook_id}"
reg_resp = await webhook_client.post(
webhook_url,
json={
"type": "register_sensor",
"data": {
"name": "Battery State",
"state": 100,
"type": "sensor",
"unique_id": "abcd",
},
},
)
assert reg_resp.status == HTTPStatus.CREATED
ent_reg = er.async_get(hass)
entry = ent_reg.async_get("sensor.test_1_battery_state")
assert entry.original_name == "Test 1 Battery State"
assert entry.device_class is None
assert entry.unit_of_measurement is None
assert entry.entity_category is None
assert entry.original_icon == "mdi:cellphone"
assert entry.disabled_by is None
await hass.async_block_till_done()
state = hass.states.get("sensor.test_1_battery_state")
assert state is not None
assert state.state == "100"
reg_resp = await webhook_client.post(
webhook_url,
json={
"type": "update_sensor_states",
"data": {
"state": 50.0000,
"type": "sensor",
"unique_id": "abcd",
},
},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_1_battery_state")
assert state is not None
assert state.state == "50.0"
+56 -1
View File
@@ -6,6 +6,7 @@ import re
from unittest.mock import AsyncMock, call, patch
import pytest
from voluptuous import MultipleInvalid
from homeassistant import config_entries
from homeassistant.components import mqtt
@@ -1494,7 +1495,7 @@ async def test_clean_up_registry_monitoring(
async def test_unique_id_collission_has_priority(
hass, mqtt_mock_entry_no_yaml_config, entity_reg
):
"""Test tehe unique_id collision detection has priority over registry disabled items."""
"""Test the unique_id collision detection has priority over registry disabled items."""
await mqtt_mock_entry_no_yaml_config()
config = {
"name": "sbfspot_12345",
@@ -1534,3 +1535,57 @@ async def test_unique_id_collission_has_priority(
assert entity_reg.async_get("sensor.sbfspot_12345_1") is not None
# Verify the second entity is not created because it is not unique
assert entity_reg.async_get("sensor.sbfspot_12345_2") is None
@pytest.mark.xfail(raises=MultipleInvalid)
@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR])
async def test_update_with_bad_config_not_breaks_discovery(
hass: ha.HomeAssistant, mqtt_mock_entry_no_yaml_config, entity_reg
) -> None:
"""Test a bad update does not break discovery."""
await mqtt_mock_entry_no_yaml_config()
# discover a sensor
config1 = {
"name": "sbfspot_12345",
"state_topic": "homeassistant_test/sensor/sbfspot_0/state",
}
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/config",
json.dumps(config1),
)
await hass.async_block_till_done()
assert hass.states.get("sensor.sbfspot_12345") is not None
# update with a breaking config
config2 = {
"name": "sbfspot_12345",
"availability": 1,
"state_topic": "homeassistant_test/sensor/sbfspot_0/state",
}
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/config",
json.dumps(config2),
)
await hass.async_block_till_done()
# update the state topic
config3 = {
"name": "sbfspot_12345",
"state_topic": "homeassistant_test/sensor/sbfspot_0/new_state_topic",
}
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/config",
json.dumps(config3),
)
await hass.async_block_till_done()
# Send an update for the state
async_fire_mqtt_message(
hass,
"homeassistant_test/sensor/sbfspot_0/new_state_topic",
"new_value",
)
await hass.async_block_till_done()
assert hass.states.get("sensor.sbfspot_12345").state == "new_value"
+46
View File
@@ -4,10 +4,12 @@ import json
from unittest.mock import ANY, patch
import pytest
from voluptuous import MultipleInvalid
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@@ -805,6 +807,50 @@ async def test_cleanup_device_with_entity2(
assert device_entry is None
@pytest.mark.xfail(raises=MultipleInvalid)
async def test_update_with_bad_config_not_breaks_discovery(
hass: HomeAssistant,
mqtt_mock_entry_no_yaml_config,
tag_mock,
) -> None:
"""Test a bad update does not break discovery."""
await mqtt_mock_entry_no_yaml_config()
config1 = {
"topic": "test-topic",
"device": {"identifiers": ["helloworld"]},
}
config2 = {
"topic": "test-topic",
"device": {"bad_key": "some bad value"},
}
config3 = {
"topic": "test-topic-update",
"device": {"identifiers": ["helloworld"]},
}
data1 = json.dumps(config1)
data2 = json.dumps(config2)
data3 = json.dumps(config3)
async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data1)
await hass.async_block_till_done()
# Update with bad identifier
async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data2)
await hass.async_block_till_done()
# Topic update
async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data3)
await hass.async_block_till_done()
# Fake tag scan.
async_fire_mqtt_message(hass, "test-topic-update", "12345")
await hass.async_block_till_done()
tag_mock.assert_called_once_with(ANY, "12345", ANY)
async def test_unload_entry(hass, device_reg, mqtt_mock, tag_mock, tmp_path) -> None:
"""Test unloading the MQTT entry."""
+114 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement
from homeassistant.components import recorder
from homeassistant.components.recorder import history, util
from homeassistant.components.recorder.const import SQLITE_URL_PREFIX
from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX
from homeassistant.components.recorder.db_schema import RecorderRuns
from homeassistant.components.recorder.models import UnsupportedDialect
from homeassistant.components.recorder.util import (
@@ -25,6 +25,7 @@ from homeassistant.components.recorder.util import (
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry
from homeassistant.util import dt as dt_util
from .common import corrupt_db_file, run_information_with_session, wait_recording_done
@@ -550,6 +551,118 @@ def test_warn_unsupported_dialect(caplog, dialect, message):
assert message in caplog.text
@pytest.mark.parametrize(
"mysql_version,min_version",
[
(
"10.5.16-MariaDB",
"10.5.17",
),
(
"10.6.8-MariaDB",
"10.6.9",
),
(
"10.7.1-MariaDB",
"10.7.5",
),
(
"10.8.0-MariaDB",
"10.8.4",
),
],
)
async def test_issue_for_mariadb_with_MDEV_25020(
hass, caplog, mysql_version, min_version
):
"""Test we create an issue for MariaDB versions affected.
See https://jira.mariadb.org/browse/MDEV-25020.
"""
instance_mock = MagicMock()
instance_mock.hass = hass
execute_args = []
close_mock = MagicMock()
def execute_mock(statement):
nonlocal execute_args
execute_args.append(statement)
def fetchall_mock():
nonlocal execute_args
if execute_args[-1] == "SELECT VERSION()":
return [[mysql_version]]
return None
def _make_cursor_mock(*_):
return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock)
dbapi_connection = MagicMock(cursor=_make_cursor_mock)
await hass.async_add_executor_job(
util.setup_connection_for_dialect,
instance_mock,
"mysql",
dbapi_connection,
True,
)
await hass.async_block_till_done()
registry = async_get_issue_registry(hass)
issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression")
assert issue is not None
assert issue.translation_placeholders == {"min_version": min_version}
@pytest.mark.parametrize(
"mysql_version",
[
"10.5.17-MariaDB",
"10.6.9-MariaDB",
"10.7.5-MariaDB",
"10.8.4-MariaDB",
"10.9.1-MariaDB",
],
)
async def test_no_issue_for_mariadb_with_MDEV_25020(hass, caplog, mysql_version):
"""Test we do not create an issue for MariaDB versions not affected.
See https://jira.mariadb.org/browse/MDEV-25020.
"""
instance_mock = MagicMock()
instance_mock.hass = hass
execute_args = []
close_mock = MagicMock()
def execute_mock(statement):
nonlocal execute_args
execute_args.append(statement)
def fetchall_mock():
nonlocal execute_args
if execute_args[-1] == "SELECT VERSION()":
return [[mysql_version]]
return None
def _make_cursor_mock(*_):
return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock)
dbapi_connection = MagicMock(cursor=_make_cursor_mock)
await hass.async_add_executor_job(
util.setup_connection_for_dialect,
instance_mock,
"mysql",
dbapi_connection,
True,
)
await hass.async_block_till_done()
registry = async_get_issue_registry(hass)
issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression")
assert issue is None
def test_basic_sanity_check(hass_recorder, recorder_db_url):
"""Test the basic sanity checks with a missing table."""
if recorder_db_url.startswith("mysql://"):
+17
View File
@@ -538,6 +538,15 @@ async def test_custom_unit(
"29.921", # Native precision is 3
"1013.24", # One digit of precision removed when converting
),
(
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
UnitOfPressure.INHG,
UnitOfPressure.HPA,
-0.0001,
3,
"0.000", # Native precision is 3
"0.00", # One digit of precision removed when converting
),
],
)
async def test_native_precision_scaling(
@@ -595,6 +604,14 @@ async def test_native_precision_scaling(
"1000.000",
"1000.0000",
),
(
SensorDeviceClass.DISTANCE,
UnitOfLength.KILOMETERS,
1,
-0.04,
"-0.040",
"0.0", # Make sure minus is dropped
),
],
)
async def test_custom_precision_native_precision(
+1 -1
View File
@@ -91,8 +91,8 @@ async def test_air_quality_sensor(hass, mock_gateway, mock_api_factory):
assert sensor_1 is not None
assert sensor_1.state == "42"
assert sensor_1.attributes["unit_of_measurement"] == "µg/m³"
assert sensor_1.attributes["device_class"] == "aqi"
assert sensor_1.attributes["state_class"] == "measurement"
assert "device_class" not in sensor_1.attributes
async def test_filter_time_left_sensor(hass, mock_gateway, mock_api_factory):
+88 -15
View File
@@ -1,6 +1,7 @@
"""Tests for ZHA config flow."""
import copy
from datetime import timedelta
import json
from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch
import uuid
@@ -67,12 +68,27 @@ def mock_app():
@pytest.fixture
def backup():
"""Zigpy network backup with non-default settings."""
backup = zigpy.backups.NetworkBackup()
backup.node_info.ieee = zigpy.types.EUI64.convert("AA:BB:CC:DD:11:22:33:44")
def make_backup():
"""Zigpy network backup factory that creates unique backups with each call."""
num_calls = 0
return backup
def inner(*, backup_time_offset=0):
nonlocal num_calls
backup = zigpy.backups.NetworkBackup()
backup.backup_time += timedelta(seconds=backup_time_offset)
backup.node_info.ieee = zigpy.types.EUI64.convert(f"AABBCCDDEE{num_calls:06X}")
num_calls += 1
return backup
return inner
@pytest.fixture
def backup(make_backup):
"""Zigpy network backup with non-default settings."""
return make_backup()
def mock_detect_radio_type(radio_type=RadioType.ezsp, ret=True):
@@ -1101,6 +1117,56 @@ async def test_formation_strategy_form_new_network(pick_radio, mock_app, hass):
assert result2["type"] == FlowResultType.CREATE_ENTRY
async def test_formation_strategy_form_initial_network(pick_radio, mock_app, hass):
"""Test forming a new network, with no previous settings on the radio."""
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed())
result, port = await pick_radio(RadioType.ezsp)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": config_flow.FORMATION_FORM_INITIAL_NETWORK},
)
await hass.async_block_till_done()
# A new network will be formed
mock_app.form_network.assert_called_once()
assert result2["type"] == FlowResultType.CREATE_ENTRY
@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True))
async def test_onboarding_auto_formation_new_hardware(mock_app, hass):
"""Test auto network formation with new hardware during onboarding."""
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed())
discovery_info = usb.UsbServiceInfo(
device="/dev/ttyZIGBEE",
pid="AAAA",
vid="AAAA",
serial_number="1234",
description="zigbee radio",
manufacturer="test",
)
with patch(
"homeassistant.components.onboarding.async_is_onboarded", return_value=False
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USB}, data=discovery_info
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "zigbee radio"
assert result["data"] == {
"device": {
"baudrate": 115200,
"flow_control": None,
"path": "/dev/ttyZIGBEE",
},
CONF_RADIO_TYPE: "znp",
}
async def test_formation_strategy_reuse_settings(pick_radio, mock_app, hass):
"""Test reusing existing network settings."""
result, port = await pick_radio(RadioType.ezsp)
@@ -1298,13 +1364,13 @@ def test_format_backup_choice():
)
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
async def test_formation_strategy_restore_automatic_backup_ezsp(
pick_radio, mock_app, hass
pick_radio, mock_app, make_backup, hass
):
"""Test restoring an automatic backup (EZSP radio)."""
mock_app.backups.backups = [
MagicMock(),
MagicMock(),
MagicMock(),
make_backup(),
make_backup(),
make_backup(),
]
backup = mock_app.backups.backups[1] # pick the second one
backup.is_compatible_with = MagicMock(return_value=False)
@@ -1347,13 +1413,13 @@ async def test_formation_strategy_restore_automatic_backup_ezsp(
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
@pytest.mark.parametrize("is_advanced", [True, False])
async def test_formation_strategy_restore_automatic_backup_non_ezsp(
is_advanced, pick_radio, mock_app, hass
is_advanced, pick_radio, mock_app, make_backup, hass
):
"""Test restoring an automatic backup (non-EZSP radio)."""
mock_app.backups.backups = [
MagicMock(),
MagicMock(),
MagicMock(),
make_backup(backup_time_offset=5),
make_backup(backup_time_offset=-3),
make_backup(backup_time_offset=2),
]
backup = mock_app.backups.backups[1] # pick the second one
backup.is_compatible_with = MagicMock(return_value=False)
@@ -1375,13 +1441,20 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp(
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "choose_automatic_backup"
# We must prompt for overwriting the IEEE address
# We don't prompt for overwriting the IEEE address, since only EZSP needs this
assert config_flow.OVERWRITE_COORDINATOR_IEEE not in result2["data_schema"].schema
# The backup choices are ordered by date
assert result2["data_schema"].schema["choose_automatic_backup"].container == [
f"choice:{mock_app.backups.backups[0]!r}",
f"choice:{mock_app.backups.backups[2]!r}",
f"choice:{mock_app.backups.backups[1]!r}",
]
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
user_input={
config_flow.CHOOSE_AUTOMATIC_BACKUP: "choice:" + repr(backup),
config_flow.CHOOSE_AUTOMATIC_BACKUP: f"choice:{backup!r}",
},
)
+49 -1
View File
@@ -9,6 +9,7 @@ from homeassistant.core import State
from homeassistant.helpers import (
area_registry,
config_validation as cv,
device_registry,
entity_registry,
intent,
)
@@ -26,6 +27,7 @@ async def test_async_match_states(hass):
"""Test async_match_state helper."""
areas = area_registry.async_get(hass)
area_kitchen = areas.async_get_or_create("kitchen")
areas.async_update(area_kitchen.id, aliases={"food room"})
area_bedroom = areas.async_get_or_create("bedroom")
state1 = State(
@@ -41,7 +43,7 @@ async def test_async_match_states(hass):
entities.async_update_entity(state1.entity_id, area_id=area_kitchen.id)
entities.async_get_or_create(
"switch", "demo", "1234", suggested_object_id="bedroom"
"switch", "demo", "5678", suggested_object_id="bedroom"
)
entities.async_update_entity(
state2.entity_id,
@@ -67,6 +69,13 @@ async def test_async_match_states(hass):
)
)
# Test area alias
assert [state1] == list(
intent.async_match_states(
hass, name="kitchen light", area_name="food room", states=[state1, state2]
)
)
# Wrong area
assert not list(
intent.async_match_states(
@@ -92,6 +101,45 @@ async def test_async_match_states(hass):
)
async def test_match_device_area(hass):
"""Test async_match_state with a device in an area."""
areas = area_registry.async_get(hass)
area_kitchen = areas.async_get_or_create("kitchen")
area_bedroom = areas.async_get_or_create("bedroom")
devices = device_registry.async_get(hass)
kitchen_device = devices.async_get_or_create(
config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")}
)
devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
state1 = State(
"light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
)
state2 = State(
"light.bedroom", "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"}
)
state3 = State(
"light.living_room", "on", attributes={ATTR_FRIENDLY_NAME: "living room light"}
)
entities = entity_registry.async_get(hass)
entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen")
entities.async_update_entity(state1.entity_id, device_id=kitchen_device.id)
entities.async_get_or_create("light", "demo", "5678", suggested_object_id="bedroom")
entities.async_update_entity(state2.entity_id, area_id=area_bedroom.id)
# Match on area/domain
assert [state1] == list(
intent.async_match_states(
hass,
domains={"light"},
area_name="kitchen",
states=[state1, state2, state3],
)
)
def test_async_validate_slots():
"""Test async_validate_slots of IntentHandler."""
handler1 = MockIntentHandler({vol.Required("name"): cv.string})