Compare commits

..

1 Commits

Author SHA1 Message Date
mib1185
5ae036a7e2 sort common state strings 2025-02-25 20:44:51 +00:00
245 changed files with 7193 additions and 28768 deletions

View File

@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@v4.1.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

View File

@@ -942,7 +942,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@v4.1.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

View File

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

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

2
CODEOWNERS generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -195,11 +195,8 @@
"options": {
"artist": "Artist",
"album": "Album",
"audiobook": "Audiobook",
"folder": "Folder",
"track": "Track",
"playlist": "Playlist",
"podcast": "Podcast",
"radio": "Radio"
}
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,7 +103,7 @@ class OneDriveDriveStateSensor(
self._attr_unique_id = f"{coordinator.data.id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=coordinator.data.name 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()}",

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
"requirements": ["aiounifi==83"],
"requirements": ["aiounifi==82"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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