Compare commits

...

72 Commits

Author SHA1 Message Date
Bram Kragten
345ba73777 Bump version to 2025.3.0b1 2025-02-27 16:48:00 +01:00
Bram Kragten
e4200a79a2 Update frontend to 20250227.0 (#139437) 2025-02-27 16:47:52 +01:00
Marcel van der Veldt
381fa65ba0 Fix Music Assistant media player entity features (#139428)
* Fix Music Assistant supported media player features

* Update supported features when player config changes

* Add tests
2025-02-27 16:47:51 +01:00
starkillerOG
16314711b8 Bump reolink-aio to 0.12.1 (#139427) 2025-02-27 16:47:50 +01:00
J. Nick Koston
553abe4a4a Bump bleak-esphome to 2.8.0 (#139426) 2025-02-27 16:47:49 +01:00
Joost Lekkerkerker
6a1bbdb3a7 Add diagnostics to SmartThings (#139423) 2025-02-27 16:47:48 +01:00
Paulus Schoutsen
59d92c75bd Fix conversation agent fallback (#139421) 2025-02-27 16:47:47 +01:00
J. Nick Koston
7732e6878e Bump habluetooth to 3.24.1 (#139420) 2025-02-27 16:47:46 +01:00
Joost Lekkerkerker
2cde317d59 Bump pysmartthings to 2.0.0 (#139418)
* Bump pysmartthings to 2.0.0

* Fix

* Fix

* Fix

* Fix
2025-02-27 16:47:45 +01:00
Josef Zweck
0c08430507 Bump onedrive to 0.0.12 (#139410)
* Bump onedrive to 0.0.12

* Add alternative name
2025-02-27 16:47:45 +01:00
J. Diego Rodríguez Royo
fa6d7d5e3c Fix fetch options error for Home connect (#139392)
* Handle errors when obtaining options definitions

* Don't fetch program options if the program key is unknown

* Test to ensure that available program endpoint is not called on unknown program
2025-02-27 16:47:43 +01:00
Michael Hansen
585b950a46 Bump intents to 2025.2.26 (#139387) 2025-02-27 16:47:42 +01:00
puddly
3effc2e182 Bump ZHA to 0.0.51 (#139383)
* Bump ZHA to 0.0.51

* Fix unit tests not accounting for primary entities
2025-02-27 16:47:42 +01:00
fwestenberg
0e1602ff71 Bump stookwijzer==1.6.1 (#139380) 2025-02-27 16:47:41 +01:00
Bram Kragten
693584ce29 Bump version to 2025.3.0b0 2025-02-26 18:23:01 +01:00
Joost Lekkerkerker
2e972422c2 Fix typo in SmartThing string (#139373) 2025-02-26 18:19:45 +01:00
Joost Lekkerkerker
3a21c36173 Don't create entities for disabled capabilities in SmartThings (#139343)
* Don't create entities for disabled capabilities in SmartThings

* Fix

* fix

* fix
2025-02-26 18:19:28 +01:00
Joost Lekkerkerker
25ee2e58a5 Add translatable states to dryer job state in SmartThings (#139370)
* Add translatable states to washer job state in SmartThings

* Add translatable states to dryer job state in Smartthings

* fix

* fix
2025-02-26 18:15:14 +01:00
Joost Lekkerkerker
561b3ae21b Add translatable states to dryer machine state in Smartthings (#139369) 2025-02-26 18:14:59 +01:00
J. Diego Rodríguez Royo
5be7f49146 Improve Home Connect oven cavity temperature sensor (#139355)
* Improve oven cavity temperature translation

* Fetch cavity temperature unit

* Handle generic Home Connect error

* Improve test clarity
2025-02-26 18:11:40 +01:00
Joost Lekkerkerker
2694828451 Add translatable states to washer job state in SmartThings (#139368)
* Add translatable states to washer job state in SmartThings

* fix

* Update homeassistant/components/smartthings/sensor.py
2025-02-26 18:07:56 +01:00
Joost Lekkerkerker
3eea932b24 Add translatable states to robot cleaner turbo mode in SmartThings (#139364) 2025-02-26 17:53:16 +01:00
Joost Lekkerkerker
468208502f Add translatable states to smoke detector in SmartThings (#139365) 2025-02-26 17:52:57 +01:00
Joost Lekkerkerker
92268f894a Add translatable states to washer machine state in SmartThings (#139366) 2025-02-26 17:34:29 +01:00
Joost Lekkerkerker
5e5fd6a2f2 Add translatable states to robot cleaner cleaning mode in SmartThings (#139362)
* Add translatable states to robot cleaner cleaning mode in SmartThings

* Update homeassistant/components/smartthings/strings.json

* Update homeassistant/components/smartthings/strings.json

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-02-26 17:33:13 +01:00
Joost Lekkerkerker
cadee73da8 Add translatable states to robot cleaner movement in SmartThings (#139363) 2025-02-26 17:25:50 +01:00
Joost Lekkerkerker
51099ae7d6 Add translatable states to oven machine state (#139358) 2025-02-26 17:13:02 +01:00
Joost Lekkerkerker
b777c29bab Add translatable states to oven job state in SmartThings (#139361) 2025-02-26 17:12:27 +01:00
Joost Lekkerkerker
fc1190dafd Add translatable states to oven mode in SmartThings (#139356) 2025-02-26 16:59:20 +01:00
Joost Lekkerkerker
775a81829b Add translatable states to SmartThings media playback (#139354)
Add translatable states to media playback
2025-02-26 16:49:00 +01:00
Joost Lekkerkerker
998757f09e Add translatable states to SmartThings media source input (#139353)
Add translatable states to media source input
2025-02-26 16:40:34 +01:00
Artur Pragacz
b964bc58be Fix variable scopes in scripts (#138883)
Co-authored-by: Erik <erik@montnemery.com>
2025-02-26 16:19:19 +01:00
Joost Lekkerkerker
bd80a78848 Set options for alarm sensor in SmartThings (#139345)
* Set options for alarm sensor in SmartThings

* Set options for alarm sensor in SmartThings

* Fix
2025-02-26 17:18:59 +02:00
Joost Lekkerkerker
37c8764426 Set options for dishwasher machine state sensor in SmartThings (#139347)
* Set options for dishwasher machine state sensor in SmartThings

* Fix
2025-02-26 17:18:37 +02:00
Joost Lekkerkerker
9262dec444 Set options for dishwasher job state sensor in SmartThings (#139349) 2025-02-26 17:18:14 +02:00
Joost Lekkerkerker
3c3c4d2641 Use particulate matter device class in SmartThings (#139351)
Use particule matter device class in SmartThings
2025-02-26 17:17:55 +02:00
Bram Kragten
c1898ece80 Update frontend to 20250226.0 (#139340)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-02-26 16:13:45 +01:00
Jan Bouwhuis
fdf69fcd7d Improve calculating supported features in template light (#139339) 2025-02-26 15:09:20 +00:00
Joost Lekkerkerker
e403bee95b Set options for carbon monoxide detector sensor in SmartThings (#139346) 2025-02-26 16:05:59 +01:00
Joost Lekkerkerker
9be8fd4eac Change no fixtures comment in SmartThings (#139344) 2025-02-26 16:59:23 +02:00
Artur Pragacz
e09b40c2bd Improve logging for selected options in Onkyo (#139279)
Different error for not selected option
2025-02-26 15:51:16 +01:00
Joost Lekkerkerker
2826198d5d Add entity translations to SmartThings (#139342)
* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* Refactor SmartThings

* fix

* fix

* Add AC tests

* Add thermostat tests

* Add cover tests

* Add device tests

* Add light tests

* Add rest of the tests

* Add oauth

* Add oauth tests

* Add oauth tests

* Add oauth tests

* Add oauth tests

* Bump version

* Add rest of the tests

* Finalize

* Finalize

* Finalize

* Finalize

* Finalize

* Finalize

* Finalize

* Finalize

* Finalize

* Finalize

* Iterate over entities instead

* use set

* use const

* uncomment

* fix handler

* Fix device info

* Fix device info

* Fix lib

* Fix lib

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Add fake fan

* Fix

* Add entity translations to SmartThings

* Fix
2025-02-26 15:48:51 +01:00
Jan Bouwhuis
5324f3e542 Add support for swing horizontal mode for mqtt climate (#139303)
* Add support for swing horizontal mode for mqtt climate

* Fix import
2025-02-26 15:44:16 +01:00
Erik Montnemery
7e97ef588b Add keys initiate_flow and entry_type to data entry translations (#138882) 2025-02-26 15:27:52 +01:00
Joost Lekkerkerker
bb120020a8 Refactor SmartThings (#137940) 2025-02-26 15:14:04 +01:00
Marcel van der Veldt
bb9aba2a7d Bump Music Assistant client to 1.1.1 (#139331) 2025-02-26 14:48:18 +01:00
Norbert Rittel
b676c2f61b Improve action descriptions of LIFX integration (#139329)
Improve action description of lifx integration

- fix sentence-casing on two action names
- change "Kelvin" unit name to proper uppercase
- reference 'Theme' and 'Palette' fields by their friendly names for matching translations
- change paint_theme action description to match HA style
2025-02-26 15:24:19 +02:00
Erik Montnemery
0c092f80c7 Add default_db_url flag to WS command recorder/info (#139333) 2025-02-26 14:09:38 +01:00
J. Nick Koston
2bf592d8aa Bump recommended ESPHome Bluetooth proxy version to 2025.2.1 (#139196) 2025-02-26 12:55:03 +00:00
Paul Bottein
e591157e37 Add translations and icon for Twinkly select entity (#139336)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-02-26 13:44:43 +01:00
Erik Montnemery
ee01aa73b8 Improve error message when failing to create backups (#139262)
* Improve error message when failing to create backups

* Check for expected error message in tests
2025-02-26 13:44:09 +01:00
fwestenberg
0f827fbf22 Bump stookwijzer==1.6.0 (#139332) 2025-02-26 13:31:07 +01:00
Ben Bridts
4dca4a64b5 Bump pybotvac to 0.0.26 (#139330) 2025-02-26 13:26:12 +01:00
Denis Shulyaka
b82886a3e1 Fix anthropic blocking call (#139299) 2025-02-26 12:25:59 +00:00
Matt Zimmerman
fe396cdf4b Update python-smarttub dependency to 0.0.39 (#139313) 2025-02-26 11:59:13 +01:00
Christophe Gagnier
5895245a31 Bump pytechnove to 2.0.0 (#139314) 2025-02-26 11:57:54 +01:00
TheJulianJES
861ba0ee5e Bump ZHA to 0.0.50 (#139318) 2025-02-26 11:52:57 +01:00
Maciej Bieniek
d15f9edc57 Bump accuweather to version 4.1.0 (#139320) 2025-02-26 11:51:35 +01:00
Erik Montnemery
cab6ec0363 Fix homeassistant/expose_entity/list (#138872)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-02-26 09:02:17 +01:00
J. Nick Koston
eb26a2124b Adjust remote ESPHome log subscription level on logging change (#139308) 2025-02-26 08:58:13 +01:00
dependabot[bot]
4530fe4bf7 Bump home-assistant/builder from 2024.08.2 to 2025.02.0 (#139316)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-26 08:48:25 +01:00
dependabot[bot]
b1865de58f Bump actions/download-artifact from 4.1.8 to 4.1.9 (#139317)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.8 to 4.1.9.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4.1.8...v4.1.9)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-26 08:13:25 +01:00
J. Nick Koston
3ff04d6d04 Bump aioesphomeapi to 29.2.0 (#139309) 2025-02-26 03:14:58 +01:00
peteS-UK
bd306abace Add album artist media browser category to Squeezebox (#139210) 2025-02-25 17:55:53 -06:00
Michael
412ceca6f7 Sort common translation strings (#139300)
sort common strings
2025-02-25 23:22:02 +01:00
J. Diego Rodríguez Royo
8644fb1887 Add missing Home Connect context at event listener registration for appliance options (#139292)
* Add missing context at event listener registration for appliance options

* Add tests
2025-02-25 23:05:52 +01:00
Abílio Costa
622be70fee Remove timeout from vscode test launch configuration (#139288) 2025-02-25 23:02:49 +01:00
Maciej Bieniek
7bc0c1b912 Bump aioshelly to version 13.0.0 (#139294)
* Bump aioshelly to version 13.0.0

* MODEL_BLU_GATEWAY_GEN3 -> MODEL_BLU_GATEWAY_G3
2025-02-25 23:52:44 +02:00
G Johansson
3230e741e9 Remove not used constants in smhi (#139298) 2025-02-25 22:49:41 +01:00
J. Nick Koston
81db3dea41 Add option to ESPHome to subscribe to logs (#139073) 2025-02-25 21:56:39 +01:00
J. Nick Koston
fe348e17a3 Revert "Bump stookwijzer==1.5.8" (#139287) 2025-02-25 21:43:06 +01:00
Pierre Ståhl
03f6508bd8 Fix re-connect logic in Apple TV integration (#139289) 2025-02-25 20:37:01 +00:00
211 changed files with 26703 additions and 6736 deletions

View File

@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: translations
@@ -197,7 +197,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2024.08.2
uses: home-assistant/builder@2025.02.0
with:
args: |
$BUILD_ARGS \
@@ -263,7 +263,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2024.08.2
uses: home-assistant/builder@2025.02.0
with:
args: |
$BUILD_ARGS \
@@ -462,7 +462,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: translations

View File

@@ -942,7 +942,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: pytest_buckets
- name: Compile English translations
@@ -1271,7 +1271,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1410,7 +1410,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
pattern: coverage-*
- name: Upload coverage to Codecov

View File

@@ -138,17 +138,17 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: requirements_diff
@@ -187,22 +187,22 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@v4.1.8
uses: actions/download-artifact@v4.1.9
with:
name: requirements_all_wheels

1
.vscode/launch.json vendored
View File

@@ -38,7 +38,6 @@
"module": "pytest",
"justMyCode": false,
"args": [
"--timeout=10",
"--picked"
],
},

2
CODEOWNERS generated
View File

@@ -1401,6 +1401,8 @@ build.json @home-assistant/supervisor
/tests/components/smappee/ @bsmappee
/homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek
/tests/components/smartthings/ @joostlek
/homeassistant/components/smarttub/ @mdz
/tests/components/smarttub/ @mdz
/homeassistant/components/smarty/ @z0mbieprocess

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==4.0.0"],
"requirements": ["accuweather==4.1.0"],
"single_config_entry": true
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from functools import partial
import anthropic
from homeassistant.config_entries import ConfigEntry
@@ -20,7 +22,9 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Set up Anthropic from a config entry."""
client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY])
client = await hass.async_add_executor_job(
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
)
try:
await client.messages.create(
model="claude-3-haiku-20240307",

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from functools import partial
import logging
from types import MappingProxyType
from typing import Any
@@ -59,7 +60,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY])
client = await hass.async_add_executor_job(
partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY])
)
await client.messages.create(
model="claude-3-haiku-20240307",
max_tokens=1,

View File

@@ -233,7 +233,6 @@ class AppleTVManager(DeviceListener):
pass
except Exception:
_LOGGER.exception("Failed to connect")
await self.disconnect()
async def _connect_loop(self) -> None:
"""Connect loop background task function."""

View File

@@ -1103,12 +1103,16 @@ class PipelineRun:
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
# Try local intents
if (
intent_response is None
and self.pipeline.prefer_local_intents
and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
)
)
):
# Local intent matched

View File

@@ -1620,7 +1620,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Generate backup contents and return the size."""
if not tar_file_path:
tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
make_backup_dir(tar_file_path.parent)
try:
make_backup_dir(tar_file_path.parent)
except OSError as err:
raise BackupReaderWriterError(
f"Failed to create dir {tar_file_path.parent}: "
f"{err} ({err.__class__.__name__})"
) from err
excludes = EXCLUDE_FROM_BACKUP
if not database_included:
@@ -1658,7 +1664,14 @@ class CoreBackupReaderWriter(BackupReaderWriter):
file_filter=is_excluded_by_filter,
arcname="data",
)
return (tar_file_path, tar_file_path.stat().st_size)
try:
stat_result = tar_file_path.stat()
except OSError as err:
raise BackupReaderWriterError(
f"Error getting size of {tar_file_path}: "
f"{err} ({err.__class__.__name__})"
) from err
return (tar_file_path, stat_result.st_size)
async def async_receive_backup(
self,

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.24.0"
"habluetooth==3.24.1"
]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"]
}

View File

@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"]
}

View File

@@ -41,6 +41,7 @@ from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
@@ -508,6 +509,10 @@ class OptionsFlowHandler(OptionsFlow):
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View File

@@ -5,6 +5,7 @@ from awesomeversion import AwesomeVersion
DOMAIN = "esphome"
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
CONF_NOISE_PSK = "noise_psk"
@@ -12,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
STABLE_BLE_VERSION_STR = "2023.8.0"
STABLE_BLE_VERSION_STR = "2025.2.1"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from functools import partial
import logging
import re
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
@@ -16,6 +17,7 @@ from aioesphomeapi import (
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
ReconnectLogic,
RequiresEncryptionAPIError,
UserService,
@@ -33,6 +35,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
@@ -61,6 +64,7 @@ from .bluetooth import async_connect_scanner
from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_URL,
DOMAIN,
@@ -74,8 +78,38 @@ from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
SubscribeLogsResponse,
)
_LOGGER = logging.getLogger(__name__)
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
LogLevel.LOG_LEVEL_WARN: logging.WARNING,
LogLevel.LOG_LEVEL_INFO: logging.INFO,
LogLevel.LOG_LEVEL_CONFIG: logging.INFO,
LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG,
LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG,
LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG,
}
LOGGER_TO_LOG_LEVEL = {
logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE,
logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE,
logging.INFO: LogLevel.LOG_LEVEL_CONFIG,
logging.WARNING: LogLevel.LOG_LEVEL_WARN,
logging.ERROR: LogLevel.LOG_LEVEL_ERROR,
logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR,
}
# 7-bit and 8-bit C1 ANSI sequences
# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
ANSI_ESCAPE_78BIT = re.compile(
rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])"
)
@callback
def _async_check_firmware_version(
@@ -136,6 +170,8 @@ class ESPHomeManager:
"""Class to manage an ESPHome connection."""
__slots__ = (
"_cancel_subscribe_logs",
"_log_level",
"cli",
"device_id",
"domain_data",
@@ -169,6 +205,8 @@ class ESPHomeManager:
self.reconnect_logic: ReconnectLogic | None = None
self.zeroconf_instance = zeroconf_instance
self.entry_data = entry.runtime_data
self._cancel_subscribe_logs: CALLBACK_TYPE | None = None
self._log_level = LogLevel.LOG_LEVEL_NONE
async def on_stop(self, event: Event) -> None:
"""Cleanup the socket client on HA close."""
@@ -341,6 +379,34 @@ class ESPHomeManager:
# Re-connection logic will trigger after this
await self.cli.disconnect()
def _async_on_log(self, msg: SubscribeLogsResponse) -> None:
"""Handle a log message from the API."""
log: bytes = msg.message
_LOGGER.log(
LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG),
"%s: %s",
self.entry.title,
ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"),
)
@callback
def _async_get_equivalent_log_level(self) -> LogLevel:
"""Get the equivalent ESPHome log level for the current logger."""
return LOGGER_TO_LOG_LEVEL.get(
_LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE
)
@callback
def _async_subscribe_logs(self, log_level: LogLevel) -> None:
"""Subscribe to logs."""
if self._cancel_subscribe_logs is not None:
self._cancel_subscribe_logs()
self._cancel_subscribe_logs = None
self._log_level = log_level
self._cancel_subscribe_logs = self.cli.subscribe_logs(
self._async_on_log, self._log_level
)
async def _on_connnect(self) -> None:
"""Subscribe to states and list entities on successful API login."""
entry = self.entry
@@ -352,6 +418,8 @@ class ESPHomeManager:
cli = self.cli
stored_device_name = entry.data.get(CONF_DEVICE_NAME)
unique_id_is_mac_address = unique_id and ":" in unique_id
if entry.options.get(CONF_SUBSCRIBE_LOGS):
self._async_subscribe_logs(self._async_get_equivalent_log_level())
results = await asyncio.gather(
create_eager_task(cli.device_info()),
create_eager_task(cli.list_entities_services()),
@@ -503,6 +571,10 @@ class ESPHomeManager:
def _async_handle_logging_changed(self, _event: Event) -> None:
"""Handle when the logging level changes."""
self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != (
new_log_level := self._async_get_equivalent_log_level()
):
self._async_subscribe_logs(new_log_level)
async def async_start(self) -> None:
"""Start the esphome connection manager."""

View File

@@ -16,9 +16,9 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.1.1",
"aioesphomeapi==29.2.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.7.1"
"bleak-esphome==2.8.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -54,7 +54,8 @@
"step": {
"init": {
"data": {
"allow_service_calls": "Allow the device to perform Home Assistant actions."
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
"subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
}
}
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250221.0"]
"requirements": ["home-assistant-frontend==20250227.0"]
}

View File

@@ -72,22 +72,27 @@ def _handle_paired_or_connected_appliance(
for entity in get_option_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
)
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
for event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
(appliance.info.ha_id, event_key),
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id

View File

@@ -4,6 +4,8 @@ from typing import cast
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from .utils import bsh_key_to_translation_key
DOMAIN = "home_connect"
@@ -21,6 +23,13 @@ APPLIANCES_WITH_PROGRAMS = (
"WasherDryer",
)
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"

View File

@@ -440,13 +440,27 @@ class HomeConnectCoordinator(
self, ha_id: str, program_key: ProgramKey
) -> dict[OptionKey, ProgramDefinitionOption]:
"""Get options with constraints for appliance."""
return {
option.key: option
for option in (
await self.client.get_available_program(ha_id, program_key=program_key)
).options
or []
}
if program_key is ProgramKey.UNKNOWN:
return {}
try:
return {
option.key: option
for option in (
await self.client.get_available_program(
ha_id, program_key=program_key
)
).options
or []
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching options for %s: %s",
ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
return {}
async def update_options(
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
@@ -456,8 +470,7 @@ class HomeConnectCoordinator(
events = self.data[ha_id].events
options_to_notify = options.copy()
options.clear()
if program_key is not ProgramKey.UNKNOWN:
options.update(await self.get_options_definitions(ha_id, program_key))
options.update(await self.get_options_definitions(ha_id, program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None

View File

@@ -11,7 +11,6 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,6 +22,7 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
@@ -32,13 +32,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
NUMBERS = (
NumberEntityDescription(
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,

View File

@@ -1,10 +1,12 @@
"""Provides a sensor for Home Connect."""
import contextlib
from dataclasses import dataclass
from datetime import timedelta
from typing import cast
from aiohomeconnect.model import EventKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -23,6 +25,7 @@ from .const import (
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
@@ -40,6 +43,7 @@ class HomeConnectSensorEntityDescription(
default_value: str | None = None
appliance_types: tuple[str, ...] | None = None
fetch_unit: bool = False
BSH_PROGRAM_SENSORS = (
@@ -183,7 +187,8 @@ SENSORS = (
key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="current_cavity_temperature",
translation_key="oven_current_cavity_temperature",
fetch_unit=True,
),
)
@@ -318,6 +323,29 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
case _:
self._attr_native_value = status
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
if self.entity_description.fetch_unit:
data = self.appliance.status[cast(StatusKey, self.bsh_key)]
if data.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(
data.unit, data.unit
)
else:
await self.fetch_unit()
async def fetch_unit(self) -> None:
"""Fetch the unit of measurement."""
with contextlib.suppress(HomeConnectError):
data = await self.coordinator.client.get_status_value(
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
)
if data.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(
data.unit, data.unit
)
class HomeConnectProgramSensor(HomeConnectSensor):
"""Sensor class for Home Connect sensors that reports information related to the running program."""

View File

@@ -1529,8 +1529,8 @@
"map3": "Map 3"
}
},
"current_cavity_temperature": {
"name": "Current cavity temperature"
"oven_current_cavity_temperature": {
"name": "Current oven cavity temperature"
},
"freezer_door_alarm": {
"name": "Freezer door alarm",

View File

@@ -437,18 +437,21 @@ def ws_expose_entity(
def ws_list_exposed_entities(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Expose an entity to an assistant."""
"""List entities which are exposed to assistants."""
result: dict[str, Any] = {}
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
entity_registry = er.async_get(hass)
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
result[entity_id] = {}
exposed_to = {}
entity_settings = async_get_entity_settings(hass, entity_id)
for assistant, settings in entity_settings.items():
if "should_expose" not in settings:
if "should_expose" not in settings or not settings["should_expose"]:
continue
result[entity_id][assistant] = settings["should_expose"]
exposed_to[assistant] = True
if not exposed_to:
continue
result[entity_id] = exposed_to
connection.send_result(msg["id"], {"exposed_entities": result})

View File

@@ -11,7 +11,6 @@
},
"config_subentries": {
"entity": {
"title": "Add entity",
"step": {
"add_sensor": {
"description": "Configure the new sensor",
@@ -27,7 +26,12 @@
"state": "Initial state"
}
}
}
},
"initiate_flow": {
"user": "Add sensor",
"reconfigure": "Reconfigure sensor"
},
"entry_type": "Sensor"
}
},
"options": {

View File

@@ -66,7 +66,7 @@
}
},
"set_state": {
"name": "Set State",
"name": "Set state",
"description": "Sets a color/brightness and possibly turn the light on/off.",
"fields": {
"infrared": {
@@ -209,11 +209,11 @@
},
"palette": {
"name": "Palette",
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute."
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect. Overrides the 'Theme' attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
"description": "Predefined color theme to use for the effect. Overridden by the palette attribute."
"description": "Predefined color theme to use for the effect. Overridden by the 'Palette' attribute."
},
"power_on": {
"name": "Power on",
@@ -243,7 +243,7 @@
},
"palette": {
"name": "Palette",
"description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect."
"description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect."
},
"power_on": {
"name": "Power on",
@@ -256,16 +256,16 @@
"description": "Stops a running effect."
},
"paint_theme": {
"name": "Paint Theme",
"description": "Paint either a provided theme or custom palette across one or more LIFX lights.",
"name": "Paint theme",
"description": "Paints either a provided theme or custom palette across one or more LIFX lights.",
"fields": {
"palette": {
"name": "Palette",
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute."
"description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to paint across the target lights. Overrides the 'Theme' attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
"description": "Predefined color theme to paint. Overridden by the palette attribute."
"description": "Predefined color theme to paint. Overridden by the 'Palette' attribute."
},
"transition": {
"name": "Transition",

View File

@@ -218,10 +218,16 @@ ABBREVIATIONS = {
"sup_vol": "support_volume_set",
"sup_feat": "supported_features",
"sup_clrm": "supported_color_modes",
"swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template",
"swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic",
"swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template",
"swing_h_mode_stat_t": "swing_horizontal_mode_state_topic",
"swing_h_modes": "swing_horizontal_modes",
"swing_mode_cmd_tpl": "swing_mode_command_template",
"swing_mode_cmd_t": "swing_mode_command_topic",
"swing_mode_stat_tpl": "swing_mode_state_template",
"swing_mode_stat_t": "swing_mode_state_topic",
"swing_modes": "swing_modes",
"temp_cmd_tpl": "temperature_command_template",
"temp_cmd_t": "temperature_command_topic",
"temp_hi_cmd_tpl": "temperature_high_command_template",

View File

@@ -113,11 +113,19 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic"
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template"
CONF_PRESET_MODES_LIST = "preset_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic"
CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes"
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template"
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic"
CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template"
CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
CONF_SWING_MODE_LIST = "swing_modes"
CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template"
CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template"
CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic"
CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template"
@@ -145,6 +153,8 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset(
climate.ATTR_MIN_TEMP,
climate.ATTR_PRESET_MODE,
climate.ATTR_PRESET_MODES,
climate.ATTR_SWING_HORIZONTAL_MODE,
climate.ATTR_SWING_HORIZONTAL_MODES,
climate.ATTR_SWING_MODE,
climate.ATTR_SWING_MODES,
climate.ATTR_TARGET_TEMP_HIGH,
@@ -162,6 +172,7 @@ VALUE_TEMPLATE_KEYS = (
CONF_MODE_STATE_TEMPLATE,
CONF_ACTION_TEMPLATE,
CONF_PRESET_MODE_VALUE_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
CONF_SWING_MODE_STATE_TEMPLATE,
CONF_TEMP_HIGH_STATE_TEMPLATE,
CONF_TEMP_LOW_STATE_TEMPLATE,
@@ -174,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = {
CONF_MODE_COMMAND_TEMPLATE,
CONF_POWER_COMMAND_TEMPLATE,
CONF_PRESET_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_MODE_COMMAND_TEMPLATE,
CONF_TEMP_COMMAND_TEMPLATE,
CONF_TEMP_HIGH_COMMAND_TEMPLATE,
@@ -194,6 +206,8 @@ TOPIC_KEYS = (
CONF_POWER_COMMAND_TOPIC,
CONF_PRESET_MODE_COMMAND_TOPIC,
CONF_PRESET_MODE_STATE_TOPIC,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
CONF_SWING_MODE_COMMAND_TOPIC,
CONF_SWING_MODE_STATE_TOPIC,
CONF_TEMP_COMMAND_TOPIC,
@@ -302,6 +316,13 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(
CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF]
): cv.ensure_list,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(
@@ -515,6 +536,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
_attr_fan_mode: str | None = None
_attr_hvac_mode: HVACMode | None = None
_attr_swing_horizontal_mode: str | None = None
_attr_swing_mode: str | None = None
_default_name = DEFAULT_NAME
_entity_id_format = climate.ENTITY_ID_FORMAT
@@ -543,6 +565,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if (precision := config.get(CONF_PRECISION)) is not None:
self._attr_precision = precision
self._attr_fan_modes = config[CONF_FAN_MODE_LIST]
self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST]
self._attr_swing_modes = config[CONF_SWING_MODE_LIST]
self._attr_target_temperature_step = config[CONF_TEMP_STEP]
@@ -568,6 +591,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic:
self._attr_fan_mode = FAN_LOW
if (
self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None
or self._optimistic
):
self._attr_swing_horizontal_mode = SWING_OFF
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic:
self._attr_swing_mode = SWING_OFF
if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic:
@@ -629,6 +657,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
):
support |= ClimateEntityFeature.FAN_MODE
if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or (
self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None
):
support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or (
self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None
):
@@ -744,6 +777,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
),
{"_attr_fan_mode"},
)
self.add_subscription(
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
partial(
self._handle_mode_received,
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
"_attr_swing_horizontal_mode",
CONF_SWING_HORIZONTAL_MODE_LIST,
),
{"_attr_swing_horizontal_mode"},
)
self.add_subscription(
CONF_SWING_MODE_STATE_TOPIC,
partial(
@@ -782,6 +825,20 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
self.async_write_ha_state()
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set new swing horizontal mode."""
payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE](
swing_horizontal_mode
)
await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload)
if (
self._optimistic
or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None
):
self._attr_swing_horizontal_mode = swing_horizontal_mode
self.async_write_ha_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode."""
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode)

View File

@@ -48,6 +48,7 @@ from .schemas import (
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track
from . import MusicAssistantConfigEntry
@@ -173,6 +174,9 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
"offset": offset,
"order_by": order_by,
}
library_result: (
list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist]
)
if media_type == MediaType.ALBUM:
library_result = await mass.music.get_library_albums(
**base_params,
@@ -181,7 +185,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
elif media_type == MediaType.ARTIST:
library_result = await mass.music.get_library_artists(
**base_params,
album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY),
album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)),
)
elif media_type == MediaType.TRACK:
library_result = await mass.music.get_library_tracks(

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.0.8"],
"requirements": ["music-assistant-client==1.1.1"],
"zeroconf": ["_mass._tcp.local."]
}

View File

@@ -166,6 +166,8 @@ async def build_playlist_items_listing(
) -> BrowseMedia:
"""Build Playlist items browse listing."""
playlist = await mass.music.get_item_by_uri(identifier)
if TYPE_CHECKING:
assert playlist.uri is not None
return BrowseMedia(
media_class=MediaClass.PLAYLIST,
@@ -219,6 +221,9 @@ async def build_artist_items_listing(
artist = await mass.music.get_item_by_uri(identifier)
albums = await mass.music.get_artist_albums(artist.item_id, artist.provider)
if TYPE_CHECKING:
assert artist.uri is not None
return BrowseMedia(
media_class=MediaType.ARTIST,
media_content_id=artist.uri,
@@ -267,6 +272,9 @@ async def build_album_items_listing(
album = await mass.music.get_item_by_uri(identifier)
tracks = await mass.music.get_album_tracks(album.item_id, album.provider)
if TYPE_CHECKING:
assert album.uri is not None
return BrowseMedia(
media_class=MediaType.ALBUM,
media_content_id=album.uri,
@@ -340,6 +348,9 @@ def build_item(
title = item.name
img_url = mass.get_media_item_image_url(item)
if TYPE_CHECKING:
assert item.uri is not None
return BrowseMedia(
media_class=media_class or item.media_type.value,
media_content_id=item.uri,

View File

@@ -9,6 +9,7 @@ import functools
import os
from typing import TYPE_CHECKING, Any, Concatenate
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
@@ -20,6 +21,7 @@ from music_assistant_models.enums import (
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
from music_assistant_models.player_queue import PlayerQueue
import voluptuous as vol
from homeassistant.components import media_source
@@ -78,21 +80,15 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.player import Player
from music_assistant_models.player_queue import PlayerQueue
SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.STOP
SUPPORTED_FEATURES_BASE = (
MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
@@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Initialize MediaPlayer entity."""
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
self._attr_supported_features = SUPPORTED_FEATURES
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._set_supported_features()
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
# we subscribe to the player config changed event to update
# the supported features of the player
async def player_config_changed(event: MassEvent) -> None:
self._set_supported_features()
await self.async_on_update()
self.async_write_ha_state()
self.async_on_remove(
self.mass.subscribe(
player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id
)
)
@property
def active_queue(self) -> PlayerQueue | None:
"""Return the active queue for this player (if any)."""
@@ -473,6 +478,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
album=album,
media_type=MediaType(media_type) if media_type else None,
):
if TYPE_CHECKING:
assert item.uri is not None
media_uris.append(item.uri)
if not media_uris:
@@ -680,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
if isinstance(queue_option, MediaPlayerEnqueue):
queue_option = QUEUE_OPTION_MAP.get(queue_option)
return queue_option
def _set_supported_features(self) -> None:
"""Set supported features based on player capabilities."""
supported_features = SUPPORTED_FEATURES_BASE
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.PAUSE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.PAUSE
if self.player.mute_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
if self.player.volume_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_STEP
supported_features |= MediaPlayerEntityFeature.VOLUME_SET
if self.player.power_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.TURN_ON
supported_features |= MediaPlayerEntityFeature.TURN_OFF
self._attr_supported_features = supported_features

View File

@@ -65,20 +65,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema(
def media_item_dict_from_mass_item(
mass: MusicAssistantClient,
item: MediaItemType | ItemMapping | None,
) -> dict[str, Any] | None:
item: MediaItemType | ItemMapping,
) -> dict[str, Any]:
"""Parse a Music Assistant MediaItem."""
if not item:
return None
base = {
base: dict[str, Any] = {
ATTR_MEDIA_TYPE: item.media_type,
ATTR_URI: item.uri,
ATTR_NAME: item.name,
ATTR_VERSION: item.version,
ATTR_IMAGE: mass.get_media_item_image_url(item),
}
artists: list[ItemMapping] | None
if artists := getattr(item, "artists", None):
base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists]
album: ItemMapping | None
if album := getattr(item, "album", None):
base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album)
return base
@@ -151,7 +151,11 @@ def queue_item_dict_from_mass_item(
ATTR_QUEUE_ITEM_ID: item.queue_item_id,
ATTR_NAME: item.name,
ATTR_DURATION: item.duration,
ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item),
ATTR_MEDIA_ITEM: (
media_item_dict_from_mass_item(mass, item.media_item)
if item.media_item
else None
),
}
if streamdetails := item.streamdetails:
base[ATTR_STREAM_TITLE] = streamdetails.stream_title

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/neato",
"iot_class": "cloud_polling",
"loggers": ["pybotvac"],
"requirements": ["pybotvac==0.0.25"]
"requirements": ["pybotvac==0.0.26"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.11"]
"requirements": ["onedrive-personal-sdk==0.0.12"]
}

View File

@@ -103,7 +103,7 @@ class OneDriveDriveStateSensor(
self._attr_unique_id = f"{coordinator.data.id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=coordinator.data.name,
name=coordinator.data.name or coordinator.config_entry.title,
identifiers={(DOMAIN, coordinator.data.id)},
manufacturer="Microsoft",
model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}",

View File

@@ -398,6 +398,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._volume_resolution = volume_resolution
self._max_volume = max_volume
self._options_sources = sources
self._source_lib_mapping = _input_source_lib_mappings(zone)
self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone)
self._source_mapping = {
@@ -409,6 +410,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
value: key for key, value in self._source_mapping.items()
}
self._options_sound_modes = sound_modes
self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone)
self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone)
self._sound_mode_mapping = {
@@ -623,11 +625,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
return
source_meaning = source.value_meaning
_LOGGER.error(
'Input source "%s" is invalid for entity: %s',
source_meaning,
self.entity_id,
)
if source not in self._options_sources:
_LOGGER.warning(
'Input source "%s" for entity: %s is not in the list. Check integration options',
source_meaning,
self.entity_id,
)
else:
_LOGGER.error(
'Input source "%s" is invalid for entity: %s',
source_meaning,
self.entity_id,
)
self._attr_source = source_meaning
@callback
@@ -638,11 +649,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
return
sound_mode_meaning = sound_mode.value_meaning
_LOGGER.error(
'Listening mode "%s" is invalid for entity: %s',
sound_mode_meaning,
self.entity_id,
)
if sound_mode not in self._options_sound_modes:
_LOGGER.warning(
'Listening mode "%s" for entity: %s is not in the list. Check integration options',
sound_mode_meaning,
self.entity_id,
)
else:
_LOGGER.error(
'Listening mode "%s" is invalid for entity: %s',
sound_mode_meaning,
self.entity_id,
)
self._attr_sound_mode = sound_mode_meaning
@callback

View File

@@ -149,9 +149,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
commit_interval = conf[CONF_COMMIT_INTERVAL]
db_max_retries = conf[CONF_DB_MAX_RETRIES]
db_retry_wait = conf[CONF_DB_RETRY_WAIT]
db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format(
hass_config_path=hass.config.path(DEFAULT_DB_FILE)
)
db_url = conf.get(CONF_DB_URL) or get_default_url(hass)
exclude = conf[CONF_EXCLUDE]
exclude_event_types: set[EventType[Any] | str] = set(
exclude.get(CONF_EVENT_TYPES, [])
@@ -200,3 +198,8 @@ async def _async_setup_integration_platform(
instance.queue_task(AddRecorderPlatformTask(domain, platform))
await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform)
def get_default_url(hass: HomeAssistant) -> str:
"""Return the default URL."""
return DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE))

View File

@@ -10,6 +10,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import recorder as recorder_helper
from . import get_default_url
from .util import get_instance
@@ -34,6 +35,7 @@ async def ws_info(
await hass.data[recorder_helper.DATA_RECORDER].db_connected
instance = get_instance(hass)
backlog = instance.backlog
db_in_default_location = instance.db_url == get_default_url(hass)
migration_in_progress = instance.migration_in_progress
migration_is_live = instance.migration_is_live
recording = instance.recording
@@ -44,6 +46,7 @@ async def ws_info(
recorder_info = {
"backlog": backlog,
"db_in_default_location": db_in_default_location,
"max_backlog": max_backlog,
"migration_in_progress": migration_in_progress,
"migration_is_live": migration_is_live,

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.12.0"]
"requirements": ["reolink-aio==0.12.1"]
}

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
"requirements": ["aioshelly==12.4.2"],
"requirements": ["aioshelly==13.0.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -2,416 +2,168 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from http import HTTPStatus
import importlib
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
from pysmartapp.event import EVENT_TYPE_DEVICE
from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings
from aiohttp import ClientError
from pysmartthings import (
Attribute,
Capability,
Device,
Scene,
SmartThings,
SmartThingsAuthenticationFailedError,
Status,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_loaded_integration
from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from .config_flow import SmartThingsFlowHandler # noqa: F401
from .const import (
CONF_APP_ID,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DATA_BROKERS,
DATA_MANAGER,
DOMAIN,
EVENT_BUTTON,
PLATFORMS,
SIGNAL_SMARTTHINGS_UPDATE,
TOKEN_REFRESH_INTERVAL,
)
from .smartapp import (
format_unique_id,
setup_smartapp,
setup_smartapp_endpoint,
smartapp_sync_subscriptions,
unload_smartapp_endpoint,
validate_installed_app,
validate_webhook_requirements,
)
from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@dataclass
class SmartThingsData:
"""Define an object to hold SmartThings data."""
devices: dict[str, FullDevice]
scenes: dict[str, Scene]
client: SmartThings
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the SmartThings platform."""
await setup_smartapp_endpoint(hass, False)
return True
@dataclass
class FullDevice:
"""Define an object to hold device data."""
device: Device
status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]]
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle migration of a previous version config entry.
type SmartThingsConfigEntry = ConfigEntry[SmartThingsData]
A config entry created under a previous version must go through the
integration setup again so we can properly retrieve the needed data
elements. Force this by removing the entry and triggering a new flow.
"""
# Remove the entry which will invoke the callback to delete the app.
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
# only create new flow if there isn't a pending one for SmartThings.
if not hass.config_entries.flow.async_progress_by_handler(DOMAIN):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}
)
)
# Return False because it could not be migrated.
return False
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) -> bool:
"""Initialize config entry which represents an installed SmartApp."""
# For backwards compat
if entry.unique_id is None:
hass.config_entries.async_update_entry(
entry,
unique_id=format_unique_id(
entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID]
),
)
if not validate_webhook_requirements(hass):
_LOGGER.warning(
"The 'base_url' of the 'http' integration must be configured and start with"
" 'https://'"
)
return False
api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
# Ensure platform modules are loaded since the DeviceBroker will
# import them below and we want them to be cached ahead of time
# so the integration does not do blocking I/O in the event loop
# to import the modules.
await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS)
# The oauth smartthings entry will have a token, older ones are version 3
# after migration but still require reauthentication
if CONF_TOKEN not in entry.data:
raise ConfigEntryAuthFailed("Config entry missing token")
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
try:
# See if the app is already setup. This occurs when there are
# installs in multiple SmartThings locations (valid use-case)
manager = hass.data[DOMAIN][DATA_MANAGER]
smart_app = manager.smartapps.get(entry.data[CONF_APP_ID])
if not smart_app:
# Validate and setup the app.
app = await api.app(entry.data[CONF_APP_ID])
smart_app = setup_smartapp(hass, app)
await session.async_ensure_token_valid()
except ClientError as err:
raise ConfigEntryNotReady from err
# Validate and retrieve the installed app.
installed_app = await validate_installed_app(
api, entry.data[CONF_INSTALLED_APP_ID]
)
client = SmartThings(session=async_get_clientsession(hass))
# Get scenes
scenes = await async_get_entry_scenes(entry, api)
async def _refresh_token() -> str:
await session.async_ensure_token_valid()
token = session.token[CONF_ACCESS_TOKEN]
if TYPE_CHECKING:
assert isinstance(token, str)
return token
# Get SmartApp token to sync subscriptions
token = await api.generate_tokens(
entry.data[CONF_CLIENT_ID],
entry.data[CONF_CLIENT_SECRET],
entry.data[CONF_REFRESH_TOKEN],
)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token}
)
client.refresh_token_function = _refresh_token
# Get devices and their current status
devices = await api.devices(location_ids=[installed_app.location_id])
device_status: dict[str, FullDevice] = {}
try:
devices = await client.get_devices()
for device in devices:
status = process_status(await client.get_device_status(device.device_id))
device_status[device.device_id] = FullDevice(device=device, status=status)
except SmartThingsAuthenticationFailedError as err:
raise ConfigEntryAuthFailed from err
async def retrieve_device_status(device):
try:
await device.status.refresh()
except ClientResponseError:
_LOGGER.debug(
(
"Unable to update status for device: %s (%s), the device will"
" be excluded"
),
device.label,
device.device_id,
exc_info=True,
)
devices.remove(device)
scenes = {
scene.scene_id: scene
for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID])
}
await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy()))
entry.runtime_data = SmartThingsData(
devices={
device_id: device
for device_id, device in device_status.items()
if MAIN in device.status
},
client=client,
scenes=scenes,
)
# Sync device subscriptions
await smartapp_sync_subscriptions(
hass,
token.access_token,
installed_app.location_id,
installed_app.installed_app_id,
devices,
)
# Setup device broker
with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
# DeviceBroker has a side effect of importing platform
# modules when its created. In the future this should be
# refactored to not do this.
broker = await hass.async_add_import_executor_job(
DeviceBroker, hass, entry, token, smart_app, devices, scenes
)
broker.connect()
hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
except APIInvalidGrant as ex:
raise ConfigEntryAuthFailed from ex
except ClientResponseError as ex:
if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
raise ConfigEntryError(
"The access token is no longer valid. Please remove the integration and set up again."
) from ex
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex
except (ClientConnectionError, RuntimeWarning) as ex:
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex
entry.async_create_background_task(
hass,
client.subscribe(
entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID]
),
"smartthings_webhook",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_get_entry_scenes(entry: ConfigEntry, api):
"""Get the scenes within an integration."""
try:
return await api.scenes(location_id=entry.data[CONF_LOCATION_ID])
except ClientResponseError as ex:
if ex.status == HTTPStatus.FORBIDDEN:
_LOGGER.exception(
(
"Unable to load scenes for configuration entry '%s' because the"
" access token does not have the required access"
),
entry.title,
)
else:
raise
return []
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: SmartThingsConfigEntry
) -> bool:
"""Unload a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None)
if broker:
broker.disconnect()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Perform clean-up when entry is being removed."""
api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle config entry migration."""
# Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error.
installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
try:
await api.delete_installed_app(installed_app_id)
except ClientResponseError as ex:
if ex.status == HTTPStatus.FORBIDDEN:
_LOGGER.debug(
"Installed app %s has already been removed",
installed_app_id,
exc_info=True,
)
else:
raise
_LOGGER.debug("Removed installed app %s", installed_app_id)
# Remove the app if not referenced by other entries, which if already
# removed raises a HTTPStatus.FORBIDDEN error.
all_entries = hass.config_entries.async_entries(DOMAIN)
app_id = entry.data[CONF_APP_ID]
app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id)
if app_count > 1:
_LOGGER.debug(
(
"App %s was not removed because it is in use by other configuration"
" entries"
),
app_id,
)
return
# Remove the app
try:
await api.delete_app(app_id)
except ClientResponseError as ex:
if ex.status == HTTPStatus.FORBIDDEN:
_LOGGER.debug("App %s has already been removed", app_id, exc_info=True)
else:
raise
_LOGGER.debug("Removed app %s", app_id)
if len(all_entries) == 1:
await unload_smartapp_endpoint(hass)
class DeviceBroker:
"""Manages an individual SmartThings config entry."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
token,
smart_app,
devices: Iterable,
scenes: Iterable,
) -> None:
"""Create a new instance of the DeviceBroker."""
self._hass = hass
self._entry = entry
self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
self._smart_app = smart_app
self._token = token
self._event_disconnect = None
self._regenerate_token_remove = None
self._assignments = self._assign_capabilities(devices)
self.devices = {device.device_id: device for device in devices}
self.scenes = {scene.scene_id: scene for scene in scenes}
def _assign_capabilities(self, devices: Iterable):
"""Assign platforms to capabilities."""
assignments = {}
for device in devices:
capabilities = device.capabilities.copy()
slots = {}
for platform in PLATFORMS:
platform_module = importlib.import_module(
f".{platform}", self.__module__
)
if not hasattr(platform_module, "get_capabilities"):
continue
assigned = platform_module.get_capabilities(capabilities)
if not assigned:
continue
# Draw-down capabilities and set slot assignment
for capability in assigned:
if capability not in capabilities:
continue
capabilities.remove(capability)
slots[capability] = platform
assignments[device.device_id] = slots
return assignments
def connect(self):
"""Connect handlers/listeners for device/lifecycle events."""
# Setup interval to regenerate the refresh token on a periodic basis.
# Tokens expire in 30 days and once expired, cannot be recovered.
async def regenerate_refresh_token(now):
"""Generate a new refresh token and update the config entry."""
await self._token.refresh(
self._entry.data[CONF_CLIENT_ID],
self._entry.data[CONF_CLIENT_SECRET],
)
self._hass.config_entries.async_update_entry(
self._entry,
data={
**self._entry.data,
CONF_REFRESH_TOKEN: self._token.refresh_token,
},
)
_LOGGER.debug(
"Regenerated refresh token for installed app: %s",
self._installed_app_id,
)
self._regenerate_token_remove = async_track_time_interval(
self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL
if entry.version < 3:
# We keep the old data around, so we can use that to clean up the webhook in the future
hass.config_entries.async_update_entry(
entry, version=3, data={OLD_DATA: dict(entry.data)}
)
# Connect handler to incoming device events
self._event_disconnect = self._smart_app.connect_event(self._event_handler)
return True
def disconnect(self):
"""Disconnects handlers/listeners for device/lifecycle events."""
if self._regenerate_token_remove:
self._regenerate_token_remove()
if self._event_disconnect:
self._event_disconnect()
def get_assigned(self, device_id: str, platform: str):
"""Get the capabilities assigned to the platform."""
slots = self._assignments.get(device_id, {})
return [key for key, value in slots.items() if value == platform]
def any_assigned(self, device_id: str, platform: str):
"""Return True if the platform has any assigned capabilities."""
slots = self._assignments.get(device_id, {})
return any(value for value in slots.values() if value == platform)
async def _event_handler(self, req, resp, app):
"""Broker for incoming events."""
# Do not process events received from a different installed app
# under the same parent SmartApp (valid use-scenario)
if req.installed_app_id != self._installed_app_id:
return
updated_devices = set()
for evt in req.events:
if evt.event_type != EVENT_TYPE_DEVICE:
continue
if not (device := self.devices.get(evt.device_id)):
continue
device.status.apply_attribute_update(
evt.component_id,
evt.capability,
evt.attribute,
evt.value,
data=evt.data,
)
# Fire events for buttons
if (
evt.capability == Capability.button
and evt.attribute == Attribute.button
):
data = {
"component_id": evt.component_id,
"device_id": evt.device_id,
"location_id": evt.location_id,
"value": evt.value,
"name": device.label,
"data": evt.data,
}
self._hass.bus.async_fire(EVENT_BUTTON, data)
_LOGGER.debug("Fired button event: %s", data)
else:
data = {
"location_id": evt.location_id,
"device_id": evt.device_id,
"component_id": evt.component_id,
"capability": evt.capability,
"attribute": evt.attribute,
"value": evt.value,
"data": evt.data,
}
_LOGGER.debug("Push update received: %s", data)
updated_devices.add(device.device_id)
async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices)
def process_status(
status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]],
) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]:
"""Remove disabled capabilities from status."""
if (main_component := status.get("main")) is None or (
disabled_capabilities_capability := main_component.get(
Capability.CUSTOM_DISABLED_CAPABILITIES
)
) is None:
return status
disabled_capabilities = cast(
list[Capability | str],
disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value,
)
for capability in disabled_capabilities:
# We still need to make sure the climate entity can work without this capability
if (
capability in main_component
and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL
):
del main_component[capability]
return status

View File

@@ -0,0 +1,64 @@
"""Application credentials platform for SmartThings."""
from json import JSONDecodeError
import logging
from typing import cast
from aiohttp import BasicAuth, ClientError
from homeassistant.components.application_credentials import (
AuthImplementation,
AuthorizationServer,
ClientCredential,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> AbstractOAuth2Implementation:
"""Return auth implementation."""
return SmartThingsOAuth2Implementation(
hass,
DOMAIN,
credential,
authorization_server=AuthorizationServer(
authorize_url="https://api.smartthings.com/oauth/authorize",
token_url="https://auth-global.api.smartthings.com/oauth/token",
),
)
class SmartThingsOAuth2Implementation(AuthImplementation):
"""Oauth2 implementation that only uses the external url."""
async def _token_request(self, data: dict) -> dict:
"""Make a token request."""
session = async_get_clientsession(self.hass)
resp = await session.post(
self.token_url,
data=data,
auth=BasicAuth(self.client_id, self.client_secret),
)
if resp.status >= 400:
try:
error_response = await resp.json()
except (ClientError, JSONDecodeError):
error_response = {}
error_code = error_response.get("error", "unknown")
error_description = error_response.get("error_description", "unknown error")
_LOGGER.error(
"Token request for %s failed (%s): %s",
self.domain,
error_code,
error_description,
)
resp.raise_for_status()
return cast(dict, await resp.json())

View File

@@ -2,84 +2,146 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from pysmartthings import Attribute, Capability
from pysmartthings import Attribute, Capability, SmartThings
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
CAPABILITY_TO_ATTRIB = {
Capability.acceleration_sensor: Attribute.acceleration,
Capability.contact_sensor: Attribute.contact,
Capability.filter_status: Attribute.filter_status,
Capability.motion_sensor: Attribute.motion,
Capability.presence_sensor: Attribute.presence,
Capability.sound_sensor: Attribute.sound,
Capability.tamper_alert: Attribute.tamper,
Capability.valve: Attribute.valve,
Capability.water_sensor: Attribute.water,
}
ATTRIB_TO_CLASS = {
Attribute.acceleration: BinarySensorDeviceClass.MOVING,
Attribute.contact: BinarySensorDeviceClass.OPENING,
Attribute.filter_status: BinarySensorDeviceClass.PROBLEM,
Attribute.motion: BinarySensorDeviceClass.MOTION,
Attribute.presence: BinarySensorDeviceClass.PRESENCE,
Attribute.sound: BinarySensorDeviceClass.SOUND,
Attribute.tamper: BinarySensorDeviceClass.PROBLEM,
Attribute.valve: BinarySensorDeviceClass.OPENING,
Attribute.water: BinarySensorDeviceClass.MOISTURE,
}
ATTRIB_TO_ENTTIY_CATEGORY = {
Attribute.tamper: EntityCategory.DIAGNOSTIC,
@dataclass(frozen=True, kw_only=True)
class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe a SmartThings binary sensor entity."""
is_on_key: str
CAPABILITY_TO_SENSORS: dict[
Capability, dict[Attribute, SmartThingsBinarySensorEntityDescription]
] = {
Capability.ACCELERATION_SENSOR: {
Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription(
key=Attribute.ACCELERATION,
translation_key="acceleration",
device_class=BinarySensorDeviceClass.MOVING,
is_on_key="active",
)
},
Capability.CONTACT_SENSOR: {
Attribute.CONTACT: SmartThingsBinarySensorEntityDescription(
key=Attribute.CONTACT,
device_class=BinarySensorDeviceClass.DOOR,
is_on_key="open",
)
},
Capability.FILTER_STATUS: {
Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription(
key=Attribute.FILTER_STATUS,
translation_key="filter_status",
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_key="replace",
)
},
Capability.MOTION_SENSOR: {
Attribute.MOTION: SmartThingsBinarySensorEntityDescription(
key=Attribute.MOTION,
device_class=BinarySensorDeviceClass.MOTION,
is_on_key="active",
)
},
Capability.PRESENCE_SENSOR: {
Attribute.PRESENCE: SmartThingsBinarySensorEntityDescription(
key=Attribute.PRESENCE,
device_class=BinarySensorDeviceClass.PRESENCE,
is_on_key="present",
)
},
Capability.SOUND_SENSOR: {
Attribute.SOUND: SmartThingsBinarySensorEntityDescription(
key=Attribute.SOUND,
device_class=BinarySensorDeviceClass.SOUND,
is_on_key="detected",
)
},
Capability.TAMPER_ALERT: {
Attribute.TAMPER: SmartThingsBinarySensorEntityDescription(
key=Attribute.TAMPER,
device_class=BinarySensorDeviceClass.TAMPER,
is_on_key="detected",
entity_category=EntityCategory.DIAGNOSTIC,
)
},
Capability.VALVE: {
Attribute.VALVE: SmartThingsBinarySensorEntityDescription(
key=Attribute.VALVE,
translation_key="valve",
device_class=BinarySensorDeviceClass.OPENING,
is_on_key="open",
)
},
Capability.WATER_SENSOR: {
Attribute.WATER: SmartThingsBinarySensorEntityDescription(
key=Attribute.WATER,
device_class=BinarySensorDeviceClass.MOISTURE,
is_on_key="wet",
)
},
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add binary sensors for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
sensors = []
for device in broker.devices.values():
for capability in broker.get_assigned(device.device_id, "binary_sensor"):
attrib = CAPABILITY_TO_ATTRIB[capability]
sensors.append(SmartThingsBinarySensor(device, attrib))
async_add_entities(sensors)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
return [
capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities
]
entry_data = entry.runtime_data
async_add_entities(
SmartThingsBinarySensor(
entry_data.client, device, description, capability, attribute
)
for device in entry_data.devices.values()
for capability, attribute_map in CAPABILITY_TO_SENSORS.items()
if capability in device.status[MAIN]
for attribute, description in attribute_map.items()
)
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
"""Define a SmartThings Binary Sensor."""
def __init__(self, device, attribute):
entity_description: SmartThingsBinarySensorEntityDescription
def __init__(
self,
client: SmartThings,
device: FullDevice,
entity_description: SmartThingsBinarySensorEntityDescription,
capability: Capability,
attribute: Attribute,
) -> None:
"""Init the class."""
super().__init__(device)
super().__init__(client, device, {capability})
self._attribute = attribute
self._attr_name = f"{device.label} {attribute}"
self._attr_unique_id = f"{device.device_id}.{attribute}"
self._attr_device_class = ATTRIB_TO_CLASS[attribute]
self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute)
self.capability = capability
self.entity_description = entity_description
self._attr_unique_id = f"{device.device.device_id}.{attribute}"
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._device.status.is_on(self._attribute)
return (
self.get_attribute_value(self.capability, self._attribute)
== self.entity_description.is_on_key
)

View File

@@ -3,17 +3,15 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterable, Sequence
import logging
from typing import Any
from pysmartthings import Attribute, Capability
from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
DOMAIN as CLIMATE_DOMAIN,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
@@ -23,12 +21,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
ATTR_OPERATION_STATE = "operation_state"
@@ -97,124 +95,108 @@ UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT}
_LOGGER = logging.getLogger(__name__)
AC_CAPABILITIES = [
Capability.AIR_CONDITIONER_MODE,
Capability.AIR_CONDITIONER_FAN_MODE,
Capability.SWITCH,
Capability.TEMPERATURE_MEASUREMENT,
Capability.THERMOSTAT_COOLING_SETPOINT,
]
THERMOSTAT_CAPABILITIES = [
Capability.TEMPERATURE_MEASUREMENT,
Capability.THERMOSTAT_HEATING_SETPOINT,
Capability.THERMOSTAT_MODE,
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add climate entities for a config entry."""
ac_capabilities = [
Capability.air_conditioner_mode,
Capability.air_conditioner_fan_mode,
Capability.switch,
Capability.temperature_measurement,
Capability.thermostat_cooling_setpoint,
entry_data = entry.runtime_data
entities: list[ClimateEntity] = [
SmartThingsAirConditioner(entry_data.client, device)
for device in entry_data.devices.values()
if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES)
]
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
entities: list[ClimateEntity] = []
for device in broker.devices.values():
if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN):
continue
if all(capability in device.capabilities for capability in ac_capabilities):
entities.append(SmartThingsAirConditioner(device))
else:
entities.append(SmartThingsThermostat(device))
async_add_entities(entities, True)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
supported = [
Capability.air_conditioner_mode,
Capability.demand_response_load_control,
Capability.air_conditioner_fan_mode,
Capability.switch,
Capability.thermostat,
Capability.thermostat_cooling_setpoint,
Capability.thermostat_fan_mode,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode,
Capability.thermostat_operating_state,
]
# Can have this legacy/deprecated capability
if Capability.thermostat in capabilities:
return supported
# Or must have all of these thermostat capabilities
thermostat_capabilities = [
Capability.temperature_measurement,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode,
]
if all(capability in capabilities for capability in thermostat_capabilities):
return supported
# Or must have all of these A/C capabilities
ac_capabilities = [
Capability.air_conditioner_mode,
Capability.air_conditioner_fan_mode,
Capability.switch,
Capability.temperature_measurement,
Capability.thermostat_cooling_setpoint,
]
if all(capability in capabilities for capability in ac_capabilities):
return supported
return None
entities.extend(
SmartThingsThermostat(entry_data.client, device)
for device in entry_data.devices.values()
if all(
capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES
)
)
async_add_entities(entities)
class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings climate entities."""
def __init__(self, device):
"""Init the class."""
super().__init__(device)
self._attr_supported_features = self._determine_features()
self._hvac_mode = None
self._hvac_modes = None
_attr_name = None
def _determine_features(self):
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
super().__init__(
client,
device,
{
Capability.THERMOSTAT_FAN_MODE,
Capability.THERMOSTAT_MODE,
Capability.TEMPERATURE_MEASUREMENT,
Capability.THERMOSTAT_HEATING_SETPOINT,
Capability.THERMOSTAT_OPERATING_STATE,
Capability.THERMOSTAT_COOLING_SETPOINT,
Capability.RELATIVE_HUMIDITY_MEASUREMENT,
},
)
self._attr_supported_features = self._determine_features()
def _determine_features(self) -> ClimateEntityFeature:
flags = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
if self._device.get_capability(
Capability.thermostat_fan_mode, Capability.thermostat
if self.get_attribute_value(
Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE
):
flags |= ClimateEntityFeature.FAN_MODE
return flags
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await self._device.set_thermostat_fan_mode(fan_mode, set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
await self.execute_device_command(
Capability.THERMOSTAT_FAN_MODE,
Command.SET_THERMOSTAT_FAN_MODE,
argument=fan_mode,
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
mode = STATE_TO_MODE[hvac_mode]
await self._device.set_thermostat_mode(mode, set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
await self.execute_device_command(
Capability.THERMOSTAT_MODE,
Command.SET_THERMOSTAT_MODE,
argument=STATE_TO_MODE[hvac_mode],
)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new operation mode and target temperatures."""
hvac_mode = self.hvac_mode
# Operation state
if operation_state := kwargs.get(ATTR_HVAC_MODE):
mode = STATE_TO_MODE[operation_state]
await self._device.set_thermostat_mode(mode, set_status=True)
await self.async_update()
await self.async_set_hvac_mode(operation_state)
hvac_mode = operation_state
# Heat/cool setpoint
heating_setpoint = None
cooling_setpoint = None
if self.hvac_mode == HVACMode.HEAT:
if hvac_mode == HVACMode.HEAT:
heating_setpoint = kwargs.get(ATTR_TEMPERATURE)
elif self.hvac_mode == HVACMode.COOL:
elif hvac_mode == HVACMode.COOL:
cooling_setpoint = kwargs.get(ATTR_TEMPERATURE)
else:
heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW)
@@ -222,135 +204,146 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
tasks = []
if heating_setpoint is not None:
tasks.append(
self._device.set_heating_setpoint(
round(heating_setpoint, 3), set_status=True
self.execute_device_command(
Capability.THERMOSTAT_HEATING_SETPOINT,
Command.SET_HEATING_SETPOINT,
argument=round(heating_setpoint, 3),
)
)
if cooling_setpoint is not None:
tasks.append(
self._device.set_cooling_setpoint(
round(cooling_setpoint, 3), set_status=True
self.execute_device_command(
Capability.THERMOSTAT_COOLING_SETPOINT,
Command.SET_COOLING_SETPOINT,
argument=round(cooling_setpoint, 3),
)
)
await asyncio.gather(*tasks)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
async def async_update(self) -> None:
"""Update the attributes of the climate device."""
thermostat_mode = self._device.status.thermostat_mode
self._hvac_mode = MODE_TO_STATE.get(thermostat_mode)
if self._hvac_mode is None:
_LOGGER.debug(
"Device %s (%s) returned an invalid hvac mode: %s",
self._device.label,
self._device.device_id,
thermostat_mode,
)
modes = set()
supported_modes = self._device.status.supported_thermostat_modes
if isinstance(supported_modes, Iterable):
for mode in supported_modes:
if (state := MODE_TO_STATE.get(mode)) is not None:
modes.add(state)
else:
_LOGGER.debug(
(
"Device %s (%s) returned an invalid supported thermostat"
" mode: %s"
),
self._device.label,
self._device.device_id,
mode,
)
else:
_LOGGER.debug(
"Device %s (%s) returned invalid supported thermostat modes: %s",
self._device.label,
self._device.device_id,
supported_modes,
)
self._hvac_modes = list(modes)
@property
def current_humidity(self):
def current_humidity(self) -> float | None:
"""Return the current humidity."""
return self._device.status.humidity
if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT):
return self.get_attribute_value(
Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY
)
return None
@property
def current_temperature(self):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._device.status.temperature
return self.get_attribute_value(
Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE
)
@property
def fan_mode(self):
def fan_mode(self) -> str | None:
"""Return the fan setting."""
return self._device.status.thermostat_fan_mode
return self.get_attribute_value(
Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE
)
@property
def fan_modes(self):
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
return self._device.status.supported_thermostat_fan_modes
return self.get_attribute_value(
Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES
)
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
return OPERATING_STATE_TO_ACTION.get(
self._device.status.thermostat_operating_state
self.get_attribute_value(
Capability.THERMOSTAT_OPERATING_STATE,
Attribute.THERMOSTAT_OPERATING_STATE,
)
)
@property
def hvac_mode(self) -> HVACMode:
def hvac_mode(self) -> HVACMode | None:
"""Return current operation ie. heat, cool, idle."""
return self._hvac_mode
return MODE_TO_STATE.get(
self.get_attribute_value(
Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE
)
)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available operation modes."""
return self._hvac_modes
return [
state
for mode in self.get_attribute_value(
Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES
)
if (state := AC_MODE_TO_STATE.get(mode)) is not None
]
@property
def target_temperature(self):
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if self.hvac_mode == HVACMode.COOL:
return self._device.status.cooling_setpoint
return self.get_attribute_value(
Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
)
if self.hvac_mode == HVACMode.HEAT:
return self._device.status.heating_setpoint
return self.get_attribute_value(
Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT
)
return None
@property
def target_temperature_high(self):
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
if self.hvac_mode == HVACMode.HEAT_COOL:
return self._device.status.cooling_setpoint
return self.get_attribute_value(
Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
)
return None
@property
def target_temperature_low(self):
"""Return the lowbound target temperature we try to reach."""
if self.hvac_mode == HVACMode.HEAT_COOL:
return self._device.status.heating_setpoint
return self.get_attribute_value(
Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT
)
return None
@property
def temperature_unit(self):
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit)
unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
Attribute.TEMPERATURE
].unit
assert unit
return UNIT_MAP[unit]
class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings Air Conditioner."""
_hvac_modes: list[HVACMode]
_attr_name = None
_attr_preset_mode = None
def __init__(self, device) -> None:
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
super().__init__(device)
self._hvac_modes = []
self._attr_preset_mode = None
super().__init__(
client,
device,
{
Capability.AIR_CONDITIONER_MODE,
Capability.SWITCH,
Capability.FAN_OSCILLATION_MODE,
Capability.AIR_CONDITIONER_FAN_MODE,
Capability.THERMOSTAT_COOLING_SETPOINT,
Capability.TEMPERATURE_MEASUREMENT,
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Capability.DEMAND_RESPONSE_LOAD_CONTROL,
},
)
self._attr_hvac_modes = self._determine_hvac_modes()
self._attr_preset_modes = self._determine_preset_modes()
self._attr_swing_modes = self._determine_swing_modes()
self._attr_supported_features = self._determine_supported_features()
@@ -362,7 +355,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
if self._device.get_capability(Capability.fan_oscillation_mode):
if self.supports_capability(Capability.FAN_OSCILLATION_MODE):
features |= ClimateEntityFeature.SWING_MODE
if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0:
features |= ClimateEntityFeature.PRESET_MODE
@@ -370,14 +363,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await self._device.set_fan_mode(fan_mode, set_status=True)
# setting the fan must reset the preset mode (it deactivates the windFree function)
self._attr_preset_mode = None
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(
Capability.AIR_CONDITIONER_FAN_MODE,
Command.SET_FAN_MODE,
argument=fan_mode,
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
@@ -386,23 +376,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
return
tasks = []
# Turn on the device if it's off before setting mode.
if not self._device.status.switch:
tasks.append(self._device.switch_on(set_status=True))
if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off":
tasks.append(self.async_turn_on())
mode = STATE_TO_AC_MODE[hvac_mode]
# If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind"
# The conversion make the mode change working
# The conversion is made only for device that wrongly has capability "wind" instead "fan_only"
if hvac_mode == HVACMode.FAN_ONLY:
supported_modes = self._device.status.supported_ac_modes
if WIND in supported_modes:
if WIND in self.get_attribute_value(
Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
):
mode = WIND
tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True))
tasks.append(
self.execute_device_command(
Capability.AIR_CONDITIONER_MODE,
Command.SET_AIR_CONDITIONER_MODE,
argument=mode,
)
)
await asyncio.gather(*tasks)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -410,53 +404,44 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
# operation mode
if operation_mode := kwargs.get(ATTR_HVAC_MODE):
if operation_mode == HVACMode.OFF:
tasks.append(self._device.switch_off(set_status=True))
tasks.append(self.async_turn_off())
else:
if not self._device.status.switch:
tasks.append(self._device.switch_on(set_status=True))
if (
self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
== "off"
):
tasks.append(self.async_turn_on())
tasks.append(self.async_set_hvac_mode(operation_mode))
# temperature
tasks.append(
self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True)
self.execute_device_command(
Capability.THERMOSTAT_COOLING_SETPOINT,
Command.SET_COOLING_SETPOINT,
argument=kwargs[ATTR_TEMPERATURE],
)
)
await asyncio.gather(*tasks)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
async def async_turn_on(self) -> None:
"""Turn device on."""
await self._device.switch_on(set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(
Capability.SWITCH,
Command.ON,
)
async def async_turn_off(self) -> None:
"""Turn device off."""
await self._device.switch_off(set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the calculated fields of the AC."""
modes = {HVACMode.OFF}
for mode in self._device.status.supported_ac_modes:
if (state := AC_MODE_TO_STATE.get(mode)) is not None:
modes.add(state)
else:
_LOGGER.debug(
"Device %s (%s) returned an invalid supported AC mode: %s",
self._device.label,
self._device.device_id,
mode,
)
self._hvac_modes = list(modes)
await self.execute_device_command(
Capability.SWITCH,
Command.OFF,
)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._device.status.temperature
return self.get_attribute_value(
Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -465,100 +450,114 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
Include attributes from the Demand Response Load Control (drlc)
and Power Consumption capabilities.
"""
attributes = [
"drlc_status_duration",
"drlc_status_level",
"drlc_status_start",
"drlc_status_override",
]
state_attributes = {}
for attribute in attributes:
value = getattr(self._device.status, attribute)
if value is not None:
state_attributes[attribute] = value
return state_attributes
drlc_status = self.get_attribute_value(
Capability.DEMAND_RESPONSE_LOAD_CONTROL,
Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS,
)
return {
"drlc_status_duration": drlc_status["duration"],
"drlc_status_level": drlc_status["drlcLevel"],
"drlc_status_start": drlc_status["start"],
"drlc_status_override": drlc_status["override"],
}
@property
def fan_mode(self) -> str:
"""Return the fan setting."""
return self._device.status.fan_mode
return self.get_attribute_value(
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
)
@property
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
return self._device.status.supported_ac_fan_modes
return self.get_attribute_value(
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation ie. heat, cool, idle."""
if not self._device.status.switch:
if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off":
return HVACMode.OFF
return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available operation modes."""
return self._hvac_modes
return AC_MODE_TO_STATE.get(
self.get_attribute_value(
Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE
)
)
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
return self._device.status.cooling_setpoint
return self.get_attribute_value(
Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit]
unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
Attribute.TEMPERATURE
].unit
assert unit
return UNIT_MAP[unit]
def _determine_swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes."""
supported_swings = None
supported_modes = self._device.status.attributes[
Attribute.supported_fan_oscillation_modes
][0]
if supported_modes is not None:
supported_swings = [
FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes
]
return supported_swings
if (
supported_modes := self.get_attribute_value(
Capability.FAN_OSCILLATION_MODE,
Attribute.SUPPORTED_FAN_OSCILLATION_MODES,
)
) is None:
return None
return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes]
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set swing mode."""
fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode]
await self._device.set_fan_oscillation_mode(fan_oscillation_mode)
# setting the fan must reset the preset mode (it deactivates the windFree function)
self._attr_preset_mode = None
self.async_schedule_update_ha_state(True)
await self.execute_device_command(
Capability.FAN_OSCILLATION_MODE,
Command.SET_FAN_OSCILLATION_MODE,
argument=SWING_TO_FAN_OSCILLATION[swing_mode],
)
@property
def swing_mode(self) -> str:
"""Return the swing setting."""
return FAN_OSCILLATION_TO_SWING.get(
self._device.status.fan_oscillation_mode, SWING_OFF
self.get_attribute_value(
Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE
),
SWING_OFF,
)
def _determine_preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
supported_modes: list | None = self._device.status.attributes[
"supportedAcOptionalMode"
].value
if supported_modes and WINDFREE in supported_modes:
return [WINDFREE]
if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE):
supported_modes = self.get_attribute_value(
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Attribute.SUPPORTED_AC_OPTIONAL_MODE,
)
if supported_modes and WINDFREE in supported_modes:
return [WINDFREE]
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set special modes (currently only windFree is supported)."""
result = await self._device.command(
"main",
"custom.airConditionerOptionalMode",
"setAcOptionalMode",
[preset_mode],
await self.execute_device_command(
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Command.SET_AC_OPTIONAL_MODE,
argument=preset_mode,
)
if result:
self._device.status.update_attribute_value("acOptionalMode", preset_mode)
self._attr_preset_mode = preset_mode
self.async_write_ha_state()
def _determine_hvac_modes(self) -> list[HVACMode]:
"""Determine the supported HVAC modes."""
modes = [HVACMode.OFF]
modes.extend(
state
for mode in self.get_attribute_value(
Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
)
if (state := AC_MODE_TO_STATE.get(mode)) is not None
)
return modes

View File

@@ -1,298 +1,83 @@
"""Config flow to configure SmartThings."""
from collections.abc import Mapping
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientResponseError
from pysmartthings import APIResponseError, AppOAuth, SmartThings
from pysmartthings.installedapp import format_install_url
import voluptuous as vol
from pysmartthings import SmartThings
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import (
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DOMAIN,
VAL_UID_MATCHER,
)
from .smartapp import (
create_app,
find_app,
format_unique_id,
get_webhook_url,
setup_smartapp,
setup_smartapp_endpoint,
update_app,
validate_webhook_requirements,
)
from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES
_LOGGER = logging.getLogger(__name__)
class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN):
class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle configuration of SmartThings integrations."""
VERSION = 2
VERSION = 3
DOMAIN = DOMAIN
api: SmartThings
app_id: str
location_id: str
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
def __init__(self) -> None:
"""Create a new instance of the flow handler."""
self.access_token: str | None = None
self.oauth_client_secret = None
self.oauth_client_id = None
self.installed_app_id = None
self.refresh_token = None
self.endpoints_initialized = False
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(SCOPES)}
async def async_step_import(self, import_data: None) -> ConfigFlowResult:
"""Occurs when a previously entry setup fails and is re-initiated."""
return await self.async_step_user(import_data)
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for SmartThings."""
client = SmartThings(session=async_get_clientsession(self.hass))
client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
locations = await client.get_locations()
location = locations[0]
# We pick to use the location id as unique id rather than the installed app id
# as the installed app id could change with the right settings in the SmartApp
# or the app used to sign in changed for any reason.
await self.async_set_unique_id(location.location_id)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Validate and confirm webhook setup."""
if not self.endpoints_initialized:
self.endpoints_initialized = True
await setup_smartapp_endpoint(
self.hass, len(self._async_current_entries()) == 0
return self.async_create_entry(
title=location.name,
data={**data, CONF_LOCATION_ID: location.location_id},
)
webhook_url = get_webhook_url(self.hass)
# Abort if the webhook is invalid
if not validate_webhook_requirements(self.hass):
return self.async_abort(
reason="invalid_webhook_url",
description_placeholders={
"webhook_url": webhook_url,
"component_url": (
"https://www.home-assistant.io/integrations/smartthings/"
),
if (entry := self._get_reauth_entry()) and CONF_TOKEN not in entry.data:
if entry.data[OLD_DATA][CONF_LOCATION_ID] != location.location_id:
return self.async_abort(reason="reauth_location_mismatch")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={
**data,
CONF_LOCATION_ID: location.location_id,
},
unique_id=location.location_id,
)
# Show the confirmation
if user_input is None:
return self.async_show_form(
step_id="user",
description_placeholders={"webhook_url": webhook_url},
)
# Show the next screen
return await self.async_step_pat()
async def async_step_pat(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Get the Personal Access Token and validate it."""
errors: dict[str, str] = {}
if user_input is None or CONF_ACCESS_TOKEN not in user_input:
return self._show_step_pat(errors)
self.access_token = user_input[CONF_ACCESS_TOKEN]
# Ensure token is a UUID
if not VAL_UID_MATCHER.match(self.access_token):
errors[CONF_ACCESS_TOKEN] = "token_invalid_format"
return self._show_step_pat(errors)
# Setup end-point
self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
try:
app = await find_app(self.hass, self.api)
if app:
await app.refresh() # load all attributes
await update_app(self.hass, app)
# Find an existing entry to copy the oauth client
existing = next(
(
entry
for entry in self._async_current_entries()
if entry.data[CONF_APP_ID] == app.app_id
),
None,
)
if existing:
self.oauth_client_id = existing.data[CONF_CLIENT_ID]
self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET]
else:
# Get oauth client id/secret by regenerating it
app_oauth = AppOAuth(app.app_id)
app_oauth.client_name = APP_OAUTH_CLIENT_NAME
app_oauth.scope.extend(APP_OAUTH_SCOPES)
client = await self.api.generate_app_oauth(app_oauth)
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
else:
app, client = await create_app(self.hass, self.api)
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
setup_smartapp(self.hass, app)
self.app_id = app.app_id
except APIResponseError as ex:
if ex.is_target_error():
errors["base"] = "webhook_error"
else:
errors["base"] = "app_setup_error"
_LOGGER.exception(
"API error setting up the SmartApp: %s", ex.raw_error_response
)
return self._show_step_pat(errors)
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
_LOGGER.debug(
"Unauthorized error received setting up SmartApp", exc_info=True
)
elif ex.status == HTTPStatus.FORBIDDEN:
errors[CONF_ACCESS_TOKEN] = "token_forbidden"
_LOGGER.debug(
"Forbidden error received setting up SmartApp", exc_info=True
)
else:
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_pat(errors)
except Exception:
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_pat(errors)
return await self.async_step_select_location()
async def async_step_select_location(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Ask user to select the location to setup."""
if user_input is None or CONF_LOCATION_ID not in user_input:
# Get available locations
existing_locations = [
entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries()
]
locations = await self.api.locations()
locations_options = {
location.location_id: location.name
for location in locations
if location.location_id not in existing_locations
}
if not locations_options:
return self.async_abort(reason="no_available_locations")
return self.async_show_form(
step_id="select_location",
data_schema=vol.Schema(
{vol.Required(CONF_LOCATION_ID): vol.In(locations_options)}
),
)
self.location_id = user_input[CONF_LOCATION_ID]
await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id))
return await self.async_step_authorize()
async def async_step_authorize(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Wait for the user to authorize the app installation."""
user_input = {} if user_input is None else user_input
self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID)
self.refresh_token = user_input.get(CONF_REFRESH_TOKEN)
if self.installed_app_id is None:
# Launch the external setup URL
url = format_install_url(self.app_id, self.location_id)
return self.async_external_step(step_id="authorize", url=url)
next_step_id = "install"
if self.source == SOURCE_REAUTH:
next_step_id = "update"
return self.async_external_step_done(next_step_id=next_step_id)
def _show_step_pat(self, errors):
if self.access_token is None:
# Get the token from an existing entry to make it easier to setup multiple locations.
self.access_token = next(
(
entry.data.get(CONF_ACCESS_TOKEN)
for entry in self._async_current_entries()
),
None,
)
return self.async_show_form(
step_id="pat",
data_schema=vol.Schema(
{vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str}
),
errors=errors,
description_placeholders={
"token_url": "https://account.smartthings.com/tokens",
"component_url": (
"https://www.home-assistant.io/integrations/smartthings/"
),
},
self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=data
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
"""Perform reauth upon migration of old entries."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
self.app_id = self._get_reauth_entry().data[CONF_APP_ID]
self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID]
self._set_confirm_only()
return await self.async_step_authorize()
async def async_step_update(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
return await self.async_step_update_confirm()
async def async_step_update_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
if user_input is None:
self._set_confirm_only()
return self.async_show_form(step_id="update_confirm")
entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token}
)
async def async_step_install(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Create a config entry at completion of a flow and authorization of the app."""
data = {
CONF_ACCESS_TOKEN: self.access_token,
CONF_REFRESH_TOKEN: self.refresh_token,
CONF_CLIENT_ID: self.oauth_client_id,
CONF_CLIENT_SECRET: self.oauth_client_secret,
CONF_LOCATION_ID: self.location_id,
CONF_APP_ID: self.app_id,
CONF_INSTALLED_APP_ID: self.installed_app_id,
}
location = await self.api.location(data[CONF_LOCATION_ID])
return self.async_create_entry(title=location.name, data=data)
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()

View File

@@ -1,15 +1,23 @@
"""Constants used by the SmartThings component and platforms."""
from datetime import timedelta
import re
from homeassistant.const import Platform
DOMAIN = "smartthings"
APP_OAUTH_CLIENT_NAME = "Home Assistant"
APP_OAUTH_SCOPES = ["r:devices:*"]
APP_NAME_PREFIX = "homeassistant."
SCOPES = [
"r:devices:*",
"w:devices:*",
"x:devices:*",
"r:hubs:*",
"r:locations:*",
"w:locations:*",
"x:locations:*",
"r:scenes:*",
"x:scenes:*",
"r:rules:*",
"w:rules:*",
"r:installedapps",
"w:installedapps",
"sse",
]
CONF_APP_ID = "app_id"
CONF_CLOUDHOOK_URL = "cloudhook_url"
@@ -18,41 +26,5 @@ CONF_INSTANCE_ID = "instance_id"
CONF_LOCATION_ID = "location_id"
CONF_REFRESH_TOKEN = "refresh_token"
DATA_MANAGER = "manager"
DATA_BROKERS = "brokers"
EVENT_BUTTON = "smartthings.button"
SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update"
SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_"
SETTINGS_INSTANCE_ID = "hassInstanceId"
SUBSCRIPTION_WARNING_LIMIT = 40
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
# Ordered 'specific to least-specific platform' in order for capabilities
# to be drawn-down and represented by the most appropriate platform.
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
]
IGNORED_CAPABILITIES = [
"execute",
"healthCheck",
"ocf",
]
TOKEN_REFRESH_INTERVAL = timedelta(days=14)
VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$"
VAL_UID_MATCHER = re.compile(VAL_UID)
MAIN = "main"
OLD_DATA = "old_data"

View File

@@ -2,25 +2,23 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pysmartthings import Attribute, Capability
from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
CoverState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
VALUE_TO_STATE = {
@@ -32,114 +30,100 @@ VALUE_TO_STATE = {
"unknown": None,
}
CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add covers for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
entry_data = entry.runtime_data
async_add_entities(
[
SmartThingsCover(device)
for device in broker.devices.values()
if broker.any_assigned(device.device_id, COVER_DOMAIN)
],
True,
SmartThingsCover(entry_data.client, device, Capability(capability))
for device in entry_data.devices.values()
for capability in device.status[MAIN]
if capability in CAPABILITIES
)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
min_required = [
Capability.door_control,
Capability.garage_door_control,
Capability.window_shade,
]
# Must have one of the min_required
if any(capability in capabilities for capability in min_required):
# Return all capabilities supported/consumed
return [
*min_required,
Capability.battery,
Capability.switch_level,
Capability.window_shade_level,
]
return None
class SmartThingsCover(SmartThingsEntity, CoverEntity):
"""Define a SmartThings cover."""
def __init__(self, device):
_attr_name = None
_state: CoverState | None = None
def __init__(
self, client: SmartThings, device: FullDevice, capability: Capability
) -> None:
"""Initialize the cover class."""
super().__init__(device)
self._current_cover_position = None
self._state = None
super().__init__(
client,
device,
{
capability,
Capability.BATTERY,
Capability.WINDOW_SHADE_LEVEL,
Capability.SWITCH_LEVEL,
},
)
self.capability = capability
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
if (
Capability.switch_level in device.capabilities
or Capability.window_shade_level in device.capabilities
):
if self.supports_capability(Capability.WINDOW_SHADE_LEVEL):
self.level_capability = Capability.WINDOW_SHADE_LEVEL
self.level_command = Command.SET_SHADE_LEVEL
else:
self.level_capability = Capability.SWITCH_LEVEL
self.level_command = Command.SET_LEVEL
if self.supports_capability(
Capability.SWITCH_LEVEL
) or self.supports_capability(Capability.WINDOW_SHADE_LEVEL):
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
if Capability.door_control in device.capabilities:
if self.supports_capability(Capability.DOOR_CONTROL):
self._attr_device_class = CoverDeviceClass.DOOR
elif Capability.window_shade in device.capabilities:
elif self.supports_capability(Capability.WINDOW_SHADE):
self._attr_device_class = CoverDeviceClass.SHADE
elif Capability.garage_door_control in device.capabilities:
self._attr_device_class = CoverDeviceClass.GARAGE
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
# Same command for all 3 supported capabilities
await self._device.close(set_status=True)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
await self.execute_device_command(self.capability, Command.CLOSE)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
# Same for all capability types
await self._device.open(set_status=True)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
await self.execute_device_command(self.capability, Command.OPEN)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if not self.supported_features & CoverEntityFeature.SET_POSITION:
return
# Do not set_status=True as device will report progress.
if Capability.window_shade_level in self._device.capabilities:
await self._device.set_window_shade_level(
kwargs[ATTR_POSITION], set_status=False
)
else:
await self._device.set_level(kwargs[ATTR_POSITION], set_status=False)
await self.execute_device_command(
self.level_capability,
self.level_command,
argument=kwargs[ATTR_POSITION],
)
async def async_update(self) -> None:
def _update_attr(self) -> None:
"""Update the attrs of the cover."""
if Capability.door_control in self._device.capabilities:
self._state = VALUE_TO_STATE.get(self._device.status.door)
elif Capability.window_shade in self._device.capabilities:
self._state = VALUE_TO_STATE.get(self._device.status.window_shade)
elif Capability.garage_door_control in self._device.capabilities:
self._state = VALUE_TO_STATE.get(self._device.status.door)
attribute = {
Capability.WINDOW_SHADE: Attribute.WINDOW_SHADE,
Capability.DOOR_CONTROL: Attribute.DOOR,
}[self.capability]
self._state = VALUE_TO_STATE.get(
self.get_attribute_value(self.capability, attribute)
)
if Capability.window_shade_level in self._device.capabilities:
self._attr_current_cover_position = self._device.status.shade_level
elif Capability.switch_level in self._device.capabilities:
self._attr_current_cover_position = self._device.status.level
if self.supports_capability(Capability.SWITCH_LEVEL):
self._attr_current_cover_position = self.get_attribute_value(
Capability.SWITCH_LEVEL, Attribute.LEVEL
)
self._attr_extra_state_attributes = {}
battery = self._device.status.attributes[Attribute.battery].value
if battery is not None:
self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery
if self.supports_capability(Capability.BATTERY):
self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = (
self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY)
)
@property
def is_opening(self) -> bool:

View File

@@ -0,0 +1,50 @@
"""Diagnostics support for SmartThings."""
from __future__ import annotations
import asyncio
from dataclasses import asdict
from typing import Any
from pysmartthings import DeviceEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import SmartThingsConfigEntry
from .const import DOMAIN
EVENT_WAIT_TIME = 5
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device entry."""
device_id = next(
identifier for identifier in device.identifiers if identifier[0] == DOMAIN
)[0]
events: list[DeviceEvent] = []
def register_event(event: DeviceEvent) -> None:
events.append(event)
client = entry.runtime_data.client
listener = client.add_device_event_listener(device_id, register_event)
await asyncio.sleep(EVENT_WAIT_TIME)
listener()
device_status = await client.get_device_status(device_id)
status: dict[str, Any] = {}
for component, capabilities in device_status.items():
status[component] = {}
for capability, attributes in capabilities.items():
status[component][capability] = {}
for attribute, value in attributes.items():
status[component][capability][attribute] = asdict(value)
return {"events": [asdict(event) for event in events], "status": status}

View File

@@ -2,49 +2,109 @@
from __future__ import annotations
from pysmartthings.device import DeviceEntity
from typing import Any, cast
from pysmartthings import (
Attribute,
Capability,
Command,
DeviceEvent,
SmartThings,
Status,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE
from . import FullDevice
from .const import DOMAIN, MAIN
class SmartThingsEntity(Entity):
"""Defines a SmartThings entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, device: DeviceEntity) -> None:
def __init__(
self, client: SmartThings, device: FullDevice, capabilities: set[Capability]
) -> None:
"""Initialize the instance."""
self._device = device
self._dispatcher_remove = None
self._attr_name = device.label
self._attr_unique_id = device.device_id
self.client = client
self.capabilities = capabilities
self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = {
capability: device.status[MAIN][capability]
for capability in capabilities
if capability in device.status[MAIN]
}
self.device = device
self._attr_unique_id = device.device.device_id
self._attr_device_info = DeviceInfo(
configuration_url="https://account.smartthings.com",
identifiers={(DOMAIN, device.device_id)},
manufacturer=device.status.ocf_manufacturer_name,
model=device.status.ocf_model_number,
name=device.label,
hw_version=device.status.ocf_hardware_version,
sw_version=device.status.ocf_firmware_version,
identifiers={(DOMAIN, device.device.device_id)},
name=device.device.label,
)
if (ocf := device.status[MAIN].get(Capability.OCF)) is not None:
self._attr_device_info.update(
{
"manufacturer": cast(
str | None, ocf[Attribute.MANUFACTURER_NAME].value
),
"model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value),
"hw_version": cast(
str | None, ocf[Attribute.HARDWARE_VERSION].value
),
"sw_version": cast(
str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value
),
}
)
async def async_added_to_hass(self):
"""Device added to hass."""
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
await super().async_added_to_hass()
for capability in self._internal_state:
self.async_on_remove(
self.client.add_device_capability_event_listener(
self.device.device.device_id,
MAIN,
capability,
self._update_handler,
)
)
self._update_attr()
async def async_update_state(devices):
"""Update device state."""
if self._device.device_id in devices:
await self.async_update_ha_state(True)
def _update_handler(self, event: DeviceEvent) -> None:
self._internal_state[event.capability][event.attribute].value = event.value
self._internal_state[event.capability][event.attribute].data = event.data
self._handle_update()
self._dispatcher_remove = async_dispatcher_connect(
self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state
def supports_capability(self, capability: Capability) -> bool:
"""Test if device supports a capability."""
return capability in self.device.status[MAIN]
def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any:
"""Get the value of a device attribute."""
return self._internal_state[capability][attribute].value
def _update_attr(self) -> None:
"""Update the attributes."""
def _handle_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_attr()
self.async_write_ha_state()
async def execute_device_command(
self,
capability: Capability,
command: Command,
argument: int | str | list[Any] | dict[str, Any] | None = None,
) -> None:
"""Execute a command on the device."""
kwargs = {}
if argument is not None:
kwargs["argument"] = argument
await self.client.execute_device_command(
self.device.device.device_id, capability, command, MAIN, **kwargs
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect the device when removed."""
if self._dispatcher_remove:
self._dispatcher_remove()

View File

@@ -2,14 +2,12 @@
from __future__ import annotations
from collections.abc import Sequence
import math
from typing import Any
from pysmartthings import Capability
from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
@@ -18,7 +16,8 @@ from homeassistant.util.percentage import (
)
from homeassistant.util.scaling import int_states_in_range
from .const import DATA_BROKERS, DOMAIN
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
SPEED_RANGE = (1, 3) # off is not included
@@ -26,86 +25,74 @@ SPEED_RANGE = (1, 3) # off is not included
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add fans for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
entry_data = entry.runtime_data
async_add_entities(
SmartThingsFan(device)
for device in broker.devices.values()
if broker.any_assigned(device.device_id, "fan")
SmartThingsFan(entry_data.client, device)
for device in entry_data.devices.values()
if Capability.SWITCH in device.status[MAIN]
and any(
capability in device.status[MAIN]
for capability in (
Capability.FAN_SPEED,
Capability.AIR_CONDITIONER_FAN_MODE,
)
)
and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN]
)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
# MUST support switch as we need a way to turn it on and off
if Capability.switch not in capabilities:
return None
# These are all optional but at least one must be supported
optional = [
Capability.air_conditioner_fan_mode,
Capability.fan_speed,
]
# At least one of the optional capabilities must be supported
# to classify this entity as a fan.
# If they are not then return None and don't setup the platform.
if not any(capability in capabilities for capability in optional):
return None
supported = [Capability.switch]
supported.extend(
capability for capability in optional if capability in capabilities
)
return supported
class SmartThingsFan(SmartThingsEntity, FanEntity):
"""Define a SmartThings Fan."""
_attr_name = None
_attr_speed_count = int_states_in_range(SPEED_RANGE)
def __init__(self, device):
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
super().__init__(device)
super().__init__(
client,
device,
{
Capability.SWITCH,
Capability.FAN_SPEED,
Capability.AIR_CONDITIONER_FAN_MODE,
},
)
self._attr_supported_features = self._determine_features()
def _determine_features(self):
flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
if self._device.get_capability(Capability.fan_speed):
if self.supports_capability(Capability.FAN_SPEED):
flags |= FanEntityFeature.SET_SPEED
if self._device.get_capability(Capability.air_conditioner_fan_mode):
if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
flags |= FanEntityFeature.PRESET_MODE
return flags
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
await self._async_set_percentage(percentage)
async def _async_set_percentage(self, percentage: int | None) -> None:
if percentage is None:
await self._device.switch_on(set_status=True)
elif percentage == 0:
await self._device.switch_off(set_status=True)
if percentage == 0:
await self.execute_device_command(Capability.SWITCH, Command.OFF)
else:
value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
await self._device.set_fan_speed(value, set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(
Capability.FAN_SPEED,
Command.SET_FAN_SPEED,
argument=value,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset_mode of the fan."""
await self._device.set_fan_mode(preset_mode, set_status=True)
self.async_write_ha_state()
await self.execute_device_command(
Capability.AIR_CONDITIONER_FAN_MODE,
Command.SET_FAN_MODE,
argument=preset_mode,
)
async def async_turn_on(
self,
@@ -114,32 +101,30 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
**kwargs: Any,
) -> None:
"""Turn the fan on."""
if FanEntityFeature.SET_SPEED in self._attr_supported_features:
# If speed is set in features then turn the fan on with the speed.
await self._async_set_percentage(percentage)
if (
FanEntityFeature.SET_SPEED in self._attr_supported_features
and percentage is not None
):
await self.async_set_percentage(percentage)
else:
# If speed is not valid then turn on the fan with the
await self._device.switch_on(set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(Capability.SWITCH, Command.ON)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self._device.switch_off(set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(Capability.SWITCH, Command.OFF)
@property
def is_on(self) -> bool:
"""Return true if fan is on."""
return self._device.status.switch
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
@property
def percentage(self) -> int | None:
"""Return the current speed percentage."""
return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed)
return ranged_value_to_percentage(
SPEED_RANGE,
self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED),
)
@property
def preset_mode(self) -> str | None:
@@ -147,7 +132,9 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
Requires FanEntityFeature.PRESET_MODE.
"""
return self._device.status.fan_mode
return self.get_attribute_value(
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
)
@property
def preset_modes(self) -> list[str] | None:
@@ -155,4 +142,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
Requires FanEntityFeature.PRESET_MODE.
"""
return self._device.status.supported_ac_fan_modes
return self.get_attribute_value(
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
)

View File

@@ -3,10 +3,9 @@
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from typing import Any
from pysmartthings import Capability
from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -18,54 +17,38 @@ from homeassistant.components.light import (
LightEntityFeature,
brightness_supported,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
CAPABILITIES = (
Capability.SWITCH_LEVEL,
Capability.COLOR_CONTROL,
Capability.COLOR_TEMPERATURE,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add lights for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
entry_data = entry.runtime_data
async_add_entities(
[
SmartThingsLight(device)
for device in broker.devices.values()
if broker.any_assigned(device.device_id, "light")
],
True,
SmartThingsLight(entry_data.client, device)
for device in entry_data.devices.values()
if Capability.SWITCH in device.status[MAIN]
and any(capability in device.status[MAIN] for capability in CAPABILITIES)
)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
supported = [
Capability.switch,
Capability.switch_level,
Capability.color_control,
Capability.color_temperature,
]
# Must be able to be turned on/off.
if Capability.switch not in capabilities:
return None
# Must have one of these
light_capabilities = [
Capability.color_control,
Capability.color_temperature,
Capability.switch_level,
]
if any(capability in capabilities for capability in light_capabilities):
return supported
return None
def convert_scale(value, value_scale, target_scale, round_digits=4):
def convert_scale(
value: float, value_scale: int, target_scale: int, round_digits: int = 4
) -> float:
"""Convert a value to a different scale."""
return round(value * target_scale / value_scale, round_digits)
@@ -73,49 +56,45 @@ def convert_scale(value, value_scale, target_scale, round_digits=4):
class SmartThingsLight(SmartThingsEntity, LightEntity):
"""Define a SmartThings Light."""
_attr_name = None
_attr_supported_color_modes: set[ColorMode]
# SmartThings does not expose this attribute, instead it's
# implemented within each device-type handler. This value is the
# implemented within each device-type handler. This value is the
# lowest kelvin found supported across 20+ handlers.
_attr_min_color_temp_kelvin = 2000 # 500 mireds
# SmartThings does not expose this attribute, instead it's
# implemented within each device-type handler. This value is the
# implemented within each device-type handler. This value is the
# highest kelvin found supported across 20+ handlers.
_attr_max_color_temp_kelvin = 9000 # 111 mireds
def __init__(self, device):
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Initialize a SmartThingsLight."""
super().__init__(device)
self._attr_supported_color_modes = self._determine_color_modes()
self._attr_supported_features = self._determine_features()
def _determine_color_modes(self):
"""Get features supported by the device."""
super().__init__(
client,
device,
{
Capability.COLOR_CONTROL,
Capability.COLOR_TEMPERATURE,
Capability.SWITCH_LEVEL,
Capability.SWITCH,
},
)
color_modes = set()
# Color Temperature
if Capability.color_temperature in self._device.capabilities:
if self.supports_capability(Capability.COLOR_TEMPERATURE):
color_modes.add(ColorMode.COLOR_TEMP)
# Color
if Capability.color_control in self._device.capabilities:
if self.supports_capability(Capability.COLOR_CONTROL):
color_modes.add(ColorMode.HS)
# Brightness
if not color_modes and Capability.switch_level in self._device.capabilities:
if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL):
color_modes.add(ColorMode.BRIGHTNESS)
if not color_modes:
color_modes.add(ColorMode.ONOFF)
return color_modes
def _determine_features(self) -> LightEntityFeature:
"""Get features supported by the device."""
self._attr_supported_color_modes = color_modes
features = LightEntityFeature(0)
# Transition
if Capability.switch_level in self._device.capabilities:
if self.supports_capability(Capability.SWITCH_LEVEL):
features |= LightEntityFeature.TRANSITION
return features
self._attr_supported_features = features
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
@@ -136,11 +115,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0)
)
else:
await self._device.switch_on(set_status=True)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
await self.execute_device_command(
Capability.SWITCH,
Command.ON,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
@@ -148,27 +126,39 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
if ATTR_TRANSITION in kwargs:
await self.async_set_level(0, int(kwargs[ATTR_TRANSITION]))
else:
await self._device.switch_off(set_status=True)
await self.execute_device_command(
Capability.SWITCH,
Command.OFF,
)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
async def async_update(self) -> None:
def _update_attr(self) -> None:
"""Update entity attributes when the device status has changed."""
# Brightness and transition
if brightness_supported(self._attr_supported_color_modes):
self._attr_brightness = int(
convert_scale(self._device.status.level, 100, 255, 0)
convert_scale(
self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL),
100,
255,
0,
)
)
# Color Temperature
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
self._attr_color_temp_kelvin = self._device.status.color_temperature
self._attr_color_temp_kelvin = self.get_attribute_value(
Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE
)
# Color
if ColorMode.HS in self._attr_supported_color_modes:
self._attr_hs_color = (
convert_scale(self._device.status.hue, 100, 360),
self._device.status.saturation,
convert_scale(
self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE),
100,
360,
),
self.get_attribute_value(
Capability.COLOR_CONTROL, Attribute.SATURATION
),
)
async def async_set_color(self, hs_color):
@@ -176,14 +166,22 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
hue = convert_scale(float(hs_color[0]), 360, 100)
hue = max(min(hue, 100.0), 0.0)
saturation = max(min(float(hs_color[1]), 100.0), 0.0)
await self._device.set_color(hue, saturation, set_status=True)
await self.execute_device_command(
Capability.COLOR_CONTROL,
Command.SET_COLOR,
argument={"hue": hue, "saturation": saturation},
)
async def async_set_color_temp(self, value: int):
"""Set the color temperature of the device."""
kelvin = max(min(value, 30000), 1)
await self._device.set_color_temperature(kelvin, set_status=True)
await self.execute_device_command(
Capability.COLOR_TEMPERATURE,
Command.SET_COLOR_TEMPERATURE,
argument=kelvin,
)
async def async_set_level(self, brightness: int, transition: int):
async def async_set_level(self, brightness: int, transition: int) -> None:
"""Set the brightness of the light over transition."""
level = int(convert_scale(brightness, 255, 100, 0))
# Due to rounding, set level to 1 (one) so we don't inadvertently
@@ -191,7 +189,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
level = 1 if level == 0 and brightness > 0 else level
level = max(min(level, 100), 0)
duration = int(transition)
await self._device.set_level(level, duration, set_status=True)
await self.execute_device_command(
Capability.SWITCH_LEVEL,
Command.SET_LEVEL,
argument=[level, duration],
)
@property
def color_mode(self) -> ColorMode:
@@ -208,4 +210,4 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._device.status.switch
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"

View File

@@ -2,17 +2,16 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pysmartthings import Attribute, Capability
from pysmartthings import Attribute, Capability, Command
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
ST_STATE_LOCKED = "locked"
@@ -28,48 +27,49 @@ ST_LOCK_ATTR_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add locks for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
entry_data = entry.runtime_data
async_add_entities(
SmartThingsLock(device)
for device in broker.devices.values()
if broker.any_assigned(device.device_id, "lock")
SmartThingsLock(entry_data.client, device, {Capability.LOCK})
for device in entry_data.devices.values()
if Capability.LOCK in device.status[MAIN]
)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
if Capability.lock in capabilities:
return [Capability.lock]
return None
class SmartThingsLock(SmartThingsEntity, LockEntity):
"""Define a SmartThings lock."""
_attr_name = None
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
await self._device.lock(set_status=True)
self.async_write_ha_state()
await self.execute_device_command(
Capability.LOCK,
Command.LOCK,
)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
await self._device.unlock(set_status=True)
self.async_write_ha_state()
await self.execute_device_command(
Capability.LOCK,
Command.UNLOCK,
)
@property
def is_locked(self) -> bool:
"""Return true if lock is locked."""
return self._device.status.lock == ST_STATE_LOCKED
return (
self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device specific state attributes."""
state_attrs = {}
status = self._device.status.attributes[Attribute.lock]
status = self._internal_state[Capability.LOCK][Attribute.LOCK]
if status.value:
state_attrs["lock_state"] = status.value
if isinstance(status.data, dict):

View File

@@ -1,10 +1,9 @@
{
"domain": "smartthings",
"name": "SmartThings",
"after_dependencies": ["cloud"],
"codeowners": [],
"codeowners": ["@joostlek"],
"config_flow": true,
"dependencies": ["webhook"],
"dependencies": ["application_credentials"],
"dhcp": [
{
"hostname": "st*",
@@ -29,6 +28,6 @@
],
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"iot_class": "cloud_push",
"loggers": ["httpsig", "pysmartapp", "pysmartthings"],
"requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"]
"loggers": ["pysmartthings"],
"requirements": ["pysmartthings==2.0.0"]
}

View File

@@ -2,39 +2,42 @@
from typing import Any
from pysmartthings import Scene as STScene, SmartThings
from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import SmartThingsConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add switches for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
async_add_entities(SmartThingsScene(scene) for scene in broker.scenes.values())
"""Add lights for a config entry."""
client = entry.runtime_data.client
scenes = entry.runtime_data.scenes
async_add_entities(SmartThingsScene(scene, client) for scene in scenes.values())
class SmartThingsScene(Scene):
"""Define a SmartThings scene."""
def __init__(self, scene):
def __init__(self, scene: STScene, client: SmartThings) -> None:
"""Init the scene class."""
self.client = client
self._scene = scene
self._attr_name = scene.name
self._attr_unique_id = scene.scene_id
async def async_activate(self, **kwargs: Any) -> None:
"""Activate scene."""
await self._scene.execute()
await self.client.execute_scene(self._scene.scene_id)
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Get attributes about the state."""
return {
"icon": self._scene.icon,

File diff suppressed because it is too large Load Diff

View File

@@ -1,545 +0,0 @@
"""SmartApp functionality to receive cloud-push notifications."""
from __future__ import annotations
import asyncio
import functools
import logging
import secrets
from typing import Any
from urllib.parse import urlparse
from uuid import uuid4
from aiohttp import web
from pysmartapp import Dispatcher, SmartAppManager
from pysmartapp.const import SETTINGS_APP_ID
from pysmartthings import (
APP_TYPE_WEBHOOK,
CAPABILITIES,
CLASSIFICATION_AUTOMATION,
App,
AppEntity,
AppOAuth,
AppSettings,
InstalledAppStatus,
SmartThings,
SourceType,
Subscription,
SubscriptionEntity,
)
from homeassistant.components import cloud, webhook
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.storage import Store
from .const import (
APP_NAME_PREFIX,
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_CLOUDHOOK_URL,
CONF_INSTALLED_APP_ID,
CONF_INSTANCE_ID,
CONF_REFRESH_TOKEN,
DATA_BROKERS,
DATA_MANAGER,
DOMAIN,
IGNORED_CAPABILITIES,
SETTINGS_INSTANCE_ID,
SIGNAL_SMARTAPP_PREFIX,
STORAGE_KEY,
STORAGE_VERSION,
SUBSCRIPTION_WARNING_LIMIT,
)
_LOGGER = logging.getLogger(__name__)
def format_unique_id(app_id: str, location_id: str) -> str:
"""Format the unique id for a config entry."""
return f"{app_id}_{location_id}"
async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None:
"""Find an existing SmartApp for this installation of hass."""
apps = await api.apps()
for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]:
# Load settings to compare instance id
settings = await app.settings()
if (
settings.settings.get(SETTINGS_INSTANCE_ID)
== hass.data[DOMAIN][CONF_INSTANCE_ID]
):
return app
return None
async def validate_installed_app(api, installed_app_id: str):
"""Ensure the specified installed SmartApp is valid and functioning.
Query the API for the installed SmartApp and validate that it is tied to
the specified app_id and is in an authorized state.
"""
installed_app = await api.installed_app(installed_app_id)
if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED:
raise RuntimeWarning(
f"Installed SmartApp instance '{installed_app.display_name}' "
f"({installed_app.installed_app_id}) is not AUTHORIZED "
f"but instead {installed_app.installed_app_status}"
)
return installed_app
def validate_webhook_requirements(hass: HomeAssistant) -> bool:
"""Ensure Home Assistant is setup properly to receive webhooks."""
if cloud.async_active_subscription(hass):
return True
if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None:
return True
return get_webhook_url(hass).lower().startswith("https://")
def get_webhook_url(hass: HomeAssistant) -> str:
"""Get the URL of the webhook.
Return the cloudhook if available, otherwise local webhook.
"""
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloud.async_active_subscription(hass) and cloudhook_url is not None:
return cloudhook_url
return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
def _get_app_template(hass: HomeAssistant):
try:
endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}"
except NoURLAvailableError:
endpoint = ""
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloudhook_url is not None:
endpoint = "via Nabu Casa"
description = f"{hass.config.location_name} {endpoint}"
return {
"app_name": APP_NAME_PREFIX + str(uuid4()),
"display_name": "Home Assistant",
"description": description,
"webhook_target_url": get_webhook_url(hass),
"app_type": APP_TYPE_WEBHOOK,
"single_instance": True,
"classifications": [CLASSIFICATION_AUTOMATION],
}
async def create_app(hass: HomeAssistant, api):
"""Create a SmartApp for this instance of hass."""
# Create app from template attributes
template = _get_app_template(hass)
app = App()
for key, value in template.items():
setattr(app, key, value)
app, client = await api.create_app(app)
_LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id)
# Set unique hass id in settings
settings = AppSettings(app.app_id)
settings.settings[SETTINGS_APP_ID] = app.app_id
settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID]
await api.update_app_settings(settings)
_LOGGER.debug(
"Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id
)
# Set oauth scopes
oauth = AppOAuth(app.app_id)
oauth.client_name = APP_OAUTH_CLIENT_NAME
oauth.scope.extend(APP_OAUTH_SCOPES)
await api.update_app_oauth(oauth)
_LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id)
return app, client
async def update_app(hass: HomeAssistant, app):
"""Ensure the SmartApp is up-to-date and update if necessary."""
template = _get_app_template(hass)
template.pop("app_name") # don't update this
update_required = False
for key, value in template.items():
if getattr(app, key) != value:
update_required = True
setattr(app, key, value)
if update_required:
await app.save()
_LOGGER.debug(
"SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id
)
def setup_smartapp(hass, app):
"""Configure an individual SmartApp in hass.
Register the SmartApp with the SmartAppManager so that hass will service
lifecycle events (install, event, etc...). A unique SmartApp is created
for each SmartThings account that is configured in hass.
"""
manager = hass.data[DOMAIN][DATA_MANAGER]
if smartapp := manager.smartapps.get(app.app_id):
# already setup
return smartapp
smartapp = manager.register(app.app_id, app.webhook_public_key)
smartapp.name = app.display_name
smartapp.description = app.description
smartapp.permissions.extend(APP_OAUTH_SCOPES)
return smartapp
async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool):
"""Configure the SmartApp webhook in hass.
SmartApps are an extension point within the SmartThings ecosystem and
is used to receive push updates (i.e. device updates) from the cloud.
"""
if hass.data.get(DOMAIN):
# already setup
if not fresh_install:
return
# We're doing a fresh install, clean up
await unload_smartapp_endpoint(hass)
# Get/create config to store a unique id for this hass instance.
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
if fresh_install or not (config := await store.async_load()):
# Create config
config = {
CONF_INSTANCE_ID: str(uuid4()),
CONF_WEBHOOK_ID: secrets.token_hex(),
CONF_CLOUDHOOK_URL: None,
}
await store.async_save(config)
# Register webhook
webhook.async_register(
hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook
)
# Create webhook if eligible
cloudhook_url = config.get(CONF_CLOUDHOOK_URL)
if (
cloudhook_url is None
and cloud.async_active_subscription(hass)
and not hass.config_entries.async_entries(DOMAIN)
):
cloudhook_url = await cloud.async_create_cloudhook(
hass, config[CONF_WEBHOOK_ID]
)
config[CONF_CLOUDHOOK_URL] = cloudhook_url
await store.async_save(config)
_LOGGER.debug("Created cloudhook '%s'", cloudhook_url)
# SmartAppManager uses a dispatcher to invoke callbacks when push events
# occur. Use hass' implementation instead of the built-in one.
dispatcher = Dispatcher(
signal_prefix=SIGNAL_SMARTAPP_PREFIX,
connect=functools.partial(async_dispatcher_connect, hass),
send=functools.partial(async_dispatcher_send, hass),
)
# Path is used in digital signature validation
path = (
urlparse(cloudhook_url).path
if cloudhook_url
else webhook.async_generate_path(config[CONF_WEBHOOK_ID])
)
manager = SmartAppManager(path, dispatcher=dispatcher)
manager.connect_install(functools.partial(smartapp_install, hass))
manager.connect_update(functools.partial(smartapp_update, hass))
manager.connect_uninstall(functools.partial(smartapp_uninstall, hass))
hass.data[DOMAIN] = {
DATA_MANAGER: manager,
CONF_INSTANCE_ID: config[CONF_INSTANCE_ID],
DATA_BROKERS: {},
CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
# Will not be present if not enabled
CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
}
_LOGGER.debug(
"Setup endpoint for %s",
cloudhook_url
if cloudhook_url
else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]),
)
async def unload_smartapp_endpoint(hass: HomeAssistant):
"""Tear down the component configuration."""
if DOMAIN not in hass.data:
return
# Remove the cloudhook if it was created
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloudhook_url and cloud.async_is_logged_in(hass):
await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
# Remove cloudhook from storage
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
await store.async_save(
{
CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID],
CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID],
CONF_CLOUDHOOK_URL: None,
}
)
_LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url)
# Remove the webhook
webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
# Disconnect all brokers
for broker in hass.data[DOMAIN][DATA_BROKERS].values():
broker.disconnect()
# Remove all handlers from manager
hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all()
# Remove the component data
hass.data.pop(DOMAIN)
async def smartapp_sync_subscriptions(
hass: HomeAssistant,
auth_token: str,
location_id: str,
installed_app_id: str,
devices,
):
"""Synchronize subscriptions of an installed up."""
api = SmartThings(async_get_clientsession(hass), auth_token)
tasks = []
async def create_subscription(target: str):
sub = Subscription()
sub.installed_app_id = installed_app_id
sub.location_id = location_id
sub.source_type = SourceType.CAPABILITY
sub.capability = target
try:
await api.create_subscription(sub)
_LOGGER.debug(
"Created subscription for '%s' under app '%s'", target, installed_app_id
)
except Exception as error: # noqa: BLE001
_LOGGER.error(
"Failed to create subscription for '%s' under app '%s': %s",
target,
installed_app_id,
error,
)
async def delete_subscription(sub: SubscriptionEntity):
try:
await api.delete_subscription(installed_app_id, sub.subscription_id)
_LOGGER.debug(
(
"Removed subscription for '%s' under app '%s' because it was no"
" longer needed"
),
sub.capability,
installed_app_id,
)
except Exception as error: # noqa: BLE001
_LOGGER.error(
"Failed to remove subscription for '%s' under app '%s': %s",
sub.capability,
installed_app_id,
error,
)
# Build set of capabilities and prune unsupported ones
capabilities = set()
for device in devices:
capabilities.update(device.capabilities)
# Remove items not defined in the library
capabilities.intersection_update(CAPABILITIES)
# Remove unused capabilities
capabilities.difference_update(IGNORED_CAPABILITIES)
capability_count = len(capabilities)
if capability_count > SUBSCRIPTION_WARNING_LIMIT:
_LOGGER.warning(
(
"Some device attributes may not receive push updates and there may be"
" subscription creation failures under app '%s' because %s"
" subscriptions are required but there is a limit of %s per app"
),
installed_app_id,
capability_count,
SUBSCRIPTION_WARNING_LIMIT,
)
_LOGGER.debug(
"Synchronizing subscriptions for %s capabilities under app '%s': %s",
capability_count,
installed_app_id,
capabilities,
)
# Get current subscriptions and find differences
subscriptions = await api.subscriptions(installed_app_id)
for subscription in subscriptions:
if subscription.capability in capabilities:
capabilities.remove(subscription.capability)
else:
# Delete the subscription
tasks.append(delete_subscription(subscription))
# Remaining capabilities need subscriptions created
tasks.extend([create_subscription(c) for c in capabilities])
if tasks:
await asyncio.gather(*tasks)
else:
_LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id)
async def _find_and_continue_flow(
hass: HomeAssistant,
app_id: str,
location_id: str,
installed_app_id: str,
refresh_token: str,
):
"""Continue a config flow if one is in progress for the specific installed app."""
unique_id = format_unique_id(app_id, location_id)
flow = next(
(
flow
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
if flow["context"].get("unique_id") == unique_id
),
None,
)
if flow is not None:
await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow)
async def _continue_flow(
hass: HomeAssistant,
app_id: str,
installed_app_id: str,
refresh_token: str,
flow: ConfigFlowResult,
) -> None:
await hass.config_entries.flow.async_configure(
flow["flow_id"],
{
CONF_INSTALLED_APP_ID: installed_app_id,
CONF_REFRESH_TOKEN: refresh_token,
},
)
_LOGGER.debug(
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
installed_app_id,
app_id,
)
async def smartapp_install(hass: HomeAssistant, req, resp, app):
"""Handle a SmartApp installation and continue the config flow."""
await _find_and_continue_flow(
hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
)
_LOGGER.debug(
"Installed SmartApp '%s' under parent app '%s'",
req.installed_app_id,
app.app_id,
)
async def smartapp_update(hass: HomeAssistant, req, resp, app):
"""Handle a SmartApp update and either update the entry or continue the flow."""
unique_id = format_unique_id(app.app_id, req.location_id)
flow = next(
(
flow
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
if flow["context"].get("unique_id") == unique_id
and flow["step_id"] == "authorize"
),
None,
)
if flow is not None:
await _continue_flow(
hass, app.app_id, req.installed_app_id, req.refresh_token, flow
)
_LOGGER.debug(
"Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
req.installed_app_id,
app.app_id,
)
return
entry = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
),
None,
)
if entry:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
)
_LOGGER.debug(
"Updated config entry '%s' for SmartApp '%s' under parent app '%s'",
entry.entry_id,
req.installed_app_id,
app.app_id,
)
await _find_and_continue_flow(
hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
)
_LOGGER.debug(
"Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id
)
async def smartapp_uninstall(hass: HomeAssistant, req, resp, app):
"""Handle when a SmartApp is removed from a location by the user.
Find and delete the config entry representing the integration.
"""
entry = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
),
None,
)
if entry:
# Add as job not needed because the current coroutine was invoked
# from the dispatcher and is not being awaited.
await hass.config_entries.async_remove(entry.entry_id)
_LOGGER.debug(
"Uninstalled SmartApp '%s' under parent app '%s'",
req.installed_app_id,
app.app_id,
)
async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request):
"""Handle a smartapp lifecycle event callback from SmartThings.
Requests from SmartThings are digitally signed and the SmartAppManager
validates the signature for authenticity.
"""
manager = hass.data[DOMAIN][DATA_MANAGER]
data = await request.json()
result = await manager.handle_request(data, request.headers)
return web.json_response(result)

View File

@@ -1,43 +1,392 @@
{
"config": {
"step": {
"user": {
"title": "Confirm Callback URL",
"description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again."
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"pat": {
"title": "Enter Personal Access Token",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**",
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
}
},
"select_location": {
"title": "Select Location",
"description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": { "location_id": "[%key:common::config_flow::data::location%]" }
},
"authorize": { "title": "Authorize Home Assistant" },
"reauth_confirm": {
"title": "Reauthorize Home Assistant",
"description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again."
},
"update_confirm": {
"title": "Finish reauthentication",
"description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process."
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The SmartThings integration needs to re-authenticate your account"
}
},
"abort": {
"invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
"no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.",
"reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings."
},
"error": {
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"app_setup_error": "Unable to set up the SmartApp. Please try again.",
"webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"abort": {
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.",
"reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location."
}
},
"entity": {
"binary_sensor": {
"acceleration": {
"name": "Acceleration"
},
"filter_status": {
"name": "Filter status"
},
"valve": {
"name": "Valve"
}
},
"sensor": {
"lighting_mode": {
"name": "Activity lighting mode"
},
"air_conditioner_mode": {
"name": "Air conditioner mode"
},
"air_quality": {
"name": "Air quality"
},
"alarm": {
"name": "Alarm",
"state": {
"both": "Strobe and siren",
"strobe": "Strobe",
"siren": "Siren",
"off": "[%key:common::state::off%]"
}
},
"audio_volume": {
"name": "Volume"
},
"body_mass_index": {
"name": "Body mass index"
},
"body_weight": {
"name": "Body weight"
},
"carbon_monoxide_detector": {
"name": "Carbon monoxide detector",
"state": {
"detected": "Detected",
"clear": "Clear",
"tested": "Tested"
}
},
"dishwasher_machine_state": {
"name": "Machine state",
"state": {
"pause": "[%key:common::state::paused%]",
"run": "Running",
"stop": "Stopped"
}
},
"dishwasher_job_state": {
"name": "Job state",
"state": {
"air_wash": "Air wash",
"cooling": "Cooling",
"drying": "Drying",
"finish": "Finish",
"pre_drain": "Pre-drain",
"pre_wash": "Pre-wash",
"rinse": "Rinse",
"spin": "Spin",
"wash": "Wash",
"wrinkle_prevent": "Wrinkle prevention"
}
},
"completion_time": {
"name": "Completion time"
},
"dryer_mode": {
"name": "Dryer mode"
},
"dryer_machine_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]",
"state": {
"pause": "[%key:common::state::paused%]",
"run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
"stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]"
}
},
"dryer_job_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
"state": {
"cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]",
"delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]",
"drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]",
"finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]",
"none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]",
"refreshing": "Refreshing",
"weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]",
"wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]",
"dehumidifying": "Dehumidifying",
"ai_drying": "AI drying",
"sanitizing": "Sanitizing",
"internal_care": "Internal care",
"freeze_protection": "Freeze protection",
"continuous_dehumidifying": "Continuous dehumidifying",
"thawing_frozen_inside": "Thawing frozen inside"
}
},
"equivalent_carbon_dioxide": {
"name": "Equivalent carbon dioxide"
},
"formaldehyde": {
"name": "Formaldehyde"
},
"gas_meter": {
"name": "Gas meter"
},
"gas_meter_calorific": {
"name": "Gas meter calorific"
},
"gas_meter_time": {
"name": "Gas meter time"
},
"infrared_level": {
"name": "Infrared level"
},
"media_input_source": {
"name": "Media input source",
"state": {
"am": "AM",
"fm": "FM",
"cd": "CD",
"hdmi": "HDMI",
"hdmi1": "HDMI 1",
"hdmi2": "HDMI 2",
"hdmi3": "HDMI 3",
"hdmi4": "HDMI 4",
"hdmi5": "HDMI 5",
"hdmi6": "HDMI 6",
"digitaltv": "Digital TV",
"usb": "USB",
"youtube": "YouTube",
"aux": "AUX",
"bluetooth": "Bluetooth",
"digital": "Digital",
"melon": "Melon",
"wifi": "Wi-Fi",
"network": "Network",
"optical": "Optical",
"coaxial": "Coaxial",
"analog1": "Analog 1",
"analog2": "Analog 2",
"analog3": "Analog 3",
"phono": "Phono"
}
},
"media_playback_repeat": {
"name": "Media playback repeat"
},
"media_playback_shuffle": {
"name": "Media playback shuffle"
},
"media_playback_status": {
"name": "Media playback status"
},
"odor_sensor": {
"name": "Odor sensor"
},
"oven_mode": {
"name": "Oven mode",
"state": {
"heating": "Heating",
"grill": "Grill",
"warming": "Warming",
"defrosting": "Defrosting",
"conventional": "Conventional",
"bake": "Bake",
"bottom_heat": "Bottom heat",
"convection_bake": "Convection bake",
"convection_roast": "Convection roast",
"broil": "Broil",
"convection_broil": "Convection broil",
"steam_cook": "Steam cook",
"steam_bake": "Steam bake",
"steam_roast": "Steam roast",
"steam_bottom_heat_plus_convection": "Steam bottom heat plus convection",
"microwave": "Microwave",
"microwave_plus_grill": "Microwave plus grill",
"microwave_plus_convection": "Microwave plus convection",
"microwave_plus_hot_blast": "Microwave plus hot blast",
"microwave_plus_hot_blast_2": "Microwave plus hot blast 2",
"slim_middle": "Slim middle",
"slim_strong": "Slim strong",
"slow_cook": "Slow cook",
"proof": "Proof",
"dehydrate": "Dehydrate",
"others": "Others",
"strong_steam": "Strong steam",
"descale": "Descale",
"rinse": "Rinse"
}
},
"oven_machine_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]",
"state": {
"ready": "Ready",
"running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
"paused": "[%key:common::state::paused%]"
}
},
"oven_job_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
"state": {
"cleaning": "Cleaning",
"cooking": "Cooking",
"cooling": "Cooling",
"draining": "Draining",
"preheat": "Preheat",
"ready": "Ready",
"rinsing": "Rinsing",
"finished": "Finished",
"scheduled_start": "Scheduled start",
"warming": "Warming",
"defrosting": "Defrosting",
"sensing": "Sensing",
"searing": "Searing",
"fast_preheat": "Fast preheat",
"scheduled_end": "Scheduled end",
"stone_heating": "Stone heating",
"time_hold_preheat": "Time hold preheat"
}
},
"oven_setpoint": {
"name": "Set point"
},
"energy_difference": {
"name": "Energy difference"
},
"power_energy": {
"name": "Power energy"
},
"energy_saved": {
"name": "Energy saved"
},
"power_source": {
"name": "Power source"
},
"refrigeration_setpoint": {
"name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]"
},
"robot_cleaner_cleaning_mode": {
"name": "Cleaning mode",
"state": {
"auto": "Auto",
"part": "Partial",
"repeat": "Repeat",
"manual": "Manual",
"stop": "[%key:common::action::stop%]",
"map": "Map"
}
},
"robot_cleaner_movement": {
"name": "Movement",
"state": {
"homing": "Homing",
"idle": "[%key:common::state::idle%]",
"charging": "[%key:common::state::charging%]",
"alarm": "Alarm",
"off": "[%key:common::state::off%]",
"reserve": "Reserve",
"point": "Point",
"after": "After",
"cleaning": "Cleaning",
"pause": "[%key:common::state::paused%]"
}
},
"robot_cleaner_turbo_mode": {
"name": "Turbo mode",
"state": {
"on": "[%key:common::state::on%]",
"off": "[%key:common::state::off%]",
"silence": "Silent",
"extra_silence": "Extra silent"
}
},
"link_quality": {
"name": "Link quality"
},
"smoke_detector": {
"name": "Smoke detector",
"state": {
"detected": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::detected%]",
"clear": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::clear%]",
"tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]"
}
},
"thermostat_cooling_setpoint": {
"name": "Cooling set point"
},
"thermostat_fan_mode": {
"name": "Fan mode"
},
"thermostat_heating_setpoint": {
"name": "Heating set point"
},
"thermostat_mode": {
"name": "Mode"
},
"thermostat_operating_state": {
"name": "Operating state"
},
"thermostat_setpoint": {
"name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]"
},
"x_coordinate": {
"name": "X coordinate"
},
"y_coordinate": {
"name": "Y coordinate"
},
"z_coordinate": {
"name": "Z coordinate"
},
"tv_channel": {
"name": "TV channel"
},
"tv_channel_name": {
"name": "TV channel name"
},
"uv_index": {
"name": "UV index"
},
"washer_mode": {
"name": "Washer mode"
},
"washer_machine_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]",
"state": {
"pause": "[%key:common::state::paused%]",
"run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
"stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]"
}
},
"washer_job_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
"state": {
"air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]",
"ai_rinse": "AI rinse",
"ai_spin": "AI spin",
"ai_wash": "AI wash",
"cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]",
"delay_wash": "Delay wash",
"drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]",
"finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]",
"none": "None",
"pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]",
"rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]",
"spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]",
"wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]",
"weight_sensing": "Weight sensing",
"wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]",
"freeze_protection": "Freeze protection"
}
}
}
}
}

View File

@@ -2,60 +2,69 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pysmartthings import Capability
from pysmartthings import Attribute, Capability, Command
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_BROKERS, DOMAIN
from . import SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
CAPABILITIES = (
Capability.SWITCH_LEVEL,
Capability.COLOR_CONTROL,
Capability.COLOR_TEMPERATURE,
Capability.FAN_SPEED,
)
AC_CAPABILITIES = (
Capability.AIR_CONDITIONER_MODE,
Capability.AIR_CONDITIONER_FAN_MODE,
Capability.TEMPERATURE_MEASUREMENT,
Capability.THERMOSTAT_COOLING_SETPOINT,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add switches for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
entry_data = entry.runtime_data
async_add_entities(
SmartThingsSwitch(device)
for device in broker.devices.values()
if broker.any_assigned(device.device_id, "switch")
SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH})
for device in entry_data.devices.values()
if Capability.SWITCH in device.status[MAIN]
and not any(capability in device.status[MAIN] for capability in CAPABILITIES)
and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES)
)
def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
"""Return all capabilities supported if minimum required are present."""
# Must be able to be turned on/off.
if Capability.switch in capabilities:
return [Capability.switch, Capability.energy_meter, Capability.power_meter]
return None
class SmartThingsSwitch(SmartThingsEntity, SwitchEntity):
"""Define a SmartThings switch."""
_attr_name = None
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._device.switch_off(set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(
Capability.SWITCH,
Command.OFF,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._device.switch_on(set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_write_ha_state()
await self.execute_device_command(
Capability.SWITCH,
Command.ON,
)
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._device.status.switch
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
"requirements": ["python-smarttub==0.0.38"]
"requirements": ["python-smarttub==0.0.39"]
}

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, Final
from pysmhi import SMHIForecast
@@ -80,12 +79,6 @@ CONDITION_MAP = {
for cond_code in cond_codes
}
TIMEOUT = 10
# 5 minutes between retrying connect to API again
RETRY_TIMEOUT = 5 * 60
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31)
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -29,6 +29,7 @@ LIBRARY = [
"Playlists",
"Genres",
"New Music",
"Album Artists",
"Apps",
"Radios",
]
@@ -41,6 +42,7 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = {
"Playlists": "playlists",
"Genres": "genres",
"New Music": "new music",
"Album Artists": "album artists",
MediaType.ALBUM: "album",
MediaType.ARTIST: "artist",
MediaType.TRACK: "title",
@@ -71,6 +73,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] =
"Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
"Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
"New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
"Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK},
MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM},
MediaType.TRACK: {"item": MediaClass.TRACK, "children": None},
@@ -98,6 +101,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[
"Radios": MediaClass.APP,
"App": None, # can only be determined after inspecting the item
"New Music": MediaType.ALBUM,
"Album Artists": MediaType.ARTIST,
MediaType.APPS: MediaType.APP,
MediaType.APP: MediaType.TRACK,
}

View File

@@ -42,12 +42,12 @@ async def async_migrate_entry(
LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
latitude, longitude = await Stookwijzer.async_transform_coordinates(
xy = await Stookwijzer.async_transform_coordinates(
entry.data[CONF_LOCATION][CONF_LATITUDE],
entry.data[CONF_LOCATION][CONF_LONGITUDE],
)
if not latitude or not longitude:
if not xy:
ir.async_create_issue(
hass,
DOMAIN,
@@ -65,8 +65,8 @@ async def async_migrate_entry(
entry,
version=2,
data={
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
CONF_LATITUDE: xy["x"],
CONF_LONGITUDE: xy["y"],
},
)

View File

@@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
latitude, longitude = await Stookwijzer.async_transform_coordinates(
xy = await Stookwijzer.async_transform_coordinates(
user_input[CONF_LOCATION][CONF_LATITUDE],
user_input[CONF_LOCATION][CONF_LONGITUDE],
)
if latitude and longitude:
if xy:
return self.async_create_entry(
title="Stookwijzer",
data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude},
data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]},
)
errors["base"] = "unknown"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["stookwijzer==1.5.8"]
"requirements": ["stookwijzer==1.6.1"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/technove",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["python-technove==1.3.1"],
"requirements": ["python-technove==2.0.0"],
"zeroconf": ["_technove-stations._tcp.local."]
}

View File

@@ -70,7 +70,7 @@
"plugged_waiting": "Plugged, waiting",
"plugged_charging": "Plugged, charging",
"out_of_activation_period": "Out of activation period",
"high_charge_period": "High charge period"
"high_tariff_period": "High tariff period"
}
}
},

View File

@@ -1013,7 +1013,7 @@ class LightTemplate(TemplateEntity, LightEntity):
if render in (None, "None", ""):
self._supports_transition = False
return
self._attr_supported_features &= LightEntityFeature.EFFECT
self._attr_supported_features &= ~LightEntityFeature.TRANSITION
self._supports_transition = bool(render)
if self._supports_transition:
self._attr_supported_features |= LightEntityFeature.TRANSITION

View File

@@ -4,6 +4,11 @@
"light": {
"default": "mdi:string-lights"
}
},
"select": {
"mode": {
"default": "mdi:cogs"
}
}
}
}

View File

@@ -29,7 +29,7 @@ async def async_setup_entry(
class TwinklyModeSelect(TwinklyEntity, SelectEntity):
"""Twinkly Mode Selection."""
_attr_name = "Mode"
_attr_translation_key = "mode"
_attr_options = TWINKLY_MODES
def __init__(self, coordinator: TwinklyCoordinator) -> None:

View File

@@ -20,5 +20,21 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"select": {
"mode": {
"name": "Mode",
"state": {
"color": "Color",
"demo": "Demo",
"effect": "Effect",
"movie": "Uploaded effect",
"off": "[%key:common::state::off%]",
"playlist": "Playlist",
"rt": "Real time"
}
}
}
}
}

View File

@@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
def name(self) -> str | UndefinedType | None:
"""Return the name of the entity."""
meta = self.entity_data.entity.info_object
if meta.primary:
self._attr_name = None
return super().name
original_name = super().name
if original_name not in (UNDEFINED, None) or meta.fallback_name is None:

View File

@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["zha==0.0.49"],
"requirements": ["zha==0.0.51"],
"usb": [
{
"vid": "10C4",

View File

@@ -1044,6 +1044,63 @@
},
"valve_duration": {
"name": "Irrigation duration"
},
"down_movement": {
"name": "Down movement"
},
"sustain_time": {
"name": "Sustain time"
},
"up_movement": {
"name": "Up movement"
},
"large_motion_detection_sensitivity": {
"name": "Motion detection sensitivity"
},
"large_motion_detection_distance": {
"name": "Motion detection distance"
},
"medium_motion_detection_distance": {
"name": "Medium motion detection distance"
},
"medium_motion_detection_sensitivity": {
"name": "Medium motion detection sensitivity"
},
"small_motion_detection_distance": {
"name": "Small motion detection distance"
},
"small_motion_detection_sensitivity": {
"name": "Small motion detection sensitivity"
},
"static_detection_sensitivity": {
"name": "Static detection sensitivity"
},
"static_detection_distance": {
"name": "Static detection distance"
},
"motion_detection_sensitivity": {
"name": "Motion detection sensitivity"
},
"holiday_temperature": {
"name": "Holiday temperature"
},
"boost_time": {
"name": "Boost time"
},
"antifrost_temperature": {
"name": "Antifrost temperature"
},
"eco_temperature": {
"name": "Eco temperature"
},
"comfort_temperature": {
"name": "Comfort temperature"
},
"valve_state_auto_shutdown": {
"name": "Valve state auto shutdown"
},
"shutdown_timer": {
"name": "Shutdown timer"
}
},
"select": {
@@ -1235,6 +1292,33 @@
},
"eco_mode": {
"name": "Eco mode"
},
"mode": {
"name": "Mode"
},
"reverse": {
"name": "Reverse"
},
"motion_state": {
"name": "Motion state"
},
"motion_detection_mode": {
"name": "Motion detection mode"
},
"screen_orientation": {
"name": "Screen orientation"
},
"motor_thrust": {
"name": "Motor thrust"
},
"display_brightness": {
"name": "Display brightness"
},
"display_orientation": {
"name": "Display orientation"
},
"hysteresis_mode": {
"name": "Hysteresis mode"
}
},
"sensor": {
@@ -1561,6 +1645,27 @@
},
"error_status": {
"name": "Error status"
},
"brightness_level": {
"name": "Brightness level"
},
"average_light_intensity_20mins": {
"name": "Average light intensity last 20 min"
},
"todays_max_light_intensity": {
"name": "Today's max light intensity"
},
"fault_code": {
"name": "Fault code"
},
"water_flow": {
"name": "Water flow"
},
"remaining_watering_time": {
"name": "Remaining watering time"
},
"last_watering_duration": {
"name": "Last watering duration"
}
},
"switch": {
@@ -1746,6 +1851,30 @@
},
"total_flow_reset_switch": {
"name": "Total flow reset switch"
},
"touch_control": {
"name": "Touch control"
},
"sound_enabled": {
"name": "Sound enabled"
},
"invert_relay": {
"name": "Invert relay"
},
"boost_heating": {
"name": "Boost heating"
},
"holiday_mode": {
"name": "Holiday mode"
},
"heating_stop": {
"name": "Heating stop"
},
"schedule_mode": {
"name": "Schedule mode"
},
"auto_clean": {
"name": "Auto clean"
}
}
}

View File

@@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 3
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)

View File

@@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [
"onedrive",
"point",
"senz",
"smartthings",
"spotify",
"tesla_fleet",
"twitch",

View File

@@ -12,7 +12,6 @@ from datetime import datetime, timedelta
from functools import partial
import itertools
import logging
from types import MappingProxyType
from typing import Any, Literal, TypedDict, cast, overload
import async_interrupt
@@ -90,7 +89,7 @@ from . import condition, config_validation as cv, service, template
from .condition import ConditionCheckerType, trace_condition_function
from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal
from .event import async_call_later, async_track_template
from .script_variables import ScriptVariables
from .script_variables import ScriptRunVariables, ScriptVariables
from .template import Template
from .trace import (
TraceElement,
@@ -177,7 +176,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None:
future.set_result(None)
def action_trace_append(variables: dict[str, Any], path: str) -> TraceElement:
def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement:
"""Append a TraceElement to trace[path]."""
trace_element = TraceElement(variables, path)
trace_append_element(trace_element, ACTION_TRACE_NODE_MAX_LEN)
@@ -189,7 +188,7 @@ async def trace_action(
hass: HomeAssistant,
script_run: _ScriptRun,
stop: asyncio.Future[None],
variables: dict[str, Any],
variables: TemplateVarsType,
) -> AsyncGenerator[TraceElement]:
"""Trace action execution."""
path = trace_path_get()
@@ -411,7 +410,7 @@ class _ScriptRun:
self,
hass: HomeAssistant,
script: Script,
variables: dict[str, Any],
variables: ScriptRunVariables,
context: Context | None,
log_exceptions: bool,
) -> None:
@@ -485,14 +484,16 @@ class _ScriptRun:
script_stack.pop()
self._finish()
return ScriptRunResult(self._conversation_response, response, self._variables)
return ScriptRunResult(
self._conversation_response, response, self._variables.local_scope
)
async def _async_step(self, log_exceptions: bool) -> None:
continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False)
with trace_path(str(self._step)):
async with trace_action(
self._hass, self, self._stop, self._variables
self._hass, self, self._stop, self._variables.non_parallel_scope
) as trace_element:
if self._stop.done():
return
@@ -526,7 +527,7 @@ class _ScriptRun:
ex, continue_on_error, self._log_exceptions or log_exceptions
)
finally:
trace_element.update_variables(self._variables)
trace_element.update_variables(self._variables.non_parallel_scope)
def _finish(self) -> None:
self._script._runs.remove(self) # noqa: SLF001
@@ -624,11 +625,16 @@ class _ScriptRun:
except ScriptStoppedError as ex:
raise asyncio.CancelledError from ex
async def _async_run_script(self, script: Script) -> None:
async def _async_run_script(
self, script: Script, *, parallel: bool = False
) -> None:
"""Execute a script."""
result = await self._async_run_long_action(
self._hass.async_create_task_internal(
script.async_run(self._variables, self._context), eager_start=True
script.async_run(
self._variables.enter_scope(parallel=parallel), self._context
),
eager_start=True,
)
)
if result and result.conversation_response is not UNDEFINED:
@@ -647,7 +653,7 @@ class _ScriptRun:
"""Run a script with a trace path."""
trace_path_stack_cv.set(copy(trace_path_stack_cv.get()))
with trace_path([str(idx), "sequence"]):
await self._async_run_script(script)
await self._async_run_script(script, parallel=True)
results = await asyncio.gather(
*(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)),
@@ -760,14 +766,11 @@ class _ScriptRun:
with trace_path("else"):
await self._async_run_script(if_data["if_else"])
@async_trace_path("repeat")
async def _async_step_repeat(self) -> None: # noqa: C901
"""Repeat a sequence."""
async def _async_do_step_repeat(self) -> None: # noqa: C901
"""Repeat a sequence helper."""
description = self._action.get(CONF_ALIAS, "sequence")
repeat = self._action[CONF_REPEAT]
saved_repeat_vars = self._variables.get("repeat")
def set_repeat_var(
iteration: int, count: int | None = None, item: Any = None
) -> None:
@@ -776,7 +779,7 @@ class _ScriptRun:
repeat_vars["last"] = iteration == count
if item is not None:
repeat_vars["item"] = item
self._variables["repeat"] = repeat_vars
self._variables.define_local("repeat", repeat_vars)
script = self._script._get_repeat_script(self._step) # noqa: SLF001
warned_too_many_loops = False
@@ -927,10 +930,14 @@ class _ScriptRun:
# while all the cpu time is consumed.
await asyncio.sleep(0)
if saved_repeat_vars:
self._variables["repeat"] = saved_repeat_vars
else:
self._variables.pop("repeat", None) # Not set if count = 0
@async_trace_path("repeat")
async def _async_step_repeat(self) -> None:
"""Repeat a sequence."""
self._variables = self._variables.enter_scope()
try:
await self._async_do_step_repeat()
finally:
self._variables = self._variables.exit_scope()
### Stop actions ###
@@ -959,11 +966,12 @@ class _ScriptRun:
## Variable actions ##
async def _async_step_variables(self) -> None:
"""Set a variable value."""
self._step_log("setting variables")
self._variables = self._action[CONF_VARIABLES].async_render(
self._hass, self._variables, render_as_defaults=False
)
"""Define a local variable."""
self._step_log("defining local variables")
for key, value in (
self._action[CONF_VARIABLES].async_simple_render(self._variables).items()
):
self._variables.define_local(key, value)
## External actions ##
@@ -1016,7 +1024,7 @@ class _ScriptRun:
"""Perform the device automation specified in the action."""
self._step_log("device automation")
await device_action.async_call_action_from_config(
self._hass, self._action, self._variables, self._context
self._hass, self._action, dict(self._variables), self._context
)
async def _async_step_event(self) -> None:
@@ -1189,12 +1197,15 @@ class _ScriptRun:
self._step_log("wait for trigger", timeout)
variables = {**self._variables}
self._variables["wait"] = {
"remaining": timeout,
"completed": False,
"trigger": None,
}
variables = dict(self._variables)
self._variables.assign_parallel_protected(
"wait",
{
"remaining": timeout,
"completed": False,
"trigger": None,
},
)
trace_set_result(wait=self._variables["wait"])
if timeout == 0:
@@ -1240,7 +1251,9 @@ class _ScriptRun:
timeout = self._get_timeout_seconds_from_action()
self._step_log("wait template", timeout)
self._variables["wait"] = {"remaining": timeout, "completed": False}
self._variables.assign_parallel_protected(
"wait", {"remaining": timeout, "completed": False}
)
trace_set_result(wait=self._variables["wait"])
wait_template = self._action[CONF_WAIT_TEMPLATE]
@@ -1369,7 +1382,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) ->
)
type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any]
type _VarsType = dict[str, Any] | Mapping[str, Any] | ScriptRunVariables
def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None:
@@ -1407,7 +1420,7 @@ class ScriptRunResult:
conversation_response: str | None | UndefinedType
service_response: ServiceResponse
variables: dict[str, Any]
variables: Mapping[str, Any]
class Script:
@@ -1422,7 +1435,6 @@ class Script:
*,
# Used in "Running <running_description>" log message
change_listener: Callable[[], Any] | None = None,
copy_variables: bool = False,
log_exceptions: bool = True,
logger: logging.Logger | None = None,
max_exceeded: str = DEFAULT_MAX_EXCEEDED,
@@ -1476,8 +1488,6 @@ class Script:
self._parallel_scripts: dict[int, list[Script]] = {}
self._sequence_scripts: dict[int, Script] = {}
self.variables = variables
self._variables_dynamic = template.is_complex(variables)
self._copy_variables_on_run = copy_variables
@property
def change_listener(self) -> Callable[..., Any] | None:
@@ -1755,25 +1765,19 @@ class Script:
if self.top_level:
if self.variables:
try:
variables = self.variables.async_render(
run_variables = self.variables.async_render(
self._hass,
run_variables,
)
except exceptions.TemplateError as err:
self._log("Error rendering variables: %s", err, level=logging.ERROR)
raise
elif run_variables:
variables = dict(run_variables)
else:
variables = {}
variables = ScriptRunVariables.create_top_level(run_variables)
variables["context"] = context
elif self._copy_variables_on_run:
# This is not the top level script, variables have been turned to a dict
variables = cast(dict[str, Any], copy(run_variables))
else:
# This is not the top level script, variables have been turned to a dict
variables = cast(dict[str, Any], run_variables)
# This is not the top level script, run_variables is an instance of ScriptRunVariables
variables = cast(ScriptRunVariables, run_variables)
# Prevent non-allowed recursive calls which will cause deadlocks when we try to
# stop (restart) or wait for (queued) our own script run.
@@ -1999,7 +2003,6 @@ class Script:
max_runs=self.max_runs,
logger=self._logger,
top_level=False,
copy_variables=True,
)
parallel_script.change_listener = partial(
self._chain_change_listener, parallel_script

View File

@@ -2,8 +2,10 @@
from __future__ import annotations
from collections import ChainMap, UserDict
from collections.abc import Mapping
from typing import Any
from dataclasses import dataclass, field
from typing import Any, cast
from homeassistant.core import HomeAssistant, callback
@@ -24,30 +26,23 @@ class ScriptVariables:
hass: HomeAssistant,
run_variables: Mapping[str, Any] | None,
*,
render_as_defaults: bool = True,
limited: bool = False,
) -> dict[str, Any]:
"""Render script variables.
The run variables are used to compute the static variables.
If `render_as_defaults` is True, the run variables will not be overridden.
The run variables are included in the result.
The run variables are used to compute the rendered variable values.
The run variables will not be overridden.
The rendering happens one at a time, with previous results influencing the next.
"""
if self._has_template is None:
self._has_template = template.is_complex(self.variables)
if not self._has_template:
if render_as_defaults:
rendered_variables = dict(self.variables)
rendered_variables = dict(self.variables)
if run_variables is not None:
rendered_variables.update(run_variables)
else:
rendered_variables = (
{} if run_variables is None else dict(run_variables)
)
rendered_variables.update(self.variables)
if run_variables is not None:
rendered_variables.update(run_variables)
return rendered_variables
@@ -56,7 +51,7 @@ class ScriptVariables:
for key, value in self.variables.items():
# We can skip if we're going to override this key with
# run variables anyway
if render_as_defaults and key in rendered_variables:
if key in rendered_variables:
continue
rendered_variables[key] = template.render_complex(
@@ -65,6 +60,197 @@ class ScriptVariables:
return rendered_variables
@callback
def async_simple_render(self, run_variables: Mapping[str, Any]) -> dict[str, Any]:
"""Render script variables.
Simply renders the variables, the run variables are not included in the result.
The run variables are used to compute the rendered variable values.
The rendering happens one at a time, with previous results influencing the next.
"""
if self._has_template is None:
self._has_template = template.is_complex(self.variables)
if not self._has_template:
return self.variables
run_variables = dict(run_variables)
rendered_variables = {}
for key, value in self.variables.items():
rendered_variable = template.render_complex(value, run_variables)
rendered_variables[key] = rendered_variable
run_variables[key] = rendered_variable
return rendered_variables
def as_dict(self) -> dict[str, Any]:
"""Return dict version of this class."""
return self.variables
@dataclass
class _ParallelData:
"""Data used in each parallel sequence."""
# `protected` is for variables that need special protection in parallel sequences.
# What this means is that such a variable defined in one parallel sequence will not be
# clobbered by the variable with the same name assigned in another parallel sequence.
# It also means that such a variable will not be visible in the outer scope.
# Currently the only such variable is `wait`.
protected: dict[str, Any] = field(default_factory=dict)
# `outer_scope_writes` is for variables that are written to the outer scope from
# a parallel sequence. This is used for generating correct traces of changed variables
# for each of the parallel sequences, isolating them from one another.
outer_scope_writes: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class ScriptRunVariables(UserDict[str, Any]):
"""Class to hold script run variables.
The purpose of this class is to provide proper variable scoping semantics for scripts.
Each instance institutes a new local scope, in which variables can be defined.
Each instance has a reference to the previous instance, except for the top-level instance.
The instances therefore form a chain, in which variable lookup and assignment is performed.
The variables defined lower in the chain naturally override those defined higher up.
"""
# _previous is the previous ScriptRunVariables in the chain
_previous: ScriptRunVariables | None = None
# _parent is the previous non-empty ScriptRunVariables in the chain
_parent: ScriptRunVariables | None = None
# _local_data is the store for local variables
_local_data: dict[str, Any] | None = None
# _parallel_data is used for each parallel sequence
_parallel_data: _ParallelData | None = None
# _non_parallel_scope includes all scopes all the way to the most recent parallel split
_non_parallel_scope: ChainMap[str, Any]
# _full_scope includes all scopes (all the way to the top-level)
_full_scope: ChainMap[str, Any]
@classmethod
def create_top_level(
cls,
initial_data: Mapping[str, Any] | None = None,
) -> ScriptRunVariables:
"""Create a new top-level ScriptRunVariables."""
local_data: dict[str, Any] = {}
non_parallel_scope = full_scope = ChainMap(local_data)
self = cls(
_local_data=local_data,
_non_parallel_scope=non_parallel_scope,
_full_scope=full_scope,
)
if initial_data is not None:
self.update(initial_data)
return self
def enter_scope(self, *, parallel: bool = False) -> ScriptRunVariables:
"""Return a new child scope.
:param parallel: Whether the new scope starts a parallel sequence.
"""
if self._local_data is not None or self._parallel_data is not None:
parent = self
else:
parent = cast( # top level always has local data, so we can cast safely
ScriptRunVariables, self._parent
)
parallel_data: _ParallelData | None
if not parallel:
parallel_data = None
non_parallel_scope = self._non_parallel_scope
full_scope = self._full_scope
else:
parallel_data = _ParallelData()
non_parallel_scope = ChainMap(
parallel_data.protected, parallel_data.outer_scope_writes
)
full_scope = self._full_scope.new_child(parallel_data.protected)
return ScriptRunVariables(
_previous=self,
_parent=parent,
_parallel_data=parallel_data,
_non_parallel_scope=non_parallel_scope,
_full_scope=full_scope,
)
def exit_scope(self) -> ScriptRunVariables:
"""Exit the current scope.
Does no clean-up, but simply returns the previous scope.
"""
if self._previous is None:
raise ValueError("Cannot exit top-level scope")
return self._previous
def __delitem__(self, key: str) -> None:
"""Delete a variable (disallowed)."""
raise TypeError("Deleting items is not allowed in ScriptRunVariables.")
def __setitem__(self, key: str, value: Any) -> None:
"""Assign value to a variable."""
self._assign(key, value, parallel_protected=False)
def assign_parallel_protected(self, key: str, value: Any) -> None:
"""Assign value to a variable which is to be protected in parallel sequences."""
self._assign(key, value, parallel_protected=True)
def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None:
"""Assign value to a variable.
Value is always assigned to the variable in the nearest scope, in which it is defined.
If the variable is not defined at all, it is created in the top-level scope.
:param parallel_protected: Whether variable is to be protected in parallel sequences.
"""
if self._local_data is not None and key in self._local_data:
self._local_data[key] = value
return
if self._parent is None:
assert self._local_data is not None # top level always has local data
self._local_data[key] = value
return
if self._parallel_data is not None:
if parallel_protected:
self._parallel_data.protected[key] = value
return
self._parallel_data.protected.pop(key, None)
self._parallel_data.outer_scope_writes[key] = value
self._parent._assign(key, value, parallel_protected=parallel_protected) # noqa: SLF001
def define_local(self, key: str, value: Any) -> None:
"""Define a local variable and assign value to it."""
if self._local_data is None:
self._local_data = {}
self._non_parallel_scope = self._non_parallel_scope.new_child(
self._local_data
)
self._full_scope = self._full_scope.new_child(self._local_data)
self._local_data[key] = value
@property
def data(self) -> Mapping[str, Any]: # type: ignore[override]
"""Return variables in full scope.
Defined here for UserDict compatibility.
"""
return self._full_scope
@property
def non_parallel_scope(self) -> Mapping[str, Any]:
"""Return variables in non-parallel scope."""
return self._non_parallel_scope
@property
def local_scope(self) -> Mapping[str, Any]:
"""Return variables in local scope."""
return self._local_data if self._local_data is not None else {}

View File

@@ -33,12 +33,12 @@ dbus-fast==2.33.0
fnv-hash-fast==1.2.6
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
habluetooth==3.24.0
habluetooth==3.24.1
hass-nabucasa==0.92.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250221.0
home-assistant-intents==2025.2.5
home-assistant-frontend==20250227.0
home-assistant-intents==2025.2.26
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.5

View File

@@ -1,13 +1,101 @@
{
"common": {
"generic": {
"model": "Model",
"ui_managed": "Managed via UI"
"action": {
"close": "Close",
"connect": "Connect",
"disable": "Disable",
"disconnect": "Disconnect",
"enable": "Enable",
"open": "Open",
"pause": "Pause",
"reload": "Reload",
"restart": "Restart",
"start": "Start",
"stop": "Stop",
"toggle": "Toggle",
"turn_off": "Turn off",
"turn_on": "Turn on"
},
"config_flow": {
"abort": {
"already_configured_account": "Account is already configured",
"already_configured_device": "Device is already configured",
"already_configured_location": "Location is already configured",
"already_configured_service": "Service is already configured",
"already_in_progress": "Configuration flow is already in progress",
"cloud_not_connected": "Not connected to Home Assistant Cloud.",
"no_devices_found": "No devices found on the network",
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
"oauth2_error": "Received invalid token data.",
"oauth2_failed": "Error while obtaining access token.",
"oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
"oauth2_missing_credentials": "The integration requires application credentials.",
"oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"oauth2_timeout": "Timeout resolving OAuth token.",
"oauth2_unauthorized": "OAuth authorization error while obtaining access token.",
"oauth2_user_rejected_authorize": "Account linking rejected: {error}",
"reauth_successful": "Re-authentication was successful",
"reconfigure_successful": "Re-configuration was successful",
"single_instance_allowed": "Already configured. Only a single configuration possible.",
"unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
"webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages."
},
"create_entry": {
"authenticated": "Successfully authenticated"
},
"data": {
"access_token": "Access token",
"api_key": "API key",
"api_token": "API token",
"device": "Device",
"elevation": "Elevation",
"email": "Email",
"host": "Host",
"ip": "IP address",
"language": "Language",
"latitude": "Latitude",
"llm_hass_api": "Control Home Assistant",
"location": "Location",
"longitude": "Longitude",
"mode": "Mode",
"name": "Name",
"password": "Password",
"path": "Path",
"pin": "PIN code",
"port": "Port",
"ssl": "Uses an SSL certificate",
"url": "URL",
"usb_path": "USB device path",
"username": "Username",
"verify_ssl": "Verify SSL certificate"
},
"description": {
"confirm_setup": "Do you want to start setup?"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_access_token": "Invalid access token",
"invalid_api_key": "Invalid API key",
"invalid_auth": "Invalid authentication",
"invalid_host": "Invalid hostname or IP address",
"timeout_connect": "Timeout establishing connection",
"unknown": "Unexpected error"
},
"title": {
"oauth2_pick_implementation": "Pick authentication method",
"reauth": "Authentication expired for {name}",
"via_hassio_addon": "{name} via Home Assistant add-on"
}
},
"device_automation": {
"action_type": {
"toggle": "Toggle {entity_name}",
"turn_off": "Turn off {entity_name}",
"turn_on": "Turn on {entity_name}"
},
"condition_type": {
"is_on": "{entity_name} is on",
"is_off": "{entity_name} is off"
"is_off": "{entity_name} is off",
"is_on": "{entity_name} is on"
},
"extra_fields": {
"above": "Above",
@@ -19,30 +107,35 @@
},
"trigger_type": {
"changed_states": "{entity_name} turned on or off",
"turned_on": "{entity_name} turned on",
"turned_off": "{entity_name} turned off"
},
"action_type": {
"toggle": "Toggle {entity_name}",
"turn_on": "Turn on {entity_name}",
"turn_off": "Turn off {entity_name}"
"turned_off": "{entity_name} turned off",
"turned_on": "{entity_name} turned on"
}
},
"action": {
"connect": "Connect",
"disconnect": "Disconnect",
"enable": "Enable",
"disable": "Disable",
"generic": {
"model": "Model",
"ui_managed": "Managed via UI"
},
"state": {
"active": "Active",
"charging": "Charging",
"closed": "Closed",
"connected": "Connected",
"disabled": "Disabled",
"discharging": "Discharging",
"disconnected": "Disconnected",
"enabled": "Enabled",
"home": "Home",
"idle": "Idle",
"locked": "Locked",
"no": "No",
"not_home": "Away",
"off": "Off",
"on": "On",
"open": "Open",
"close": "Close",
"reload": "Reload",
"restart": "Restart",
"start": "Start",
"stop": "Stop",
"pause": "Pause",
"turn_on": "Turn on",
"turn_off": "Turn off",
"toggle": "Toggle"
"paused": "Paused",
"standby": "Standby",
"unlocked": "Unlocked",
"yes": "Yes"
},
"time": {
"monday": "Monday",
@@ -52,99 +145,6 @@
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
},
"state": {
"off": "Off",
"on": "On",
"yes": "Yes",
"no": "No",
"open": "Open",
"closed": "Closed",
"enabled": "Enabled",
"disabled": "Disabled",
"connected": "Connected",
"disconnected": "Disconnected",
"locked": "Locked",
"unlocked": "Unlocked",
"active": "Active",
"idle": "Idle",
"standby": "Standby",
"paused": "Paused",
"home": "Home",
"not_home": "Away",
"charging": "Charging",
"discharging": "Discharging"
},
"config_flow": {
"title": {
"oauth2_pick_implementation": "Pick authentication method",
"reauth": "Authentication expired for {name}",
"via_hassio_addon": "{name} via Home Assistant add-on"
},
"description": {
"confirm_setup": "Do you want to start setup?"
},
"data": {
"device": "Device",
"name": "Name",
"email": "Email",
"username": "Username",
"password": "Password",
"host": "Host",
"ip": "IP address",
"port": "Port",
"url": "URL",
"usb_path": "USB device path",
"access_token": "Access token",
"api_key": "API key",
"api_token": "API token",
"llm_hass_api": "Control Home Assistant",
"ssl": "Uses an SSL certificate",
"verify_ssl": "Verify SSL certificate",
"elevation": "Elevation",
"longitude": "Longitude",
"latitude": "Latitude",
"location": "Location",
"pin": "PIN code",
"mode": "Mode",
"path": "Path",
"language": "Language"
},
"create_entry": {
"authenticated": "Successfully authenticated"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_access_token": "Invalid access token",
"invalid_api_key": "Invalid API key",
"invalid_auth": "Invalid authentication",
"invalid_host": "Invalid hostname or IP address",
"unknown": "Unexpected error",
"timeout_connect": "Timeout establishing connection"
},
"abort": {
"single_instance_allowed": "Already configured. Only a single configuration possible.",
"already_configured_account": "Account is already configured",
"already_configured_device": "Device is already configured",
"already_configured_location": "Location is already configured",
"already_configured_service": "Service is already configured",
"already_in_progress": "Configuration flow is already in progress",
"no_devices_found": "No devices found on the network",
"webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.",
"oauth2_error": "Received invalid token data.",
"oauth2_timeout": "Timeout resolving OAuth token.",
"oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
"oauth2_missing_credentials": "The integration requires application credentials.",
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
"oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"oauth2_user_rejected_authorize": "Account linking rejected: {error}",
"oauth2_unauthorized": "OAuth authorization error while obtaining access token.",
"oauth2_failed": "Error while obtaining access token.",
"reauth_successful": "Re-authentication was successful",
"reconfigure_successful": "Re-configuration was successful",
"unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
"cloud_not_connected": "Not connected to Home Assistant Cloud."
}
}
}
}

View File

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

35
requirements_all.txt generated
View File

@@ -131,7 +131,7 @@ TwitterAPI==2.7.12
WSDiscovery==2.1.2
# homeassistant.components.accuweather
accuweather==4.0.0
accuweather==4.1.0
# homeassistant.components.adax
adax==0.4.0
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==29.1.1
aioesphomeapi==29.2.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==12.4.2
aioshelly==13.0.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -603,7 +603,7 @@ bizkaibus==0.1.1
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
bleak-esphome==2.7.1
bleak-esphome==2.8.0
# homeassistant.components.bluetooth
bleak-retry-connector==3.9.0
@@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.24.0
habluetooth==3.24.1
# homeassistant.components.cloud
hass-nabucasa==0.92.0
@@ -1152,10 +1152,10 @@ hole==0.8.0
holidays==0.67
# homeassistant.components.frontend
home-assistant-frontend==20250221.0
home-assistant-frontend==20250227.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.5
home-assistant-intents==2025.2.26
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
@@ -1447,7 +1447,7 @@ mozart-api==4.1.1.116.4
mullvad-api==1.0.0
# homeassistant.components.music_assistant
music-assistant-client==1.0.8
music-assistant-client==1.1.1
# homeassistant.components.tts
mutagen==1.47.0
@@ -1565,7 +1565,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.11
onedrive-personal-sdk==0.0.12
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -1843,7 +1843,7 @@ pyblackbird==0.6
pyblu==2.0.0
# homeassistant.components.neato
pybotvac==0.0.25
pybotvac==0.0.26
# homeassistant.components.braviatv
pybravia==0.3.4
@@ -2310,10 +2310,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartapp==0.3.5
# homeassistant.components.smartthings
pysmartthings==0.7.8
pysmartthings==2.0.0
# homeassistant.components.smarty
pysmarty2==0.10.2
@@ -2467,7 +2464,7 @@ python-ripple-api==0.0.3
python-roborock==2.11.1
# homeassistant.components.smarttub
python-smarttub==0.0.38
python-smarttub==0.0.39
# homeassistant.components.snoo
python-snoo==0.6.0
@@ -2479,7 +2476,7 @@ python-songpal==0.16.2
python-tado==0.18.6
# homeassistant.components.technove
python-technove==1.3.1
python-technove==2.0.0
# homeassistant.components.telegram_bot
python-telegram-bot[socks]==21.5
@@ -2621,7 +2618,7 @@ renault-api==0.2.9
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.12.0
reolink-aio==0.12.1
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2811,7 +2808,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
stookwijzer==1.5.8
stookwijzer==1.6.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -3152,7 +3149,7 @@ zeroconf==0.145.1
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.49
zha==0.0.51
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0
WSDiscovery==2.1.2
# homeassistant.components.accuweather
accuweather==4.0.0
accuweather==4.1.0
# homeassistant.components.adax
adax==0.4.0
@@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==29.1.1
aioesphomeapi==29.2.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -353,7 +353,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==12.4.2
aioshelly==13.0.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
bleak-esphome==2.7.1
bleak-esphome==2.8.0
# homeassistant.components.bluetooth
bleak-retry-connector==3.9.0
@@ -950,7 +950,7 @@ ha-philipsjs==3.2.2
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.24.0
habluetooth==3.24.1
# homeassistant.components.cloud
hass-nabucasa==0.92.0
@@ -981,10 +981,10 @@ hole==0.8.0
holidays==0.67
# homeassistant.components.frontend
home-assistant-frontend==20250221.0
home-assistant-frontend==20250227.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.5
home-assistant-intents==2025.2.26
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
@@ -1219,7 +1219,7 @@ mozart-api==4.1.1.116.4
mullvad-api==1.0.0
# homeassistant.components.music_assistant
music-assistant-client==1.0.8
music-assistant-client==1.1.1
# homeassistant.components.tts
mutagen==1.47.0
@@ -1313,7 +1313,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.11
onedrive-personal-sdk==0.0.12
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -1520,7 +1520,7 @@ pyblackbird==0.6
pyblu==2.0.0
# homeassistant.components.neato
pybotvac==0.0.25
pybotvac==0.0.26
# homeassistant.components.braviatv
pybravia==0.3.4
@@ -1882,10 +1882,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartapp==0.3.5
# homeassistant.components.smartthings
pysmartthings==0.7.8
pysmartthings==2.0.0
# homeassistant.components.smarty
pysmarty2==0.10.2
@@ -2000,7 +1997,7 @@ python-rabbitair==0.0.8
python-roborock==2.11.1
# homeassistant.components.smarttub
python-smarttub==0.0.38
python-smarttub==0.0.39
# homeassistant.components.snoo
python-snoo==0.6.0
@@ -2012,7 +2009,7 @@ python-songpal==0.16.2
python-tado==0.18.6
# homeassistant.components.technove
python-technove==1.3.1
python-technove==2.0.0
# homeassistant.components.telegram_bot
python-telegram-bot[socks]==21.5
@@ -2124,7 +2121,7 @@ renault-api==0.2.9
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.12.0
reolink-aio==0.12.1
# homeassistant.components.rflink
rflink==0.0.66
@@ -2272,7 +2269,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
stookwijzer==1.5.8
stookwijzer==1.6.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2541,7 +2538,7 @@ zeroconf==0.145.1
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.49
zha==0.0.51
# homeassistant.components.zwave_js
zwave-js-server-python==0.60.1

View File

@@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \
stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"

View File

@@ -185,6 +185,8 @@ def gen_data_entry_schema(
vol.Optional("abort"): {str: translation_value_validator},
vol.Optional("progress"): {str: translation_value_validator},
vol.Optional("create_entry"): {str: translation_value_validator},
vol.Optional("initiate_flow"): {str: translation_value_validator},
vol.Optional("entry_type"): translation_value_validator,
}
if flow_title == REQUIRED:
schema[vol.Required("title")] = translation_value_validator
@@ -289,7 +291,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
gen_data_entry_schema(
config=config,
integration=integration,
flow_title=REQUIRED,
flow_title=REMOVED,
require_step_title=False,
),
slug_validator=vol.Any("_", cv.slug),

View File

@@ -488,6 +488,7 @@ async def test_unknown_hass_api(
CONF_LLM_HASS_API: "non-existing",
},
)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "hello", "1234", Context(), agent_id="conversation.claude"

View File

@@ -1311,7 +1311,7 @@ async def test_initiate_backup_with_task_error(
(1, None, 1, None, 1, None, 1, OSError("Boom!")),
],
)
async def test_initiate_backup_file_error(
async def test_initiate_backup_file_error_upload_to_agents(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
@@ -1325,7 +1325,7 @@ async def test_initiate_backup_file_error(
unlink_call_count: int,
unlink_exception: Exception | None,
) -> None:
"""Test file error during generate backup."""
"""Test file error during generate backup, while uploading to agents."""
agent_ids = ["test.remote"]
await setup_backup_integration(hass, remote_agents=["test.remote"])
@@ -1418,6 +1418,122 @@ async def test_initiate_backup_file_error(
assert unlink_mock.call_count == unlink_call_count
@pytest.mark.usefixtures("mock_backup_generation")
@pytest.mark.parametrize(
(
"mkdir_call_count",
"mkdir_exception",
"atomic_contents_add_call_count",
"atomic_contents_add_exception",
"stat_call_count",
"stat_exception",
"error_message",
),
[
(1, OSError("Boom!"), 0, None, 0, None, "Failed to create dir"),
(1, None, 1, OSError("Boom!"), 0, None, "Boom!"),
(1, None, 1, None, 1, OSError("Boom!"), "Error getting size"),
],
)
async def test_initiate_backup_file_error_create_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
path_glob: MagicMock,
caplog: pytest.LogCaptureFixture,
mkdir_call_count: int,
mkdir_exception: Exception | None,
atomic_contents_add_call_count: int,
atomic_contents_add_exception: Exception | None,
stat_call_count: int,
stat_exception: Exception | None,
error_message: str,
) -> None:
"""Test file error during generate backup, while creating backup."""
agent_ids = ["test.remote"]
await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
path_glob.return_value = []
await ws_client.send_json_auto_id({"type": "backup/info"})
result = await ws_client.receive_json()
assert result["success"] is True
assert result["result"] == {
"backups": [],
"agent_errors": {},
"last_attempted_automatic_backup": None,
"last_completed_automatic_backup": None,
"last_non_idle_event": None,
"next_automatic_backup": None,
"next_automatic_backup_additional": False,
"state": "idle",
}
await ws_client.send_json_auto_id({"type": "backup/subscribe_events"})
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
result = await ws_client.receive_json()
assert result["success"] is True
with (
patch(
"homeassistant.components.backup.manager.atomic_contents_add",
side_effect=atomic_contents_add_exception,
) as atomic_contents_add_mock,
patch("pathlib.Path.mkdir", side_effect=mkdir_exception) as mkdir_mock,
patch("pathlib.Path.stat", side_effect=stat_exception) as stat_mock,
):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": None,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["success"] is True
backup_id = result["result"]["backup_job_id"]
assert backup_id == generate_backup_id.return_value
await hass.async_block_till_done()
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": CreateBackupStage.HOME_ASSISTANT,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": "upload_failed",
"stage": None,
"state": CreateBackupState.FAILED,
}
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
assert atomic_contents_add_mock.call_count == atomic_contents_add_call_count
assert mkdir_mock.call_count == mkdir_call_count
assert stat_mock.call_count == stat_call_count
assert error_message in caplog.text
def _mock_local_backup_agent(name: str) -> Mock:
local_agent = mock_backup_agent(name)
# This makes the local_agent pass isinstance checks for LocalBackupAgent

View File

@@ -11,6 +11,7 @@ from homeassistant.components.climate import (
ATTR_HUMIDITY,
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -20,10 +21,11 @@ from homeassistant.components.climate import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.components.climate.const import HVACMode
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
@@ -211,6 +213,20 @@ def set_operation_mode(
hass.services.call(DOMAIN, SERVICE_SET_HVAC_MODE, data)
async def async_set_swing_horizontal_mode(
hass: HomeAssistant, swing_horizontal_mode: str, entity_id: str = ENTITY_MATCH_ALL
) -> None:
"""Set new target swing horizontal mode."""
data = {ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}
if entity_id is not None:
data[ATTR_ENTITY_ID] = entity_id
await hass.services.async_call(
DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, data, blocking=True
)
async def async_set_swing_mode(
hass: HomeAssistant, swing_mode: str, entity_id: str = ENTITY_MATCH_ALL
) -> None:

View File

@@ -6,7 +6,7 @@ import asyncio
from asyncio import Event
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from aioesphomeapi import (
@@ -17,6 +17,7 @@ from aioesphomeapi import (
EntityInfo,
EntityState,
HomeassistantServiceCall,
LogLevel,
ReconnectLogic,
UserService,
VoiceAssistantAnnounceFinished,
@@ -42,6 +43,10 @@ from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG
from tests.common import MockConfigEntry
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import SubscribeLogsResponse
_ONE_SECOND = 16000 * 2 # 16Khz 16-bit
@@ -154,6 +159,7 @@ def mock_client(mock_device_info) -> APIClient:
mock_client.device_info = AsyncMock(return_value=mock_device_info)
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.subscribe_logs = Mock()
mock_client.list_entities_services = AsyncMock(return_value=([], []))
mock_client.address = "127.0.0.1"
mock_client.api_version = APIVersion(99, 99)
@@ -222,7 +228,9 @@ class MockESPHomeDevice:
]
| None
)
self.on_log_message: Callable[[SubscribeLogsResponse], None]
self.device_info = device_info
self.current_log_level = LogLevel.LOG_LEVEL_NONE
def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None:
"""Set the state callback."""
@@ -250,6 +258,16 @@ class MockESPHomeDevice:
"""Mock disconnecting."""
await self.on_disconnect(expected_disconnect)
def set_on_log_message(
self, on_log_message: Callable[[SubscribeLogsResponse], None]
) -> None:
"""Set the log message callback."""
self.on_log_message = on_log_message
def mock_on_log_message(self, log_message: SubscribeLogsResponse) -> None:
"""Mock on log message."""
self.on_log_message(log_message)
def set_on_connect(self, on_connect: Callable[[], None]) -> None:
"""Set the connect callback."""
self.on_connect = on_connect
@@ -413,6 +431,14 @@ async def _mock_generic_device_entry(
on_state_sub, on_state_request
)
def _subscribe_logs(
on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel
) -> Callable[[], None]:
"""Subscribe to log messages."""
mock_device.set_on_log_message(on_log_message)
mock_device.current_log_level = log_level
return lambda: None
def _subscribe_voice_assistant(
*,
handle_start: Callable[
@@ -453,6 +479,7 @@ async def _mock_generic_device_entry(
mock_client.subscribe_states = _subscribe_states
mock_client.subscribe_service_calls = _subscribe_service_calls
mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states
mock_client.subscribe_logs = _subscribe_logs
try_connect_done = Event()

View File

@@ -23,6 +23,7 @@ from homeassistant.components.esphome.const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
@@ -1295,14 +1296,57 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert result["step_id"] == "encryption_key"
@pytest.mark.parametrize("option_value", [True, False])
async def test_option_flow(
async def test_option_flow_allow_service_calls(
hass: HomeAssistant,
option_value: bool,
mock_client: APIClient,
mock_generic_device_entry,
) -> None:
"""Test config flow options."""
"""Test config flow options for allow service calls."""
entry = await mock_generic_device_entry(
mock_client=mock_client,
entity_info=[],
user_service=[],
states=[],
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
assert result["data_schema"]({}) == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
CONF_SUBSCRIBE_LOGS: False,
}
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
assert result["data_schema"]({}) == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
CONF_SUBSCRIBE_LOGS: False,
}
with patch(
"homeassistant.components.esphome.async_setup_entry", return_value=True
) as mock_reload:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_ALLOW_SERVICE_CALLS: True, CONF_SUBSCRIBE_LOGS: False},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_ALLOW_SERVICE_CALLS: True,
CONF_SUBSCRIBE_LOGS: False,
}
assert len(mock_reload.mock_calls) == 1
async def test_option_flow_subscribe_logs(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry,
) -> None:
"""Test config flow options with subscribe logs."""
entry = await mock_generic_device_entry(
mock_client=mock_client,
entity_info=[],
@@ -1315,7 +1359,8 @@ async def test_option_flow(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
assert result["data_schema"]({}) == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
CONF_SUBSCRIBE_LOGS: False,
}
with patch(
@@ -1323,15 +1368,16 @@ async def test_option_flow(
) as mock_reload:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_ALLOW_SERVICE_CALLS: option_value,
},
user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: True},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value}
assert len(mock_reload.mock_calls) == int(option_value)
assert result["data"] == {
CONF_ALLOW_SERVICE_CALLS: False,
CONF_SUBSCRIBE_LOGS: True,
}
assert len(mock_reload.mock_calls) == 1
@pytest.mark.usefixtures("mock_zeroconf")

View File

@@ -2,7 +2,8 @@
import asyncio
from collections.abc import Awaitable, Callable
from unittest.mock import AsyncMock, call
import logging
from unittest.mock import AsyncMock, Mock, call
from aioesphomeapi import (
APIClient,
@@ -13,6 +14,7 @@ from aioesphomeapi import (
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
RequiresEncryptionAPIError,
UserService,
UserServiceArg,
@@ -24,6 +26,7 @@ from homeassistant import config_entries
from homeassistant.components.esphome.const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_SUBSCRIBE_LOGS,
DOMAIN,
STABLE_BLE_VERSION_STR,
)
@@ -44,6 +47,95 @@ from .conftest import MockESPHomeDevice
from tests.common import MockConfigEntry, async_capture_events, async_mock_service
async def test_esphome_device_subscribe_logs(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: Callable[
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
Awaitable[MockESPHomeDevice],
],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test configuring a device to subscribe to logs."""
assert await async_setup_component(hass, "logger", {"logger": {}})
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "fe80::1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
},
options={CONF_SUBSCRIBE_LOGS: True},
)
entry.add_to_hass(hass)
device: MockESPHomeDevice = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
entity_info=[],
user_service=[],
device_info={},
states=[],
)
await hass.async_block_till_done()
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "DEBUG"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
caplog.set_level(logging.DEBUG)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message")
)
await hass.async_block_till_done()
assert "test_log_message" in caplog.text
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message")
)
await hass.async_block_till_done()
assert "test_error_log_message" in caplog.text
caplog.set_level(logging.ERROR)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
)
await hass.async_block_till_done()
assert "test_debug_log_message" not in caplog.text
caplog.set_level(logging.DEBUG)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
)
await hass.async_block_till_done()
assert "test_debug_log_message" in caplog.text
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "WARNING"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_WARN
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "ERROR"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "INFO"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG
async def test_esphome_device_service_calls_not_allowed(
hass: HomeAssistant,
mock_client: APIClient,

View File

@@ -75,21 +75,35 @@ async def test_coordinator_update_failing_get_appliances(
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_coordinator_update_failing_get_settings_status(
@pytest.mark.parametrize(
"mock_method",
[
"get_settings",
"get_status",
"get_all_programs",
"get_available_commands",
"get_available_program",
],
)
async def test_coordinator_update_failing(
mock_method: str,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client_with_exception: MagicMock,
client: MagicMock,
) -> None:
"""Test that although is not possible to get settings and status, the config entry is loaded.
This is for cases where some appliances are reachable and some are not in the same configuration entry.
"""
# Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again
setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError()))
assert config_entry.state == ConfigEntryState.NOT_LOADED
await integration_setup(client_with_exception)
await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
getattr(client, mock_method).assert_called()
@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True)
@pytest.mark.parametrize(

View File

@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import (
ArrayOfEvents,
ArrayOfHomeAppliances,
ArrayOfPrograms,
Event,
EventKey,
@@ -22,6 +23,7 @@ from aiohomeconnect.model.error import (
SelectedProgramNotSetError,
)
from aiohomeconnect.model.program import (
EnumerateProgram,
ProgramDefinitionConstraints,
ProgramDefinitionOption,
)
@@ -233,6 +235,198 @@ async def test_program_options_retrieval(
assert hass.states.is_state(entity_id, STATE_UNKNOWN)
@pytest.mark.parametrize(
("array_of_programs_program_arg", "event_key"),
[
(
"active",
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
(
"selected",
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
],
)
async def test_no_options_retrieval_on_unknown_program(
array_of_programs_program_arg: str,
event_key: EventKey,
appliance_ha_id: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that no options are retrieved when the program is unknown."""
async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms:
return ArrayOfPrograms(
**(
{
"programs": [
EnumerateProgram(ProgramKey.UNKNOWN, "unknown program")
],
array_of_programs_program_arg: Program(
ProgramKey.UNKNOWN, options=[]
),
}
)
)
client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert client.get_available_program.call_count == 0
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.UNKNOWN,
)
]
),
)
]
)
await hass.async_block_till_done()
assert client.get_available_program.call_count == 0
@pytest.mark.parametrize(
"event_key",
[
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
],
)
@pytest.mark.parametrize(
("appliance_ha_id", "option_key", "option_entity_id"),
[
(
"Dishwasher",
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
"switch.dishwasher_half_load",
)
],
indirect=["appliance_ha_id"],
)
async def test_program_options_retrieval_after_appliance_connection(
event_key: EventKey,
appliance_ha_id: str,
option_key: OptionKey,
option_entity_id: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that the options are correctly retrieved at the start and updated on program updates."""
array_of_home_appliances = client.get_home_appliances.return_value
async def get_home_appliances_with_options_mock() -> ArrayOfHomeAppliances:
return ArrayOfHomeAppliances(
[
appliance
for appliance in array_of_home_appliances.homeappliances
if appliance.ha_id != appliance_ha_id
]
)
client.get_home_appliances = AsyncMock(
side_effect=get_home_appliances_with_options_mock
)
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[],
)
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert not hass.states.get(option_entity_id)
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.CONNECTED,
data=ArrayOfEvents(
[
Event(
key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
raw_key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED.value,
timestamp=0,
level="",
handling="",
value="",
)
]
),
)
]
)
await hass.async_block_till_done()
assert not hass.states.get(option_entity_id)
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
option_key,
"Boolean",
constraints=ProgramDefinitionConstraints(
default=False,
),
),
],
)
)
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1,
)
]
),
)
]
)
await hass.async_block_till_done()
assert hass.states.get(option_entity_id)
@pytest.mark.parametrize(
(
"set_active_program_option_side_effect",

View File

@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import (
ArrayOfEvents,
ArrayOfStatus,
Event,
EventKey,
EventMessage,
@@ -565,3 +566,85 @@ async def test_sensors_states(
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected)
@pytest.mark.parametrize(
(
"appliance_ha_id",
"entity_id",
"status_key",
"unit_get_status",
"unit_get_status_value",
"get_status_value_call_count",
),
[
(
"Oven",
"sensor.oven_current_oven_cavity_temperature",
StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
"°C",
None,
0,
),
(
"Oven",
"sensor.oven_current_oven_cavity_temperature",
StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
None,
"°C",
1,
),
],
indirect=["appliance_ha_id"],
)
async def test_sensor_unit_fetching(
appliance_ha_id: str,
entity_id: str,
status_key: StatusKey,
unit_get_status: str | None,
unit_get_status_value: str | None,
get_status_value_call_count: int,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that the sensor entities are capable of fetching units."""
async def get_status_mock(ha_id: str) -> ArrayOfStatus:
if ha_id != appliance_ha_id:
return ArrayOfStatus([])
return ArrayOfStatus(
[
Status(
key=status_key,
raw_key=status_key.value,
value=0,
unit=unit_get_status,
)
]
)
client.get_status = AsyncMock(side_effect=get_status_mock)
client.get_status_value = AsyncMock(
return_value=Status(
key=status_key,
raw_key=status_key.value,
value=0,
unit=unit_get_status_value,
)
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
entity_state = hass.states.get(entity_id)
assert entity_state
assert (
entity_state.attributes["unit_of_measurement"] == unit_get_status
or unit_get_status_value
)
assert client.get_status_value.call_count == get_status_value_call_count

View File

@@ -497,28 +497,48 @@ async def test_list_exposed_entities(
entry1 = entity_registry.async_get_or_create("test", "test", "unique1")
entry2 = entity_registry.async_get_or_create("test", "test", "unique2")
entity_registry.async_get_or_create("test", "test", "unique3")
# Set options for registered entities
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa", "cloud.google_assistant"],
"entity_ids": [entry1.entity_id, entry2.entity_id],
"entity_ids": [entry1.entity_id],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa", "cloud.google_assistant"],
"entity_ids": [entry2.entity_id],
"should_expose": False,
}
)
response = await ws_client.receive_json()
assert response["success"]
# Set options for entities not in the entity registry
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa", "cloud.google_assistant"],
"entity_ids": [
"test.test",
"test.test2",
],
"entity_ids": ["test.test"],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa", "cloud.google_assistant"],
"entity_ids": ["test.test2"],
"should_expose": False,
}
)
@@ -531,10 +551,8 @@ async def test_list_exposed_entities(
assert response["success"]
assert response["result"] == {
"exposed_entities": {
"test.test": {"cloud.alexa": False, "cloud.google_assistant": False},
"test.test2": {"cloud.alexa": False, "cloud.google_assistant": False},
"test.test": {"cloud.alexa": True, "cloud.google_assistant": True},
"test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True},
"test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True},
},
}

View File

@@ -15,6 +15,7 @@ from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -85,6 +86,7 @@ DEFAULT_CONFIG = {
"temperature_high_command_topic": "temperature-high-topic",
"fan_mode_command_topic": "fan-mode-topic",
"swing_mode_command_topic": "swing-mode-topic",
"swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic",
"preset_mode_command_topic": "preset-mode-topic",
"preset_modes": [
"eco",
@@ -111,6 +113,7 @@ async def test_setup_params(
assert state.attributes.get("temperature") == 21
assert state.attributes.get("fan_mode") == "low"
assert state.attributes.get("swing_mode") == "off"
assert state.attributes.get("swing_horizontal_mode") == "off"
assert state.state == "off"
assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP
assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP
@@ -123,6 +126,7 @@ async def test_setup_params(
| ClimateEntityFeature.TARGET_HUMIDITY
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_HORIZONTAL_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
@@ -159,6 +163,7 @@ async def test_supported_features(
state = hass.states.get(ENTITY_CLIMATE)
support = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.SWING_HORIZONTAL_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
@@ -562,12 +567,29 @@ async def test_set_swing_mode_bad_attr(
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "off"
assert state.attributes.get("swing_horizontal_mode") == "off"
with pytest.raises(vol.Invalid) as excinfo:
await common.async_set_swing_horizontal_mode(hass, None, ENTITY_CLIMATE) # type:ignore[arg-type]
assert (
"string value is None for dictionary value @ data['swing_horizontal_mode']"
in str(excinfo.value)
)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "off"
@pytest.mark.parametrize(
"hass_config",
[
help_custom_config(
climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},)
climate.DOMAIN,
DEFAULT_CONFIG,
(
{
"swing_mode_state_topic": "swing-state",
"swing_horizontal_mode_state_topic": "swing-horizontal-state",
},
),
)
],
)
@@ -579,19 +601,32 @@ async def test_set_swing_pessimistic(
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") is None
assert state.attributes.get("swing_horizontal_mode") is None
await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") is None
await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") is None
async_fire_mqtt_message(hass, "swing-state", "on")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "on"
async_fire_mqtt_message(hass, "swing-horizontal-state", "on")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "on"
async_fire_mqtt_message(hass, "swing-state", "bogus state")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "on"
async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "on"
@pytest.mark.parametrize(
"hass_config",
@@ -599,7 +634,13 @@ async def test_set_swing_pessimistic(
help_custom_config(
climate.DOMAIN,
DEFAULT_CONFIG,
({"swing_mode_state_topic": "swing-state", "optimistic": True},),
(
{
"swing_mode_state_topic": "swing-state",
"swing_horizontal_mode_state_topic": "swing-horizontal-state",
"optimistic": True,
},
),
)
],
)
@@ -611,19 +652,32 @@ async def test_set_swing_optimistic(
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "off"
assert state.attributes.get("swing_horizontal_mode") == "off"
await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "on"
await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "on"
async_fire_mqtt_message(hass, "swing-state", "off")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "off"
async_fire_mqtt_message(hass, "swing-horizontal-state", "off")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "off"
async_fire_mqtt_message(hass, "swing-state", "bogus state")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "off"
async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state")
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "off"
@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG])
async def test_set_swing(
@@ -638,6 +692,15 @@ async def test_set_swing(
mqtt_mock.async_publish.assert_called_once_with("swing-mode-topic", "on", 0, False)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "on"
mqtt_mock.reset_mock()
assert state.attributes.get("swing_horizontal_mode") == "off"
await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE)
mqtt_mock.async_publish.assert_called_once_with(
"swing-horizontal-mode-topic", "on", 0, False
)
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "on"
@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG])
@@ -1337,6 +1400,7 @@ async def test_get_target_temperature_low_high_with_templates(
"temperature_low_command_topic": "temperature-low-topic",
"temperature_high_command_topic": "temperature-high-topic",
"fan_mode_command_topic": "fan-mode-topic",
"swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic",
"swing_mode_command_topic": "swing-mode-topic",
"preset_mode_command_topic": "preset-mode-topic",
"preset_modes": [
@@ -1359,6 +1423,7 @@ async def test_get_target_temperature_low_high_with_templates(
"action_topic": "action",
"mode_state_topic": "mode-state",
"fan_mode_state_topic": "fan-state",
"swing_horizontal_mode_state_topic": "swing-horizontal-state",
"swing_mode_state_topic": "swing-state",
"temperature_state_topic": "temperature-state",
"target_humidity_state_topic": "humidity-state",
@@ -1396,6 +1461,12 @@ async def test_get_with_templates(
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_mode") == "on"
# Swing Horizontal Mode
assert state.attributes.get("swing_horizontal_mode") is None
async_fire_mqtt_message(hass, "swing-horizontal-state", '"on"')
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "on"
# Temperature - with valid value
assert state.attributes.get("temperature") is None
async_fire_mqtt_message(hass, "temperature-state", '"1031"')
@@ -1495,6 +1566,7 @@ async def test_get_with_templates(
"temperature_low_command_topic": "temperature-low-topic",
"temperature_high_command_topic": "temperature-high-topic",
"fan_mode_command_topic": "fan-mode-topic",
"swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic",
"swing_mode_command_topic": "swing-mode-topic",
"preset_mode_command_topic": "preset-mode-topic",
"preset_modes": [
@@ -1511,6 +1583,7 @@ async def test_get_with_templates(
"power_command_template": "power: {{ value }}",
"preset_mode_command_template": "preset_mode: {{ value }}",
"mode_command_template": "mode: {{ value }}",
"swing_horizontal_mode_command_template": "swing_horizontal_mode: {{ value }}",
"swing_mode_command_template": "swing_mode: {{ value }}",
"temperature_command_template": "temp: {{ value }}",
"temperature_high_command_template": "temp_hi: {{ value }}",
@@ -1580,6 +1653,15 @@ async def test_set_and_templates(
state = hass.states.get(ENTITY_CLIMATE)
assert state.state == "off"
# Swing Horizontal Mode
await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE)
mqtt_mock.async_publish.assert_called_once_with(
"swing-horizontal-mode-topic", "swing_horizontal_mode: on", 0, False
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get(ENTITY_CLIMATE)
assert state.attributes.get("swing_horizontal_mode") == "on"
# Swing Mode
await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE)
mqtt_mock.async_publish.assert_called_once_with(
@@ -1940,6 +2022,7 @@ async def test_unique_id(
("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"),
("mode_state_topic", "cool", None, None),
("mode_state_topic", "fan_only", None, None),
("swing_horizontal_mode_state_topic", "on", ATTR_SWING_HORIZONTAL_MODE, "on"),
("swing_mode_state_topic", "on", ATTR_SWING_MODE, "on"),
("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1),
("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9),
@@ -2178,6 +2261,13 @@ async def test_precision_whole(
"medium",
"fan_mode_command_template",
),
(
climate.SERVICE_SET_SWING_HORIZONTAL_MODE,
"swing_horizontal_mode_command_topic",
{"swing_horizontal_mode": "on"},
"on",
"swing_horizontal_mode_command_template",
),
(
climate.SERVICE_SET_SWING_MODE,
"swing_mode_command_topic",
@@ -2378,6 +2468,7 @@ async def test_unload_entry(
"current_temperature_topic": "current-temperature-topic",
"preset_mode_state_topic": "preset-mode-state-topic",
"preset_modes": ["eco", "away"],
"swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic",
"swing_mode_state_topic": "swing-mode-state-topic",
"target_humidity_state_topic": "target-humidity-state-topic",
"temperature_high_state_topic": "temperature-high-state-topic",
@@ -2399,6 +2490,7 @@ async def test_unload_entry(
("current-humidity-topic", "45", "46"),
("current-temperature-topic", "18.0", "18.1"),
("preset-mode-state-topic", "eco", "away"),
("swing-horizontal-mode-state-topic", "on", "off"),
("swing-mode-state-topic", "on", "off"),
("target-humidity-state-topic", "45", "50"),
("temperature-state-topic", "18", "19"),

View File

@@ -2380,7 +2380,6 @@ ABBREVIATIONS_WHITE_LIST = [
"CONF_PRECISION",
"CONF_QOS",
"CONF_SCHEMA",
"CONF_SWING_MODE_LIST",
"CONF_TEMP_STEP",
# Removed
"CONF_WHITE_VALUE",

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