Compare commits

...

77 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
Paulus Schoutsen
6397cc5d04 Bumped version to 2023.2.0b2 2023-01-26 21:47:21 -05:00
Jesse Hills
b7311dc655 Remove esphome password from config flow data if not needed (#86763)
* Remove esphome password if not needed

* Add test

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2023-01-26 21:47:00 -05:00
Paulus Schoutsen
e20c7491c1 ESPHome update: Store reference to runtime data, not one of its values (#86762)
Store reference to runtime data, not one of its values
2023-01-26 21:46:59 -05:00
Aaron Bach
8cbefd5f97 Fix state class issues in Ambient PWS (#86758)
fixes undefined
2023-01-26 21:46:58 -05:00
Paulus Schoutsen
c7665b479a OpenAI: Fix device without model (#86754) 2023-01-26 21:46:57 -05:00
Shay Levy
4f2966674a Bump aioshelly to 5.3.1 (#86751) 2023-01-26 21:46:56 -05:00
Franck Nijhof
b464179eac Fix state classes for duration device class (#86727) 2023-01-26 21:46:55 -05:00
Franck Nijhof
cd59705c4b Remove gas device class from current sensor in dsmr_reader (#86725) 2023-01-26 21:46:54 -05:00
Martin Hjelmare
77bd23899f Bump python-matter-server to 2.0.2 (#86712) 2023-01-26 21:46:53 -05:00
David F. Mulcahey
d211603ba7 Update Inovelli Blue Series switch support in ZHA (#86711) 2023-01-26 21:46:52 -05:00
Erik Montnemery
1dc3bb6eb1 Terminate strings at NUL when recording states and events (#86687) 2023-01-26 21:46:51 -05:00
Robert Svensson
22afc7c7fb Fix missing interface key in deCONZ logbook (#86684)
fixes undefined
2023-01-26 21:46:50 -05:00
Paulus Schoutsen
ba82f13821 Make openai conversation prompt template more readable + test case (#86676) 2023-01-26 21:46:49 -05:00
MHFDoge
41add96bab Add known webostv button to list (#86674)
Add known button to list.
2023-01-26 21:46:48 -05:00
Andrey Kupreychik
c8c3f4bef6 Update ndms2_client to 0.1.2 (#86624)
fix https://github.com/home-assistant/core/issues/86379
fixes undefined
2023-01-26 21:46:47 -05:00
Patrick ZAJDA
8cb8ecdae9 Migrate Nuki to new entity naming style (#80021)
Co-authored-by: Pascal Vizeli <pvizeli@syshack.ch>
2023-01-26 21:46:46 -05:00
Pascal Reeb
bd1371680f Add device registration to the Nuki component (#79806)
* Add device registration to the Nuki component

* Name is always given by the API

* implement pvizeli's suggestions

* switch device_registry to snake_case

* fix entity naming

* unify manufacturer names
2023-01-26 21:46:45 -05:00
Paulus Schoutsen
8f684e962a Bumped version to 2023.2.0b1 2023-01-25 23:00:35 -05:00
Paulus Schoutsen
07a1259db9 Add error handling for OpenAI (#86671)
* Add error handling for OpenAI

* Simplify area filtering

* better prompt
2023-01-25 23:00:28 -05:00
David F. Mulcahey
ea2bf34647 Bump ZHA quirks lib (#86669) 2023-01-25 23:00:27 -05:00
J. Nick Koston
a6fdf1d09a Correct units on mopeka battery voltage sensor (#86663) 2023-01-25 23:00:26 -05:00
Paulus Schoutsen
9ca04dbfa1 Google Assistant: unset agent on unload (#86635) 2023-01-25 23:00:24 -05:00
Paulus Schoutsen
e1c8dff536 Fix oauth2 error (#86634) 2023-01-25 23:00:23 -05:00
Joakim Plate
a1416b9044 Print expected device class units in error log (#86125) 2023-01-25 23:00:22 -05:00
Franck Nijhof
123aafd772 Bumped version to 2023.2.0b0 2023-01-25 18:39:20 +01:00
119 changed files with 2167 additions and 557 deletions

View File

@@ -128,7 +128,6 @@ SENSOR_DESCRIPTIONS = (
key=TYPE_AQI_PM25_24H,
name="AQI PM2.5 24h avg",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=TYPE_AQI_PM25_IN,
@@ -140,7 +139,6 @@ SENSOR_DESCRIPTIONS = (
key=TYPE_AQI_PM25_IN_24H,
name="AQI PM2.5 indoor 24h avg",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=TYPE_BAROMABSIN,
@@ -182,7 +180,7 @@ SENSOR_DESCRIPTIONS = (
name="Event rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_FEELSLIKE,
@@ -287,7 +285,6 @@ SENSOR_DESCRIPTIONS = (
name="Last rain",
icon="mdi:water",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_LIGHTNING_PER_DAY,
@@ -315,7 +312,7 @@ SENSOR_DESCRIPTIONS = (
name="Monthly rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_PM25_24H,
@@ -586,7 +583,7 @@ SENSOR_DESCRIPTIONS = (
name="Lifetime rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=TYPE_UV,
@@ -599,7 +596,7 @@ SENSOR_DESCRIPTIONS = (
name="Weekly rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_WINDDIR,

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)

View File

@@ -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 = {

View File

@@ -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:

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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

View File

@@ -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",

View File

@@ -17,6 +17,10 @@ from .device_trigger import (
CONF_BUTTON_2,
CONF_BUTTON_3,
CONF_BUTTON_4,
CONF_BUTTON_5,
CONF_BUTTON_6,
CONF_BUTTON_7,
CONF_BUTTON_8,
CONF_CLOSE,
CONF_DIM_DOWN,
CONF_DIM_UP,
@@ -95,6 +99,10 @@ INTERFACES = {
CONF_BUTTON_2: "Button 2",
CONF_BUTTON_3: "Button 3",
CONF_BUTTON_4: "Button 4",
CONF_BUTTON_5: "Button 5",
CONF_BUTTON_6: "Button 6",
CONF_BUTTON_7: "Button 7",
CONF_BUTTON_8: "Button 8",
CONF_SIDE_1: "Side 1",
CONF_SIDE_2: "Side 2",
CONF_SIDE_3: "Side 3",

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)

View File

@@ -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

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

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

View File

@@ -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": [

View File

@@ -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": [

View File

@@ -209,7 +209,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
DSMRReaderSensorEntityDescription(
key="dsmr/consumption/gas/currently_delivered",
name="Current gas usage",
device_class=SensorDeviceClass.GAS,
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
state_class=SensorStateClass.MEASUREMENT,
),

View File

@@ -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

View File

@@ -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",

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(

View File

@@ -92,10 +92,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._name = entry.title
self._device_name = entry.data.get(CONF_DEVICE_NAME)
if await self._retrieve_encryption_key_from_dashboard():
error = await self.fetch_device_info()
if error is None:
return await self._async_authenticate_or_add()
# Device without encryption allows fetching device info. We can then check
# if the device is no longer using a password. If we did try with a password,
# we know setting password to empty will allow us to authenticate.
error = await self.fetch_device_info()
if (
error is None
and self._password
and self._device_info
and not self._device_info.uses_password
):
self._password = ""
return await self._async_authenticate_or_add()
return await self.async_step_reauth_confirm()
@@ -105,6 +113,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow."""
errors = {}
if await self._retrieve_encryption_key_from_dashboard():
error = await self.fetch_device_info()
if error is None:
return await self._async_authenticate_or_add()
if user_input is not None:
self._noise_psk = user_input[CONF_NOISE_PSK]
error = await self.fetch_device_info()
@@ -153,6 +166,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if self._device_info.uses_password:
return await self.async_step_authenticate()
self._password = ""
return self._async_get_entry()
async def async_step_discovery_confirm(

View File

@@ -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]]):

View File

@@ -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"],

View File

@@ -72,15 +72,13 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
_attr_title = "ESPHome"
_attr_name = "Firmware"
_device_info: ESPHomeDeviceInfo
def __init__(
self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard
) -> None:
"""Initialize the update entity."""
super().__init__(coordinator=coordinator)
assert entry_data.device_info is not None
self._device_info = entry_data.device_info
self._entry_data = entry_data
self._attr_unique_id = entry_data.device_info.mac_address
self._attr_device_info = DeviceInfo(
connections={
@@ -88,6 +86,12 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
}
)
@property
def _device_info(self) -> ESPHomeDeviceInfo:
"""Return the device info."""
assert self._entry_data.device_info is not None
return self._entry_data.device_info
@property
def available(self) -> bool:
"""Return if update is available."""

View File

@@ -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",

View File

@@ -98,6 +98,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for service_name in hass.services.async_services()[DOMAIN]:
hass.services.async_remove(DOMAIN, service_name)
if entry.options.get(CONF_ENABLE_CONVERSATION_AGENT, False):
conversation.async_unset_agent(hass, entry)
return True

View File

@@ -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],
)
)

View File

@@ -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)},
)

View File

@@ -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",

View File

@@ -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,

View File

@@ -7,5 +7,5 @@
"requirements": ["google-api-python-client==2.71.0"],
"codeowners": ["@tkdrob"],
"iot_class": "cloud_polling",
"integration_type": "device"
"integration_type": "service"
}

View File

@@ -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)

View File

@@ -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%]"

View File

@@ -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"

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:

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,

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}"

View File

@@ -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": [

View File

@@ -3,7 +3,7 @@
"name": "Keenetic NDMS2 Router",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2",
"requirements": ["ndms2_client==0.1.1"],
"requirements": ["ndms2_client==0.1.2"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1",

View File

@@ -3,7 +3,7 @@
"name": "Matter (BETA)",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/matter",
"requirements": ["python-matter-server==2.0.1"],
"requirements": ["python-matter-server==2.0.2"],
"dependencies": ["websocket_api"],
"codeowners": ["@home-assistant/matter"],
"iot_class": "local_push"

View File

@@ -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)

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,
}

View File

@@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfElectricPotential,
UnitOfLength,
UnitOfTemperature,
)
@@ -41,7 +42,7 @@ SENSOR_DESCRIPTIONS = {
"battery_voltage": SensorEntityDescription(
key="battery_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=PERCENTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,

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

View File

@@ -13,7 +13,7 @@ from homeassistant import exceptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry, entity_registry
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -41,37 +41,6 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp
return bridge.locks, bridge.openers
def _update_devices(devices: list[NukiDevice]) -> dict[str, set[str]]:
"""
Update the Nuki devices.
Returns:
A dict with the events to be fired. The event type is the key and the device ids are the value
"""
events: dict[str, set[str]] = defaultdict(set)
for device in devices:
for level in (False, True):
try:
if isinstance(device, NukiOpener):
last_ring_action_state = device.ring_action_state
device.update(level)
if not last_ring_action_state and device.ring_action_state:
events["ring"].add(device.nuki_id)
else:
device.update(level)
except RequestException:
continue
if device.state not in ERROR_STATES:
break
return events
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Nuki entry."""
@@ -101,42 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except RequestException as err:
raise exceptions.ConfigEntryNotReady from err
async def async_update_data() -> None:
"""Fetch data from Nuki bridge."""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with async_timeout.timeout(10):
events = await hass.async_add_executor_job(
_update_devices, locks + openers
)
except InvalidCredentialsException as err:
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
except RequestException as err:
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
ent_reg = er.async_get(hass)
for event, device_ids in events.items():
for device_id in device_ids:
entity_id = ent_reg.async_get_entity_id(
Platform.LOCK, DOMAIN, device_id
)
event_data = {
"entity_id": entity_id,
"type": event,
}
hass.bus.async_fire("nuki_event", event_data)
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
# Name of the data. For logging purposes.
name="nuki devices",
update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers.
update_interval=UPDATE_INTERVAL,
# Device registration for the bridge
info = bridge.info()
bridge_id = parse_id(info["ids"]["hardwareId"])
dev_reg = device_registry.async_get(hass)
dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, bridge_id)},
manufacturer="Nuki Home Solutions GmbH",
name=f"Nuki Bridge {bridge_id}",
model="Hardware Bridge",
sw_version=info["versions"]["firmwareVersion"],
)
coordinator = NukiCoordinator(hass, bridge, locks, openers)
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_BRIDGE: bridge,
@@ -178,3 +126,94 @@ class NukiEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
self._nuki_device = nuki_device
@property
def device_info(self):
"""Device info for Nuki entities."""
return {
"identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))},
"name": self._nuki_device.name,
"manufacturer": "Nuki Home Solutions GmbH",
"model": self._nuki_device.device_type_str.capitalize(),
"sw_version": self._nuki_device.firmware_version,
"via_device": (DOMAIN, self.coordinator.bridge_id),
}
class NukiCoordinator(DataUpdateCoordinator):
"""Data Update Coordinator for the Nuki integration."""
def __init__(self, hass, bridge, locks, openers):
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
# Name of the data. For logging purposes.
name="nuki devices",
# Polling interval. Will only be polled if there are subscribers.
update_interval=UPDATE_INTERVAL,
)
self.bridge = bridge
self.locks = locks
self.openers = openers
@property
def bridge_id(self):
"""Return the parsed id of the Nuki bridge."""
return parse_id(self.bridge.info()["ids"]["hardwareId"])
async def _async_update_data(self) -> None:
"""Fetch data from Nuki bridge."""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with async_timeout.timeout(10):
events = await self.hass.async_add_executor_job(
self.update_devices, self.locks + self.openers
)
except InvalidCredentialsException as err:
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
except RequestException as err:
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
ent_reg = entity_registry.async_get(self.hass)
for event, device_ids in events.items():
for device_id in device_ids:
entity_id = ent_reg.async_get_entity_id(
Platform.LOCK, DOMAIN, device_id
)
event_data = {
"entity_id": entity_id,
"type": event,
}
self.hass.bus.async_fire("nuki_event", event_data)
def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]:
"""
Update the Nuki devices.
Returns:
A dict with the events to be fired. The event type is the key and the device ids are the value
"""
events: dict[str, set[str]] = defaultdict(set)
for device in devices:
for level in (False, True):
try:
if isinstance(device, NukiOpener):
last_ring_action_state = device.ring_action_state
device.update(level)
if not last_ring_action_state and device.ring_action_state:
events["ring"].add(device.nuki_id)
else:
device.update(level)
except RequestException:
continue
if device.state not in ERROR_STATES:
break
return events

View File

@@ -34,13 +34,10 @@ async def async_setup_entry(
class NukiDoorsensorEntity(NukiEntity, BinarySensorEntity):
"""Representation of a Nuki Lock Doorsensor."""
_attr_has_entity_name = True
_attr_name = "Door sensor"
_attr_device_class = BinarySensorDeviceClass.DOOR
@property
def name(self):
"""Return the name of the lock."""
return self._nuki_device.name
@property
def unique_id(self) -> str:
"""Return a unique ID."""

View File

@@ -67,13 +67,9 @@ async def async_setup_entry(
class NukiDeviceEntity(NukiEntity, LockEntity, ABC):
"""Representation of a Nuki device."""
_attr_has_entity_name = True
_attr_supported_features = LockEntityFeature.OPEN
@property
def name(self) -> str | None:
"""Return the name of the lock."""
return self._nuki_device.name
@property
def unique_id(self) -> str | None:
"""Return a unique ID."""

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from functools import partial
import logging
from typing import cast
import openai
from openai import error
@@ -13,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, TemplateError
from homeassistant.helpers import area_registry, device_registry, intent, template
from homeassistant.helpers import area_registry, intent, template
from homeassistant.util import ulid
from .const import DEFAULT_MODEL, DEFAULT_PROMPT
@@ -74,6 +73,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
try:
prompt = self._async_generate_prompt()
except TemplateError as err:
_LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
@@ -97,15 +97,26 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
_LOGGER.debug("Prompt for %s: %s", model, prompt)
result = await self.hass.async_add_executor_job(
partial(
openai.Completion.create,
engine=model,
prompt=prompt,
max_tokens=150,
user=conversation_id,
try:
result = await self.hass.async_add_executor_job(
partial(
openai.Completion.create,
engine=model,
prompt=prompt,
max_tokens=150,
user=conversation_id,
)
)
)
except error.OpenAIError as err:
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem talking to OpenAI: {err}",
)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
_LOGGER.debug("Response %s", result)
response = result["choices"][0]["text"].strip()
self.history[conversation_id] = prompt + response
@@ -122,20 +133,9 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
def _async_generate_prompt(self) -> str:
"""Generate a prompt for the user."""
dev_reg = device_registry.async_get(self.hass)
return template.Template(DEFAULT_PROMPT, self.hass).async_render(
{
"ha_name": self.hass.config.location_name,
"areas": [
area
for area in area_registry.async_get(self.hass).areas.values()
# Filter out areas without devices
if any(
not dev.disabled_by
for dev in device_registry.async_entries_for_area(
dev_reg, cast(str, area.id)
)
)
],
"areas": list(area_registry.async_get(self.hass).areas.values()),
}
)

View File

@@ -3,19 +3,26 @@
DOMAIN = "openai_conversation"
CONF_PROMPT = "prompt"
DEFAULT_MODEL = "text-davinci-003"
DEFAULT_PROMPT = """
You are a conversational AI for a smart home named {{ ha_name }}.
If a user wants to control a device, reject the request and suggest using the Home Assistant UI.
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
An overview of the areas and the devices in this smart home:
{% for area in areas %}
{%- for area in areas %}
{%- set area_info = namespace(printed=false) %}
{%- for device in area_devices(area.name) -%}
{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %}
{%- if not area_info.printed %}
{{ area.name }}:
{% for device in area_devices(area.name) -%}
{%- if not device_attr(device, "disabled_by") %}
- {{ device_attr(device, "name") }} ({{ device_attr(device, "model") }} by {{ device_attr(device, "manufacturer") }})
{%- endif %}
{%- set area_info.printed = true %}
{%- endif %}
- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %}
{%- endif %}
{%- endfor %}
{%- endfor %}
{% endfor %}
Answer the users questions about the world truthfully.
If the user wants to control a device, reject the request and suggest using the Home Assistant app.
Now finish this conversation:

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",
),
]

View File

@@ -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,

View File

@@ -836,7 +836,9 @@ class Recorder(threading.Thread):
return
try:
shared_data_bytes = EventData.shared_data_bytes_from_event(event)
shared_data_bytes = EventData.shared_data_bytes_from_event(
event, self.dialect_name
)
except JSON_ENCODE_EXCEPTIONS as ex:
_LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex)
return
@@ -869,7 +871,7 @@ class Recorder(threading.Thread):
try:
dbstate = States.from_event(event)
shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event(
event, self._exclude_attributes_by_domain
event, self._exclude_attributes_by_domain, self.dialect_name
)
except JSON_ENCODE_EXCEPTIONS as ex:
_LOGGER.warning(
@@ -1024,7 +1026,9 @@ class Recorder(threading.Thread):
def _post_schema_migration(self, old_version: int, new_version: int) -> None:
"""Run post schema migration tasks."""
migration.post_schema_migration(self.event_session, old_version, new_version)
migration.post_schema_migration(
self.engine, self.event_session, old_version, new_version
)
def _send_keep_alive(self) -> None:
"""Send a keep alive to keep the db connection open."""
@@ -1040,6 +1044,8 @@ class Recorder(threading.Thread):
async def async_block_till_done(self) -> None:
"""Async version of block_till_done."""
if self._queue.empty() and not self._event_session_has_pending_writes():
return
event = asyncio.Event()
self.queue_task(SynchronizeTask(event))
await event.wait()

View File

@@ -43,18 +43,19 @@ from homeassistant.helpers.json import (
JSON_DECODE_EXCEPTIONS,
JSON_DUMP,
json_bytes,
json_bytes_strip_null,
json_loads,
)
import homeassistant.util.dt as dt_util
from .const import ALL_DOMAIN_EXCLUDE_ATTRS
from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
from .models import StatisticData, StatisticMetaData, process_timestamp
# SQLAlchemy Schema
# pylint: disable=invalid-name
Base = declarative_base()
SCHEMA_VERSION = 32
SCHEMA_VERSION = 33
_StatisticsBaseSelfT = TypeVar("_StatisticsBaseSelfT", bound="StatisticsBase")
@@ -251,8 +252,12 @@ class EventData(Base): # type: ignore[misc,valid-type]
)
@staticmethod
def shared_data_bytes_from_event(event: Event) -> bytes:
def shared_data_bytes_from_event(
event: Event, dialect: SupportedDialect | None
) -> bytes:
"""Create shared_data from an event."""
if dialect == SupportedDialect.POSTGRESQL:
return json_bytes_strip_null(event.data)
return json_bytes(event.data)
@staticmethod
@@ -416,7 +421,9 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
@staticmethod
def shared_attrs_bytes_from_event(
event: Event, exclude_attrs_by_domain: dict[str, set[str]]
event: Event,
exclude_attrs_by_domain: dict[str, set[str]],
dialect: SupportedDialect | None,
) -> bytes:
"""Create shared_attrs from a state_changed event."""
state: State | None = event.data.get("new_state")
@@ -427,6 +434,10 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
exclude_attrs = (
exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS
)
if dialect == SupportedDialect.POSTGRESQL:
return json_bytes_strip_null(
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
)
return json_bytes(
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
)

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:

View File

@@ -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."
}
}
}

View File

@@ -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",

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(

View File

@@ -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": [
{

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 (
@@ -665,6 +675,7 @@ class SensorEntity(Entity):
(
"Entity %s (%s) is using native unit of measurement '%s' which "
"is not a valid unit for the device class ('%s') it is using; "
"expected one of %s; "
"Please update your configuration if your entity is manually "
"configured, otherwise %s"
),
@@ -672,6 +683,7 @@ class SensorEntity(Entity):
type(self),
native_unit_of_measurement,
device_class,
[str(unit) if unit else "no unit of measurement" for unit in units],
report_issue,
)

View File

@@ -513,7 +513,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]
SensorDeviceClass.DATA_SIZE: set(SensorStateClass),
SensorDeviceClass.DATE: set(),
SensorDeviceClass.DISTANCE: set(SensorStateClass),
SensorDeviceClass.DURATION: set(),
SensorDeviceClass.DURATION: set(SensorStateClass),
SensorDeviceClass.ENERGY: {
SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING,
@@ -535,10 +535,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]
SensorDeviceClass.PM25: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.POWER_FACTOR: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.POWER: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.PRECIPITATION: {
SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING,
},
SensorDeviceClass.PRECIPITATION: set(SensorStateClass),
SensorDeviceClass.PRECIPITATION_INTENSITY: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.PRESSURE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.REACTIVE_POWER: {SensorStateClass.MEASUREMENT},

View File

@@ -3,7 +3,7 @@
"name": "Shelly",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly",
"requirements": ["aioshelly==5.3.0"],
"requirements": ["aioshelly==5.3.1"],
"dependencies": ["bluetooth", "http"],
"zeroconf": [
{

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)
}

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": [],

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(

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": [

View File

@@ -17,7 +17,7 @@ button:
description: >-
Name of the button to press. Known possible values are
LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT,
MUTE, RED, GREEN, BLUE, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
MUTE, RED, GREEN, BLUE, YELLOW, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
PLAY, PAUSE, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
required: true
example: "LEFT"

View File

@@ -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}}

View File

@@ -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()}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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"],

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 (

View File

@@ -224,7 +224,8 @@ class InovelliConfigEntityChannel(ZigbeeChannel):
"switch_type": False,
"button_delay": False,
"smart_bulb_mode": False,
"double_tap_up_for_full_brightness": True,
"double_tap_up_for_max_brightness": True,
"double_tap_down_for_min_brightness": True,
"led_color_when_on": True,
"led_color_when_off": True,
"led_intensity_when_on": True,

View File

@@ -4,10 +4,10 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zha",
"requirements": [
"bellows==0.34.6",
"bellows==0.34.7",
"pyserial==3.5",
"pyserial-asyncio==0.6",
"zha-quirks==0.0.91",
"zha-quirks==0.0.92",
"zigpy-deconz==0.19.2",
"zigpy==0.53.0",
"zigpy-xbee==0.16.2",

View File

@@ -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

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."""

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%]"

View File

@@ -372,14 +372,26 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_
channel_names=CHANNEL_INOVELLI,
)
class InovelliDoubleTapForFullBrightness(
ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_full_brightness"
ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_max_brightness"
):
"""Inovelli double tap for full brightness control."""
_zcl_attribute: str = "double_tap_up_for_full_brightness"
_zcl_attribute: str = "double_tap_up_for_max_brightness"
_attr_name: str = "Double tap full brightness"
@CONFIG_DIAGNOSTIC_MATCH(
channel_names=CHANNEL_INOVELLI,
)
class InovelliDoubleTapForMinBrightness(
ZHASwitchConfigurationEntity, id_suffix="double_tap_down_for_min_brightness"
):
"""Inovelli double tap down for minimum brightness control."""
_zcl_attribute: str = "double_tap_down_for_min_brightness"
_attr_name: str = "Double tap minimum brightness"
@CONFIG_DIAGNOSTIC_MATCH(
channel_names=CHANNEL_INOVELLI,
)

View File

@@ -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": {

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 = "0.dev0"
PATCH_VERSION: Final = "0b8"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)

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"

View File

@@ -13,7 +13,7 @@ from collections.abc import Awaitable, Callable
import logging
import secrets
import time
from typing import Any, cast
from typing import Any, Optional, cast
from aiohttp import client, web
import async_timeout
@@ -437,7 +437,10 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
state = _decode_jwt(hass, request.query["state"])
if state is None:
return web.Response(text="Invalid state")
return web.Response(
text="Invalid state. Is My Home Assistant configured to go to the right instance?",
status=400,
)
user_input: dict[str, Any] = {"state": state}
@@ -538,7 +541,10 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str:
@callback
def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None:
"""JWT encode data."""
secret = cast(str, hass.data.get(DATA_JWT_SECRET))
secret = cast(Optional[str], hass.data.get(DATA_JWT_SECRET))
if secret is None:
return None
try:
return jwt.decode(encoded, secret, algorithms=["HS256"])

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:

View File

@@ -71,6 +71,40 @@ def json_bytes(data: Any) -> bytes:
)
def json_bytes_strip_null(data: Any) -> bytes:
"""Dump json bytes after terminating strings at the first NUL."""
def process_dict(_dict: dict[Any, Any]) -> dict[Any, Any]:
"""Strip NUL from items in a dict."""
return {key: strip_null(o) for key, o in _dict.items()}
def process_list(_list: list[Any]) -> list[Any]:
"""Strip NUL from items in a list."""
return [strip_null(o) for o in _list]
def strip_null(obj: Any) -> Any:
"""Strip NUL from an object."""
if isinstance(obj, str):
return obj.split("\0", 1)[0]
if isinstance(obj, dict):
return process_dict(obj)
if isinstance(obj, list):
return process_list(obj)
return obj
# We expect null-characters to be very rare, hence try encoding first and look
# for an escaped null-character in the output.
result = json_bytes(data)
if b"\\u0000" in result:
# We work on the processed result so we don't need to worry about
# Home Assistant extensions which allows encoding sets, tuples, etc.
data_processed = orjson.loads(result)
data_processed = strip_null(data_processed)
result = json_bytes(data_processed)
return result
def json_dumps(data: Any) -> str:
"""Dump json string.

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2023.2.0.dev0"
version = "2023.2.0b8"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

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
@@ -267,7 +267,7 @@ aiosenseme==0.6.1
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==5.3.0
aioshelly==5.3.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -371,7 +371,7 @@ asterisk_mbox==0.5.0
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
async-upnp-client==0.33.0
async-upnp-client==0.33.1
# homeassistant.components.supla
asyncpysupla==0.0.5
@@ -422,7 +422,7 @@ beautifulsoup4==4.11.1
# beewi_smartclim==0.0.10
# homeassistant.components.zha
bellows==0.34.6
bellows==0.34.7
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.12.0
@@ -658,7 +658,7 @@ enocean==0.50
enturclient==0.2.4
# homeassistant.components.environment_canada
env_canada==0.5.22
env_canada==0.5.27
# homeassistant.components.enphase_envoy
envoy_reader==0.20.1
@@ -673,7 +673,7 @@ epson-projector==0.5.0
epsonprinter==0.0.9
# homeassistant.components.esphome
esphome-dashboard-api==1.2.1
esphome-dashboard-api==1.2.3
# homeassistant.components.netgear_lte
eternalegypt==0.0.12
@@ -874,7 +874,7 @@ hass-nabucasa==0.61.0
hass_splunk==0.1.1
# homeassistant.components.conversation
hassil==0.2.5
hassil==0.2.6
# homeassistant.components.tasmota
hatasmota==0.6.3
@@ -907,10 +907,10 @@ hole==0.8.0
holidays==0.18.0
# homeassistant.components.frontend
home-assistant-frontend==20230125.0
home-assistant-frontend==20230130.0
# homeassistant.components.conversation
home-assistant-intents==2023.1.25
home-assistant-intents==2023.1.31
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -1168,7 +1168,7 @@ mycroftapi==2.0
nad_receiver==0.3.0
# homeassistant.components.keenetic_ndms2
ndms2_client==0.1.1
ndms2_client==0.1.2
# homeassistant.components.ness_alarm
nessclient==0.10.0
@@ -1373,7 +1373,7 @@ plexauth==0.0.6
plexwebsocket==0.0.13
# homeassistant.components.plugwise
plugwise==0.27.1
plugwise==0.27.5
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1702,7 +1702,7 @@ pyirishrail==0.0.2
pyiss==1.0.1
# homeassistant.components.isy994
pyisy==3.1.9
pyisy==3.1.11
# homeassistant.components.itach
pyitachip2ir==0.0.7
@@ -2072,7 +2072,7 @@ python-kasa==0.5.0
# python-lirc==1.2.3
# homeassistant.components.matter
python-matter-server==2.0.1
python-matter-server==2.0.2
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2693,7 +2693,7 @@ zeroconf==0.47.1
zeversolar==0.2.0
# homeassistant.components.zha
zha-quirks==0.0.91
zha-quirks==0.0.92
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9

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
@@ -245,7 +245,7 @@ aiosenseme==0.6.1
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==5.3.0
aioshelly==5.3.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -328,7 +328,7 @@ arcam-fmj==1.0.1
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
async-upnp-client==0.33.0
async-upnp-client==0.33.1
# homeassistant.components.sleepiq
asyncsleepiq==1.2.3
@@ -352,7 +352,7 @@ base36==0.1.1
beautifulsoup4==4.11.1
# homeassistant.components.zha
bellows==0.34.6
bellows==0.34.7
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.12.0
@@ -511,7 +511,7 @@ energyzero==0.3.1
enocean==0.50
# homeassistant.components.environment_canada
env_canada==0.5.22
env_canada==0.5.27
# homeassistant.components.enphase_envoy
envoy_reader==0.20.1
@@ -523,7 +523,7 @@ ephem==4.1.2
epson-projector==0.5.0
# homeassistant.components.esphome
esphome-dashboard-api==1.2.1
esphome-dashboard-api==1.2.3
# homeassistant.components.eufylife_ble
eufylife_ble_client==0.1.7
@@ -666,7 +666,7 @@ habitipy==0.2.0
hass-nabucasa==0.61.0
# homeassistant.components.conversation
hassil==0.2.5
hassil==0.2.6
# homeassistant.components.tasmota
hatasmota==0.6.3
@@ -690,10 +690,10 @@ hole==0.8.0
holidays==0.18.0
# homeassistant.components.frontend
home-assistant-frontend==20230125.0
home-assistant-frontend==20230130.0
# homeassistant.components.conversation
home-assistant-intents==2023.1.25
home-assistant-intents==2023.1.31
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -867,7 +867,7 @@ mutagen==1.46.0
mutesync==0.0.1
# homeassistant.components.keenetic_ndms2
ndms2_client==0.1.1
ndms2_client==0.1.2
# homeassistant.components.ness_alarm
nessclient==0.10.0
@@ -1003,7 +1003,7 @@ plexauth==0.0.6
plexwebsocket==0.0.13
# homeassistant.components.plugwise
plugwise==0.27.1
plugwise==0.27.5
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1221,7 +1221,7 @@ pyiqvia==2022.04.0
pyiss==1.0.1
# homeassistant.components.isy994
pyisy==3.1.9
pyisy==3.1.11
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1
@@ -1468,7 +1468,7 @@ python-juicenet==1.1.0
python-kasa==0.5.0
# homeassistant.components.matter
python-matter-server==2.0.1
python-matter-server==2.0.2
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -1906,7 +1906,7 @@ zeroconf==0.47.1
zeversolar==0.2.0
# homeassistant.components.zha
zha-quirks==0.0.91
zha-quirks==0.0.92
# homeassistant.components.zha
zigpy-deconz==0.19.2

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."""

View File

@@ -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

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

View File

@@ -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(

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(

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}

View File

@@ -3,3 +3,4 @@
DASHBOARD_SLUG = "mock-slug"
DASHBOARD_HOST = "mock-host"
DASHBOARD_PORT = 1234
VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="

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()

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,6 +519,116 @@ async def test_reauth_fixed_via_dashboard(
assert len(mock_get_encryption_key.mock_calls) == 1
async def test_reauth_fixed_via_dashboard_add_encryption_remove_password(
hass, mock_client, mock_zeroconf, mock_dashboard, mock_config_entry
):
"""Test reauth fixed automatically via dashboard with password removed."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"),
)
mock_dashboard["configured"].append(
{
"name": "test",
"configuration": "test.yaml",
}
)
await dashboard.async_get_dashboard(hass).async_refresh()
with patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
return_value=VALID_NOISE_PSK,
) as mock_get_encryption_key:
result = await hass.config_entries.flow.async_init(
"esphome",
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
"unique_id": mock_config_entry.unique_id,
},
)
assert result["type"] == FlowResultType.ABORT, result
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
assert mock_config_entry.data[CONF_PASSWORD] == ""
assert len(mock_get_encryption_key.mock_calls) == 1
async def test_reauth_fixed_via_remove_password(hass, mock_client, mock_config_entry):
"""Test reauth fixed automatically by seeing password removed."""
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
result = await hass.config_entries.flow.async_init(
"esphome",
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
"unique_id": mock_config_entry.unique_id,
},
)
assert result["type"] == FlowResultType.ABORT, result
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == ""
async def test_reauth_fixed_via_dashboard_at_confirm(
hass, mock_client, mock_zeroconf, mock_dashboard
):
"""Test reauth fixed automatically via dashboard at confirm step."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
)
entry.add_to_hass(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
result = await hass.config_entries.flow.async_init(
"esphome",
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": entry.entry_id,
"unique_id": entry.unique_id,
},
)
assert result["type"] == FlowResultType.FORM, result
assert result["step_id"] == "reauth_confirm"
mock_dashboard["configured"].append(
{
"name": "test",
"configuration": "test.yaml",
}
)
await dashboard.async_get_dashboard(hass).async_refresh()
with patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
return_value=VALID_NOISE_PSK,
) as mock_get_encryption_key:
# We just fetch the form
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT, result
assert result["reason"] == "reauth_successful"
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
assert len(mock_get_encryption_key.mock_calls) == 1
async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf):
"""Test reauth initiation with invalid PSK."""
entry = MockConfigEntry(

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

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**",

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

Some files were not shown because too many files have changed in this diff Show More