Compare commits

..

102 Commits

Author SHA1 Message Date
G Johansson 66a82d65d3 Store 2025-11-07 11:38:15 +00:00
G Johansson 24b0e1e653 Mods 2025-11-06 20:51:22 +00:00
G Johansson bec30d6a73 Mods 2025-11-06 20:06:38 +00:00
G Johansson c6176a50c6 Add preview to sql config flow 2025-11-06 16:27:14 +00:00
G Johansson 67ccdd36fb Allow template in query in sql (#150287) 2025-11-06 17:11:46 +01:00
Andrea Turri 2ddf55a60d Miele time sensors 3/3 - Add absolute time sensors (#146055)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-06 17:09:19 +01:00
TheJulianJES 57e7bc81d4 Bump ZHA to 0.0.78 (#155937) 2025-11-06 16:30:24 +01:00
Erik Montnemery 777f09598f Fix waze_travel_time tests opening sockets (#155902) 2025-11-06 16:09:29 +01:00
G Johansson 81a9ef1df0 Fix spelling in smhi strings (#155951) 2025-11-06 15:54:30 +01:00
Erik Montnemery d063bc87a1 Fix nam tests opening sockets (#155898) 2025-11-06 15:02:03 +01:00
epenet 5fce08de65 Remove getattr in Tuya find_dpcode function (#155941) 2025-11-06 14:51:35 +01:00
epenet c0db966afd Move find_dpcode function out of Tuya entity (#155934) 2025-11-06 13:43:07 +01:00
G Johansson 9288995cad Add fans and battery sensor to systemmonitor (#151066) 2025-11-06 13:08:44 +01:00
Erik Montnemery 4d2abb4f65 Fix ezviz tests opening sockets (#155896) 2025-11-06 12:10:33 +01:00
Artur Pragacz 60014b6530 Rename misspelled service python files (#155909) 2025-11-06 09:59:45 +01:00
Erik Montnemery 3b57cab6b4 Revert "Allow opening sockets in logbook tests" (#155899) 2025-11-06 09:20:28 +01:00
Erik Montnemery 967467664b Disable automatic start of HTTP server in tests (#155857) 2025-11-06 08:37:04 +01:00
alexqzd b87b5cffd8 SmartThings: Expose the entity to control the AC unit beep (#151546) 2025-11-06 07:55:51 +01:00
Artur Pragacz bb44987af1 Clear dynamic encryption key in ESPHome on remove (#155858) 2025-11-06 02:11:32 +01:00
Christopher Fenner 8d3ef2b224 Add icons for presets in ViCare ventilation entity (#155845) 2025-11-05 20:57:02 +01:00
wollew 5e409295f9 velux: add one more missing data_description (#155854) 2025-11-05 20:56:19 +01:00
J. Nick Koston 530c189f9c Add Bluetooth WiFi provisioning for Shelly (#155822) 2025-11-05 13:20:24 -06:00
giuseppeg88 f05fef9588 Add bad code attempt event to manual alarm control panel (#146315)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-05 18:15:27 +00:00
Paulus Schoutsen a257b5c54c Rename DALI Center to Sunricher DALI (#155865)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-05 19:15:07 +01:00
puddly 5b9f7372fc Allow hardware integrations to specify TX power for ZHA (#155855)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-05 19:13:54 +01:00
puddly a4c0a9b3a5 Revert "Fix progress step recursion (#153906)" (#155866) 2025-11-05 18:46:39 +01:00
Bram Kragten 7d65b4c941 Update frontend to 20251105.0 (#155853) 2025-11-05 16:32:06 +01:00
Martin Hjelmare abd0ee7bce Fix progress step recursion (#153906) 2025-11-05 15:48:35 +01:00
Will Moss 9e3eb20a04 Fix account link no internet on startup (#154579)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-05 15:23:20 +01:00
Erik Montnemery 6dc655c3b4 Allow opening sockets in logbook tests (#155840) 2025-11-05 14:58:21 +01:00
Maciej Bieniek 9f595a94fb Check if the Brother printer serial number matches (#155842) 2025-11-05 14:15:46 +01:00
Lukas 5dc215a143 Bump python-pooldose to 0.7.8 (#155307)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-11-05 13:04:49 +00:00
starkillerOG 306b78ba5f Bring Reolink test coverage back to 100% (#155839) 2025-11-05 12:22:44 +01:00
Erik Montnemery bccb646a07 Create issue to warn against using http.server_host in supervised installs (#155837) 2025-11-05 12:13:56 +01:00
Christopher Fenner 4a5dc8cdd6 Add labels to selector in AndroidTV config flow (#155660) 2025-11-05 12:05:58 +01:00
Erik Montnemery 52a751507a Revert "Deprecate http.server_host option and raise issue if used" (#155834) 2025-11-05 11:26:14 +01:00
wollew 533b9f969d velux: add missing data_descriptions in config flow (#155832) 2025-11-05 11:25:07 +01:00
G Johansson 5de7928bc0 Fix sentence casing in smhi (#155831) 2025-11-05 11:24:52 +01:00
epenet aad9b07f86 Simplify tuya sensor code (#155835) 2025-11-05 11:24:06 +01:00
Tom Matheussen 3e2c401253 Allow multiple config entries for Satel Integra (#155833) 2025-11-05 11:21:56 +01:00
Bouwe Westerdijk 762e63d042 Bugfix: implement RestoreState and bump backend for Plugwise climate (#155126) 2025-11-05 11:18:15 +01:00
puddly ec6d40a51c Add progress to ZHA migration steps (#155764)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-05 11:10:10 +01:00
Erik Montnemery 47c2c61626 Deprecate http.server_host option and raise issue if used (#155828) 2025-11-05 11:08:49 +01:00
Erik Montnemery 73c941f6c5 Fix ESPHome config entry unload (#155830) 2025-11-05 10:32:29 +01:00
epenet 685edb5f76 Add Tuya test fixtures for cz category (#155827) 2025-11-05 09:54:27 +01:00
G Johansson 5987b6dcb9 Improve code formatting in System monitor (#155800) 2025-11-04 22:09:04 -08:00
Oliver Gründel cb029e0bb0 Remove state class for rolling window in ecowitt (#155812) 2025-11-04 22:06:15 -08:00
steinmn 553ec35947 Set LG Thinq energy sensor state_class as total_increasing (#155816) 2025-11-04 22:01:38 -08:00
G Johansson f93940bfa9 Revert "Make influxdb batch settings configurable" (#155808) 2025-11-04 22:00:02 -08:00
Foscam-wangzhengyu 486f93eb28 Bump libpyfoscamcgi to 0.0.9 (#155824) 2025-11-04 21:58:24 -08:00
cdnninja 462db36fef add update platform to vesync (#154915) 2025-11-04 21:40:35 -08:00
Nathan Spencer 485f7f45e8 Bump pylitterbot to 2025.0.0 (#155821) 2025-11-04 18:03:24 -08:00
G Johansson a446d8a98c Add fire sensors to smhi (#153224) 2025-11-04 17:37:32 -08:00
J. Nick Koston b4a31fc578 Bump aioshelly to 13.16.0 (#155813) 2025-11-04 22:20:00 +01:00
G Johansson 22321c22cc Bump holidays to 0.84 (#155802) 2025-11-04 22:18:02 +01:00
TheJulianJES 4419c236e2 Add ZHA migration retry steps for unplugged adapters (#155537) 2025-11-04 20:34:51 +01:00
Maciej Bieniek 1731a2534c Implement base entity class for Brother integration (#155714)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-04 20:28:52 +01:00
Bram Kragten ec0edf47b1 Update frontend to 20251104.0 (#155799) 2025-11-04 14:08:34 -05:00
Tom Matheussen 57c69738e3 Migrate Satel Integra entities unique_id to use config flow entry_id (#154187) 2025-11-04 20:03:08 +01:00
Robert Resch fb1f258b2b Readd deprecated archs to build wheels (#155792) 2025-11-04 19:30:19 +01:00
puddly d419dd0c05 Fix non-unique ZHA serial port paths and migrate USB integration to always list unique paths (#155019) 2025-11-04 11:42:56 -05:00
Paul Bottein 65960aa3f7 Rename safety panel to security panel (#155795) 2025-11-04 17:23:39 +01:00
Marc Mueller a25afe2834 Fix hassio test RuntimeWarning (#155787) 2025-11-04 17:15:20 +01:00
Marc Mueller 4cdfa3bddb Add mkdocs and sphinx to forbidden packages (#155781) 2025-11-04 17:08:33 +01:00
Erwin Douna 9e7bef9fa7 Bump pyportainer 1.0.13 (#155783) 2025-11-04 16:38:27 +01:00
Marc Mueller 68a1b1f91f Fix hassio tests (#155791) 2025-11-04 16:09:47 +01:00
ekutner 1659ca532d Add retry and error logging if communication with the CoolMaster device fails (#148699) 2025-11-04 14:57:32 +01:00
OzGav 8ea16daae4 Correctly map repeat mode in Music Assistant (#155777) 2025-11-04 14:07:15 +01:00
OzGav 5bd89acf9a Use typed config entry in Music Assistant (#155778) 2025-11-04 14:05:44 +01:00
starkillerOG 2b8db74be4 Bump reolink-aio to 0.16.4 (#155776) 2025-11-04 14:03:44 +01:00
krahabb d7f9a7114d Deprecate TemperatureConverter.convert_interval (#155689)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-04 13:40:41 +01:00
Marc Mueller f7a59eb86e Sort hassio strings (#155784) 2025-11-04 13:34:57 +01:00
Manu 37eef965ad Add friend count sensor to Xbox integration (#155761) 2025-11-04 11:27:48 +01:00
Amit Finkelstein b706430e66 Add binary sensor for HassOS share mount status (#149197) 2025-11-04 11:14:10 +01:00
Fredrik Mårtensson 5012aa5cb0 Catch exception from libsoundtouch if device not available (#155749)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-11-04 10:24:38 +01:00
karwosts 1c5f7adf4e Fix Ambient Weather incorrect state classes (#155751) 2025-11-04 09:35:08 +01:00
Manu ff364e3913 Add support for multiple entries to Xbox integration (#155771) 2025-11-04 09:00:40 +01:00
jgaalen 0e2a4605ff Make influxdb batch settings configurable (#134758)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-04 08:33:21 +01:00
cdnninja ca5b9ce0d3 Correct Vesync Humidifier Mode (#155638) 2025-11-03 22:44:19 -08:00
Brett Adams 953196ec21 Bump Tesla Fleet API to v1.2.5 (#155763) 2025-11-03 22:15:34 -08:00
Kamil Breguła b5be3d5ac3 Use data_description in config_flow for WLED (#155572)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 22:07:15 -08:00
puddly 5d9e8287d3 Bump ZHA to 0.0.77 (#155766) 2025-11-03 21:40:15 -08:00
Matt Zimmerman dc291708ae Update python-smarttub to 0.0.45 (#155768) 2025-11-03 21:39:54 -08:00
Paulus Schoutsen 257e82fe4e Add multiple selection to media selector (#154350) 2025-11-04 01:44:31 +01:00
starkillerOG ab6d4d645e Add Reolink audio noise reduction number entity (#155757)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-11-03 22:41:56 +00:00
starkillerOG 58ebd84326 Add Reolink exposure mode select entity (#155759) 2025-11-03 23:17:52 +01:00
J. Nick Koston 76b24dafed Bump aioesphomeapi to 42.6.0 (#155728) 2025-11-03 22:04:05 +00:00
Kamil Breguła 431f563ff6 Add translation of exceptions in WLED (#155570)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-03 22:59:08 +01:00
starkillerOG e308e610c6 Add Reolink PIR interval number entity (#155758) 2025-11-03 21:53:07 +00:00
Christopher Fenner 5e77cbd185 Add integration_type to Vicare manifest (#155726) 2025-11-03 22:50:41 +01:00
tronikos 2dbc7ff4b7 Remove Enmax Energy virtual integration (#155475) 2025-11-03 22:48:58 +01:00
Kamil Breguła 49a6c5776d Fix typing of ConfigEntry in WLED (#155571)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-03 22:01:42 +01:00
TheJulianJES 98f6001c9c Fix ZBT-2 Thread to Zigbee migration discovery failing (#155735) 2025-11-03 20:02:13 +00:00
Bram Kragten ce38a93177 Update frontend to 20251103.0 (#155734) 2025-11-03 20:45:08 +01:00
Mike Degatano 92fbf468f2 Disable deprecated addon repair (#155739) 2025-11-03 13:08:30 -05:00
Michael Hansen e09ec4a6f3 Use character code in language matching (voice) (#155738) 2025-11-03 13:07:38 -05:00
Jan Bouwhuis db63e0c829 Add RSSI signal strength sensor to incomfort boiler (#155688)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-03 18:03:46 +01:00
starkillerOG 8ed88d4a58 Add Reolink restart button for IPC cams (#155710) 2025-11-03 16:57:38 +01:00
dependabot[bot] d098ada777 Bump github/codeql-action from 4.31.0 to 4.31.2 (#155538)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 16:52:53 +01:00
Ілля Піскурьов 1add999c5a Add separate scale and offset for current temperature for modbus climates (#150985)
Co-authored-by: jan iversen <jancasacondor@gmail.com>
Co-authored-by: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com>
Co-authored-by: crug80 <claudio@cr-tech.it>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-03 16:51:41 +01:00
Artur Pragacz fad217837f Accept more templates in service fields (#150239) 2025-11-03 16:40:42 +01:00
Simone Chemelli 983af1af7b Bump aioamazondevices to 6.5.6 (#155723) 2025-11-03 15:59:39 +01:00
293 changed files with 14966 additions and 1240 deletions
+4
View File
@@ -88,6 +88,10 @@ jobs:
fail-fast: false
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
exclude:
- arch: armv7
- arch: armhf
- arch: i386
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with:
category: "/language:python"
Generated
+2 -2
View File
@@ -1539,8 +1539,8 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali_center/ @niracler
/tests/components/sunricher_dali_center/ @niracler
/homeassistant/components/sunricher_dali/ @niracler
/tests/components/sunricher_dali/ @niracler
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen
+3
View File
@@ -1,7 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*
@@ -30,6 +30,7 @@ generate_data:
media:
accept:
- "*"
multiple: true
generate_image:
fields:
task_name:
@@ -57,3 +58,4 @@ generate_image:
media:
accept:
- "*"
multiple: true
+5 -2
View File
@@ -58,7 +58,10 @@ from homeassistant.const import (
from homeassistant.helpers import network
from homeassistant.util import color as color_util, dt as dt_util
from homeassistant.util.decorator import Registry
from homeassistant.util.unit_conversion import TemperatureConverter
from homeassistant.util.unit_conversion import (
TemperatureConverter,
TemperatureDeltaConverter,
)
from .config import AbstractConfig
from .const import (
@@ -844,7 +847,7 @@ def temperature_from_object(
temp -= 273.15
if interval:
return TemperatureConverter.convert_interval(temp, from_unit, to_unit)
return TemperatureDeltaConverter.convert(temp, from_unit, to_unit)
return TemperatureConverter.convert(temp, from_unit, to_unit)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.5.5"]
"requirements": ["aioamazondevices==6.5.6"]
}
@@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="daily_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
SensorEntityDescription(
@@ -150,7 +150,7 @@ SENSOR_DESCRIPTIONS = (
key=TYPE_LIGHTNING_PER_DAY,
translation_key="lightning_strikes_per_day",
native_unit_of_measurement="strikes",
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
@@ -182,7 +182,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="monthly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
entity_registry_enabled_default=False,
),
@@ -229,7 +229,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="weekly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
entity_registry_enabled_default=False,
),
@@ -262,7 +262,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="yearly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
entity_registry_enabled_default=False,
),
@@ -39,11 +39,11 @@ from .const import (
CONF_TURN_OFF_COMMAND,
CONF_TURN_ON_COMMAND,
DEFAULT_ADB_SERVER_PORT,
DEFAULT_DEVICE_CLASS,
DEFAULT_EXCLUDE_UNNAMED_APPS,
DEFAULT_GET_SOURCES,
DEFAULT_PORT,
DEFAULT_SCREENCAP_INTERVAL,
DEVICE_AUTO,
DEVICE_CLASSES,
DOMAIN,
PROP_ETHMAC,
@@ -89,8 +89,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In(
DEVICE_CLASSES
vol.Required(CONF_DEVICE_CLASS, default=DEVICE_AUTO): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=k, label=v)
for k, v in DEVICE_CLASSES.items()
],
translation_key="device_class",
)
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
},
+6 -2
View File
@@ -15,15 +15,19 @@ CONF_TURN_OFF_COMMAND = "turn_off_command"
CONF_TURN_ON_COMMAND = "turn_on_command"
DEFAULT_ADB_SERVER_PORT = 5037
DEFAULT_DEVICE_CLASS = "auto"
DEFAULT_EXCLUDE_UNNAMED_APPS = False
DEFAULT_GET_SOURCES = True
DEFAULT_PORT = 5555
DEFAULT_SCREENCAP_INTERVAL = 5
DEVICE_AUTO = "auto"
DEVICE_ANDROIDTV = "androidtv"
DEVICE_FIRETV = "firetv"
DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV]
DEVICE_CLASSES = {
DEVICE_AUTO: "auto",
DEVICE_ANDROIDTV: "Android TV",
DEVICE_FIRETV: "Fire TV",
}
PROP_ETHMAC = "ethmac"
PROP_SERIALNO = "serialno"
@@ -65,6 +65,13 @@
}
}
},
"selector": {
"device_class": {
"options": {
"auto": "Auto-detect device type"
}
}
},
"services": {
"adb_command": {
"description": "Sends an ADB command to an Android / Fire TV device.",
+10 -1
View File
@@ -9,7 +9,7 @@ from brother import Brother, SnmpError
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from .const import (
CONF_COMMUNITY,
@@ -50,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
coordinator = BrotherDataUpdateCoordinator(hass, entry, brother)
await coordinator.async_config_entry_first_refresh()
if brother.serial.lower() != entry.unique_id:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="serial_mismatch",
translation_placeholders={
"device": entry.title,
},
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -0,0 +1,30 @@
"""Define the Brother entity."""
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BrotherDataUpdateCoordinator
class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
"""Define a Brother Printer entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BrotherDataUpdateCoordinator,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.brother.host}/",
identifiers={(DOMAIN, coordinator.brother.serial)},
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
serial_number=coordinator.brother.serial,
manufacturer="Brother",
model=coordinator.brother.model,
name=coordinator.brother.model,
sw_version=coordinator.brother.firmware,
)
+4 -17
View File
@@ -19,13 +19,12 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
from .entity import BrotherPrinterEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -333,12 +332,9 @@ async def async_setup_entry(
)
class BrotherPrinterSensor(
CoordinatorEntity[BrotherDataUpdateCoordinator], SensorEntity
):
"""Define an Brother Printer sensor."""
class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
"""Define a Brother Printer sensor."""
_attr_has_entity_name = True
entity_description: BrotherSensorEntityDescription
def __init__(
@@ -348,16 +344,7 @@ class BrotherPrinterSensor(
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.brother.host}/",
identifiers={(DOMAIN, coordinator.brother.serial)},
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
serial_number=coordinator.brother.serial,
manufacturer="Brother",
model=coordinator.brother.model,
name=coordinator.brother.model,
sw_version=coordinator.brother.firmware,
)
self._attr_native_value = description.value(coordinator.data)
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
self.entity_description = description
@@ -207,6 +207,9 @@
"cannot_connect": {
"message": "An error occurred while connecting to the {device} printer: {error}"
},
"serial_mismatch": {
"message": "The serial number for {device} doesn't match the one in the configuration. It's possible that the two Brother printers have swapped IP addresses. Restore the previous IP address configuration or reconfigure the devices with Home Assistant."
},
"update_error": {
"message": "An error occurred while retrieving data from the {device} printer: {error}"
}
@@ -71,8 +71,11 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]:
services = await account_link.async_fetch_available_services(
hass.data[DATA_CLOUD]
)
except (aiohttp.ClientError, TimeoutError):
return []
except (aiohttp.ClientError, TimeoutError) as err:
raise config_entry_oauth2_flow.ImplementationUnavailableError(
"Cannot provide OAuth2 implementation for cloud services. "
"Failed to fetch from account link server."
) from err
hass.data[DATA_SERVICES] = services
@@ -6,3 +6,5 @@ DEFAULT_PORT = 10102
CONF_SUPPORTED_MODES = "supported_modes"
CONF_SWING_SUPPORT = "swing_support"
MAX_RETRIES = 3
BACKOFF_BASE_DELAY = 2
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from pycoolmasternet_async import CoolMasterNet
@@ -12,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .const import BACKOFF_BASE_DELAY, DOMAIN, MAX_RETRIES
_LOGGER = logging.getLogger(__name__)
@@ -46,7 +47,34 @@ class CoolmasterDataUpdateCoordinator(
async def _async_update_data(self) -> dict[str, CoolMasterNetUnit]:
"""Fetch data from Coolmaster."""
try:
return await self._coolmaster.status()
except OSError as error:
raise UpdateFailed from error
retries_left = MAX_RETRIES
status: dict[str, CoolMasterNetUnit] = {}
while retries_left > 0 and not status:
retries_left -= 1
try:
status = await self._coolmaster.status()
except OSError as error:
if retries_left == 0:
raise UpdateFailed(
f"Error communicating with Coolmaster (aborting after {MAX_RETRIES} retries): {error}"
) from error
_LOGGER.debug(
"Error communicating with coolmaster (%d retries left): %s",
retries_left,
str(error),
)
else:
if status:
return status
_LOGGER.debug(
"Error communicating with coolmaster: empty status received (%d retries left)",
retries_left,
)
backoff = BACKOFF_BASE_DELAY ** (MAX_RETRIES - retries_left)
await asyncio.sleep(backoff)
raise UpdateFailed(
f"Error communicating with Coolmaster (aborting after {MAX_RETRIES} retries): empty status received"
)
@@ -151,14 +151,12 @@ ECOWITT_SENSORS_MAPPING: Final = {
key="RAIN_COUNT_MM",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=1,
),
EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription(
key="RAIN_COUNT_INCHES",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=2,
),
EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription(
@@ -1 +0,0 @@
"""Virtual integration: Enmax Energy."""
@@ -1,6 +0,0 @@
{
"domain": "enmax",
"name": "Enmax Energy",
"integration_type": "virtual",
"supported_by": "opower"
}
+65 -4
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from aioesphomeapi import APIClient
import logging
from aioesphomeapi import APIClient, APIConnectionError
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
@@ -20,9 +22,12 @@ from homeassistant.helpers.typing import ConfigType
from . import assist_satellite, dashboard, ffmpeg_proxy
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
CLIENT_INFO = f"Home Assistant {ha_version}"
@@ -75,10 +80,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
"""Unload an esphome config entry."""
entry_data = await cleanup_instance(entry)
return await hass.config_entries.async_unload_platforms(
entry, entry_data.loaded_platforms
unload_ok = await hass.config_entries.async_unload_platforms(
entry, entry.runtime_data.loaded_platforms
)
if unload_ok:
await cleanup_instance(entry)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
@@ -89,3 +96,57 @@ async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) ->
hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id)
)
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()
await _async_clear_dynamic_encryption_key(hass, entry)
async def _async_clear_dynamic_encryption_key(
hass: HomeAssistant, entry: ESPHomeConfigEntry
) -> None:
"""Clear the dynamic encryption key on the device and from storage."""
if entry.unique_id is None or entry.data.get(CONF_NOISE_PSK) is None:
return
# Only clear the key if it's stored in our storage, meaning it was
# dynamically generated by us and not user-provided
storage = await async_get_encryption_key_storage(hass)
if await storage.async_get_key(entry.unique_id) is None:
return
host: str = entry.data[CONF_HOST]
port: int = entry.data[CONF_PORT]
password: str | None = entry.data[CONF_PASSWORD]
noise_psk: str | None = entry.data.get(CONF_NOISE_PSK)
zeroconf_instance = await zeroconf.async_get_instance(hass)
cli = APIClient(
host,
port,
password,
client_info=CLIENT_INFO,
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
timezone=hass.config.time_zone,
)
try:
await cli.connect()
# Clear the encryption key on the device by passing an empty key
if not await cli.noise_encryption_set_key(b""):
_LOGGER.debug(
"Could not clear dynamic encryption key for ESPHome device %s: Device rejected key removal",
entry.unique_id,
)
return
except APIConnectionError as exc:
_LOGGER.debug(
"Could not connect to ESPHome device %s to clear dynamic encryption key: %s",
entry.unique_id,
exc,
)
return
finally:
await cli.disconnect()
await storage.async_remove_key(entry.unique_id)
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==42.5.0",
"aioesphomeapi==42.6.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/foscam",
"iot_class": "local_polling",
"loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.8"]
"requirements": ["libpyfoscamcgi==0.0.9"]
}
@@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.app.router.register_resource(IndexView(repo_path, hass))
async_register_built_in_panel(hass, "light")
async_register_built_in_panel(hass, "safety")
async_register_built_in_panel(hass, "security")
async_register_built_in_panel(hass, "climate")
async_register_built_in_panel(hass, "profile")
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251029.1"]
"requirements": ["home-assistant-frontend==20251105.0"]
}
@@ -3,6 +3,9 @@
from __future__ import annotations
from dataclasses import dataclass
import itertools
from aiohasupervisor.models.mounts import MountState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -13,8 +16,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
from .entity import HassioAddonEntity
from .const import (
ADDONS_COORDINATOR,
ATTR_STARTED,
ATTR_STATE,
DATA_KEY_ADDONS,
DATA_KEY_MOUNTS,
)
from .entity import HassioAddonEntity, HassioMountEntity
@dataclass(frozen=True)
@@ -34,6 +43,16 @@ ADDON_ENTITY_DESCRIPTIONS = (
),
)
MOUNT_ENTITY_DESCRIPTIONS = (
HassioBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
key=ATTR_STATE,
translation_key="mount",
target=MountState.ACTIVE.value,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -44,13 +63,26 @@ async def async_setup_entry(
coordinator = hass.data[ADDONS_COORDINATOR]
async_add_entities(
HassioAddonBinarySensor(
addon=addon,
coordinator=coordinator,
entity_description=entity_description,
itertools.chain(
[
HassioAddonBinarySensor(
addon=addon,
coordinator=coordinator,
entity_description=entity_description,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
],
[
HassioMountBinarySensor(
mount=mount,
coordinator=coordinator,
entity_description=entity_description,
)
for mount in coordinator.data[DATA_KEY_MOUNTS].values()
for entity_description in MOUNT_ENTITY_DESCRIPTIONS
],
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
)
@@ -68,3 +100,20 @@ class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity):
if self.entity_description.target is None:
return value
return value == self.entity_description.target
class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity):
"""Binary sensor for Hass.io mount."""
entity_description: HassioBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
value = getattr(
self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name],
self.entity_description.key,
)
if self.entity_description.target is None:
return value
return value == self.entity_description.target
+3
View File
@@ -90,6 +90,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_MOUNTS_INFO = "hassio_mounts_info"
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
ATTR_AUTO_UPDATE = "auto_update"
@@ -110,6 +111,7 @@ DATA_KEY_SUPERVISOR = "supervisor"
DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
DATA_KEY_MOUNTS = "mounts"
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
@@ -174,3 +176,4 @@ class SupervisorEntityModel(StrEnum):
CORE = "Home Assistant Core"
SUPERVISOR = "Home Assistant Supervisor"
HOST = "Home Assistant Host"
MOUNT = "Home Assistant Mount"
+67 -9
View File
@@ -10,6 +10,11 @@ from typing import TYPE_CHECKING, Any
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import StoreInfo
from aiohasupervisor.models.mounts import (
CIFSMountResponse,
MountsInfo,
NFSMountResponse,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
@@ -41,9 +46,11 @@ from .const import (
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_MOUNTS_INFO,
DATA_NETWORK_INFO,
DATA_OS_INFO,
DATA_STORE,
@@ -174,6 +181,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
return hass.data.get(DATA_CORE_INFO)
@callback
@bind_hass
def get_mounts_info(hass: HomeAssistant) -> MountsInfo | None:
"""Return Home Assistant mounts information from Supervisor.
Async friendly.
"""
return hass.data.get(DATA_MOUNTS_INFO)
@callback
@bind_hass
def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
@@ -203,6 +220,25 @@ def async_register_addons_in_dev_reg(
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@callback
def async_register_mounts_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
mounts: list[CIFSMountResponse | NFSMountResponse],
) -> None:
"""Register mounts in the device registry."""
for mount in mounts:
params = DeviceInfo(
identifiers={(DOMAIN, f"mount_{mount.name}")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.MOUNT,
model_id=f"{mount.usage}/{mount.type}",
name=mount.name,
entry_type=dr.DeviceEntryType.SERVICE,
)
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@callback
def async_register_os_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
@@ -272,12 +308,12 @@ def async_register_supervisor_in_dev_reg(
@callback
def async_remove_addons_from_dev_reg(
dev_reg: dr.DeviceRegistry, addons: set[str]
def async_remove_devices_from_dev_reg(
dev_reg: dr.DeviceRegistry, devices: set[str]
) -> None:
"""Remove addons from the device registry."""
for addon_slug in addons:
if dev := dev_reg.async_get_device(identifiers={(DOMAIN, addon_slug)}):
"""Remove devices from the device registry."""
for device in devices:
if dev := dev_reg.async_get_device(identifiers={(DOMAIN, device)}):
dev_reg.async_remove_device(dev.id)
@@ -362,12 +398,19 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
**get_supervisor_stats(self.hass),
}
new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {}
new_data[DATA_KEY_MOUNTS] = {
mount.name: mount
for mount in getattr(get_mounts_info(self.hass), "mounts", [])
}
# If this is the initial refresh, register all addons and return the dict
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
)
async_register_mounts_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values()
)
async_register_core_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
)
@@ -389,7 +432,20 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
async_remove_addons_from_dev_reg(self.dev_reg, stale_addons)
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# Remove mounts that no longer exists from device registry
supervisor_mount_devices = {
device.name
for device in self.dev_reg.devices.get_devices_for_config_entry_id(
self.entry_id
)
if device.model == SupervisorEntityModel.MOUNT
}
if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]):
async_remove_devices_from_dev_reg(
self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts}
)
if not self.is_hass_os and (
dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")})
@@ -397,11 +453,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
# Remove the OS device if it exists and the installation is not hassos
self.dev_reg.async_remove_device(dev.id)
# If there are new add-ons, we should reload the config entry so we can
# If there are new add-ons or mounts, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
if self.data and set(new_data[DATA_KEY_ADDONS]) - set(
self.data[DATA_KEY_ADDONS]
if self.data and (
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
or set(new_data[DATA_KEY_MOUNTS]) - set(self.data[DATA_KEY_MOUNTS])
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
@@ -428,6 +485,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
DATA_CORE_INFO: hassio.get_core_info(),
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
DATA_OS_INFO: hassio.get_os_info(),
DATA_MOUNTS_INFO: self.supervisor_client.mounts.info(),
}
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
updates[DATA_CORE_STATS] = hassio.get_core_stats()
+34
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -15,6 +17,7 @@ from .const import (
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DOMAIN,
@@ -192,3 +195,34 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
)
if CONTAINER_STATS in update_types:
await self.coordinator.async_request_refresh()
class HassioMountEntity(CoordinatorEntity[HassioDataUpdateCoordinator]):
"""Base Entity for Mount."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HassioDataUpdateCoordinator,
entity_description: EntityDescription,
mount: CIFSMountResponse | NFSMountResponse,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"home_assistant_mount_{mount.name}_{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"mount_{mount.name}")}
)
self._mount = mount
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS]
)
@@ -44,7 +44,6 @@ from .const import (
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
@@ -87,7 +86,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED,
}
_LOGGER = logging.getLogger(__name__)
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"mount": {
"name": "Connected"
},
"state": {
"name": "Running"
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.83", "babel==2.15.0"]
"requirements": ["holidays==0.84", "babel==2.15.0"]
}
@@ -39,6 +39,8 @@ from .const import (
NABU_CASA_FIRMWARE_RELEASES_URL,
PID,
PRODUCT,
RADIO_TX_POWER_DBM_BY_COUNTRY,
RADIO_TX_POWER_DBM_DEFAULT,
SERIAL_NUMBER,
VID,
)
@@ -75,6 +77,7 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
@@ -102,6 +105,21 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
next_step_id="finish_thread_installation",
)
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
country = self.hass.config.country
if country is None:
tx_power = RADIO_TX_POWER_DBM_DEFAULT
else:
tx_power = RADIO_TX_POWER_DBM_BY_COUNTRY.get(
country, RADIO_TX_POWER_DBM_DEFAULT
)
return {
"tx_power": tx_power,
}
class HomeAssistantConnectZBT2ConfigFlow(
ZBT2FirmwareMixin,
@@ -112,7 +130,6 @@ class HomeAssistantConnectZBT2ConfigFlow(
VERSION = 1
MINOR_VERSION = 1
ZIGBEE_BAUDRATE = 460800
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""
@@ -1,5 +1,7 @@
"""Constants for the Home Assistant Connect ZBT-2 integration."""
from homeassistant.generated.countries import COUNTRIES
DOMAIN = "homeassistant_connect_zbt2"
NABU_CASA_FIRMWARE_RELEASES_URL = (
@@ -17,3 +19,59 @@ VID = "vid"
DEVICE = "device"
HARDWARE_NAME = "Home Assistant Connect ZBT-2"
RADIO_TX_POWER_DBM_DEFAULT = 8
RADIO_TX_POWER_DBM_BY_COUNTRY = {
# EU Member States
"AT": 10,
"BE": 10,
"BG": 10,
"HR": 10,
"CY": 10,
"CZ": 10,
"DK": 10,
"EE": 10,
"FI": 10,
"FR": 10,
"DE": 10,
"GR": 10,
"HU": 10,
"IE": 10,
"IT": 10,
"LV": 10,
"LT": 10,
"LU": 10,
"MT": 10,
"NL": 10,
"PL": 10,
"PT": 10,
"RO": 10,
"SK": 10,
"SI": 10,
"ES": 10,
"SE": 10,
# EEA Members
"IS": 10,
"LI": 10,
"NO": 10,
# Standards harmonized with RED or ETSI
"CH": 10,
"GB": 10,
"TR": 10,
"AL": 10,
"BA": 10,
"GE": 10,
"MD": 10,
"ME": 10,
"MK": 10,
"RS": 10,
"UA": 10,
# Other CEPT nations
"AD": 10,
"AZ": 10,
"MC": 10,
"SM": 10,
"VA": 10,
}
assert set(RADIO_TX_POWER_DBM_BY_COUNTRY) <= COUNTRIES
@@ -456,6 +456,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_continue_zigbee()
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
return {}
async def async_step_continue_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -478,6 +482,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
"radio_type": "ezsp",
"flow_strategy": self._zigbee_flow_strategy,
**self._extra_zha_hardware_options(),
},
)
return self._continue_zha_flow(result)
+13 -2
View File
@@ -38,6 +38,7 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
KEY_AUTHENTICATED, # noqa: F401
@@ -109,7 +110,7 @@ HTTP_SCHEMA: Final = vol.All(
cv.deprecated(CONF_BASE_URL),
vol.Schema(
{
vol.Optional(CONF_SERVER_HOST, default=_DEFAULT_BIND): vol.All(
vol.Optional(CONF_SERVER_HOST): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
@@ -207,7 +208,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if conf is None:
conf = cast(ConfData, HTTP_SCHEMA({}))
server_host = conf[CONF_SERVER_HOST]
if CONF_SERVER_HOST in conf and is_hassio(hass):
ir.async_create_issue(
hass,
DOMAIN,
"server_host_may_break_hassio",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="server_host_may_break_hassio",
)
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
server_port = conf[CONF_SERVER_PORT]
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
@@ -1,5 +1,9 @@
{
"issues": {
"server_host_may_break_hassio": {
"description": "The `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
},
"ssl_configured_without_configured_urls": {
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
"title": "SSL is configured without an external URL or internal URL"
@@ -20,6 +20,11 @@
}
}
},
"sensor": {
"rf_message_rssi": {
"default": "mdi:signal"
}
},
"water_heater": {
"boiler": {
"state": {
@@ -61,6 +61,16 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
value_key="tap_temp",
entity_registry_enabled_default=False,
),
# A lower RSSI value is better
# A typical RSSI value is 28 for connection just in range
IncomfortSensorEntityDescription(
key="rf_message_rssi",
translation_key="rf_message_rssi",
state_class=SensorStateClass.MEASUREMENT,
value_key="rf_message_rssi",
extra_key="rfstatus_cntr",
entity_registry_enabled_default=False,
),
)
@@ -76,6 +76,9 @@
}
},
"sensor": {
"rf_message_rssi": {
"name": "RSSI"
},
"tap_temperature": {
"name": "Tap temperature"
}
@@ -622,6 +622,7 @@ ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
usage_period=USAGE_MONTHLY,
start_date_fn=lambda today: today,
end_date_fn=lambda today: today,
state_class=SensorStateClass.TOTAL_INCREASING,
),
ThinQEnergySensorEntityDescription(
key="last_month",
@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.7"]
"requirements": ["pylitterbot==2025.0.0"]
}
@@ -408,6 +408,20 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
if not alarm_code or code == alarm_code:
return
current_context = (
self._context if hasattr(self, "_context") and self._context else None
)
user_id_from_context = current_context.user_id if current_context else None
self.hass.bus.async_fire(
"manual_alarm_bad_code_attempt",
{
"entity_id": self.entity_id,
"user_id": user_id_from_context,
"target_state": state,
},
)
raise ServiceValidationError(
"Invalid alarm code provided",
translation_domain=DOMAIN,
@@ -41,6 +41,9 @@
"energy_forecast": {
"default": "mdi:lightning-bolt-outline"
},
"finish": {
"default": "mdi:clock-end"
},
"plate": {
"default": "mdi:circle-outline",
"state": {
@@ -83,6 +86,9 @@
"spin_speed": {
"default": "mdi:sync"
},
"start": {
"default": "mdi:clock-start"
},
"start_time": {
"default": "mdi:clock-start"
},
+132 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any, Final, cast
@@ -29,6 +30,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .const import (
COFFEE_SYSTEM_PROFILE,
@@ -102,12 +104,47 @@ def _get_coffee_profile(value: MieleDevice) -> str | None:
return None
def _convert_start_timestamp(
elapsed_time_list: list[int], start_time_list: list[int]
) -> datetime | None:
"""Convert raw values representing time into start timestamp."""
now = dt_util.utcnow()
elapsed_duration = _convert_duration(elapsed_time_list)
delayed_start_duration = _convert_duration(start_time_list)
if (elapsed_duration is None or elapsed_duration == 0) and (
delayed_start_duration is None or delayed_start_duration == 0
):
return None
if elapsed_duration is not None and elapsed_duration > 0:
duration = -elapsed_duration
elif delayed_start_duration is not None and delayed_start_duration > 0:
duration = delayed_start_duration
delta = timedelta(minutes=duration)
return (now + delta).replace(second=0, microsecond=0)
def _convert_finish_timestamp(
remaining_time_list: list[int], start_time_list: list[int]
) -> datetime | None:
"""Convert raw values representing time into finish timestamp."""
now = dt_util.utcnow()
program_duration = _convert_duration(remaining_time_list)
delayed_start_duration = _convert_duration(start_time_list)
if program_duration is None or program_duration == 0:
return None
duration = program_duration + (
delayed_start_duration if delayed_start_duration is not None else 0
)
delta = timedelta(minutes=duration)
return (now + delta).replace(second=0, microsecond=0)
@dataclass(frozen=True, kw_only=True)
class MieleSensorDescription(SensorEntityDescription):
"""Class describing Miele sensor entities."""
value_fn: Callable[[MieleDevice], StateType]
end_value_fn: Callable[[StateType], StateType] | None = None
value_fn: Callable[[MieleDevice], StateType | datetime]
end_value_fn: Callable[[StateType | datetime], StateType | datetime] | None = None
extra_attributes: dict[str, Callable[[MieleDevice], StateType]] | None = None
zone: int | None = None
unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None
@@ -428,6 +465,60 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
suggested_unit_of_measurement=UnitOfTime.HOURS,
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL,
MieleAppliance.TUMBLE_DRYER,
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.DISHWASHER,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_finish_timestamp",
translation_key="finish",
value_fn=lambda value: _convert_finish_timestamp(
value.state_remaining_time, value.state_start_time
),
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
MieleAppliance.TUMBLE_DRYER,
MieleAppliance.DISHWASHER,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_start_timestamp",
translation_key="start",
value_fn=lambda value: _convert_start_timestamp(
value.state_elapsed_time, value.state_start_time
),
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
MieleSensorDefinition(
types=(
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
@@ -620,6 +711,8 @@ async def async_setup_entry(
"state_elapsed_time": MieleTimeSensor,
"state_remaining_time": MieleTimeSensor,
"state_start_time": MieleTimeSensor,
"state_start_timestamp": MieleAbsoluteTimeSensor,
"state_finish_timestamp": MieleAbsoluteTimeSensor,
"current_energy_consumption": MieleConsumptionSensor,
"current_water_consumption": MieleConsumptionSensor,
}.get(definition.description.key, MieleSensor)
@@ -743,7 +836,7 @@ class MieleSensor(MieleEntity, SensorEntity):
self._attr_unique_id = description.unique_id_fn(device_id, description)
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.device)
@@ -761,7 +854,7 @@ class MieleSensor(MieleEntity, SensorEntity):
class MieleRestorableSensor(MieleSensor, RestoreSensor):
"""Representation of a Sensor whose internal state can be restored."""
_attr_native_value: StateType
_attr_native_value: StateType | datetime
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@@ -773,7 +866,7 @@ class MieleRestorableSensor(MieleSensor, RestoreSensor):
self._attr_native_value = last_data.native_value # type: ignore[assignment]
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor.
It is necessary to override `native_value` to fall back to the default
@@ -934,6 +1027,40 @@ class MieleTimeSensor(MieleRestorableSensor):
self._attr_native_value = current_value
class MieleAbsoluteTimeSensor(MieleRestorableSensor):
"""Representation of absolute time sensors handling precision correctness."""
_previous_value: StateType | datetime = None
def _update_native_value(self) -> None:
"""Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status)
# The API reports with minute precision, to avoid changing
# the value too often, we keep the cached value if it differs
# less than 90s from the new value
if (
isinstance(self._previous_value, datetime)
and isinstance(current_value, datetime)
and (
self._previous_value - timedelta(seconds=90)
< current_value
< self._previous_value + timedelta(seconds=90)
)
) or current_status == StateStatus.PROGRAM_ENDED:
return
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
if current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
self._attr_native_value = None
# otherwise, cache value and return it
else:
self._attr_native_value = current_value
self._previous_value = current_value
class MieleConsumptionSensor(MieleRestorableSensor):
"""Representation of consumption sensors keeping state from cache."""
@@ -216,6 +216,9 @@
"energy_forecast": {
"name": "Energy forecast"
},
"finish": {
"name": "Finish"
},
"plate": {
"name": "Plate {plate_no}",
"state": {
@@ -1015,6 +1018,9 @@
"spin_speed": {
"name": "Spin speed"
},
"start": {
"name": "Start"
},
"start_time": {
"name": "Start in"
},
+23 -2
View File
@@ -66,6 +66,8 @@ from .const import (
CONF_BYTESIZE,
CONF_CLIMATES,
CONF_COLOR_TEMP_REGISTER,
CONF_CURRENT_TEMP_OFFSET,
CONF_CURRENT_TEMP_SCALE,
CONF_DATA_TYPE,
CONF_DEVICE_ADDRESS,
CONF_FAN_MODE_AUTO,
@@ -137,6 +139,8 @@ from .const import (
CONF_SWING_MODE_SWING_VERT,
CONF_SWING_MODE_VALUES,
CONF_TARGET_TEMP,
CONF_TARGET_TEMP_OFFSET,
CONF_TARGET_TEMP_SCALE,
CONF_TARGET_TEMP_WRITE_REGISTERS,
CONF_VERIFY,
CONF_VIRTUAL_COUNT,
@@ -159,8 +163,10 @@ from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup
from .validators import (
duplicate_fan_mode_validator,
duplicate_swing_mode_validator,
ensure_and_check_conflicting_scales_and_offsets,
hvac_fixedsize_reglist_validator,
nan_validator,
not_zero_value,
register_int_list_validator,
struct_validator,
)
@@ -210,8 +216,10 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
]
),
vol.Optional(CONF_STRUCTURE): cv.string,
vol.Optional(CONF_SCALE, default=1): vol.Coerce(float),
vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float),
vol.Optional(CONF_SCALE): vol.All(
vol.Coerce(float), lambda v: not_zero_value(v, "Scale cannot be zero.")
),
vol.Optional(CONF_OFFSET): vol.Coerce(float),
vol.Optional(CONF_PRECISION): cv.positive_int,
vol.Optional(
CONF_SWAP,
@@ -273,6 +281,18 @@ CLIMATE_SCHEMA = vol.All(
vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string,
vol.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int,
vol.Exclusive(CONF_HVAC_ONOFF_REGISTER, "hvac_onoff_type"): cv.positive_int,
vol.Optional(CONF_CURRENT_TEMP_SCALE): vol.All(
vol.Coerce(float),
lambda v: not_zero_value(
v, "Current temperature scale cannot be zero."
),
),
vol.Optional(CONF_TARGET_TEMP_SCALE): vol.All(
vol.Coerce(float),
lambda v: not_zero_value(v, "Target temperature scale cannot be zero."),
),
vol.Optional(CONF_CURRENT_TEMP_OFFSET): vol.Coerce(float),
vol.Optional(CONF_TARGET_TEMP_OFFSET): vol.Coerce(float),
vol.Optional(
CONF_HVAC_ON_VALUE, default=DEFAULT_HVAC_ON_VALUE
): cv.positive_int,
@@ -385,6 +405,7 @@ CLIMATE_SCHEMA = vol.All(
),
},
),
ensure_and_check_conflicting_scales_and_offsets,
)
COVERS_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
+45 -8
View File
@@ -50,6 +50,8 @@ from .const import (
CALL_TYPE_WRITE_REGISTER,
CALL_TYPE_WRITE_REGISTERS,
CONF_CLIMATES,
CONF_CURRENT_TEMP_OFFSET,
CONF_CURRENT_TEMP_SCALE,
CONF_FAN_MODE_AUTO,
CONF_FAN_MODE_DIFFUSE,
CONF_FAN_MODE_FOCUS,
@@ -97,8 +99,12 @@ from .const import (
CONF_SWING_MODE_SWING_VERT,
CONF_SWING_MODE_VALUES,
CONF_TARGET_TEMP,
CONF_TARGET_TEMP_OFFSET,
CONF_TARGET_TEMP_SCALE,
CONF_TARGET_TEMP_WRITE_REGISTERS,
CONF_WRITE_REGISTERS,
DEFAULT_OFFSET,
DEFAULT_SCALE,
DataType,
)
from .entity import ModbusStructEntity
@@ -166,6 +172,10 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
self._attr_min_temp = config[CONF_MIN_TEMP]
self._attr_max_temp = config[CONF_MAX_TEMP]
self._attr_target_temperature_step = config[CONF_STEP]
self._current_temp_scale = config[CONF_CURRENT_TEMP_SCALE]
self._current_temp_offset = config[CONF_CURRENT_TEMP_OFFSET]
self._target_temp_scale = config[CONF_TARGET_TEMP_SCALE]
self._target_temp_offset = config[CONF_TARGET_TEMP_OFFSET]
if CONF_HVAC_MODE_REGISTER in config:
mode_config = config[CONF_HVAC_MODE_REGISTER]
@@ -413,8 +423,8 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_temperature = (
float(kwargs[ATTR_TEMPERATURE]) - self._offset
) / self._scale
float(kwargs[ATTR_TEMPERATURE]) - self._target_temp_offset
) / self._target_temp_scale
if self._data_type in (
DataType.INT16,
DataType.INT32,
@@ -472,15 +482,25 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
self._target_temperature_register[
HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode]
],
self._target_temp_scale,
self._target_temp_offset,
)
self._attr_current_temperature = await self._async_read_register(
self._input_type, self._address
self._input_type,
self._address,
self._current_temp_scale,
self._current_temp_offset,
)
# Read the HVAC mode register if defined
if self._hvac_mode_register is not None:
hvac_mode = await self._async_read_register(
CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True
CALL_TYPE_REGISTER_HOLDING,
self._hvac_mode_register,
DEFAULT_SCALE,
DEFAULT_OFFSET,
raw=True,
)
# Translate the value received
@@ -499,7 +519,11 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
# Read the HVAC action register if defined
if self._hvac_action_register is not None:
hvac_action = await self._async_read_register(
self._hvac_action_type, self._hvac_action_register, raw=True
self._hvac_action_type,
self._hvac_action_register,
DEFAULT_SCALE,
DEFAULT_OFFSET,
raw=True,
)
# Translate the value received
@@ -517,6 +541,8 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
self._fan_mode_register
if isinstance(self._fan_mode_register, int)
else self._fan_mode_register[0],
DEFAULT_SCALE,
DEFAULT_OFFSET,
raw=True,
)
@@ -533,6 +559,8 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
self._swing_mode_register
if isinstance(self._swing_mode_register, int)
else self._swing_mode_register[0],
DEFAULT_SCALE,
DEFAULT_OFFSET,
raw=True,
)
@@ -551,7 +579,11 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
# in the mode register.
if self._hvac_onoff_register is not None:
onoff = await self._async_read_register(
CALL_TYPE_REGISTER_HOLDING, self._hvac_onoff_register, raw=True
CALL_TYPE_REGISTER_HOLDING,
self._hvac_onoff_register,
DEFAULT_SCALE,
DEFAULT_OFFSET,
raw=True,
)
if onoff == self._hvac_off_value:
self._attr_hvac_mode = HVACMode.OFF
@@ -562,7 +594,12 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
self._attr_hvac_mode = HVACMode.OFF
async def _async_read_register(
self, register_type: str, register: int, raw: bool | None = False
self,
register_type: str,
register: int,
scale: float,
offset: float,
raw: bool | None = False,
) -> float | None:
"""Read register using the Modbus hub slave."""
result = await self._hub.async_pb_call(
@@ -579,7 +616,7 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
return int(result.registers[0])
# The regular handling of the value
self._value = self.unpack_structure_result(result.registers)
self._value = self.unpack_structure_result(result.registers, scale, offset)
if not self._value:
self._attr_available = False
return None
+7
View File
@@ -19,6 +19,8 @@ CONF_BYTESIZE = "bytesize"
CONF_CLIMATES = "climates"
CONF_BRIGHTNESS_REGISTER = "brightness_address"
CONF_COLOR_TEMP_REGISTER = "color_temp_address"
CONF_CURRENT_TEMP_OFFSET = "current_temp_offset"
CONF_CURRENT_TEMP_SCALE = "current_temp_scale"
CONF_DATA_TYPE = "data_type"
CONF_DEVICE_ADDRESS = "device_address"
CONF_FANS = "fans"
@@ -48,6 +50,8 @@ CONF_SWAP_BYTE = "byte"
CONF_SWAP_WORD = "word"
CONF_SWAP_WORD_BYTE = "word_byte"
CONF_TARGET_TEMP = "target_temp_register"
CONF_TARGET_TEMP_OFFSET = "target_temp_offset"
CONF_TARGET_TEMP_SCALE = "target_temp_scale"
CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers"
CONF_FAN_MODE_REGISTER = "fan_mode_register"
CONF_FAN_MODE_ON = "state_fan_on"
@@ -181,4 +185,7 @@ LIGHT_MODBUS_SCALE_MIN = 0
LIGHT_MODBUS_SCALE_MAX = 100
LIGHT_MODBUS_INVALID_VALUE = 0xFFFF
DEFAULT_SCALE = 1.0
DEFAULT_OFFSET = 0
_LOGGER = logging.getLogger(__package__)
+17 -11
View File
@@ -17,7 +17,6 @@ from homeassistant.const import (
CONF_DELAY,
CONF_DEVICE_CLASS,
CONF_NAME,
CONF_OFFSET,
CONF_SCAN_INTERVAL,
CONF_SLAVE,
CONF_STRUCTURE,
@@ -50,7 +49,6 @@ from .const import (
CONF_MIN_VALUE,
CONF_NAN_VALUE,
CONF_PRECISION,
CONF_SCALE,
CONF_SLAVE_COUNT,
CONF_STATE_OFF,
CONF_STATE_ON,
@@ -62,6 +60,8 @@ from .const import (
CONF_VIRTUAL_COUNT,
CONF_WRITE_TYPE,
CONF_ZERO_SUPPRESS,
DEFAULT_OFFSET,
DEFAULT_SCALE,
SIGNAL_STOP_ENTITY,
DataType,
)
@@ -163,8 +163,6 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
self._swap = config[CONF_SWAP]
self._data_type = config[CONF_DATA_TYPE]
self._structure: str = config[CONF_STRUCTURE]
self._scale = config[CONF_SCALE]
self._offset = config[CONF_OFFSET]
self._slave_count = config.get(CONF_SLAVE_COUNT) or config.get(
CONF_VIRTUAL_COUNT, 0
)
@@ -181,8 +179,6 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
self._precision = config.get(CONF_PRECISION, 2)
else:
self._precision = config.get(CONF_PRECISION, 0)
if self._precision > 0 or self._scale != int(self._scale):
self._value_is_int = False
def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]:
"""Do swap as needed."""
@@ -206,7 +202,12 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
registers.reverse()
return registers
def __process_raw_value(self, entry: float | str | bytes) -> str | None:
def __process_raw_value(
self,
entry: float | bytes,
scale: float = DEFAULT_SCALE,
offset: float = DEFAULT_OFFSET,
) -> str | None:
"""Process value from sensor with NaN handling, scaling, offset, min/max etc."""
if self._nan_value is not None and entry in (self._nan_value, -self._nan_value):
return None
@@ -215,7 +216,7 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
if entry != entry: # noqa: PLR0124
# NaN float detection replace with None
return None
val: float | int = self._scale * entry + self._offset
val: float | int = scale * entry + offset
if self._min_value is not None and val < self._min_value:
val = self._min_value
if self._max_value is not None and val > self._max_value:
@@ -226,7 +227,12 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
return str(round(val))
return f"{float(val):.{self._precision}f}"
def unpack_structure_result(self, registers: list[int]) -> str | None:
def unpack_structure_result(
self,
registers: list[int],
scale: float = DEFAULT_SCALE,
offset: float = DEFAULT_OFFSET,
) -> str | None:
"""Convert registers to proper result."""
if self._swap:
@@ -250,7 +256,7 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
# Apply scale, precision, limits to floats and ints
v_result = []
for entry in val:
v_temp = self.__process_raw_value(entry)
v_temp = self.__process_raw_value(entry, scale, offset)
if self._data_type != DataType.CUSTOM:
v_result.append(str(v_temp))
else:
@@ -258,7 +264,7 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
return ",".join(map(str, v_result))
# Apply scale, precision, limits to floats and ints
return self.__process_raw_value(val[0])
return self.__process_raw_value(val[0], scale, offset)
class ModbusToggleEntity(ModbusBaseEntity, ToggleEntity, RestoreEntity):
+16 -2
View File
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_NAME,
CONF_OFFSET,
CONF_SENSORS,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
@@ -25,7 +26,14 @@ from homeassistant.helpers.update_coordinator import (
)
from . import get_hub
from .const import _LOGGER, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT
from .const import (
_LOGGER,
CONF_SCALE,
CONF_SLAVE_COUNT,
CONF_VIRTUAL_COUNT,
DEFAULT_OFFSET,
DEFAULT_SCALE,
)
from .entity import ModbusStructEntity
from .modbus import ModbusHub
@@ -73,9 +81,13 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
self._coordinator: DataUpdateCoordinator[list[float | None] | None] | None = (
None
)
self._scale = entry.get(CONF_SCALE, DEFAULT_SCALE)
self._offset = entry.get(CONF_OFFSET, DEFAULT_OFFSET)
self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT)
self._attr_state_class = entry.get(CONF_STATE_CLASS)
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
if self._precision > 0 or self._scale != int(self._scale):
self._value_is_int = False
async def async_setup_slaves(
self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any]
@@ -117,7 +129,9 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
self.async_write_ha_state()
return
self._attr_available = True
result = self.unpack_structure_result(raw_result.registers)
result = self.unpack_structure_result(
raw_result.registers, self._scale, self._offset
)
if self._coordinator:
result_array: list[float | None] = []
if result:
@@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_COUNT,
CONF_HOST,
CONF_NAME,
CONF_OFFSET,
CONF_PORT,
CONF_SCAN_INTERVAL,
CONF_STRUCTURE,
@@ -25,16 +26,23 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import (
CONF_CURRENT_TEMP_OFFSET,
CONF_CURRENT_TEMP_SCALE,
CONF_DATA_TYPE,
CONF_FAN_MODE_VALUES,
CONF_SCALE,
CONF_SLAVE_COUNT,
CONF_SWAP,
CONF_SWAP_BYTE,
CONF_SWAP_WORD,
CONF_SWAP_WORD_BYTE,
CONF_SWING_MODE_VALUES,
CONF_TARGET_TEMP_OFFSET,
CONF_TARGET_TEMP_SCALE,
CONF_VIRTUAL_COUNT,
DEFAULT_HUB,
DEFAULT_OFFSET,
DEFAULT_SCALE,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
PLATFORMS,
@@ -243,6 +251,46 @@ def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict:
return config
def not_zero_value(val: float, errMsg: str) -> float:
"""Check value is not zero."""
if val == 0:
raise vol.Invalid(errMsg)
return val
def ensure_and_check_conflicting_scales_and_offsets(config: dict[str, Any]) -> dict:
"""Check for conflicts in scale/offset and ensure target/current temp scale/offset is set."""
config_keys = [
(CONF_SCALE, CONF_TARGET_TEMP_SCALE, CONF_CURRENT_TEMP_SCALE, DEFAULT_SCALE),
(
CONF_OFFSET,
CONF_TARGET_TEMP_OFFSET,
CONF_CURRENT_TEMP_OFFSET,
DEFAULT_OFFSET,
),
]
for generic_key, target_key, current_key, default_value in config_keys:
if generic_key in config and (target_key in config or current_key in config):
raise vol.Invalid(
f"Cannot use both '{generic_key}' and temperature-specific parameters "
f"('{target_key}' or '{current_key}') in the same configuration. "
f"Either the '{generic_key}' parameter (which applies to both temperatures) "
"or the new temperature-specific parameters, but not both."
)
if generic_key in config:
value = config.pop(generic_key)
config[target_key] = value
config[current_key] = value
if target_key not in config:
config[target_key] = default_value
if current_key not in config:
config[current_key] = default_value
return config
def duplicate_swing_mode_validator(config: dict[str, Any]) -> dict:
"""Control modbus climate swing mode values for duplicates."""
swing_modes: set[int] = set()
@@ -26,8 +26,8 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .actions import get_music_assistant_client, register_actions
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
from .services import get_music_assistant_client, register_actions
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent
@@ -238,12 +238,14 @@ async def _client_listen(
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: MusicAssistantConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
mass_entry_data: MusicAssistantEntryData = entry.runtime_data
mass_entry_data = entry.runtime_data
mass_entry_data.listen_task.cancel()
await mass_entry_data.mass.disconnect()
@@ -251,7 +253,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant,
config_entry: MusicAssistantConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
player_id = next(
@@ -115,6 +115,13 @@ QUEUE_OPTION_MAP = {
MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE,
}
REPEAT_MODE_MAPPING_TO_HA = {
MassRepeatMode.OFF: RepeatMode.OFF,
MassRepeatMode.ONE: RepeatMode.ONE,
MassRepeatMode.ALL: RepeatMode.ALL,
# UNKNOWN is intentionally not mapped - will return None
}
SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
SERVICE_TRANSFER_QUEUE = "transfer_queue"
@@ -657,7 +664,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
# player has an MA queue active (either its own queue or some group queue)
self._attr_app_id = DOMAIN
self._attr_shuffle = queue.shuffle_enabled
self._attr_repeat = queue.repeat_mode.value
self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get(queue.repeat_mode)
if not (cur_item := queue.current_item):
# queue is empty
return
+86 -26
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.climate import (
@@ -13,18 +14,44 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from .const import DOMAIN, MASTER_THERMOSTATS
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
ERROR_NO_SCHEDULE = "set_schedule_first"
PARALLEL_UPDATES = 0
@dataclass
class PlugwiseClimateExtraStoredData(ExtraStoredData):
"""Object to hold extra stored data."""
last_active_schedule: str | None
previous_action_mode: str | None
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the text data."""
return {
"last_active_schedule": self.last_active_schedule,
"previous_action_mode": self.previous_action_mode,
}
@classmethod
def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData:
"""Initialize a stored data object from a dict."""
return cls(
last_active_schedule=restored.get("last_active_schedule"),
previous_action_mode=restored.get("previous_action_mode"),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
@@ -56,14 +83,26 @@ async def async_setup_entry(
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
"""Representation of a Plugwise thermostat."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
_previous_mode: str = "heating"
_last_active_schedule: str | None = None
_previous_action_mode: str | None = HVACAction.HEATING.value
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added."""
await super().async_added_to_hass()
if extra_data := await self.async_get_last_extra_data():
plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
extra_data.as_dict()
)
self._last_active_schedule = plugwise_extra_data.last_active_schedule
self._previous_action_mode = plugwise_extra_data.previous_action_mode
def __init__(
self,
@@ -76,7 +115,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
gateway_id: str = coordinator.api.gateway_id
self._gateway_data = coordinator.data[gateway_id]
self._location = device_id
if (location := self.device.get("location")) is not None:
self._location = location
@@ -105,25 +143,19 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
self.device["thermostat"]["resolution"], 0.1
)
def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None:
"""Return the previous action-mode when the regulation-mode is not heating or cooling.
Helper for set_hvac_mode().
"""
# When no cooling available, _previous_mode is always heating
if (
"regulation_modes" in self._gateway_data
and "cooling" in self._gateway_data["regulation_modes"]
):
mode = self._gateway_data["select_regulation_mode"]
if mode in ("cooling", "heating"):
self._previous_mode = mode
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self.device["sensors"]["temperature"]
@property
def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
"""Return text specific state data to be restored."""
return PlugwiseClimateExtraStoredData(
last_active_schedule=self._last_active_schedule,
previous_action_mode=self._previous_action_mode,
)
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach.
@@ -170,9 +202,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
if self.coordinator.api.cooling_present:
if "regulation_modes" in self._gateway_data:
if self._gateway_data["select_regulation_mode"] == "cooling":
selected = self._gateway_data.get("select_regulation_mode")
if selected == HVACAction.COOLING.value:
hvac_modes.append(HVACMode.COOL)
if self._gateway_data["select_regulation_mode"] == "heating":
if selected == HVACAction.HEATING.value:
hvac_modes.append(HVACMode.HEAT)
else:
hvac_modes.append(HVACMode.HEAT_COOL)
@@ -184,8 +217,16 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation if supported."""
# Keep track of the previous action-mode
self._previous_action_mode(self.coordinator)
# Keep track of the previous hvac_action mode.
# When no cooling available, _previous_action_mode is always heating
if (
"regulation_modes" in self._gateway_data
and HVACAction.COOLING.value in self._gateway_data["regulation_modes"]
):
mode = self._gateway_data["select_regulation_mode"]
if mode in (HVACAction.COOLING.value, HVACAction.HEATING.value):
self._previous_action_mode = mode
if (action := self.device.get("control_state")) is not None:
return HVACAction(action)
@@ -219,14 +260,33 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
return
if hvac_mode == HVACMode.OFF:
await self.coordinator.api.set_regulation_mode(hvac_mode)
await self.coordinator.api.set_regulation_mode(hvac_mode.value)
else:
current = self.device.get("select_schedule")
desired = current
# Capture the last valid schedule
if desired and desired != "off":
self._last_active_schedule = desired
elif desired == "off":
desired = self._last_active_schedule
# Enabling HVACMode.AUTO requires a previously set schedule for saving and restoring
if hvac_mode == HVACMode.AUTO and not desired:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=ERROR_NO_SCHEDULE,
)
await self.coordinator.api.set_schedule_state(
self._location,
"on" if hvac_mode == HVACMode.AUTO else "off",
STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF,
desired,
)
if self.hvac_mode == HVACMode.OFF:
await self.coordinator.api.set_regulation_mode(self._previous_mode)
if self.hvac_mode == HVACMode.OFF and self._previous_action_mode:
await self.coordinator.api.set_regulation_mode(
self._previous_action_mode
)
@plugwise_command
async def async_set_preset_mode(self, preset_mode: str) -> None:
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.8.2"],
"requirements": ["plugwise==1.8.3"],
"zeroconf": ["_plugwise._tcp.local."]
}
@@ -314,6 +314,9 @@
"invalid_xml_data": {
"message": "[%key:component::plugwise::config::error::response_error%]"
},
"set_schedule_first": {
"message": "Failed setting HVACMode, set a schedule first."
},
"unsupported_firmware": {
"message": "[%key:component::plugwise::config::error::unsupported%]"
}
+33 -1
View File
@@ -3,13 +3,15 @@
from __future__ import annotations
import logging
from typing import Any
from pooldose.client import PooldoseClient
from pooldose.request_status import RequestStatus
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from .coordinator import PooldoseConfigEntry, PooldoseCoordinator
@@ -18,6 +20,36 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
"""Migrate old entry."""
# Version 1.1 -> 1.2: Migrate entity unique IDs
# - ofa_orp_value -> ofa_orp_time
# - ofa_ph_value -> ofa_ph_time
if entry.version == 1 and entry.minor_version < 2:
@callback
def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
"""Migrate entity unique IDs for pooldose sensors."""
new_unique_id = entity_entry.unique_id
# Check if this entry needs migration
if "_ofa_orp_value" in new_unique_id:
new_unique_id = new_unique_id.replace("_ofa_orp_value", "_ofa_orp_time")
elif "_ofa_ph_value" in new_unique_id:
new_unique_id = new_unique_id.replace("_ofa_ph_value", "_ofa_ph_time")
else:
# No migration needed
return None
return {"new_unique_id": new_unique_id}
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
hass.config_entries.async_update_entry(entry, version=1, minor_version=2)
return True
async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
"""Set up Seko PoolDose from a config entry."""
# Get host from config entry data (connection-critical configuration)
@@ -31,6 +31,7 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for the Pooldose integration including DHCP discovery."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow and store the discovered IP address and MAC."""
+2 -2
View File
@@ -1,10 +1,10 @@
{
"entity": {
"sensor": {
"ofa_orp_value": {
"ofa_orp_time": {
"default": "mdi:clock"
},
"ofa_ph_value": {
"ofa_ph_time": {
"default": "mdi:clock"
},
"orp": {
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-pooldose==0.7.0"]
"requirements": ["python-pooldose==0.7.8"]
}
+4 -4
View File
@@ -48,8 +48,8 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
options=["proportional", "on_off", "timed"],
),
SensorEntityDescription(
key="ofa_ph_value",
translation_key="ofa_ph_value",
key="ofa_ph_time",
translation_key="ofa_ph_time",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,
@@ -72,8 +72,8 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
options=["off", "proportional", "on_off", "timed"],
),
SensorEntityDescription(
key="ofa_orp_value",
translation_key="ofa_orp_value",
key="ofa_orp_time",
translation_key="ofa_orp_time",
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -34,10 +34,10 @@
},
"entity": {
"sensor": {
"ofa_orp_value": {
"ofa_orp_time": {
"name": "ORP overfeed alert time"
},
"ofa_ph_value": {
"ofa_ph_time": {
"name": "pH overfeed alert time"
},
"orp": {
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.12"]
"requirements": ["pyportainer==1.0.13"]
}
@@ -133,6 +133,15 @@ BUTTON_ENTITIES = (
supported=lambda api, ch: api.supported(ch, "ptz_guard"),
method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.set.value),
),
ReolinkButtonEntityDescription(
key="reboot",
always_available=True,
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
supported=lambda api, ch: api.supported(ch, "reboot"),
method=lambda api, ch: api.reboot(ch),
),
)
HOST_BUTTON_ENTITIES = (
@@ -223,6 +223,12 @@
"0": "mdi:volume-off"
}
},
"audio_noise_reduction": {
"default": "mdi:volume-vibrate",
"state": {
"0": "mdi:volume-variant-off"
}
},
"auto_quick_reply_time": {
"default": "mdi:message-reply-text-outline"
},
@@ -310,6 +316,9 @@
"motion_sensitivity": {
"default": "mdi:motion-sensor"
},
"pir_interval": {
"default": "mdi:motion-sensor"
},
"pir_sensitivity": {
"default": "mdi:motion-sensor"
},
@@ -363,6 +372,9 @@
"day_night_mode": {
"default": "mdi:theme-light-dark"
},
"exposure": {
"default": "mdi:camera-iris"
},
"floodlight_event_mode": {
"default": "mdi:spotlight-beam"
},
@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.16.3"]
"requirements": ["reolink-aio==0.16.4"]
}
@@ -227,6 +227,19 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.volume_doorbell(ch),
method=lambda api, ch, value: api.set_volume(ch, volume_doorbell=int(value)),
),
ReolinkNumberEntityDescription(
key="audio_noise_reduction",
cmd_key="439",
translation_key="audio_noise_reduction",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_step=1,
native_min_value=0,
native_max_value=5,
supported=lambda api, ch: api.supported(ch, "noise_reduction"),
value=lambda api, ch: api.baichuan.audio_noise_reduction(ch),
method=lambda api, ch, value: api.baichuan.SetAudioNoise(ch, int(value)),
),
ReolinkNumberEntityDescription(
key="guard_return_time",
cmd_key="GetPtzGuard",
@@ -265,6 +278,21 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.pir_sensitivity(ch),
method=lambda api, ch, value: api.set_pir(ch, sensitivity=int(value)),
),
ReolinkNumberEntityDescription(
key="pir_interval",
cmd_key="GetPirInfo",
translation_key="pir_interval",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=1,
native_min_value=5,
native_max_value=120,
supported=lambda api, ch: api.supported(ch, "PIR_interval"),
value=lambda api, ch: api.pir_interval(ch),
method=lambda api, ch, value: api.set_pir(ch, interval=int(value)),
),
ReolinkNumberEntityDescription(
key="ai_face_sensititvity",
cmd_key="GetAiAlarm",
@@ -13,6 +13,7 @@ from reolink_aio.api import (
ChimeToneEnum,
DayNightEnum,
EncodingEnum,
ExposureEnum,
HDREnum,
Host,
HubToneEnum,
@@ -208,6 +209,17 @@ SELECT_ENTITIES = (
value=lambda api, ch: HDREnum(api.HDR_state(ch)).name,
method=lambda api, ch, name: api.set_HDR(ch, HDREnum[name].value),
),
ReolinkSelectEntityDescription(
key="exposure",
cmd_key="GetIsp",
translation_key="exposure",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
get_options=[method.name for method in ExposureEnum],
supported=lambda api, ch: api.supported(ch, "exposure"),
value=lambda api, ch: ExposureEnum(api.exposure(ch)).name,
method=lambda api, ch, name: api.set_exposure(ch, ExposureEnum[name].value),
),
ReolinkSelectEntityDescription(
key="binning_mode",
cmd_key="GetIsp",
@@ -445,6 +445,9 @@
"alarm_volume": {
"name": "Alarm volume"
},
"audio_noise_reduction": {
"name": "Audio noise reduction"
},
"auto_quick_reply_time": {
"name": "Auto quick reply time"
},
@@ -529,6 +532,9 @@
"motion_sensitivity": {
"name": "Motion sensitivity"
},
"pir_interval": {
"name": "PIR interval"
},
"pir_sensitivity": {
"name": "PIR sensitivity"
},
@@ -601,6 +607,15 @@
"stayoff": "Stay off"
}
},
"exposure": {
"name": "Image exposure mode",
"state": {
"antismearing": "Anti-smearing",
"auto": "[%key:common::state::auto%]",
"lownoise": "Low noise",
"manual": "Manual"
}
},
"floodlight_event_mode": {
"name": "Floodlight event mode",
"state": {
@@ -19,6 +19,7 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -257,10 +258,11 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1:
if config_entry.version > 2:
# This means the user has downgraded from a future version
return False
# 1.2 Migrate subentries to include configured numbers to title
if config_entry.version == 1 and config_entry.minor_version == 1:
for subentry in config_entry.subentries.values():
property_map = {
@@ -278,6 +280,21 @@ async def async_migrate_entry(
hass.config_entries.async_update_entry(config_entry, minor_version=2)
# 2.1 Migrate all entity unique IDs to replace "satel" prefix with config entry ID, allows multiple entries to be configured
if config_entry.version == 1:
@callback
def migrate_unique_id(entity_entry: RegistryEntry) -> dict[str, str]:
"""Migrate the unique ID to a new format."""
return {
"new_unique_id": entity_entry.unique_id.replace(
"satel", config_entry.entry_id
)
}
await async_migrate_entries(hass, config_entry.entry_id, migrate_unique_id)
hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
config_entry.version,
@@ -52,7 +52,11 @@ async def async_setup_entry(
async_add_entities(
[
SatelIntegraAlarmPanel(
controller, zone_name, arm_home_mode, partition_num
controller,
zone_name,
arm_home_mode,
partition_num,
config_entry.entry_id,
)
],
config_subentry_id=subentry.subentry_id,
@@ -69,10 +73,12 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
def __init__(self, controller, name, arm_home_mode, partition_id) -> None:
def __init__(
self, controller, name, arm_home_mode, partition_id, config_entry_id
) -> None:
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"satel_alarm_panel_{partition_id}"
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
@@ -53,6 +53,7 @@ async def async_setup_entry(
zone_type,
CONF_ZONES,
SIGNAL_ZONES_UPDATED,
config_entry.entry_id,
)
],
config_subentry_id=subentry.subentry_id,
@@ -77,6 +78,7 @@ async def async_setup_entry(
ouput_type,
CONF_OUTPUTS,
SIGNAL_OUTPUTS_UPDATED,
config_entry.entry_id,
)
],
config_subentry_id=subentry.subentry_id,
@@ -96,10 +98,11 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
zone_type,
sensor_type,
react_to_signal,
config_entry_id,
):
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"satel_{sensor_type}_{device_number}"
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._name = device_name
self._zone_type = zone_type
self._state = 0
@@ -90,8 +90,8 @@ SWITCHABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a Satel Integra config flow."""
VERSION = 1
MINOR_VERSION = 2
VERSION = 2
MINOR_VERSION = 1
@staticmethod
@callback
@@ -121,6 +121,8 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
valid = await self.test_connection(
user_input[CONF_HOST], user_input[CONF_PORT]
)
@@ -7,6 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["satel_integra"],
"requirements": ["satel-integra==0.3.7"],
"single_config_entry": true
"requirements": ["satel-integra==0.3.7"]
}
@@ -4,6 +4,9 @@
"code_input_description": "Code to toggle switchable outputs"
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
@@ -46,6 +46,7 @@ async def async_setup_entry(
switchable_output_num,
switchable_output_name,
config_entry.options.get(CONF_CODE),
config_entry.entry_id,
),
],
config_subentry_id=subentry.subentry_id,
@@ -57,10 +58,10 @@ class SatelIntegraSwitch(SwitchEntity):
_attr_should_poll = False
def __init__(self, controller, device_number, device_name, code):
def __init__(self, controller, device_number, device_name, code, config_entry_id):
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"satel_switch_{device_number}"
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
self._name = device_name
self._state = False
self._code = code
@@ -0,0 +1,85 @@
"""BLE provisioning helpers for Shelly integration."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
import logging
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
@dataclass
class ProvisioningState:
"""State for tracking zeroconf discovery during BLE provisioning."""
event: asyncio.Event = field(default_factory=asyncio.Event)
host: str | None = None
port: int | None = None
PROVISIONING_FUTURES: HassKey[dict[str, ProvisioningState]] = HassKey(
"shelly_provisioning_futures"
)
@callback
def async_get_provisioning_registry(
hass: HomeAssistant,
) -> dict[str, ProvisioningState]:
"""Get the provisioning registry, creating it if needed.
This is a helper function for internal use.
It ensures the registry exists without requiring async_setup to run first.
"""
return hass.data.setdefault(PROVISIONING_FUTURES, {})
@callback
def async_register_zeroconf_discovery(
hass: HomeAssistant, mac: str, host: str, port: int
) -> None:
"""Register a zeroconf discovery for a device that was provisioned via BLE.
Called by zeroconf discovery when it finds a device that may have been
provisioned via BLE. If BLE provisioning is waiting for this device,
the host and port will be stored (replacing any previous values).
Multiple zeroconf discoveries can happen (Shelly service, HTTP service, etc.)
and the last one wins.
Args:
hass: Home Assistant instance
mac: Device MAC address (will be normalized)
host: Device IP address/hostname from zeroconf
port: Device port from zeroconf
"""
registry = async_get_provisioning_registry(hass)
normalized_mac = format_mac(mac)
state = registry.get(normalized_mac)
if not state:
_LOGGER.debug(
"No BLE provisioning state found for %s (host %s, port %s)",
normalized_mac,
host,
port,
)
return
_LOGGER.debug(
"Registering zeroconf discovery for %s at %s:%s (replacing previous)",
normalized_mac,
host,
port,
)
# Store host and port (replacing any previous values) and signal the event
state.host = host
state.port = port
state.event.set()
+388 -6
View File
@@ -2,9 +2,13 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Final
import asyncio
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, Final
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -14,10 +18,18 @@ from aioshelly.exceptions import (
InvalidAuthError,
InvalidHostError,
MacAddressMismatchError,
RpcCallError,
)
from aioshelly.rpc_device import RpcDevice
from aioshelly.zeroconf import async_lookup_device_by_name
from bleak.backends.device import BLEDevice
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_HOST,
@@ -29,15 +41,27 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .ble_provisioning import (
ProvisioningState,
async_get_provisioning_registry,
async_register_zeroconf_discovery,
)
from .const import (
CONF_BLE_SCANNER_MODE,
CONF_GEN,
CONF_SLEEP_PERIOD,
CONF_SSID,
DOMAIN,
LOGGER,
PROVISIONING_TIMEOUT,
BLEScannerMode,
)
from .coordinator import ShellyConfigEntry, async_reconnect_soon
@@ -70,6 +94,10 @@ BLE_SCANNER_OPTIONS = [
INTERNAL_WIFI_AP_IP = "192.168.33.1"
# BLE provisioning flow steps that are in the finishing state
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
async def validate_input(
hass: HomeAssistant,
@@ -145,6 +173,12 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
port: int = DEFAULT_HTTP_PORT
info: dict[str, Any] = {}
device_info: dict[str, Any] = {}
ble_device: BLEDevice | None = None
device_name: str = ""
wifi_networks: list[dict[str, Any]] = []
selected_ssid: str = ""
_provision_task: asyncio.Task | None = None
_provision_result: ConfigFlowResult | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -262,6 +296,45 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="credentials", data_schema=vol.Schema(schema), errors=errors
)
def _abort_idle_ble_flows(self, mac: str) -> None:
"""Abort idle BLE provisioning flows for this device.
When zeroconf discovers a device, it means the device is already on WiFi.
If there's an idle BLE flow (user hasn't started provisioning yet), abort it.
Active provisioning flows (do_provision/provision_done) should not be aborted
as they're waiting for zeroconf handoff.
"""
for flow in self._async_in_progress(include_uninitialized=True):
if (
flow["flow_id"] != self.flow_id
and flow["context"].get("unique_id") == mac
and flow["context"].get("source") == "bluetooth"
and flow.get("step_id") not in BLUETOOTH_FINISHING_STEPS
):
LOGGER.debug(
"Aborting idle BLE flow %s for %s (device discovered via zeroconf)",
flow["flow_id"],
mac,
)
self.hass.config_entries.flow.async_abort(flow["flow_id"])
async def _async_handle_zeroconf_mac_discovery(
self, mac: str, host: str, port: int
) -> None:
"""Handle MAC address discovery from zeroconf.
Registers discovery info for BLE handoff and aborts idle BLE flows.
"""
# Register this zeroconf discovery with BLE provisioning in case
# this device was just provisioned via BLE
async_register_zeroconf_discovery(self.hass, mac, host, port)
# Check for idle BLE provisioning flows and abort them since
# device is already on WiFi (discovered via zeroconf)
self._abort_idle_ble_flows(mac)
await self._async_discovered_mac(mac, host)
async def _async_discovered_mac(self, mac: str, host: str) -> None:
"""Abort and reconnect soon if the device with the mac address is already configured."""
if (
@@ -281,6 +354,313 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
else:
self._abort_if_unique_id_configured({CONF_HOST: host})
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle bluetooth discovery."""
# Parse MAC address from the Bluetooth device name
if not (mac := mac_address_from_name(discovery_info.name)):
return self.async_abort(reason="invalid_discovery_info")
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
if not has_rpc_over_ble(discovery_info.manufacturer_data):
LOGGER.debug(
"Device %s does not have RPC-over-BLE enabled, skipping provisioning",
discovery_info.name,
)
return self.async_abort(reason="invalid_discovery_info")
# Check if already configured - abort if device is already set up
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
# Store BLE device and name for WiFi provisioning
self.ble_device = async_ble_device_from_address(
self.hass, discovery_info.address, connectable=True
)
if not self.ble_device:
return self.async_abort(reason="cannot_connect")
self.device_name = discovery_info.name
self.context.update(
{
"title_placeholders": {"name": discovery_info.name},
}
)
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm bluetooth provisioning."""
if user_input is not None:
return await self.async_step_wifi_scan()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={
"name": self.context["title_placeholders"]["name"]
},
)
async def async_step_wifi_scan(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Scan for WiFi networks via BLE."""
if user_input is not None:
self.selected_ssid = user_input[CONF_SSID]
return await self.async_step_wifi_credentials()
# Scan for WiFi networks via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
try:
self.wifi_networks = await async_scan_wifi_networks(self.ble_device)
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to scan WiFi networks via BLE: %s", err)
# "Writing is not permitted" error means device rejects BLE writes
# and BLE provisioning is disabled - user must use Shelly app
if "not permitted" in str(err):
return self.async_abort(reason="ble_not_permitted")
return await self.async_step_wifi_scan_failed()
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi scan")
return self.async_abort(reason="unknown")
# Create list of SSIDs for selection
# If no networks found, still allow custom SSID entry
ssid_options = [network["ssid"] for network in self.wifi_networks]
return self.async_show_form(
step_id="wifi_scan",
data_schema=vol.Schema(
{
vol.Required(CONF_SSID): SelectSelector(
SelectSelectorConfig(
options=ssid_options,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
}
),
)
async def async_step_wifi_scan_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle failed WiFi scan - allow retry."""
if user_input is not None:
# User wants to retry - go back to wifi_scan
return await self.async_step_wifi_scan()
return self.async_show_form(step_id="wifi_scan_failed")
@asynccontextmanager
async def _async_provision_context(
self, mac: str
) -> AsyncIterator[ProvisioningState]:
"""Context manager to register and cleanup provisioning state."""
state = ProvisioningState()
provisioning_registry = async_get_provisioning_registry(self.hass)
normalized_mac = format_mac(mac)
provisioning_registry[normalized_mac] = state
try:
yield state
finally:
provisioning_registry.pop(normalized_mac, None)
async def async_step_wifi_credentials(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Get WiFi credentials and provision device."""
if user_input is not None:
self.selected_ssid = user_input.get(CONF_SSID, self.selected_ssid)
password = user_input[CONF_PASSWORD]
return await self.async_step_do_provision({"password": password})
return self.async_show_form(
step_id="wifi_credentials",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={"ssid": self.selected_ssid},
)
async def _async_provision_wifi_and_wait_for_zeroconf(
self, mac: str, password: str, state: ProvisioningState
) -> ConfigFlowResult | None:
"""Provision WiFi credentials via BLE and wait for zeroconf discovery.
Returns the flow result to be stored in self._provision_result, or None if failed.
"""
# Provision WiFi via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
try:
await async_provision_wifi(self.ble_device, self.selected_ssid, password)
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to provision WiFi via BLE: %s", err)
# BLE connection/communication failed - allow retry from network selection
return None
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi provisioning")
return self.async_abort(reason="unknown")
LOGGER.debug(
"WiFi provisioning successful for %s, waiting for zeroconf discovery",
mac,
)
# Two-phase device discovery after WiFi provisioning:
#
# Phase 1: Wait for zeroconf discovery callback (via event)
# - Callback only fires on NEW zeroconf advertisements
# - If device appears on network, we get notified immediately
# - This is the fast path for successful provisioning
#
# Phase 2: Active lookup on timeout (poll)
# - Handles case where device was factory reset and has stale zeroconf data
# - Factory reset devices don't send zeroconf goodbye, leaving stale records
# - The timeout ensures device has enough time to connect to WiFi
# - Active poll forces fresh lookup, ignoring stale cached data
#
# Why not just poll? If we polled immediately, we'd get stale data and
# try to connect right away, causing false failures before device is ready.
try:
await asyncio.wait_for(state.event.wait(), timeout=PROVISIONING_TIMEOUT)
except TimeoutError:
LOGGER.debug("Timeout waiting for zeroconf discovery, trying active lookup")
# No new discovery received - device may have stale zeroconf data
# Do active lookup to force fresh resolution
aiozc = await zeroconf.async_get_async_instance(self.hass)
result = await async_lookup_device_by_name(aiozc, self.device_name)
# If we still don't have a host, provisioning failed
if not result:
LOGGER.debug("Active lookup failed - provisioning unsuccessful")
# Store failure info and return None - provision_done will handle redirect
return None
state.host, state.port = result
else:
LOGGER.debug(
"Zeroconf discovery received for device after WiFi provisioning at %s",
state.host,
)
# Device discovered via zeroconf - get device info and set up directly
if TYPE_CHECKING:
assert state.host is not None
assert state.port is not None
self.host = state.host
self.port = state.port
try:
self.info = await self._async_get_info(self.host, self.port)
except DeviceConnectionError as err:
LOGGER.debug("Failed to connect to device after WiFi provisioning: %s", err)
# Device appeared on network but can't connect - allow retry
return None
if get_info_auth(self.info):
# Device requires authentication - show credentials step
return await self.async_step_credentials()
try:
device_info = await validate_input(
self.hass, self.host, self.port, self.info, {}
)
except DeviceConnectionError as err:
LOGGER.debug("Failed to validate device after WiFi provisioning: %s", err)
# Device info validation failed - allow retry
return None
if not device_info[CONF_MODEL]:
return self.async_abort(reason="firmware_not_fully_provisioned")
# User just provisioned this device - create entry directly without confirmation
return self.async_create_entry(
title=device_info["title"],
data={
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
CONF_MODEL: device_info[CONF_MODEL],
CONF_GEN: device_info[CONF_GEN],
},
)
async def _do_provision(self, password: str) -> None:
"""Provision WiFi credentials to device via BLE."""
if TYPE_CHECKING:
assert self.ble_device is not None
mac = self.unique_id
if TYPE_CHECKING:
assert mac is not None
async with self._async_provision_context(mac) as state:
self._provision_result = (
await self._async_provision_wifi_and_wait_for_zeroconf(
mac, password, state
)
)
async def async_step_do_provision(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Execute WiFi provisioning via BLE."""
if not self._provision_task:
if TYPE_CHECKING:
assert user_input is not None
password = user_input["password"]
self._provision_task = self.hass.async_create_task(
self._do_provision(password), eager_start=False
)
if not self._provision_task.done():
return self.async_show_progress(
step_id="do_provision",
progress_action="provisioning",
progress_task=self._provision_task,
)
self._provision_task = None
return self.async_show_progress_done(next_step_id="provision_done")
async def async_step_provision_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle failed provisioning - allow retry."""
if user_input is not None:
# User wants to retry - clear state and go back to wifi_scan
self.selected_ssid = ""
self.wifi_networks = []
return await self.async_step_wifi_scan()
return self.async_show_form(
step_id="provision_failed",
description_placeholders={"ssid": self.selected_ssid},
)
async def async_step_provision_done(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the result of the provision step."""
result = self._provision_result
self._provision_result = None
# If provisioning failed, redirect to provision_failed step
if result is None:
return await self.async_step_provision_failed()
return result
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -288,23 +668,25 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_not_supported")
host = discovery_info.host
port = discovery_info.port or DEFAULT_HTTP_PORT
# First try to get the mac address from the name
# so we can avoid making another connection to the
# device if we already have it configured
if mac := mac_address_from_name(discovery_info.name):
await self._async_discovered_mac(mac, host)
await self._async_handle_zeroconf_mac_discovery(mac, host, port)
try:
# Devices behind range extender doesn't generate zeroconf packets
# so port is always the default one
self.info = await self._async_get_info(host, DEFAULT_HTTP_PORT)
self.info = await self._async_get_info(host, port)
except DeviceConnectionError:
return self.async_abort(reason="cannot_connect")
if not mac:
# We could not get the mac address from the name
# so need to check here since we just got the info
await self._async_discovered_mac(self.info[CONF_MAC], host)
mac = self.info[CONF_MAC]
await self._async_handle_zeroconf_mac_discovery(mac, host, port)
self.host = host
self.context.update(
+4
View File
@@ -36,6 +36,10 @@ DOMAIN: Final = "shelly"
LOGGER: Logger = getLogger(__package__)
# BLE provisioning
PROVISIONING_TIMEOUT: Final = 35 # 35 seconds to wait for device to connect to WiFi
CONF_SSID: Final = "ssid"
CONF_COAP_PORT: Final = "coap_port"
FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})")
@@ -1,6 +1,11 @@
{
"domain": "shelly",
"name": "Shelly",
"bluetooth": [
{
"local_name": "Shelly*"
}
],
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
"config_flow": true,
"dependencies": ["bluetooth", "http", "network"],
@@ -9,7 +14,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
"requirements": ["aioshelly==13.15.0"],
"requirements": ["aioshelly==13.16.0"],
"zeroconf": [
{
"name": "shelly*",
+38 -1
View File
@@ -2,13 +2,20 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_on_wifi": "Device is already connected to WiFi and was discovered via the network.",
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
"ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision WiFi credentials, then add the device when it appears on your network.",
"cannot_connect": "Failed to connect to the device. Ensure the device is powered on and within range.",
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
"invalid_discovery_info": "Invalid Bluetooth discovery information.",
"ipv6_not_supported": "IPv6 is not supported.",
"mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]",
"no_wifi_networks": "No WiFi networks found during scan.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wifi_provisioned": "WiFi credentials for {ssid} have been provisioned to {name}. The device is connecting to WiFi and will complete setup automatically."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -19,7 +26,13 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"progress": {
"provisioning": "Provisioning WiFi credentials and waiting for device to connect"
},
"step": {
"bluetooth_confirm": {
"description": "The Shelly device {name} has been discovered via Bluetooth but is not connected to WiFi.\n\nDo you want to provision WiFi credentials to this device?"
},
"confirm_discovery": {
"description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password-protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password-protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device."
},
@@ -33,6 +46,9 @@
"username": "Username for the device's web panel."
}
},
"provision_failed": {
"description": "The device did not connect to {ssid}. This may be due to an incorrect password or the network being out of range. Would you like to try again?"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -64,6 +80,27 @@
"port": "The TCP port of the Shelly device to connect to (Gen2+)."
},
"description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
},
"wifi_credentials": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "Password for the WiFi network."
},
"description": "Enter the password for {ssid}."
},
"wifi_scan": {
"data": {
"ssid": "WiFi network"
},
"data_description": {
"ssid": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
},
"description": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
},
"wifi_scan_failed": {
"description": "Failed to scan for WiFi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?"
}
}
},
@@ -505,6 +505,9 @@ KEEP_CAPABILITY_QUIRK: dict[
Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: (
lambda status: status[Attribute.LIGHTING].value is not None
),
Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP: (
lambda status: status[Attribute.BEEP].value is not None
),
}
@@ -156,6 +156,13 @@
"sanitize": {
"default": "mdi:lotion"
},
"sound_effect": {
"default": "mdi:volume-high",
"state": {
"off": "mdi:volume-off",
"on": "mdi:volume-high"
}
},
"wrinkle_prevent": {
"default": "mdi:tumble-dryer",
"state": {
@@ -653,6 +653,9 @@
"sanitize": {
"name": "Sanitize"
},
"sound_effect": {
"name": "Sound effect"
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
}
@@ -91,6 +91,15 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[
),
}
CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = {
Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP: SmartThingsSwitchEntityDescription(
key=Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP,
translation_key="sound_effect",
status_attribute=Attribute.BEEP,
on_key="on",
on_command=Command.ON,
off_command=Command.OFF,
entity_category=EntityCategory.CONFIG,
),
Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription(
key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK,
translation_key="bubble_soak",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
"requirements": ["python-smarttub==0.0.44"]
"requirements": ["python-smarttub==0.0.45"]
}
+8 -2
View File
@@ -9,7 +9,11 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator
from .coordinator import (
SMHIConfigEntry,
SMHIDataUpdateCoordinator,
SMHIFireDataUpdateCoordinator,
)
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
@@ -24,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool
coordinator = SMHIDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
fire_coordinator = SMHIFireDataUpdateCoordinator(hass, entry)
await fire_coordinator.async_config_entry_first_refresh()
entry.runtime_data = (coordinator, fire_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+65 -2
View File
@@ -5,7 +5,14 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast
from pysmhi import (
SMHIFireForecast,
SmhiFireForecastException,
SMHIFirePointForecast,
SMHIForecast,
SmhiForecastException,
SMHIPointForecast,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
@@ -15,7 +22,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator]
type SMHIConfigEntry = ConfigEntry[
tuple[SMHIDataUpdateCoordinator, SMHIFireDataUpdateCoordinator]
]
@dataclass
@@ -27,6 +36,14 @@ class SMHIForecastData:
twice_daily: list[SMHIForecast]
@dataclass
class SMHIFireForecastData:
"""Dataclass for SMHI fire data."""
fire_daily: list[SMHIFireForecast]
fire_hourly: list[SMHIFireForecast]
class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]):
"""A SMHI Data Update Coordinator."""
@@ -71,3 +88,49 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]):
def current(self) -> SMHIForecast:
"""Return the current metrics."""
return self.data.daily[0]
class SMHIFireDataUpdateCoordinator(DataUpdateCoordinator[SMHIFireForecastData]):
"""A SMHI Fire Data Update Coordinator."""
config_entry: SMHIConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None:
"""Initialize the SMHI coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self._smhi_fire_api = SMHIFirePointForecast(
config_entry.data[CONF_LOCATION][CONF_LONGITUDE],
config_entry.data[CONF_LOCATION][CONF_LATITUDE],
session=aiohttp_client.async_get_clientsession(hass),
)
async def _async_update_data(self) -> SMHIFireForecastData:
"""Fetch data from SMHI."""
try:
async with asyncio.timeout(TIMEOUT):
_forecast_fire_daily = (
await self._smhi_fire_api.async_get_daily_forecast()
)
_forecast_fire_hourly = (
await self._smhi_fire_api.async_get_hourly_forecast()
)
except SmhiFireForecastException as ex:
raise UpdateFailed(
"Failed to retrieve the forecast from the SMHI API"
) from ex
return SMHIFireForecastData(
fire_daily=_forecast_fire_daily,
fire_hourly=_forecast_fire_hourly,
)
@property
def fire_current(self) -> SMHIFireForecast:
"""Return the current fire metrics."""
return self.data.fire_daily[0]
+44 -7
View File
@@ -6,13 +6,14 @@ from abc import abstractmethod
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import SMHIDataUpdateCoordinator
from .coordinator import SMHIDataUpdateCoordinator, SMHIFireDataUpdateCoordinator
class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]):
class SmhiWeatherBaseEntity(Entity):
"""Representation of a base weather entity."""
_attr_attribution = "Swedish weather institute (SMHI)"
@@ -22,10 +23,8 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]):
self,
latitude: str,
longitude: str,
coordinator: SMHIDataUpdateCoordinator,
) -> None:
"""Initialize the SMHI base weather entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{latitude}, {longitude}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
@@ -36,12 +35,50 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]):
)
self.update_entity_data()
@abstractmethod
def update_entity_data(self) -> None:
"""Refresh the entity data."""
class SmhiWeatherEntity(
CoordinatorEntity[SMHIDataUpdateCoordinator], SmhiWeatherBaseEntity
):
"""Representation of a weather entity."""
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIDataUpdateCoordinator,
) -> None:
"""Initialize the SMHI base weather entity."""
super().__init__(coordinator)
SmhiWeatherBaseEntity.__init__(self, latitude, longitude)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_entity_data()
super()._handle_coordinator_update()
@abstractmethod
def update_entity_data(self) -> None:
"""Refresh the entity data."""
class SmhiFireEntity(
CoordinatorEntity[SMHIFireDataUpdateCoordinator], SmhiWeatherBaseEntity
):
"""Representation of a weather entity."""
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIFireDataUpdateCoordinator,
) -> None:
"""Initialize the SMHI base weather entity."""
super().__init__(coordinator)
SmhiWeatherBaseEntity.__init__(self, latitude, longitude)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_entity_data()
super()._handle_coordinator_update()
+33
View File
@@ -1,12 +1,42 @@
{
"entity": {
"sensor": {
"build_up_index": {
"default": "mdi:grass"
},
"drought_code": {
"default": "mdi:grass"
},
"duff_moisture_code": {
"default": "mdi:grass"
},
"fine_fuel_moisture_code": {
"default": "mdi:grass"
},
"fire_weather_index": {
"default": "mdi:pine-tree-fire"
},
"forestdry": {
"default": "mdi:forest"
},
"frozen_precipitation": {
"default": "mdi:weather-snowy-rainy"
},
"fwi": {
"default": "mdi:pine-tree-fire"
},
"fwiindex": {
"default": "mdi:pine-tree-fire"
},
"grassfire": {
"default": "mdi:fire-circle"
},
"high_cloud": {
"default": "mdi:cloud-arrow-up"
},
"initial_spread_index": {
"default": "mdi:grass"
},
"low_cloud": {
"default": "mdi:cloud-arrow-down"
},
@@ -16,6 +46,9 @@
"precipitation_category": {
"default": "mdi:weather-pouring"
},
"rate_of_spread": {
"default": "mdi:grass"
},
"thunder": {
"default": "mdi:weather-lightning"
},
+201 -23
View File
@@ -10,19 +10,55 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
PERCENTAGE,
UnitOfSpeed,
)
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator
from .entity import SmhiWeatherBaseEntity
from .coordinator import (
SMHIConfigEntry,
SMHIDataUpdateCoordinator,
SMHIFireDataUpdateCoordinator,
)
from .entity import SmhiFireEntity, SmhiWeatherEntity
PARALLEL_UPDATES = 0
FWI_INDEX_MAP = {
"1": "very_low",
"2": "low",
"3": "moderate",
"4": "high",
"5": "very_high",
"6": "extreme",
}
GRASSFIRE_MAP = {
"1": "snow_cover",
"2": "season_over",
"3": "low",
"4": "moderate",
"5": "high",
"6": "very_high",
}
FORESTDRY_MAP = {
"1": "very_wet",
"2": "wet",
"3": "moderate_wet",
"4": "dry",
"5": "very_dry",
"6": "extremely_dry",
}
def get_percentage_values(entity: SMHISensor, key: str) -> int | None:
def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None:
"""Return percentage values in correct range."""
value: int | None = entity.coordinator.current.get(key) # type: ignore[assignment]
if value is not None and 0 <= value <= 100:
@@ -32,49 +68,64 @@ def get_percentage_values(entity: SMHISensor, key: str) -> int | None:
return None
def get_fire_index_value(entity: SMHIFireSensor, key: str) -> str:
"""Return index value as string."""
value: int | None = entity.coordinator.fire_current.get(key) # type: ignore[assignment]
if value is not None and value > 0:
return str(int(value))
return "0"
@dataclass(frozen=True, kw_only=True)
class SMHISensorEntityDescription(SensorEntityDescription):
"""Describes SMHI sensor entity."""
class SMHIWeatherEntityDescription(SensorEntityDescription):
"""Describes SMHI weather entity."""
value_fn: Callable[[SMHISensor], StateType | datetime]
value_fn: Callable[[SMHIWeatherSensor], StateType | datetime]
SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = (
SMHISensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class SMHIFireEntityDescription(SensorEntityDescription):
"""Describes SMHI fire entity."""
value_fn: Callable[[SMHIFireSensor], StateType | datetime]
WEATHER_SENSOR_DESCRIPTIONS: tuple[SMHIWeatherEntityDescription, ...] = (
SMHIWeatherEntityDescription(
key="thunder",
translation_key="thunder",
value_fn=lambda entity: get_percentage_values(entity, "thunder"),
native_unit_of_measurement=PERCENTAGE,
),
SMHISensorEntityDescription(
SMHIWeatherEntityDescription(
key="total_cloud",
translation_key="total_cloud",
value_fn=lambda entity: get_percentage_values(entity, "total_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHISensorEntityDescription(
SMHIWeatherEntityDescription(
key="low_cloud",
translation_key="low_cloud",
value_fn=lambda entity: get_percentage_values(entity, "low_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHISensorEntityDescription(
SMHIWeatherEntityDescription(
key="medium_cloud",
translation_key="medium_cloud",
value_fn=lambda entity: get_percentage_values(entity, "medium_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHISensorEntityDescription(
SMHIWeatherEntityDescription(
key="high_cloud",
translation_key="high_cloud",
value_fn=lambda entity: get_percentage_values(entity, "high_cloud"),
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
SMHISensorEntityDescription(
SMHIWeatherEntityDescription(
key="precipitation_category",
translation_key="precipitation_category",
value_fn=lambda entity: str(
@@ -83,13 +134,100 @@ SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["0", "1", "2", "3", "4", "5", "6"],
),
SMHISensorEntityDescription(
SMHIWeatherEntityDescription(
key="frozen_precipitation",
translation_key="frozen_precipitation",
value_fn=lambda entity: get_percentage_values(entity, "frozen_precipitation"),
native_unit_of_measurement=PERCENTAGE,
),
)
FIRE_SENSOR_DESCRIPTIONS: tuple[SMHIFireEntityDescription, ...] = (
SMHIFireEntityDescription(
key="fwiindex",
translation_key="fwiindex",
value_fn=(
lambda entity: FWI_INDEX_MAP.get(get_fire_index_value(entity, "fwiindex"))
),
device_class=SensorDeviceClass.ENUM,
options=[*FWI_INDEX_MAP.values()],
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="fire_weather_index",
translation_key="fire_weather_index",
value_fn=lambda entity: entity.coordinator.fire_current.get("fwi"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="initial_spread_index",
translation_key="initial_spread_index",
value_fn=lambda entity: entity.coordinator.fire_current.get("isi"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="build_up_index",
translation_key="build_up_index",
value_fn=(
lambda entity: entity.coordinator.fire_current.get(
"bui" # codespell:ignore bui
)
),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="fine_fuel_moisture_code",
translation_key="fine_fuel_moisture_code",
value_fn=lambda entity: entity.coordinator.fire_current.get("ffmc"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="duff_moisture_code",
translation_key="duff_moisture_code",
value_fn=lambda entity: entity.coordinator.fire_current.get("dmc"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="drought_code",
translation_key="drought_code",
value_fn=lambda entity: entity.coordinator.fire_current.get("dc"),
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="grassfire",
translation_key="grassfire",
value_fn=(
lambda entity: GRASSFIRE_MAP.get(get_fire_index_value(entity, "grassfire"))
),
device_class=SensorDeviceClass.ENUM,
options=[*GRASSFIRE_MAP.values()],
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="rate_of_spread",
translation_key="rate_of_spread",
value_fn=lambda entity: entity.coordinator.fire_current.get("rn"),
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_MINUTE,
entity_registry_enabled_default=False,
),
SMHIFireEntityDescription(
key="forestdry",
translation_key="forestdry",
value_fn=(
lambda entity: FORESTDRY_MAP.get(get_fire_index_value(entity, "forestdry"))
),
device_class=SensorDeviceClass.ENUM,
options=[*FORESTDRY_MAP.values()],
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
@@ -99,30 +237,43 @@ async def async_setup_entry(
) -> None:
"""Set up SMHI sensor platform."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data[0]
fire_coordinator = entry.runtime_data[1]
location = entry.data
async_add_entities(
SMHISensor(
entities: list[SMHIWeatherSensor | SMHIFireSensor] = []
entities.extend(
SMHIWeatherSensor(
location[CONF_LOCATION][CONF_LATITUDE],
location[CONF_LOCATION][CONF_LONGITUDE],
coordinator=coordinator,
entity_description=description,
)
for description in SENSOR_DESCRIPTIONS
for description in WEATHER_SENSOR_DESCRIPTIONS
)
entities.extend(
SMHIFireSensor(
location[CONF_LOCATION][CONF_LATITUDE],
location[CONF_LOCATION][CONF_LONGITUDE],
coordinator=fire_coordinator,
entity_description=description,
)
for description in FIRE_SENSOR_DESCRIPTIONS
)
async_add_entities(entities)
class SMHISensor(SmhiWeatherBaseEntity, SensorEntity):
"""Representation of a SMHI Sensor."""
entity_description: SMHISensorEntityDescription
class SMHIWeatherSensor(SmhiWeatherEntity, SensorEntity):
"""Representation of a SMHI Weather Sensor."""
entity_description: SMHIWeatherEntityDescription
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIDataUpdateCoordinator,
entity_description: SMHISensorEntityDescription,
entity_description: SMHIWeatherEntityDescription,
) -> None:
"""Initiate SMHI Sensor."""
self.entity_description = entity_description
@@ -137,3 +288,30 @@ class SMHISensor(SmhiWeatherBaseEntity, SensorEntity):
"""Refresh the entity data."""
if self.coordinator.data.daily:
self._attr_native_value = self.entity_description.value_fn(self)
class SMHIFireSensor(SmhiFireEntity, SensorEntity):
"""Representation of a SMHI Weather Sensor."""
entity_description: SMHIFireEntityDescription
def __init__(
self,
latitude: str,
longitude: str,
coordinator: SMHIFireDataUpdateCoordinator,
entity_description: SMHIFireEntityDescription,
) -> None:
"""Initiate SMHI Sensor."""
self.entity_description = entity_description
super().__init__(
latitude,
longitude,
coordinator,
)
self._attr_unique_id = f"{latitude}, {longitude}-{entity_description.key}"
def update_entity_data(self) -> None:
"""Refresh the entity data."""
if self.coordinator.data.fire_daily:
self._attr_native_value = self.entity_description.value_fn(self)
@@ -26,12 +26,66 @@
},
"entity": {
"sensor": {
"build_up_index": {
"name": "Build up index"
},
"drought_code": {
"name": "Drought code"
},
"duff_moisture_code": {
"name": "Duff moisture code"
},
"fine_fuel_moisture_code": {
"name": "Fine fuel moisture code"
},
"fire_weather_index": {
"name": "Fire weather index"
},
"forestdry": {
"name": "Fuel drying",
"state": {
"dry": "Dry",
"extremely_dry": "Extremely dry",
"moderate_wet": "Moderately wet",
"very_dry": "Very dry",
"very_wet": "Very wet",
"wet": "Wet"
}
},
"frozen_precipitation": {
"name": "Frozen precipitation"
},
"fwi": {
"name": "Fire weather index"
},
"fwiindex": {
"name": "FWI index",
"state": {
"extreme": "Extremely high risk",
"high": "High risk",
"low": "Low risk",
"moderate": "Moderate risk",
"very_high": "Very high risk",
"very_low": "Very low risk"
}
},
"grassfire": {
"name": "Highest grass fire risk",
"state": {
"high": "High",
"low": "Low",
"moderate": "Moderate",
"season_over": "Grass fire season over",
"snow_cover": "Snow cover",
"very_high": "Very high"
}
},
"high_cloud": {
"name": "High cloud coverage"
},
"initial_spread_index": {
"name": "Initial spread index"
},
"low_cloud": {
"name": "Low cloud coverage"
},
@@ -50,6 +104,9 @@
"6": "Freezing drizzle"
}
},
"rate_of_spread": {
"name": "Potential rate of spread"
},
"thunder": {
"name": "Thunder probability"
},
+3 -3
View File
@@ -55,7 +55,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT
from .coordinator import SMHIConfigEntry
from .entity import SmhiWeatherBaseEntity
from .entity import SmhiWeatherEntity
# Used to map condition from API results
CONDITION_CLASSES: Final[dict[str, list[int]]] = {
@@ -89,7 +89,7 @@ async def async_setup_entry(
"""Add a weather entity from map location."""
location = config_entry.data
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data[0]
entity = SmhiWeather(
location[CONF_LOCATION][CONF_LATITUDE],
@@ -101,7 +101,7 @@ async def async_setup_entry(
async_add_entities([entity])
class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity):
class SmhiWeather(SmhiWeatherEntity, SingleCoordinatorWeatherEntity):
"""Representation of a weather entity."""
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
@@ -4,11 +4,13 @@ import logging
from libsoundtouch import soundtouch_device
from libsoundtouch.device import SoundTouchDevice
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -130,7 +132,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bose SoundTouch from a config entry."""
device = await hass.async_add_executor_job(soundtouch_device, entry.data[CONF_HOST])
try:
device = await hass.async_add_executor_job(
soundtouch_device, entry.data[CONF_HOST]
)
except requests.exceptions.ConnectionError as err:
raise ConfigEntryNotReady(
f"Unable to connect to SoundTouch device at {entry.data[CONF_HOST]}"
) from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SoundTouchData(device)
+3 -1
View File
@@ -49,7 +49,9 @@ QUERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_COLUMN_NAME): cv.string,
vol.Required(CONF_NAME): cv.template,
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
vol.Required(CONF_QUERY): vol.All(
cv.template, ValueTemplate.from_template, validate_sql_select
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): vol.All(
cv.template, ValueTemplate.from_template
+141 -22
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -9,13 +10,13 @@ import sqlalchemy
from sqlalchemy.engine import Engine, Result
from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError
from sqlalchemy.orm import Session, scoped_session, sessionmaker
import sqlparse
from sqlparse.exceptions import SQLParseError
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.recorder import CONF_DB_URL, get_instance
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
@@ -31,21 +32,33 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, async_get_hass, callback
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import selector
from homeassistant.helpers.entity_platform import PlatformData
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import ValueTemplate
from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from .util import resolve_db_url
from .sensor import TRIGGER_ENTITY_OPTIONS, SQLSensor
from .util import (
EmptyQueryError,
InvalidSqlQuery,
MultipleQueryError,
NotSelectQueryError,
UnknownQueryTypeError,
async_create_sessionmaker,
check_and_render_sql_query,
resolve_db_url,
)
_LOGGER = logging.getLogger(__name__)
OPTIONS_SCHEMA: vol.Schema = vol.Schema(
{
vol.Required(CONF_QUERY): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
),
vol.Required(CONF_QUERY): selector.TemplateSelector(),
vol.Required(CONF_COLUMN_NAME): selector.TextSelector(),
vol.Required(CONF_ADVANCED_OPTIONS): section(
vol.Schema(
@@ -89,14 +102,12 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema(
def validate_sql_select(value: str) -> str:
"""Validate that value is a SQL SELECT query."""
if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1:
raise MultipleResultsFound
if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN":
raise ValueError
if query_type != "SELECT":
_LOGGER.debug("The SQL query %s is of type %s", query, query_type)
raise SQLParseError
return str(query[0])
hass = async_get_hass()
try:
return check_and_render_sql_query(hass, value)
except (TemplateError, InvalidSqlQuery) as err:
_LOGGER.debug("Invalid query '%s' results in '%s'", value, err.args[0])
raise
def validate_db_connection(db_url: str) -> bool:
@@ -138,7 +149,7 @@ def validate_query(db_url: str, query: str, column: str) -> bool:
if sess:
sess.close()
engine.dispose()
raise ValueError(error) from error
raise InvalidSqlQuery from error
for res in result.mappings():
if column not in res:
@@ -165,6 +176,11 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN):
data: dict[str, Any]
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)
@staticmethod
@callback
def async_get_options_flow(
@@ -224,13 +240,13 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN):
except NoSuchColumnError:
errors["column"] = "column_invalid"
description_placeholders = {"column": column}
except MultipleResultsFound:
except (MultipleResultsFound, MultipleQueryError):
errors["query"] = "multiple_queries"
except SQLAlchemyError:
errors["db_url"] = "db_url_invalid"
except SQLParseError:
except (NotSelectQueryError, UnknownQueryTypeError):
errors["query"] = "query_no_read_only"
except ValueError as err:
except (TemplateError, EmptyQueryError, InvalidSqlQuery) as err:
_LOGGER.debug("Invalid query: %s", err)
errors["query"] = "query_invalid"
@@ -255,6 +271,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, user_input),
errors=errors,
description_placeholders=description_placeholders,
preview="sql",
)
@@ -282,13 +299,13 @@ class SQLOptionsFlowHandler(OptionsFlowWithReload):
except NoSuchColumnError:
errors["column"] = "column_invalid"
description_placeholders = {"column": column}
except MultipleResultsFound:
except (MultipleResultsFound, MultipleQueryError):
errors["query"] = "multiple_queries"
except SQLAlchemyError:
errors["db_url"] = "db_url_invalid"
except SQLParseError:
except (NotSelectQueryError, UnknownQueryTypeError):
errors["query"] = "query_no_read_only"
except ValueError as err:
except (TemplateError, EmptyQueryError, InvalidSqlQuery) as err:
_LOGGER.debug("Invalid query: %s", err)
errors["query"] = "query_invalid"
else:
@@ -318,4 +335,106 @@ class SQLOptionsFlowHandler(OptionsFlowWithReload):
),
errors=errors,
description_placeholders=description_placeholders,
preview="sql",
)
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)
@websocket_api.websocket_command(
{
vol.Required("type"): "sql/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@websocket_api.async_response
async def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a preview."""
if msg["flow_type"] == "config_flow":
flow_status = hass.config_entries.flow.async_get(msg["flow_id"])
flow_sets = hass.config_entries.flow._handler_progress_index.get( # noqa: SLF001
flow_status["handler"]
)
assert flow_sets
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
print(list(flow_sets)[0].data)
name = list(flow_sets)[0].data[CONF_NAME]
db_url = resolve_db_url(hass, list(flow_sets)[0].data.get(CONF_DB_URL))
else:
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
if not config_entry:
raise HomeAssistantError("Config entry not found")
name = config_entry.title
db_url = resolve_db_url(hass, config_entry.data.get(CONF_DB_URL))
@callback
def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None:
"""Forward config entry state events to websocket."""
connection.send_message(
websocket_api.event_message(
msg["id"], {"attributes": attributes, "state": state}
)
)
(
sessmaker,
_,
use_database_executor,
) = await async_create_sessionmaker(hass, db_url)
print(sessmaker)
print(db_url)
if sessmaker is None:
# Can not just return, needs to pass something
return
name_template = Template(name, hass)
trigger_entity_config = {CONF_NAME: name_template}
for key in TRIGGER_ENTITY_OPTIONS:
if key in msg["user_input"]:
trigger_entity_config[key] = msg["user_input"][key]
if key in msg["user_input"].get(CONF_ADVANCED_OPTIONS, {}):
trigger_entity_config[key] = msg["user_input"][CONF_ADVANCED_OPTIONS][key]
query_str: str = msg["user_input"].get(CONF_QUERY)
template: str | None = msg["user_input"].get(CONF_VALUE_TEMPLATE)
column_name: str = msg["user_input"].get(CONF_COLUMN_NAME)
value_template: ValueTemplate | None = None
if template is not None:
try:
value_template = ValueTemplate(template, hass)
value_template.ensure_valid()
except TemplateError:
value_template = None
preview_entity = SQLSensor(
trigger_entity_config=trigger_entity_config,
sessmaker=sessmaker,
query=ValueTemplate(query_str, hass),
column=column_name,
value_template=value_template,
yaml=False,
use_database_executor=use_database_executor,
)
preview_entity.hass = hass
# Create PlatformData, needed for name translations
platform_data = PlatformData(hass=hass, domain=SENSOR_DOMAIN, platform_name=DOMAIN)
await platform_data.async_load_translations()
connection.send_result(msg["id"])
connection.subscriptions[msg["id"]] = await preview_entity.async_start_preview(
async_preview_updated
)
+63 -16
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
import logging
from typing import Any
@@ -21,8 +22,8 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
MATCH_ALL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import PlatformNotReady, TemplateError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
@@ -40,7 +41,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from .util import (
InvalidSqlQuery,
async_create_sessionmaker,
check_and_render_sql_query,
convert_value,
generate_lambda_stmt,
redact_credentials,
@@ -81,7 +84,7 @@ async def async_setup_platform(
return
name: Template = conf[CONF_NAME]
query_str: str = conf[CONF_QUERY]
query_template: ValueTemplate = conf[CONF_QUERY]
value_template: ValueTemplate | None = conf.get(CONF_VALUE_TEMPLATE)
column_name: str = conf[CONF_COLUMN_NAME]
unique_id: str | None = conf.get(CONF_UNIQUE_ID)
@@ -96,7 +99,7 @@ async def async_setup_platform(
await async_setup_sensor(
hass,
trigger_entity_config,
query_str,
query_template,
column_name,
value_template,
unique_id,
@@ -119,6 +122,13 @@ async def async_setup_entry(
template: str | None = entry.options[CONF_ADVANCED_OPTIONS].get(CONF_VALUE_TEMPLATE)
column_name: str = entry.options[CONF_COLUMN_NAME]
query_template: ValueTemplate | None = None
try:
query_template = ValueTemplate(query_str, hass)
query_template.ensure_valid()
except TemplateError as err:
raise PlatformNotReady("Invalid SQL query template") from err
value_template: ValueTemplate | None = None
if template is not None:
try:
@@ -137,7 +147,7 @@ async def async_setup_entry(
await async_setup_sensor(
hass,
trigger_entity_config,
query_str,
query_template,
column_name,
value_template,
entry.entry_id,
@@ -150,7 +160,7 @@ async def async_setup_entry(
async def async_setup_sensor(
hass: HomeAssistant,
trigger_entity_config: ConfigType,
query_str: str,
query_template: ValueTemplate,
column_name: str,
value_template: ValueTemplate | None,
unique_id: str | None,
@@ -166,22 +176,25 @@ async def async_setup_sensor(
) = await async_create_sessionmaker(hass, db_url)
if sessmaker is None:
return
validate_query(hass, query_str, uses_recorder_db, unique_id)
validate_query(hass, query_template, uses_recorder_db, unique_id)
query_str = check_and_render_sql_query(hass, query_template)
upper_query = query_str.upper()
# MSSQL uses TOP and not LIMIT
mod_query_template = query_template
if not ("LIMIT" in upper_query or "SELECT TOP" in upper_query):
if "mssql" in db_url:
query_str = upper_query.replace("SELECT", "SELECT TOP 1")
_query = query_template.template.replace("SELECT", "SELECT TOP 1")
else:
query_str = query_str.replace(";", "") + " LIMIT 1;"
_query = query_template.template.replace(";", "") + " LIMIT 1;"
mod_query_template = ValueTemplate(_query, hass)
async_add_entities(
[
SQLSensor(
trigger_entity_config,
sessmaker,
query_str,
mod_query_template,
column_name,
value_template,
yaml,
@@ -200,7 +213,7 @@ class SQLSensor(ManualTriggerSensorEntity):
self,
trigger_entity_config: ConfigType,
sessmaker: scoped_session,
query: str,
query: ValueTemplate,
column: str,
value_template: ValueTemplate | None,
yaml: bool,
@@ -214,7 +227,6 @@ class SQLSensor(ManualTriggerSensorEntity):
self.sessionmaker = sessmaker
self._attr_extra_state_attributes = {}
self._use_database_executor = use_database_executor
self._lambda_stmt = generate_lambda_stmt(query)
if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)):
self._attr_name = None
self._attr_has_entity_name = True
@@ -224,6 +236,7 @@ class SQLSensor(ManualTriggerSensorEntity):
manufacturer="SQL",
name=self._rendered.get(CONF_NAME),
)
self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
@property
def name(self) -> str | None:
@@ -242,12 +255,32 @@ class SQLSensor(ManualTriggerSensorEntity):
"""Return extra attributes."""
return dict(self._attr_extra_state_attributes)
async def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
# abort early if there is needed data missing
if not self._query or not self._column_name:
self._attr_available = False
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
return self._call_on_remove_callbacks
self._preview_callback = preview_callback
await self.async_update()
return self._call_on_remove_callbacks
async def async_update(self) -> None:
"""Retrieve sensor data from the query using the right executor."""
if self._use_database_executor:
await get_instance(self.hass).async_add_executor_job(self._update)
else:
await self.hass.async_add_executor_job(self._update)
if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(calculated_state.state, calculated_state.attributes)
def _update(self) -> None:
"""Retrieve sensor data from the query."""
@@ -255,11 +288,22 @@ class SQLSensor(ManualTriggerSensorEntity):
self._attr_extra_state_attributes = {}
sess: scoped_session = self.sessionmaker()
try:
result: Result = sess.execute(self._lambda_stmt)
rendered_query = check_and_render_sql_query(self.hass, self._query)
_lambda_stmt = generate_lambda_stmt(rendered_query)
result: Result = sess.execute(_lambda_stmt)
except (TemplateError, InvalidSqlQuery) as err:
_LOGGER.error(
"Error rendering query %s: %s",
redact_credentials(self._query.template),
redact_credentials(str(err)),
)
sess.rollback()
sess.close()
return
except SQLAlchemyError as err:
_LOGGER.error(
"Error executing query %s: %s",
self._query,
rendered_query,
redact_credentials(str(err)),
)
sess.rollback()
@@ -267,7 +311,7 @@ class SQLSensor(ManualTriggerSensorEntity):
return
for res in result.mappings():
_LOGGER.debug("Query %s result in %s", self._query, res.items())
_LOGGER.debug("Query %s result in %s", rendered_query, res.items())
data = res[self._column_name]
for key, value in res.items():
self._attr_extra_state_attributes[key] = convert_value(value)
@@ -275,6 +319,8 @@ class SQLSensor(ManualTriggerSensorEntity):
if data is not None and isinstance(data, (bytes, bytearray)):
data = f"0x{data.hex()}"
print(data, self._template)
if data is not None and self._template is not None:
variables = self._template_variables_with_value(data)
if self._render_availability_template(variables):
@@ -283,10 +329,11 @@ class SQLSensor(ManualTriggerSensorEntity):
)
self._set_native_value_with_possible_timestamp(_value)
self._process_manual_data(variables)
print(self._attr_native_value)
else:
self._attr_native_value = data
if data is None:
_LOGGER.warning("%s returned no results", self._query)
_LOGGER.warning("%s returned no results", rendered_query)
sess.close()

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