mirror of
https://github.com/home-assistant/core.git
synced 2025-09-25 04:49:24 +00:00
Compare commits
1 Commits
2025.3.0b2
...
sort-commo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5ae036a7e2 |
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -175,7 +175,7 @@ jobs:
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.02.0
|
||||
uses: home-assistant/builder@2024.08.2
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.02.0
|
||||
uses: home-assistant/builder@2024.08.2
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -462,7 +462,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: translations
|
||||
|
||||
|
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -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.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
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.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
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.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
|
54
.github/workflows/wheels.yml
vendored
54
.github/workflows/wheels.yml
vendored
@@ -138,17 +138,17 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
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.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
@@ -218,7 +218,15 @@ jobs:
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
- name: Split requirements all
|
||||
run: |
|
||||
# We split requirements all into multiple files.
|
||||
# This is to prevent the build from running out of memory when
|
||||
# resolving packages on 32-bits systems (like armhf, armv7).
|
||||
|
||||
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
|
||||
|
||||
- name: Build wheels (part 1)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
@@ -230,4 +238,32 @@ jobs:
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txt"
|
||||
requirements: "requirements_all.txtaa"
|
||||
|
||||
- name: Build wheels (part 2)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtab"
|
||||
|
||||
- name: Build wheels (part 3)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtac"
|
||||
|
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -38,6 +38,7 @@
|
||||
"module": "pytest",
|
||||
"justMyCode": false,
|
||||
"args": [
|
||||
"--timeout=10",
|
||||
"--picked"
|
||||
],
|
||||
},
|
||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1401,8 +1401,6 @@ 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
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==4.1.0"],
|
||||
"requirements": ["accuweather==4.0.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
|
||||
import anthropic
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -22,9 +20,7 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Set up Anthropic from a config entry."""
|
||||
client = await hass.async_add_executor_job(
|
||||
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
||||
)
|
||||
client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY])
|
||||
try:
|
||||
await client.messages.create(
|
||||
model="claude-3-haiku-20240307",
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
@@ -60,9 +59,7 @@ 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 = await hass.async_add_executor_job(
|
||||
partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY])
|
||||
)
|
||||
client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY])
|
||||
await client.messages.create(
|
||||
model="claude-3-haiku-20240307",
|
||||
max_tokens=1,
|
||||
|
@@ -233,6 +233,7 @@ 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."""
|
||||
|
@@ -1103,16 +1103,12 @@ class PipelineRun:
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_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,
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
|
@@ -14,7 +14,6 @@ from itertools import chain
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
|
||||
@@ -309,12 +308,6 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
|
||||
_message = "On-the-fly decryption is not supported for this backup."
|
||||
|
||||
|
||||
class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
|
||||
"""Raised when multiple exceptions occur."""
|
||||
|
||||
error_code = "multiple_errors"
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Define the format that backup managers can have."""
|
||||
|
||||
@@ -1612,24 +1605,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
finally:
|
||||
# Inform integrations the backup is done
|
||||
# If there's an unhandled exception, we keep it so we can rethrow it in case
|
||||
# the post backup actions also fail.
|
||||
unhandled_exc = sys.exception()
|
||||
try:
|
||||
try:
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
except Exception as err:
|
||||
if not unhandled_exc:
|
||||
raise
|
||||
# If there's an unhandled exception, we wrap both that and the exception
|
||||
# from the post backup actions in an ExceptionGroup so the caller is
|
||||
# aware of both exceptions.
|
||||
raise BackupManagerExceptionGroup(
|
||||
f"Multiple errors when creating backup: {unhandled_exc}, {err}",
|
||||
[unhandled_exc, err],
|
||||
) from None
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
|
||||
def _mkdir_and_generate_backup_contents(
|
||||
self,
|
||||
@@ -1641,13 +1620,7 @@ 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"
|
||||
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
|
||||
make_backup_dir(tar_file_path.parent)
|
||||
|
||||
excludes = EXCLUDE_FROM_BACKUP
|
||||
if not database_included:
|
||||
@@ -1685,14 +1658,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
file_filter=is_excluded_by_filter,
|
||||
arcname="data",
|
||||
)
|
||||
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)
|
||||
return (tar_file_path, tar_file_path.stat().st_size)
|
||||
|
||||
async def async_receive_backup(
|
||||
self,
|
||||
|
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.4.4",
|
||||
"bluetooth-data-tools==1.23.4",
|
||||
"dbus-fast==2.33.0",
|
||||
"habluetooth==3.24.1"
|
||||
"habluetooth==3.24.0"
|
||||
]
|
||||
}
|
||||
|
@@ -68,6 +68,7 @@ from .const import ( # noqa: F401
|
||||
FAN_ON,
|
||||
FAN_TOP,
|
||||
HVAC_MODES,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
|
@@ -126,6 +126,7 @@ DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
DOMAIN = "climate"
|
||||
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||
|
||||
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"""Intents for the climate integration."""
|
||||
"""Intents for the client integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.helpers import config_validation as cv, intent
|
||||
from . import (
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
@@ -19,9 +20,49 @@ from . import (
|
||||
|
||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"""Set up the climate intents."""
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
intent.async_register(hass, SetTemperatureIntent())
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
class SetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle SetTemperature intents."""
|
||||
|
||||
|
@@ -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.26"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
|
||||
}
|
||||
|
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"]
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
|
||||
}
|
||||
|
@@ -41,7 +41,6 @@ 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,
|
||||
@@ -509,10 +508,6 @@ 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)
|
||||
|
@@ -5,7 +5,6 @@ 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"
|
||||
|
||||
@@ -13,7 +12,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.2.1"
|
||||
STABLE_BLE_VERSION_STR = "2023.8.0"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
@@ -17,7 +16,6 @@ from aioesphomeapi import (
|
||||
HomeassistantServiceCall,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
ReconnectLogic,
|
||||
RequiresEncryptionAPIError,
|
||||
UserService,
|
||||
@@ -35,7 +33,6 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
@@ -64,7 +61,6 @@ 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,
|
||||
@@ -78,38 +74,8 @@ 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(
|
||||
@@ -170,8 +136,6 @@ class ESPHomeManager:
|
||||
"""Class to manage an ESPHome connection."""
|
||||
|
||||
__slots__ = (
|
||||
"_cancel_subscribe_logs",
|
||||
"_log_level",
|
||||
"cli",
|
||||
"device_id",
|
||||
"domain_data",
|
||||
@@ -205,8 +169,6 @@ 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."""
|
||||
@@ -379,34 +341,6 @@ 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
|
||||
@@ -418,8 +352,6 @@ 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()),
|
||||
@@ -571,10 +503,6 @@ 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."""
|
||||
|
@@ -16,9 +16,9 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.2.0",
|
||||
"aioesphomeapi==29.1.1",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.8.0"
|
||||
"bleak-esphome==2.7.1"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
@@ -54,8 +54,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"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."
|
||||
"allow_service_calls": "Allow the device to perform Home Assistant actions."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250228.0"]
|
||||
"requirements": ["home-assistant-frontend==20250221.0"]
|
||||
}
|
||||
|
@@ -111,20 +111,9 @@ def _format_schema(schema: dict[str, Any]) -> Schema:
|
||||
continue
|
||||
if key == "any_of":
|
||||
val = [_format_schema(subschema) for subschema in val]
|
||||
elif key == "type":
|
||||
if key == "type":
|
||||
val = val.upper()
|
||||
elif key == "format":
|
||||
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
|
||||
# formats that are not supported are ignored
|
||||
if schema.get("type") == "string" and val not in ("enum", "date-time"):
|
||||
continue
|
||||
if schema.get("type") == "number" and val not in ("float", "double"):
|
||||
continue
|
||||
if schema.get("type") == "integer" and val not in ("int32", "int64"):
|
||||
continue
|
||||
if schema.get("type") not in ("string", "number", "integer"):
|
||||
continue
|
||||
elif key == "items":
|
||||
if key == "items":
|
||||
val = _format_schema(val)
|
||||
elif key == "properties":
|
||||
val = {k: _format_schema(v) for k, v in val.items()}
|
||||
|
@@ -72,27 +72,22 @@ 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
|
||||
)
|
||||
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
|
||||
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,
|
||||
),
|
||||
)
|
||||
)
|
||||
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
|
||||
|
@@ -4,8 +4,6 @@ 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"
|
||||
@@ -23,13 +21,6 @@ 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"
|
||||
|
@@ -440,27 +440,13 @@ class HomeConnectCoordinator(
|
||||
self, ha_id: str, program_key: ProgramKey
|
||||
) -> dict[OptionKey, ProgramDefinitionOption]:
|
||||
"""Get options with constraints for appliance."""
|
||||
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 {}
|
||||
return {
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(ha_id, program_key=program_key)
|
||||
).options
|
||||
or []
|
||||
}
|
||||
|
||||
async def update_options(
|
||||
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
|
||||
@@ -470,7 +456,8 @@ class HomeConnectCoordinator(
|
||||
events = self.data[ha_id].events
|
||||
options_to_notify = options.copy()
|
||||
options.clear()
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
if program_key is not ProgramKey.UNKNOWN:
|
||||
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
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.15.1"],
|
||||
"requirements": ["aiohomeconnect==0.15.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ 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
|
||||
@@ -22,7 +23,6 @@ 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,6 +32,13 @@ _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,
|
||||
|
@@ -1,12 +1,10 @@
|
||||
"""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,
|
||||
@@ -25,7 +23,6 @@ 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
|
||||
@@ -43,7 +40,6 @@ class HomeConnectSensorEntityDescription(
|
||||
|
||||
default_value: str | None = None
|
||||
appliance_types: tuple[str, ...] | None = None
|
||||
fetch_unit: bool = False
|
||||
|
||||
|
||||
BSH_PROGRAM_SENSORS = (
|
||||
@@ -187,8 +183,7 @@ SENSORS = (
|
||||
key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="oven_current_cavity_temperature",
|
||||
fetch_unit=True,
|
||||
translation_key="current_cavity_temperature",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -323,29 +318,6 @@ 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."""
|
||||
|
@@ -1529,8 +1529,8 @@
|
||||
"map3": "Map 3"
|
||||
}
|
||||
},
|
||||
"oven_current_cavity_temperature": {
|
||||
"name": "Current oven cavity temperature"
|
||||
"current_cavity_temperature": {
|
||||
"name": "Current cavity temperature"
|
||||
},
|
||||
"freezer_door_alarm": {
|
||||
"name": "Freezer door alarm",
|
||||
|
@@ -437,21 +437,18 @@ def ws_expose_entity(
|
||||
def ws_list_exposed_entities(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""List entities which are exposed to assistants."""
|
||||
"""Expose an entity to an assistant."""
|
||||
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):
|
||||
exposed_to = {}
|
||||
result[entity_id] = {}
|
||||
entity_settings = async_get_entity_settings(hass, entity_id)
|
||||
for assistant, settings in entity_settings.items():
|
||||
if "should_expose" not in settings or not settings["should_expose"]:
|
||||
if "should_expose" not in settings:
|
||||
continue
|
||||
exposed_to[assistant] = True
|
||||
if not exposed_to:
|
||||
continue
|
||||
result[entity_id] = exposed_to
|
||||
result[entity_id][assistant] = settings["should_expose"]
|
||||
connection.send_result(msg["id"], {"exposed_entities": result})
|
||||
|
||||
|
||||
|
@@ -9,7 +9,6 @@ from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
@@ -141,7 +140,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
intent.async_register(hass, GetCurrentDateIntentHandler())
|
||||
intent.async_register(hass, GetCurrentTimeIntentHandler())
|
||||
intent.async_register(hass, RespondIntentHandler())
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
|
||||
return True
|
||||
|
||||
@@ -446,48 +444,6 @@ class RespondIntentHandler(intent.IntentHandler):
|
||||
return response
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = intent.INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {CLIMATE_DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name,
|
||||
area_name=area,
|
||||
domains=[CLIMATE_DOMAIN],
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
async def _async_process_intent(
|
||||
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
|
||||
) -> None:
|
||||
|
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"config_subentries": {
|
||||
"entity": {
|
||||
"title": "Add entity",
|
||||
"step": {
|
||||
"add_sensor": {
|
||||
"description": "Configure the new sensor",
|
||||
@@ -26,12 +27,7 @@
|
||||
"state": "Initial state"
|
||||
}
|
||||
}
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add sensor",
|
||||
"reconfigure": "Reconfigure sensor"
|
||||
},
|
||||
"entry_type": "Sensor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
@@ -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": "Paints either a provided theme or custom palette across one or more LIFX lights.",
|
||||
"name": "Paint Theme",
|
||||
"description": "Paint 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",
|
||||
|
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.02.19"],
|
||||
"requirements": ["yt-dlp[default]==2025.01.26"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -218,16 +218,10 @@ 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",
|
||||
|
@@ -113,19 +113,11 @@ 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"
|
||||
@@ -153,8 +145,6 @@ 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,
|
||||
@@ -172,7 +162,6 @@ 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,
|
||||
@@ -185,7 +174,6 @@ 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,
|
||||
@@ -206,8 +194,6 @@ 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,
|
||||
@@ -316,13 +302,6 @@ _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(
|
||||
@@ -536,7 +515,6 @@ 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
|
||||
@@ -565,7 +543,6 @@ 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]
|
||||
|
||||
@@ -591,11 +568,6 @@ 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:
|
||||
@@ -657,11 +629,6 @@ 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
|
||||
):
|
||||
@@ -777,16 +744,6 @@ 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(
|
||||
@@ -825,20 +782,6 @@ 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)
|
||||
|
@@ -23,7 +23,6 @@ from .const import (
|
||||
ATTR_ALBUM_TYPE,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_CONFIG_ENTRY_ID,
|
||||
ATTR_FAVORITE,
|
||||
ATTR_ITEMS,
|
||||
@@ -33,7 +32,6 @@ from .const import (
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_RADIO,
|
||||
ATTR_SEARCH,
|
||||
ATTR_SEARCH_ALBUM,
|
||||
@@ -50,15 +48,6 @@ from .schemas import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_models.media_items import (
|
||||
Album,
|
||||
Artist,
|
||||
Audiobook,
|
||||
Playlist,
|
||||
Podcast,
|
||||
Radio,
|
||||
Track,
|
||||
)
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
|
||||
@@ -165,14 +154,6 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.radio
|
||||
],
|
||||
ATTR_AUDIOBOOKS: [
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.audiobooks
|
||||
],
|
||||
ATTR_PODCASTS: [
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.podcasts
|
||||
],
|
||||
}
|
||||
)
|
||||
return response
|
||||
@@ -192,15 +173,6 @@ 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]
|
||||
| list[Audiobook]
|
||||
| list[Podcast]
|
||||
)
|
||||
if media_type == MediaType.ALBUM:
|
||||
library_result = await mass.music.get_library_albums(
|
||||
**base_params,
|
||||
@@ -209,7 +181,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=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)),
|
||||
album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY),
|
||||
)
|
||||
elif media_type == MediaType.TRACK:
|
||||
library_result = await mass.music.get_library_tracks(
|
||||
@@ -223,14 +195,6 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
|
||||
library_result = await mass.music.get_library_playlists(
|
||||
**base_params,
|
||||
)
|
||||
elif media_type == MediaType.AUDIOBOOK:
|
||||
library_result = await mass.music.get_library_audiobooks(
|
||||
**base_params,
|
||||
)
|
||||
elif media_type == MediaType.PODCAST:
|
||||
library_result = await mass.music.get_library_podcasts(
|
||||
**base_params,
|
||||
)
|
||||
else:
|
||||
raise ServiceValidationError(f"Unsupported media type {media_type}")
|
||||
|
||||
|
@@ -34,8 +34,6 @@ ATTR_ARTISTS = "artists"
|
||||
ATTR_ALBUMS = "albums"
|
||||
ATTR_TRACKS = "tracks"
|
||||
ATTR_PLAYLISTS = "playlists"
|
||||
ATTR_AUDIOBOOKS = "audiobooks"
|
||||
ATTR_PODCASTS = "podcasts"
|
||||
ATTR_RADIO = "radio"
|
||||
ATTR_ITEMS = "items"
|
||||
ATTR_RADIO_MODE = "radio_mode"
|
||||
|
@@ -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.1.1"],
|
||||
"requirements": ["music-assistant-client==1.0.8"],
|
||||
"zeroconf": ["_mass._tcp.local."]
|
||||
}
|
||||
|
@@ -166,8 +166,6 @@ 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,
|
||||
@@ -221,9 +219,6 @@ 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,
|
||||
@@ -272,9 +267,6 @@ 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,
|
||||
@@ -348,9 +340,6 @@ 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,
|
||||
|
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -21,7 +20,6 @@ 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
|
||||
@@ -80,15 +78,21 @@ 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_BASE = (
|
||||
MediaPlayerEntityFeature.STOP
|
||||
SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| 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
|
||||
@@ -208,7 +212,11 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
"""Initialize MediaPlayer entity."""
|
||||
super().__init__(mass, player_id)
|
||||
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
|
||||
self._set_supported_features()
|
||||
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._attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
self._prev_time: float = 0
|
||||
|
||||
@@ -233,19 +241,6 @@ 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)."""
|
||||
@@ -478,8 +473,6 @@ 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:
|
||||
@@ -687,20 +680,3 @@ 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
|
||||
|
@@ -15,7 +15,6 @@ from .const import (
|
||||
ATTR_ALBUM,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_BIT_DEPTH,
|
||||
ATTR_CONTENT_TYPE,
|
||||
ATTR_CURRENT_INDEX,
|
||||
@@ -32,7 +31,6 @@ from .const import (
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_PROVIDER,
|
||||
ATTR_QUEUE_ID,
|
||||
ATTR_QUEUE_ITEM_ID,
|
||||
@@ -67,20 +65,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema(
|
||||
|
||||
def media_item_dict_from_mass_item(
|
||||
mass: MusicAssistantClient,
|
||||
item: MediaItemType | ItemMapping,
|
||||
) -> dict[str, Any]:
|
||||
item: MediaItemType | ItemMapping | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Parse a Music Assistant MediaItem."""
|
||||
base: dict[str, Any] = {
|
||||
if not item:
|
||||
return None
|
||||
base = {
|
||||
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
|
||||
@@ -103,12 +101,6 @@ SEARCH_RESULT_SCHEMA = vol.Schema(
|
||||
vol.Required(ATTR_RADIO): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
vol.Required(ATTR_AUDIOBOOKS): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
vol.Required(ATTR_PODCASTS): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -159,11 +151,7 @@ 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)
|
||||
if item.media_item
|
||||
else None
|
||||
),
|
||||
ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item),
|
||||
}
|
||||
if streamdetails := item.streamdetails:
|
||||
base[ATTR_STREAM_TITLE] = streamdetails.stream_title
|
||||
|
@@ -21,10 +21,7 @@ play_media:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- folder
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
artist:
|
||||
@@ -121,9 +118,7 @@ search:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
artist:
|
||||
@@ -165,9 +160,7 @@ get_library:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
favorite:
|
||||
|
@@ -195,11 +195,8 @@
|
||||
"options": {
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"audiobook": "Audiobook",
|
||||
"folder": "Folder",
|
||||
"track": "Track",
|
||||
"playlist": "Playlist",
|
||||
"podcast": "Podcast",
|
||||
"radio": "Radio"
|
||||
}
|
||||
},
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/neato",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pybotvac"],
|
||||
"requirements": ["pybotvac==0.0.26"]
|
||||
"requirements": ["pybotvac==0.0.25"]
|
||||
}
|
||||
|
@@ -41,7 +41,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
|
||||
"""Set up OneDrive from a config entry."""
|
||||
client, get_access_token = await _get_onedrive_client(hass, entry)
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
async def get_access_token() -> str:
|
||||
await session.async_ensure_token_valid()
|
||||
return cast(str, session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
|
||||
|
||||
# get approot, will be created automatically if it does not exist
|
||||
approot = await _handle_item_operation(client.get_approot, "approot")
|
||||
@@ -157,47 +164,20 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -
|
||||
_LOGGER.debug(
|
||||
"Migrating OneDrive config entry from version %s.%s", version, minor_version
|
||||
)
|
||||
client, _ = await _get_onedrive_client(hass, entry)
|
||||
instance_id = await async_get_instance_id(hass)
|
||||
try:
|
||||
approot = await client.get_approot()
|
||||
folder = await client.get_drive_item(
|
||||
f"{approot.id}:/backups_{instance_id[:8]}:"
|
||||
)
|
||||
except OneDriveException:
|
||||
_LOGGER.exception("Migration to version 1.2 failed")
|
||||
return False
|
||||
|
||||
instance_id = await async_get_instance_id(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_FOLDER_ID: folder.id,
|
||||
CONF_FOLDER_ID: "id", # will be updated during setup_entry
|
||||
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
|
||||
},
|
||||
minor_version=2,
|
||||
)
|
||||
_LOGGER.debug("Migration to version 1.2 successful")
|
||||
return True
|
||||
|
||||
|
||||
async def _get_onedrive_client(
|
||||
hass: HomeAssistant, entry: OneDriveConfigEntry
|
||||
) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]:
|
||||
"""Get OneDrive client."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
async def get_access_token() -> str:
|
||||
await session.async_ensure_token_valid()
|
||||
return cast(str, session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
return (
|
||||
OneDriveClient(get_access_token, async_get_clientsession(hass)),
|
||||
get_access_token,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_item_operation(
|
||||
func: Callable[[], Awaitable[Item]], folder: str
|
||||
) -> Item:
|
||||
|
@@ -1,33 +0,0 @@
|
||||
"""Diagnostics support for OneDrive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: OneDriveConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
data = {
|
||||
"drive": asdict(coordinator.data),
|
||||
"config": {
|
||||
**entry.data,
|
||||
**entry.options,
|
||||
},
|
||||
}
|
||||
|
||||
return async_redact_data(data, TO_REDACT)
|
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.0.12"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.11"]
|
||||
}
|
||||
|
@@ -41,7 +41,10 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
@@ -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 or coordinator.config_entry.title,
|
||||
name=coordinator.data.name,
|
||||
identifiers={(DOMAIN, coordinator.data.id)},
|
||||
manufacturer="Microsoft",
|
||||
model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}",
|
||||
|
@@ -398,7 +398,6 @@ 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 = {
|
||||
@@ -410,7 +409,6 @@ 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 = {
|
||||
@@ -625,20 +623,11 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
return
|
||||
|
||||
source_meaning = source.value_meaning
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
_LOGGER.error(
|
||||
'Input source "%s" is invalid for entity: %s',
|
||||
source_meaning,
|
||||
self.entity_id,
|
||||
)
|
||||
self._attr_source = source_meaning
|
||||
|
||||
@callback
|
||||
@@ -649,20 +638,11 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
return
|
||||
|
||||
sound_mode_meaning = sound_mode.value_meaning
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
_LOGGER.error(
|
||||
'Listening mode "%s" is invalid for entity: %s',
|
||||
sound_mode_meaning,
|
||||
self.entity_id,
|
||||
)
|
||||
self._attr_sound_mode = sound_mode_meaning
|
||||
|
||||
@callback
|
||||
|
@@ -149,7 +149,9 @@ 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 get_default_url(hass)
|
||||
db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format(
|
||||
hass_config_path=hass.config.path(DEFAULT_DB_FILE)
|
||||
)
|
||||
exclude = conf[CONF_EXCLUDE]
|
||||
exclude_event_types: set[EventType[Any] | str] = set(
|
||||
exclude.get(CONF_EVENT_TYPES, [])
|
||||
@@ -198,8 +200,3 @@ 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))
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .util import async_migration_in_progress, get_instance
|
||||
@@ -14,8 +14,6 @@ async def async_pre_backup(hass: HomeAssistant) -> None:
|
||||
"""Perform operations before a backup starts."""
|
||||
_LOGGER.info("Backup start notification, locking database for writes")
|
||||
instance = get_instance(hass)
|
||||
if hass.state is not CoreState.running:
|
||||
raise HomeAssistantError("Home Assistant is not running")
|
||||
if async_migration_in_progress(hass):
|
||||
raise HomeAssistantError("Database migration in progress")
|
||||
await instance.lock_database()
|
||||
|
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
|
||||
@@ -35,7 +34,6 @@ 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
|
||||
@@ -46,7 +44,6 @@ 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,
|
||||
|
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.12.1"]
|
||||
"requirements": ["reolink-aio==0.12.0"]
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"requirements": ["aioshelly==13.0.0"],
|
||||
"requirements": ["aioshelly==12.4.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
@@ -2,168 +2,416 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from http import HTTPStatus
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pysmartthings import (
|
||||
Attribute,
|
||||
Capability,
|
||||
Device,
|
||||
Scene,
|
||||
SmartThings,
|
||||
SmartThingsAuthenticationFailedError,
|
||||
Status,
|
||||
)
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
|
||||
from pysmartapp.event import EVENT_TYPE_DEVICE
|
||||
from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
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 .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA
|
||||
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,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartThingsData:
|
||||
"""Define an object to hold SmartThings data."""
|
||||
|
||||
devices: dict[str, FullDevice]
|
||||
scenes: dict[str, Scene]
|
||||
client: SmartThings
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FullDevice:
|
||||
"""Define an object to hold device data."""
|
||||
|
||||
device: Device
|
||||
status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]]
|
||||
|
||||
|
||||
type SmartThingsConfigEntry = ConfigEntry[SmartThingsData]
|
||||
|
||||
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: SmartThingsConfigEntry) -> bool:
|
||||
"""Initialize config entry which represents an installed SmartApp."""
|
||||
# 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:
|
||||
await session.async_ensure_token_valid()
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
client = SmartThings(session=async_get_clientsession(hass))
|
||||
|
||||
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
|
||||
|
||||
client.refresh_token_function = _refresh_token
|
||||
|
||||
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
|
||||
|
||||
scenes = {
|
||||
scene.scene_id: scene
|
||||
for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID])
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the SmartThings platform."""
|
||||
await setup_smartapp_endpoint(hass, False)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SmartThingsConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle config entry migration."""
|
||||
"""Handle migration of a previous version config entry.
|
||||
|
||||
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)}
|
||||
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
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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)
|
||||
|
||||
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)
|
||||
|
||||
# Validate and retrieve the installed app.
|
||||
installed_app = await validate_installed_app(
|
||||
api, entry.data[CONF_INSTALLED_APP_ID]
|
||||
)
|
||||
|
||||
# Get scenes
|
||||
scenes = await async_get_entry_scenes(entry, api)
|
||||
|
||||
# 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}
|
||||
)
|
||||
|
||||
# Get devices and their current status
|
||||
devices = await api.devices(location_ids=[installed_app.location_id])
|
||||
|
||||
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)
|
||||
|
||||
await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy()))
|
||||
|
||||
# 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
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
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:
|
||||
"""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])
|
||||
|
||||
# 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,
|
||||
)
|
||||
) 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
|
||||
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
|
||||
)
|
||||
|
||||
# Connect handler to incoming device events
|
||||
self._event_disconnect = self._smart_app.connect_event(self._event_handler)
|
||||
|
||||
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)
|
||||
|
@@ -1,64 +0,0 @@
|
||||
"""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())
|
@@ -2,146 +2,84 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pysmartthings import Attribute, Capability, SmartThings
|
||||
from pysmartthings import Attribute, Capability
|
||||
|
||||
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 . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
},
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add binary sensors for a config entry."""
|
||||
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()
|
||||
)
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
|
||||
"""Define a SmartThings Binary Sensor."""
|
||||
|
||||
entity_description: SmartThingsBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: SmartThings,
|
||||
device: FullDevice,
|
||||
entity_description: SmartThingsBinarySensorEntityDescription,
|
||||
capability: Capability,
|
||||
attribute: Attribute,
|
||||
) -> None:
|
||||
def __init__(self, device, attribute):
|
||||
"""Init the class."""
|
||||
super().__init__(client, device, {capability})
|
||||
super().__init__(device)
|
||||
self._attribute = attribute
|
||||
self.capability = capability
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{device.device.device_id}.{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)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return (
|
||||
self.get_attribute_value(self.capability, self._attribute)
|
||||
== self.entity_description.is_on_key
|
||||
)
|
||||
return self._device.status.is_on(self._attribute)
|
||||
|
@@ -3,15 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable, Sequence
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command, SmartThings
|
||||
from pysmartthings import Attribute, Capability
|
||||
|
||||
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,
|
||||
@@ -21,12 +23,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 . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
ATTR_OPERATION_STATE = "operation_state"
|
||||
@@ -95,108 +97,124 @@ 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,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add climate entities for a config entry."""
|
||||
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)
|
||||
ac_capabilities = [
|
||||
Capability.air_conditioner_mode,
|
||||
Capability.air_conditioner_fan_mode,
|
||||
Capability.switch,
|
||||
Capability.temperature_measurement,
|
||||
Capability.thermostat_cooling_setpoint,
|
||||
]
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
|
||||
"""Define a SmartThings climate entities."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, client: SmartThings, device: FullDevice) -> None:
|
||||
def __init__(self, device):
|
||||
"""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,
|
||||
},
|
||||
)
|
||||
super().__init__(device)
|
||||
self._attr_supported_features = self._determine_features()
|
||||
self._hvac_mode = None
|
||||
self._hvac_modes = None
|
||||
|
||||
def _determine_features(self) -> ClimateEntityFeature:
|
||||
def _determine_features(self):
|
||||
flags = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
if self.get_attribute_value(
|
||||
Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE
|
||||
if self._device.get_capability(
|
||||
Capability.thermostat_fan_mode, Capability.thermostat
|
||||
):
|
||||
flags |= ClimateEntityFeature.FAN_MODE
|
||||
return flags
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self.execute_device_command(
|
||||
Capability.THERMOSTAT_FAN_MODE,
|
||||
Command.SET_THERMOSTAT_FAN_MODE,
|
||||
argument=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)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target operation mode."""
|
||||
await self.execute_device_command(
|
||||
Capability.THERMOSTAT_MODE,
|
||||
Command.SET_THERMOSTAT_MODE,
|
||||
argument=STATE_TO_MODE[hvac_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)
|
||||
|
||||
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):
|
||||
await self.async_set_hvac_mode(operation_state)
|
||||
hvac_mode = operation_state
|
||||
mode = STATE_TO_MODE[operation_state]
|
||||
await self._device.set_thermostat_mode(mode, set_status=True)
|
||||
await self.async_update()
|
||||
|
||||
# Heat/cool setpoint
|
||||
heating_setpoint = None
|
||||
cooling_setpoint = None
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
if self.hvac_mode == HVACMode.HEAT:
|
||||
heating_setpoint = kwargs.get(ATTR_TEMPERATURE)
|
||||
elif hvac_mode == HVACMode.COOL:
|
||||
elif self.hvac_mode == HVACMode.COOL:
|
||||
cooling_setpoint = kwargs.get(ATTR_TEMPERATURE)
|
||||
else:
|
||||
heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
@@ -204,146 +222,135 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
|
||||
tasks = []
|
||||
if heating_setpoint is not None:
|
||||
tasks.append(
|
||||
self.execute_device_command(
|
||||
Capability.THERMOSTAT_HEATING_SETPOINT,
|
||||
Command.SET_HEATING_SETPOINT,
|
||||
argument=round(heating_setpoint, 3),
|
||||
self._device.set_heating_setpoint(
|
||||
round(heating_setpoint, 3), set_status=True
|
||||
)
|
||||
)
|
||||
if cooling_setpoint is not None:
|
||||
tasks.append(
|
||||
self.execute_device_command(
|
||||
Capability.THERMOSTAT_COOLING_SETPOINT,
|
||||
Command.SET_COOLING_SETPOINT,
|
||||
argument=round(cooling_setpoint, 3),
|
||||
self._device.set_cooling_setpoint(
|
||||
round(cooling_setpoint, 3), set_status=True
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT):
|
||||
return self.get_attribute_value(
|
||||
Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY
|
||||
# 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,
|
||||
)
|
||||
return None
|
||||
|
||||
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_temperature(self) -> float | None:
|
||||
def current_humidity(self):
|
||||
"""Return the current humidity."""
|
||||
return self._device.status.humidity
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self.get_attribute_value(
|
||||
Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE
|
||||
)
|
||||
return self._device.status.temperature
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self.get_attribute_value(
|
||||
Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE
|
||||
)
|
||||
return self._device.status.thermostat_fan_mode
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
return self.get_attribute_value(
|
||||
Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES
|
||||
)
|
||||
return self._device.status.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.get_attribute_value(
|
||||
Capability.THERMOSTAT_OPERATING_STATE,
|
||||
Attribute.THERMOSTAT_OPERATING_STATE,
|
||||
)
|
||||
self._device.status.thermostat_operating_state
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
return MODE_TO_STATE.get(
|
||||
self.get_attribute_value(
|
||||
Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE
|
||||
)
|
||||
)
|
||||
return self._hvac_mode
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available operation 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
|
||||
]
|
||||
return self._hvac_modes
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
if self.hvac_mode == HVACMode.COOL:
|
||||
return self.get_attribute_value(
|
||||
Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
|
||||
)
|
||||
return self._device.status.cooling_setpoint
|
||||
if self.hvac_mode == HVACMode.HEAT:
|
||||
return self.get_attribute_value(
|
||||
Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT
|
||||
)
|
||||
return self._device.status.heating_setpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
def target_temperature_high(self):
|
||||
"""Return the highbound target temperature we try to reach."""
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return self.get_attribute_value(
|
||||
Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
|
||||
)
|
||||
return self._device.status.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.get_attribute_value(
|
||||
Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT
|
||||
)
|
||||
return self._device.status.heating_setpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
|
||||
Attribute.TEMPERATURE
|
||||
].unit
|
||||
assert unit
|
||||
return UNIT_MAP[unit]
|
||||
return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit)
|
||||
|
||||
|
||||
class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
"""Define a SmartThings Air Conditioner."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_preset_mode = None
|
||||
_hvac_modes: list[HVACMode]
|
||||
|
||||
def __init__(self, client: SmartThings, device: FullDevice) -> None:
|
||||
def __init__(self, device) -> None:
|
||||
"""Init the class."""
|
||||
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()
|
||||
super().__init__(device)
|
||||
self._hvac_modes = []
|
||||
self._attr_preset_mode = None
|
||||
self._attr_preset_modes = self._determine_preset_modes()
|
||||
self._attr_swing_modes = self._determine_swing_modes()
|
||||
self._attr_supported_features = self._determine_supported_features()
|
||||
@@ -355,7 +362,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
if self.supports_capability(Capability.FAN_OSCILLATION_MODE):
|
||||
if self._device.get_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
|
||||
@@ -363,11 +370,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self.execute_device_command(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE,
|
||||
Command.SET_FAN_MODE,
|
||||
argument=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()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target operation mode."""
|
||||
@@ -376,27 +386,23 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
return
|
||||
tasks = []
|
||||
# Turn on the device if it's off before setting mode.
|
||||
if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off":
|
||||
tasks.append(self.async_turn_on())
|
||||
if not self._device.status.switch:
|
||||
tasks.append(self._device.switch_on(set_status=True))
|
||||
|
||||
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:
|
||||
if WIND in self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
|
||||
):
|
||||
supported_modes = self._device.status.supported_ac_modes
|
||||
if WIND in supported_modes:
|
||||
mode = WIND
|
||||
|
||||
tasks.append(
|
||||
self.execute_device_command(
|
||||
Capability.AIR_CONDITIONER_MODE,
|
||||
Command.SET_AIR_CONDITIONER_MODE,
|
||||
argument=mode,
|
||||
)
|
||||
)
|
||||
tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True))
|
||||
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."""
|
||||
@@ -404,44 +410,53 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
# operation mode
|
||||
if operation_mode := kwargs.get(ATTR_HVAC_MODE):
|
||||
if operation_mode == HVACMode.OFF:
|
||||
tasks.append(self.async_turn_off())
|
||||
tasks.append(self._device.switch_off(set_status=True))
|
||||
else:
|
||||
if (
|
||||
self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
|
||||
== "off"
|
||||
):
|
||||
tasks.append(self.async_turn_on())
|
||||
if not self._device.status.switch:
|
||||
tasks.append(self._device.switch_on(set_status=True))
|
||||
tasks.append(self.async_set_hvac_mode(operation_mode))
|
||||
# temperature
|
||||
tasks.append(
|
||||
self.execute_device_command(
|
||||
Capability.THERMOSTAT_COOLING_SETPOINT,
|
||||
Command.SET_COOLING_SETPOINT,
|
||||
argument=kwargs[ATTR_TEMPERATURE],
|
||||
)
|
||||
self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True)
|
||||
)
|
||||
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.execute_device_command(
|
||||
Capability.SWITCH,
|
||||
Command.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()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn device off."""
|
||||
await self.execute_device_command(
|
||||
Capability.SWITCH,
|
||||
Command.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)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self.get_attribute_value(
|
||||
Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE
|
||||
)
|
||||
return self._device.status.temperature
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -450,114 +465,100 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
Include attributes from the Demand Response Load Control (drlc)
|
||||
and Power Consumption capabilities.
|
||||
"""
|
||||
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"],
|
||||
}
|
||||
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
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
"""Return the fan setting."""
|
||||
return self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
|
||||
)
|
||||
return self._device.status.fan_mode
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
"""Return the list of available fan modes."""
|
||||
return self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
|
||||
)
|
||||
return self._device.status.supported_ac_fan_modes
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off":
|
||||
if not self._device.status.switch:
|
||||
return HVACMode.OFF
|
||||
return AC_MODE_TO_STATE.get(
|
||||
self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the temperature we try to reach."""
|
||||
return self.get_attribute_value(
|
||||
Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
|
||||
)
|
||||
return self._device.status.cooling_setpoint
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
|
||||
Attribute.TEMPERATURE
|
||||
].unit
|
||||
assert unit
|
||||
return UNIT_MAP[unit]
|
||||
return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit]
|
||||
|
||||
def _determine_swing_modes(self) -> list[str] | None:
|
||||
"""Return the list of available swing modes."""
|
||||
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]
|
||||
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
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set swing mode."""
|
||||
await self.execute_device_command(
|
||||
Capability.FAN_OSCILLATION_MODE,
|
||||
Command.SET_FAN_OSCILLATION_MODE,
|
||||
argument=SWING_TO_FAN_OSCILLATION[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)
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
"""Return the swing setting."""
|
||||
return FAN_OSCILLATION_TO_SWING.get(
|
||||
self.get_attribute_value(
|
||||
Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE
|
||||
),
|
||||
SWING_OFF,
|
||||
self._device.status.fan_oscillation_mode, SWING_OFF
|
||||
)
|
||||
|
||||
def _determine_preset_modes(self) -> list[str] | None:
|
||||
"""Return a list of available preset modes."""
|
||||
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]
|
||||
supported_modes: list | None = self._device.status.attributes[
|
||||
"supportedAcOptionalMode"
|
||||
].value
|
||||
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)."""
|
||||
await self.execute_device_command(
|
||||
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
|
||||
Command.SET_AC_OPTIONAL_MODE,
|
||||
argument=preset_mode,
|
||||
result = await self._device.command(
|
||||
"main",
|
||||
"custom.airConditionerOptionalMode",
|
||||
"setAcOptionalMode",
|
||||
[preset_mode],
|
||||
)
|
||||
if result:
|
||||
self._device.status.update_attribute_value("acOptionalMode", preset_mode)
|
||||
|
||||
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
|
||||
self._attr_preset_mode = preset_mode
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
@@ -1,83 +1,298 @@
|
||||
"""Config flow to configure SmartThings."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import SmartThings
|
||||
from aiohttp import ClientResponseError
|
||||
from pysmartthings import APIResponseError, AppOAuth, SmartThings
|
||||
from pysmartthings.installedapp import format_install_url
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES
|
||||
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,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle configuration of SmartThings integrations."""
|
||||
|
||||
VERSION = 3
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = 2
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
api: SmartThings
|
||||
app_id: str
|
||||
location_id: str
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": " ".join(SCOPES)}
|
||||
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
|
||||
|
||||
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_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)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=location.name,
|
||||
data={**data, CONF_LOCATION_ID: location.location_id},
|
||||
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
|
||||
)
|
||||
webhook_url = get_webhook_url(self.hass)
|
||||
|
||||
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,
|
||||
# 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/"
|
||||
),
|
||||
},
|
||||
unique_id=location.location_id,
|
||||
)
|
||||
self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=data
|
||||
|
||||
# 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/"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon migration of old entries."""
|
||||
"""Handle re-authentication of an existing config entry."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
"""Handle re-authentication of an existing config entry."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
)
|
||||
return await self.async_step_user()
|
||||
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)
|
||||
|
@@ -1,23 +1,15 @@
|
||||
"""Constants used by the SmartThings component and platforms."""
|
||||
|
||||
from datetime import timedelta
|
||||
import re
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "smartthings"
|
||||
|
||||
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",
|
||||
]
|
||||
APP_OAUTH_CLIENT_NAME = "Home Assistant"
|
||||
APP_OAUTH_SCOPES = ["r:devices:*"]
|
||||
APP_NAME_PREFIX = "homeassistant."
|
||||
|
||||
CONF_APP_ID = "app_id"
|
||||
CONF_CLOUDHOOK_URL = "cloudhook_url"
|
||||
@@ -26,5 +18,41 @@ CONF_INSTANCE_ID = "instance_id"
|
||||
CONF_LOCATION_ID = "location_id"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
MAIN = "main"
|
||||
OLD_DATA = "old_data"
|
||||
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)
|
||||
|
@@ -2,23 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command, SmartThings
|
||||
from pysmartthings import Attribute, Capability
|
||||
|
||||
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 . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
VALUE_TO_STATE = {
|
||||
@@ -30,100 +32,114 @@ VALUE_TO_STATE = {
|
||||
"unknown": None,
|
||||
}
|
||||
|
||||
CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add covers for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
SmartThingsCover(entry_data.client, device, Capability(capability))
|
||||
for device in entry_data.devices.values()
|
||||
for capability in device.status[MAIN]
|
||||
if capability in CAPABILITIES
|
||||
[
|
||||
SmartThingsCover(device)
|
||||
for device in broker.devices.values()
|
||||
if broker.any_assigned(device.device_id, COVER_DOMAIN)
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
_attr_name = None
|
||||
_state: CoverState | None = None
|
||||
|
||||
def __init__(
|
||||
self, client: SmartThings, device: FullDevice, capability: Capability
|
||||
) -> None:
|
||||
def __init__(self, device):
|
||||
"""Initialize the cover class."""
|
||||
super().__init__(
|
||||
client,
|
||||
device,
|
||||
{
|
||||
capability,
|
||||
Capability.BATTERY,
|
||||
Capability.WINDOW_SHADE_LEVEL,
|
||||
Capability.SWITCH_LEVEL,
|
||||
},
|
||||
)
|
||||
self.capability = capability
|
||||
super().__init__(device)
|
||||
self._current_cover_position = None
|
||||
self._state = None
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
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):
|
||||
if (
|
||||
Capability.switch_level in device.capabilities
|
||||
or Capability.window_shade_level in device.capabilities
|
||||
):
|
||||
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
|
||||
|
||||
if self.supports_capability(Capability.DOOR_CONTROL):
|
||||
if Capability.door_control in device.capabilities:
|
||||
self._attr_device_class = CoverDeviceClass.DOOR
|
||||
elif self.supports_capability(Capability.WINDOW_SHADE):
|
||||
elif Capability.window_shade in device.capabilities:
|
||||
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."""
|
||||
await self.execute_device_command(self.capability, Command.CLOSE)
|
||||
# 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)
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
await self.execute_device_command(self.capability, Command.OPEN)
|
||||
# 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)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
await self.execute_device_command(
|
||||
self.level_capability,
|
||||
self.level_command,
|
||||
argument=kwargs[ATTR_POSITION],
|
||||
)
|
||||
|
||||
def _update_attr(self) -> None:
|
||||
"""Update the attrs of the cover."""
|
||||
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 self.supports_capability(Capability.SWITCH_LEVEL):
|
||||
self._attr_current_cover_position = self.get_attribute_value(
|
||||
Capability.SWITCH_LEVEL, Attribute.LEVEL
|
||||
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)
|
||||
|
||||
async def async_update(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)
|
||||
|
||||
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
|
||||
|
||||
self._attr_extra_state_attributes = {}
|
||||
if self.supports_capability(Capability.BATTERY):
|
||||
self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = (
|
||||
self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY)
|
||||
)
|
||||
battery = self._device.status.attributes[Attribute.battery].value
|
||||
if battery is not None:
|
||||
self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
|
@@ -1,49 +0,0 @@
|
||||
"""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."""
|
||||
client = entry.runtime_data.client
|
||||
device_id = next(
|
||||
identifier for identifier in device.identifiers if identifier[0] == DOMAIN
|
||||
)[1]
|
||||
|
||||
device_status = await client.get_device_status(device_id)
|
||||
|
||||
events: list[DeviceEvent] = []
|
||||
|
||||
def register_event(event: DeviceEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
listener = client.add_device_event_listener(device_id, register_event)
|
||||
|
||||
await asyncio.sleep(EVENT_WAIT_TIME)
|
||||
|
||||
listener()
|
||||
|
||||
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}
|
@@ -2,109 +2,49 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from pysmartthings import (
|
||||
Attribute,
|
||||
Capability,
|
||||
Command,
|
||||
DeviceEvent,
|
||||
SmartThings,
|
||||
Status,
|
||||
)
|
||||
from pysmartthings.device import DeviceEntity
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import FullDevice
|
||||
from .const import DOMAIN, MAIN
|
||||
from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE
|
||||
|
||||
|
||||
class SmartThingsEntity(Entity):
|
||||
"""Defines a SmartThings entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, client: SmartThings, device: FullDevice, capabilities: set[Capability]
|
||||
) -> None:
|
||||
def __init__(self, device: DeviceEntity) -> None:
|
||||
"""Initialize the instance."""
|
||||
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._device = device
|
||||
self._dispatcher_remove = None
|
||||
self._attr_name = device.label
|
||||
self._attr_unique_id = device.device_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url="https://account.smartthings.com",
|
||||
identifiers={(DOMAIN, device.device.device_id)},
|
||||
name=device.device.label,
|
||||
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,
|
||||
)
|
||||
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) -> 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_added_to_hass(self):
|
||||
"""Device added to hass."""
|
||||
|
||||
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()
|
||||
async def async_update_state(devices):
|
||||
"""Update device state."""
|
||||
if self._device.device_id in devices:
|
||||
await self.async_update_ha_state(True)
|
||||
|
||||
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
|
||||
self._dispatcher_remove = async_dispatcher_connect(
|
||||
self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect the device when removed."""
|
||||
if self._dispatcher_remove:
|
||||
self._dispatcher_remove()
|
||||
|
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command, SmartThings
|
||||
from pysmartthings import Capability
|
||||
|
||||
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 (
|
||||
@@ -16,8 +18,7 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
SPEED_RANGE = (1, 3) # off is not included
|
||||
@@ -25,74 +26,86 @@ SPEED_RANGE = (1, 3) # off is not included
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add fans for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
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]
|
||||
SmartThingsFan(device)
|
||||
for device in broker.devices.values()
|
||||
if broker.any_assigned(device.device_id, "fan")
|
||||
)
|
||||
|
||||
|
||||
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, client: SmartThings, device: FullDevice) -> None:
|
||||
def __init__(self, device):
|
||||
"""Init the class."""
|
||||
super().__init__(
|
||||
client,
|
||||
device,
|
||||
{
|
||||
Capability.SWITCH,
|
||||
Capability.FAN_SPEED,
|
||||
Capability.AIR_CONDITIONER_FAN_MODE,
|
||||
},
|
||||
)
|
||||
super().__init__(device)
|
||||
self._attr_supported_features = self._determine_features()
|
||||
|
||||
def _determine_features(self):
|
||||
flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
|
||||
|
||||
if self.supports_capability(Capability.FAN_SPEED):
|
||||
if self._device.get_capability(Capability.fan_speed):
|
||||
flags |= FanEntityFeature.SET_SPEED
|
||||
if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
|
||||
if self._device.get_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."""
|
||||
if percentage == 0:
|
||||
await self.execute_device_command(Capability.SWITCH, Command.OFF)
|
||||
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)
|
||||
else:
|
||||
value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
|
||||
await self.execute_device_command(
|
||||
Capability.FAN_SPEED,
|
||||
Command.SET_FAN_SPEED,
|
||||
argument=value,
|
||||
)
|
||||
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()
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset_mode of the fan."""
|
||||
await self.execute_device_command(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE,
|
||||
Command.SET_FAN_MODE,
|
||||
argument=preset_mode,
|
||||
)
|
||||
await self._device.set_fan_mode(preset_mode, set_status=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
@@ -101,30 +114,32 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn the fan on."""
|
||||
if (
|
||||
FanEntityFeature.SET_SPEED in self._attr_supported_features
|
||||
and percentage is not None
|
||||
):
|
||||
await self.async_set_percentage(percentage)
|
||||
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)
|
||||
else:
|
||||
await self.execute_device_command(Capability.SWITCH, Command.ON)
|
||||
# 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()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self.execute_device_command(Capability.SWITCH, Command.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()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if fan is on."""
|
||||
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
|
||||
return self._device.status.switch
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed percentage."""
|
||||
return ranged_value_to_percentage(
|
||||
SPEED_RANGE,
|
||||
self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED),
|
||||
)
|
||||
return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
@@ -132,9 +147,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
|
||||
|
||||
Requires FanEntityFeature.PRESET_MODE.
|
||||
"""
|
||||
return self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
|
||||
)
|
||||
return self._device.status.fan_mode
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
@@ -142,6 +155,4 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
|
||||
|
||||
Requires FanEntityFeature.PRESET_MODE.
|
||||
"""
|
||||
return self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
|
||||
)
|
||||
return self._device.status.supported_ac_fan_modes
|
||||
|
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command, SmartThings
|
||||
from pysmartthings import Capability
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -17,38 +18,54 @@ 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 . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
CAPABILITIES = (
|
||||
Capability.SWITCH_LEVEL,
|
||||
Capability.COLOR_CONTROL,
|
||||
Capability.COLOR_TEMPERATURE,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add lights for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
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)
|
||||
[
|
||||
SmartThingsLight(device)
|
||||
for device in broker.devices.values()
|
||||
if broker.any_assigned(device.device_id, "light")
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def convert_scale(
|
||||
value: float, value_scale: int, target_scale: int, round_digits: int = 4
|
||||
) -> float:
|
||||
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):
|
||||
"""Convert a value to a different scale."""
|
||||
return round(value * target_scale / value_scale, round_digits)
|
||||
|
||||
@@ -56,45 +73,49 @@ def convert_scale(
|
||||
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, client: SmartThings, device: FullDevice) -> None:
|
||||
def __init__(self, device):
|
||||
"""Initialize a SmartThingsLight."""
|
||||
super().__init__(
|
||||
client,
|
||||
device,
|
||||
{
|
||||
Capability.COLOR_CONTROL,
|
||||
Capability.COLOR_TEMPERATURE,
|
||||
Capability.SWITCH_LEVEL,
|
||||
Capability.SWITCH,
|
||||
},
|
||||
)
|
||||
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."""
|
||||
color_modes = set()
|
||||
if self.supports_capability(Capability.COLOR_TEMPERATURE):
|
||||
# Color Temperature
|
||||
if Capability.color_temperature in self._device.capabilities:
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if self.supports_capability(Capability.COLOR_CONTROL):
|
||||
# Color
|
||||
if Capability.color_control in self._device.capabilities:
|
||||
color_modes.add(ColorMode.HS)
|
||||
if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL):
|
||||
# Brightness
|
||||
if not color_modes and Capability.switch_level in self._device.capabilities:
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if not color_modes:
|
||||
color_modes.add(ColorMode.ONOFF)
|
||||
self._attr_supported_color_modes = color_modes
|
||||
|
||||
return color_modes
|
||||
|
||||
def _determine_features(self) -> LightEntityFeature:
|
||||
"""Get features supported by the device."""
|
||||
features = LightEntityFeature(0)
|
||||
if self.supports_capability(Capability.SWITCH_LEVEL):
|
||||
# Transition
|
||||
if Capability.switch_level in self._device.capabilities:
|
||||
features |= LightEntityFeature.TRANSITION
|
||||
self._attr_supported_features = features
|
||||
|
||||
return features
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
@@ -115,10 +136,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
|
||||
kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0)
|
||||
)
|
||||
else:
|
||||
await self.execute_device_command(
|
||||
Capability.SWITCH,
|
||||
Command.ON,
|
||||
)
|
||||
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)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
@@ -126,39 +148,27 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
await self.async_set_level(0, int(kwargs[ATTR_TRANSITION]))
|
||||
else:
|
||||
await self.execute_device_command(
|
||||
Capability.SWITCH,
|
||||
Command.OFF,
|
||||
)
|
||||
await self._device.switch_off(set_status=True)
|
||||
|
||||
def _update_attr(self) -> None:
|
||||
# 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 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.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL),
|
||||
100,
|
||||
255,
|
||||
0,
|
||||
)
|
||||
convert_scale(self._device.status.level, 100, 255, 0)
|
||||
)
|
||||
# Color Temperature
|
||||
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
|
||||
self._attr_color_temp_kelvin = self.get_attribute_value(
|
||||
Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE
|
||||
)
|
||||
self._attr_color_temp_kelvin = self._device.status.color_temperature
|
||||
# Color
|
||||
if ColorMode.HS in self._attr_supported_color_modes:
|
||||
self._attr_hs_color = (
|
||||
convert_scale(
|
||||
self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE),
|
||||
100,
|
||||
360,
|
||||
),
|
||||
self.get_attribute_value(
|
||||
Capability.COLOR_CONTROL, Attribute.SATURATION
|
||||
),
|
||||
convert_scale(self._device.status.hue, 100, 360),
|
||||
self._device.status.saturation,
|
||||
)
|
||||
|
||||
async def async_set_color(self, hs_color):
|
||||
@@ -166,22 +176,14 @@ 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.execute_device_command(
|
||||
Capability.COLOR_CONTROL,
|
||||
Command.SET_COLOR,
|
||||
argument={"hue": hue, "saturation": saturation},
|
||||
)
|
||||
await self._device.set_color(hue, saturation, set_status=True)
|
||||
|
||||
async def async_set_color_temp(self, value: int):
|
||||
"""Set the color temperature of the device."""
|
||||
kelvin = max(min(value, 30000), 1)
|
||||
await self.execute_device_command(
|
||||
Capability.COLOR_TEMPERATURE,
|
||||
Command.SET_COLOR_TEMPERATURE,
|
||||
argument=kelvin,
|
||||
)
|
||||
await self._device.set_color_temperature(kelvin, set_status=True)
|
||||
|
||||
async def async_set_level(self, brightness: int, transition: int) -> None:
|
||||
async def async_set_level(self, brightness: int, transition: int):
|
||||
"""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
|
||||
@@ -189,11 +191,7 @@ 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.execute_device_command(
|
||||
Capability.SWITCH_LEVEL,
|
||||
Command.SET_LEVEL,
|
||||
argument=[level, duration],
|
||||
)
|
||||
await self._device.set_level(level, duration, set_status=True)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
@@ -210,4 +208,4 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"
|
||||
return self._device.status.switch
|
||||
|
@@ -2,16 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command
|
||||
from pysmartthings import Attribute, Capability
|
||||
|
||||
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 . import SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
ST_STATE_LOCKED = "locked"
|
||||
@@ -27,49 +28,48 @@ ST_LOCK_ATTR_MAP = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add locks for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
SmartThingsLock(entry_data.client, device, {Capability.LOCK})
|
||||
for device in entry_data.devices.values()
|
||||
if Capability.LOCK in device.status[MAIN]
|
||||
SmartThingsLock(device)
|
||||
for device in broker.devices.values()
|
||||
if broker.any_assigned(device.device_id, "lock")
|
||||
)
|
||||
|
||||
|
||||
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.execute_device_command(
|
||||
Capability.LOCK,
|
||||
Command.LOCK,
|
||||
)
|
||||
await self._device.lock(set_status=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
await self.execute_device_command(
|
||||
Capability.LOCK,
|
||||
Command.UNLOCK,
|
||||
)
|
||||
await self._device.unlock(set_status=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
"""Return true if lock is locked."""
|
||||
return (
|
||||
self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED
|
||||
)
|
||||
return self._device.status.lock == ST_STATE_LOCKED
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return device specific state attributes."""
|
||||
state_attrs = {}
|
||||
status = self._internal_state[Capability.LOCK][Attribute.LOCK]
|
||||
status = self._device.status.attributes[Attribute.lock]
|
||||
if status.value:
|
||||
state_attrs["lock_state"] = status.value
|
||||
if isinstance(status.data, dict):
|
||||
|
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"domain": "smartthings",
|
||||
"name": "SmartThings",
|
||||
"codeowners": ["@joostlek"],
|
||||
"after_dependencies": ["cloud"],
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"dependencies": ["webhook"],
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "st*",
|
||||
@@ -28,6 +29,6 @@
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/smartthings",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"requirements": ["pysmartthings==2.1.0"]
|
||||
"loggers": ["httpsig", "pysmartapp", "pysmartthings"],
|
||||
"requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"]
|
||||
}
|
||||
|
@@ -2,42 +2,39 @@
|
||||
|
||||
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 . import SmartThingsConfigEntry
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""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())
|
||||
"""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())
|
||||
|
||||
|
||||
class SmartThingsScene(Scene):
|
||||
"""Define a SmartThings scene."""
|
||||
|
||||
def __init__(self, scene: STScene, client: SmartThings) -> None:
|
||||
def __init__(self, scene):
|
||||
"""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.client.execute_scene(self._scene.scene_id)
|
||||
await self._scene.execute()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
def extra_state_attributes(self):
|
||||
"""Get attributes about the state."""
|
||||
return {
|
||||
"icon": self._scene.icon,
|
||||
|
File diff suppressed because it is too large
Load Diff
545
homeassistant/components/smartthings/smartapp.py
Normal file
545
homeassistant/components/smartthings/smartapp.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""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)
|
@@ -1,392 +1,43 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
"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."
|
||||
},
|
||||
"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": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The SmartThings integration needs to re-authenticate your account"
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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"
|
||||
}
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,69 +2,60 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command
|
||||
from pysmartthings import Capability
|
||||
|
||||
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 . import SmartThingsConfigEntry
|
||||
from .const import MAIN
|
||||
from .const import DATA_BROKERS, DOMAIN
|
||||
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,
|
||||
entry: SmartThingsConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add switches for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
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)
|
||||
SmartThingsSwitch(device)
|
||||
for device in broker.devices.values()
|
||||
if broker.any_assigned(device.device_id, "switch")
|
||||
)
|
||||
|
||||
|
||||
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.execute_device_command(
|
||||
Capability.SWITCH,
|
||||
Command.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_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.execute_device_command(
|
||||
Capability.SWITCH,
|
||||
Command.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()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"
|
||||
return self._device.status.switch
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarttub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["smarttub"],
|
||||
"requirements": ["python-smarttub==0.0.39"]
|
||||
"requirements": ["python-smarttub==0.0.38"]
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from pysmhi import SMHIForecast
|
||||
@@ -79,6 +80,12 @@ 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,
|
||||
|
@@ -29,7 +29,6 @@ LIBRARY = [
|
||||
"Playlists",
|
||||
"Genres",
|
||||
"New Music",
|
||||
"Album Artists",
|
||||
"Apps",
|
||||
"Radios",
|
||||
]
|
||||
@@ -42,7 +41,6 @@ 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",
|
||||
@@ -73,7 +71,6 @@ 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},
|
||||
@@ -101,7 +98,6 @@ 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,
|
||||
}
|
||||
|
@@ -42,12 +42,12 @@ async def async_migrate_entry(
|
||||
LOGGER.debug("Migrating from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
xy = await Stookwijzer.async_transform_coordinates(
|
||||
latitude, longitude = await Stookwijzer.async_transform_coordinates(
|
||||
entry.data[CONF_LOCATION][CONF_LATITUDE],
|
||||
entry.data[CONF_LOCATION][CONF_LONGITUDE],
|
||||
)
|
||||
|
||||
if not xy:
|
||||
if not latitude or not longitude:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
@@ -65,8 +65,8 @@ async def async_migrate_entry(
|
||||
entry,
|
||||
version=2,
|
||||
data={
|
||||
CONF_LATITUDE: xy["x"],
|
||||
CONF_LONGITUDE: xy["y"],
|
||||
CONF_LATITUDE: latitude,
|
||||
CONF_LONGITUDE: longitude,
|
||||
},
|
||||
)
|
||||
|
||||
|
@@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
xy = await Stookwijzer.async_transform_coordinates(
|
||||
latitude, longitude = await Stookwijzer.async_transform_coordinates(
|
||||
user_input[CONF_LOCATION][CONF_LATITUDE],
|
||||
user_input[CONF_LOCATION][CONF_LONGITUDE],
|
||||
)
|
||||
if xy:
|
||||
if latitude and longitude:
|
||||
return self.async_create_entry(
|
||||
title="Stookwijzer",
|
||||
data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]},
|
||||
data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude},
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["stookwijzer==1.6.1"]
|
||||
"requirements": ["stookwijzer==1.5.8"]
|
||||
}
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/technove",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-technove==2.0.0"],
|
||||
"requirements": ["python-technove==1.3.1"],
|
||||
"zeroconf": ["_technove-stations._tcp.local."]
|
||||
}
|
||||
|
@@ -70,7 +70,7 @@
|
||||
"plugged_waiting": "Plugged, waiting",
|
||||
"plugged_charging": "Plugged, charging",
|
||||
"out_of_activation_period": "Out of activation period",
|
||||
"high_tariff_period": "High tariff period"
|
||||
"high_charge_period": "High charge period"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -1013,7 +1013,7 @@ class LightTemplate(TemplateEntity, LightEntity):
|
||||
if render in (None, "None", ""):
|
||||
self._supports_transition = False
|
||||
return
|
||||
self._attr_supported_features &= ~LightEntityFeature.TRANSITION
|
||||
self._attr_supported_features &= LightEntityFeature.EFFECT
|
||||
self._supports_transition = bool(render)
|
||||
if self._supports_transition:
|
||||
self._attr_supported_features |= LightEntityFeature.TRANSITION
|
||||
|
@@ -8,7 +8,6 @@ from datetime import datetime, timedelta
|
||||
|
||||
from propcache.api import cached_property
|
||||
from teslemetry_stream import Signal
|
||||
from teslemetry_stream.const import ShiftState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
RestoreSensor,
|
||||
@@ -70,7 +69,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription):
|
||||
polling_value_fn: Callable[[StateType], StateType] = lambda x: x
|
||||
polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None
|
||||
streaming_key: Signal | None = None
|
||||
streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x
|
||||
streaming_value_fn: Callable[[StateType], StateType] = lambda x: x
|
||||
streaming_firmware: str = "2024.26"
|
||||
|
||||
|
||||
@@ -213,7 +212,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
|
||||
polling_available_fn=lambda x: True,
|
||||
polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
|
||||
streaming_key=Signal.GEAR,
|
||||
streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(),
|
||||
streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)),
|
||||
options=list(SHIFT_STATES.values()),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_registry_enabled_default=False,
|
||||
|
@@ -3,8 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
from urllib.parse import urlsplit
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from tuya_sharing import (
|
||||
CustomerDevice,
|
||||
@@ -12,7 +11,6 @@ from tuya_sharing import (
|
||||
SharingDeviceListener,
|
||||
SharingTokenListener,
|
||||
)
|
||||
from tuya_sharing.mq import SharingMQ, SharingMQConfig
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -47,81 +45,13 @@ class HomeAssistantTuyaData(NamedTuple):
|
||||
listener: SharingDeviceListener
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
|
||||
class ManagerCompat(Manager):
|
||||
"""Extended Manager class from the Tuya device sharing SDK.
|
||||
|
||||
The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
|
||||
It overrides extend refresh_mq method to ensure correct paho.mqtt client calls.
|
||||
|
||||
This code can be removed when a version of tuya-device-sharing with
|
||||
https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
|
||||
"""
|
||||
|
||||
def refresh_mq(self):
|
||||
"""Refresh the MQTT connection."""
|
||||
if self.mq is not None:
|
||||
self.mq.stop()
|
||||
self.mq = None
|
||||
|
||||
home_ids = [home.id for home in self.user_homes]
|
||||
device = [
|
||||
device
|
||||
for device in self.device_map.values()
|
||||
if hasattr(device, "id") and getattr(device, "set_up", False)
|
||||
]
|
||||
|
||||
sharing_mq = SharingMQCompat(self.customer_api, home_ids, device)
|
||||
sharing_mq.start()
|
||||
sharing_mq.add_message_listener(self.on_message)
|
||||
self.mq = sharing_mq
|
||||
|
||||
|
||||
class SharingMQCompat(SharingMQ):
|
||||
"""Extended SharingMQ class from the Tuya device sharing SDK.
|
||||
|
||||
The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
|
||||
It overrides _start method to ensure correct paho.mqtt client calls.
|
||||
|
||||
This code can be removed when a version of tuya-device-sharing with
|
||||
https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
|
||||
"""
|
||||
|
||||
def _start(self, mq_config: SharingMQConfig) -> mqtt.Client:
|
||||
"""Start the MQTT client."""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
mqttc = mqtt.Client(client_id=mq_config.client_id)
|
||||
mqttc.username_pw_set(mq_config.username, mq_config.password)
|
||||
mqttc.user_data_set({"mqConfig": mq_config})
|
||||
mqttc.on_connect = self._on_connect
|
||||
mqttc.on_message = self._on_message
|
||||
mqttc.on_subscribe = self._on_subscribe
|
||||
mqttc.on_log = self._on_log
|
||||
mqttc.on_disconnect = self._on_disconnect
|
||||
|
||||
url = urlsplit(mq_config.url)
|
||||
if url.scheme == "ssl":
|
||||
mqttc.tls_set()
|
||||
|
||||
mqttc.connect(url.hostname, url.port)
|
||||
|
||||
mqttc.loop_start()
|
||||
return mqttc
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
|
||||
"""Async setup hass config entry."""
|
||||
if CONF_APP_TYPE in entry.data:
|
||||
raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
|
||||
|
||||
token_listener = TokenListener(hass, entry)
|
||||
manager = ManagerCompat(
|
||||
manager = Manager(
|
||||
TUYA_CLIENT_ID,
|
||||
entry.data[CONF_USER_CODE],
|
||||
entry.data[CONF_TERMINAL_ID],
|
||||
|
@@ -4,11 +4,6 @@
|
||||
"light": {
|
||||
"default": "mdi:string-lights"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"mode": {
|
||||
"default": "mdi:cogs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@ async def async_setup_entry(
|
||||
class TwinklyModeSelect(TwinklyEntity, SelectEntity):
|
||||
"""Twinkly Mode Selection."""
|
||||
|
||||
_attr_translation_key = "mode"
|
||||
_attr_name = "Mode"
|
||||
_attr_options = TWINKLY_MODES
|
||||
|
||||
def __init__(self, coordinator: TwinklyCoordinator) -> None:
|
||||
|
@@ -20,21 +20,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"requirements": ["aiounifi==83"],
|
||||
"requirements": ["aiounifi==82"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["weatherflow4py"],
|
||||
"requirements": ["weatherflow4py==1.3.1"]
|
||||
"requirements": ["weatherflow4py==1.0.6"]
|
||||
}
|
||||
|
@@ -30,7 +30,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
METADATA_VERSION = "1"
|
||||
BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200)
|
||||
NAMESPACE = "https://home-assistant.io"
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
@@ -101,14 +100,14 @@ def _is_current_metadata_version(properties: list[Property]) -> bool:
|
||||
return any(
|
||||
prop.value == METADATA_VERSION
|
||||
for prop in properties
|
||||
if prop.namespace == NAMESPACE and prop.name == "metadata_version"
|
||||
if prop.namespace == "homeassistant" and prop.name == "metadata_version"
|
||||
)
|
||||
|
||||
|
||||
def _backup_id_from_properties(properties: list[Property]) -> str | None:
|
||||
"""Return the backup ID from properties."""
|
||||
for prop in properties:
|
||||
if prop.namespace == NAMESPACE and prop.name == "backup_id":
|
||||
if prop.namespace == "homeassistant" and prop.name == "backup_id":
|
||||
return prop.value
|
||||
return None
|
||||
|
||||
@@ -187,12 +186,12 @@ class WebDavBackupAgent(BackupAgent):
|
||||
f"{self._backup_path}/{filename_meta}",
|
||||
[
|
||||
Property(
|
||||
namespace=NAMESPACE,
|
||||
namespace="homeassistant",
|
||||
name="backup_id",
|
||||
value=backup.backup_id,
|
||||
),
|
||||
Property(
|
||||
namespace=NAMESPACE,
|
||||
namespace="homeassistant",
|
||||
name="metadata_version",
|
||||
value=METADATA_VERSION,
|
||||
),
|
||||
@@ -253,11 +252,11 @@ class WebDavBackupAgent(BackupAgent):
|
||||
self._backup_path,
|
||||
[
|
||||
PropertyRequest(
|
||||
namespace=NAMESPACE,
|
||||
namespace="homeassistant",
|
||||
name="metadata_version",
|
||||
),
|
||||
PropertyRequest(
|
||||
namespace=NAMESPACE,
|
||||
namespace="homeassistant",
|
||||
name="backup_id",
|
||||
),
|
||||
],
|
||||
|
@@ -59,10 +59,6 @@ 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:
|
||||
|
@@ -21,7 +21,7 @@
|
||||
"zha",
|
||||
"universal_silabs_flasher"
|
||||
],
|
||||
"requirements": ["zha==0.0.51"],
|
||||
"requirements": ["zha==0.0.49"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "10C4",
|
||||
|
@@ -1044,63 +1044,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -1292,33 +1235,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -1645,27 +1561,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -1851,30 +1746,6 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2025
|
||||
MINOR_VERSION: Final = 3
|
||||
PATCH_VERSION: Final = "0b2"
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
__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)
|
||||
|
@@ -28,7 +28,6 @@ APPLICATION_CREDENTIALS = [
|
||||
"onedrive",
|
||||
"point",
|
||||
"senz",
|
||||
"smartthings",
|
||||
"spotify",
|
||||
"tesla_fleet",
|
||||
"twitch",
|
||||
|
@@ -59,7 +59,6 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
|
||||
INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
|
||||
INTENT_RESPOND = "HassRespond"
|
||||
INTENT_BROADCAST = "HassBroadcast"
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
|
||||
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
@@ -19,6 +19,7 @@ from homeassistant.components.calendar import (
|
||||
DOMAIN as CALENDAR_DOMAIN,
|
||||
SERVICE_GET_EVENTS,
|
||||
)
|
||||
from homeassistant.components.climate import INTENT_GET_TEMPERATURE
|
||||
from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
|
||||
from homeassistant.components.homeassistant import async_should_expose
|
||||
from homeassistant.components.intent import async_device_supports_timers
|
||||
@@ -284,7 +285,7 @@ class AssistAPI(API):
|
||||
"""API exposing Assist API to LLMs."""
|
||||
|
||||
IGNORE_INTENTS = {
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_GET_WEATHER,
|
||||
INTENT_OPEN_COVER, # deprecated
|
||||
INTENT_CLOSE_COVER, # deprecated
|
||||
@@ -529,11 +530,9 @@ def _get_exposed_entities(
|
||||
info["areas"] = ", ".join(area_names)
|
||||
|
||||
if attributes := {
|
||||
attr_name: (
|
||||
str(attr_value)
|
||||
if isinstance(attr_value, (Enum, Decimal, int))
|
||||
else attr_value
|
||||
)
|
||||
attr_name: str(attr_value)
|
||||
if isinstance(attr_value, (Enum, Decimal, int))
|
||||
else attr_value
|
||||
for attr_name, attr_value in state.attributes.items()
|
||||
if attr_name in interesting_attributes
|
||||
}:
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user