mirror of
https://github.com/home-assistant/core.git
synced 2025-10-05 01:39:29 +00:00
Compare commits
56 Commits
progress-a
...
mqtt-suben
Author | SHA1 | Date | |
---|---|---|---|
![]() |
98e0513866 | ||
![]() |
972e643d88 | ||
![]() |
b0a08782e0 | ||
![]() |
6c9955f220 | ||
![]() |
f56b94c0f9 | ||
![]() |
3cf035820b | ||
![]() |
99a796d066 | ||
![]() |
1cd1b1aba8 | ||
![]() |
4131c14629 | ||
![]() |
c2acda5796 | ||
![]() |
4806e7e9d9 | ||
![]() |
76606fd44f | ||
![]() |
2983f1a3b6 | ||
![]() |
8019779b3a | ||
![]() |
62cdcbf422 | ||
![]() |
b12a5a36e1 | ||
![]() |
e32763e464 | ||
![]() |
b85cf3f9d2 | ||
![]() |
3777bcc2af | ||
![]() |
52cde48ff0 | ||
![]() |
bf1da35303 | ||
![]() |
c1bf11da34 | ||
![]() |
3c20325b37 | ||
![]() |
fd8ccb8d8f | ||
![]() |
d76e947021 | ||
![]() |
c91ed96543 | ||
![]() |
b164531ba8 | ||
![]() |
7c623a8704 | ||
![]() |
7ae3340336 | ||
![]() |
653b73c601 | ||
![]() |
7c93d91bae | ||
![]() |
07da0cfb2b | ||
![]() |
b411a11c2c | ||
![]() |
0555b84d05 | ||
![]() |
790bddef63 | ||
![]() |
a3089b8aa7 | ||
![]() |
77c8426d63 | ||
![]() |
faf226f6c2 | ||
![]() |
06d143b81a | ||
![]() |
08b6a0a702 | ||
![]() |
a20d1e3656 | ||
![]() |
36cc3682ca | ||
![]() |
1b495ecafa | ||
![]() |
7d1a0be07e | ||
![]() |
327f65c991 | ||
![]() |
4ac89f6849 | ||
![]() |
db3b070ed0 | ||
![]() |
6d940f476a | ||
![]() |
1ca701dda4 | ||
![]() |
291c44100c | ||
![]() |
c8d676e06b | ||
![]() |
4c1ae0eddc | ||
![]() |
39eadc814f | ||
![]() |
f7ecad61ba | ||
![]() |
fa4cb54549 | ||
![]() |
2be33c5e0a |
@@ -203,6 +203,7 @@ homeassistant.components.feedreader.*
|
||||
homeassistant.components.file_upload.*
|
||||
homeassistant.components.filesize.*
|
||||
homeassistant.components.filter.*
|
||||
homeassistant.components.firefly_iii.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
|
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -492,6 +492,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/filesize/ @gjohansson-ST
|
||||
/homeassistant/components/filter/ @dgomes
|
||||
/tests/components/filter/ @dgomes
|
||||
/homeassistant/components/firefly_iii/ @erwindouna
|
||||
/tests/components/firefly_iii/ @erwindouna
|
||||
/homeassistant/components/fireservicerota/ @cyberjunky
|
||||
/tests/components/fireservicerota/ @cyberjunky
|
||||
/homeassistant/components/firmata/ @DaAwesomeP
|
||||
@@ -953,6 +955,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/met_eireann/ @DylanGore
|
||||
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||
/homeassistant/components/meteo_lt/ @xE1H
|
||||
/tests/components/meteo_lt/ @xE1H
|
||||
/homeassistant/components/meteoalarm/ @rolfberkenbosch
|
||||
/homeassistant/components/meteoclimatic/ @adrianmo
|
||||
/tests/components/meteoclimatic/ @adrianmo
|
||||
|
@@ -616,34 +616,44 @@ async def async_enable_logging(
|
||||
),
|
||||
)
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
|
||||
if log_file is None:
|
||||
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
if "SUPERVISOR" in os.environ:
|
||||
_LOGGER.info("Running in Supervisor, not logging to file")
|
||||
# Rename the default log file if it exists, since previous versions created
|
||||
# it even on Supervisor
|
||||
if os.path.isfile(default_log_path):
|
||||
with contextlib.suppress(OSError):
|
||||
os.rename(default_log_path, f"{default_log_path}.old")
|
||||
err_log_path = None
|
||||
else:
|
||||
err_log_path = default_log_path
|
||||
else:
|
||||
err_log_path = os.path.abspath(log_file)
|
||||
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
err_dir = os.path.dirname(err_log_path)
|
||||
if err_log_path:
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
err_dir = os.path.dirname(err_log_path)
|
||||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
|
||||
not err_path_exists and os.access(err_dir, os.W_OK)
|
||||
):
|
||||
err_handler = await hass.async_add_executor_job(
|
||||
_create_log_file, err_log_path, log_rotate_days
|
||||
)
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
|
||||
not err_path_exists and os.access(err_dir, os.W_OK)
|
||||
):
|
||||
err_handler = await hass.async_add_executor_job(
|
||||
_create_log_file, err_log_path, log_rotate_days
|
||||
)
|
||||
|
||||
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
|
||||
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
|
||||
logger.addHandler(err_handler)
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(err_handler)
|
||||
logger.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
|
||||
# Save the log file location for access by other components.
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
|
||||
# Save the log file location for access by other components.
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
|
||||
|
||||
async_activate_log_queue_handler(hass)
|
||||
|
||||
|
5
homeassistant/brands/eltako.json
Normal file
5
homeassistant/brands/eltako.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "eltako",
|
||||
"name": "Eltako",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
5
homeassistant/brands/konnected.json
Normal file
5
homeassistant/brands/konnected.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected",
|
||||
"integrations": ["konnected", "konnected_esphome"]
|
||||
}
|
5
homeassistant/brands/level.json
Normal file
5
homeassistant/brands/level.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "level",
|
||||
"name": "Level",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airos==0.5.3"]
|
||||
"requirements": ["airos==0.5.4"]
|
||||
}
|
||||
|
@@ -114,6 +114,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
),
|
||||
}
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
|
||||
|
@@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"""Return detailed information about entities and devices."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
@@ -513,6 +513,8 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||
|
||||
removed_devices: set[str] = set()
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
@@ -525,6 +527,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
if config_entry is None:
|
||||
continue
|
||||
|
||||
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
|
||||
removed_devices.add(device_entry.id)
|
||||
continue
|
||||
|
||||
integration_domain = config_entry.domain
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
@@ -614,11 +620,12 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
device_config = integration_config.devices.get(device_id, device_config)
|
||||
|
||||
if device_config.remove:
|
||||
removed_devices.add(device_id)
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
device_id_mapping[device_id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
@@ -669,7 +676,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
@@ -690,15 +697,19 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id_ := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
if (device_id_ := entity_entry.device_id) is not None:
|
||||
if device_id_ in removed_devices:
|
||||
# The device was removed, so we remove the entity too
|
||||
continue
|
||||
|
||||
if (
|
||||
new_device_id := device_id_mapping.get(device_id_)
|
||||
) is not None and (new_device_id[0] == integration_domain):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
continue
|
||||
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
|
@@ -2,9 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
T = TypeVar("T", dict[str, Any], list[Any], None)
|
||||
from typing import Any
|
||||
|
||||
TRANSLATION_MAP = {
|
||||
"wan_rx": "sensor_rx_bytes",
|
||||
@@ -36,7 +34,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
|
||||
|
||||
|
||||
def translate_to_legacy(raw: T) -> T:
|
||||
def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T:
|
||||
"""Translate raw data to legacy format for dicts and lists."""
|
||||
|
||||
if raw is None:
|
||||
|
@@ -272,6 +272,13 @@ async def async_setup_entry(
|
||||
observations: list[ConfigType] = [
|
||||
dict(subentry.data) for subentry in config_entry.subentries.values()
|
||||
]
|
||||
|
||||
for observation in observations:
|
||||
if observation[CONF_PLATFORM] == CONF_TEMPLATE:
|
||||
observation[CONF_VALUE_TEMPLATE] = Template(
|
||||
observation[CONF_VALUE_TEMPLATE], hass
|
||||
)
|
||||
|
||||
prior: float = config[CONF_PRIOR]
|
||||
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
|
||||
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
|
@@ -51,12 +51,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -118,12 +112,6 @@ ATTR_FILENAME: Final = "filename"
|
||||
ATTR_MEDIA_PLAYER: Final = "media_player"
|
||||
ATTR_FORMAT: Final = "format"
|
||||
|
||||
# These constants are deprecated as of Home Assistant 2024.10
|
||||
# Please use the StreamType enum instead.
|
||||
_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10")
|
||||
_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10")
|
||||
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10")
|
||||
|
||||
|
||||
class CameraEntityFeature(IntFlag):
|
||||
"""Supported features of the camera entity."""
|
||||
@@ -1117,11 +1105,3 @@ async def async_handle_record_service(
|
||||
duration=service_call.data[CONF_DURATION],
|
||||
lookback=service_call.data[CONF_LOOKBACK],
|
||||
)
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.10.1"]
|
||||
}
|
||||
|
@@ -176,7 +176,7 @@
|
||||
"description": "Sets the participating sensors for a climate program.",
|
||||
"fields": {
|
||||
"preset_mode": {
|
||||
"name": "Climate Name",
|
||||
"name": "Climate program",
|
||||
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
|
||||
},
|
||||
"device_ids": {
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_preset": {
|
||||
"message": "Invalid climate name, available options are: {options}"
|
||||
"message": "Invalid climate program, available options are: {options}"
|
||||
},
|
||||
"invalid_sensor": {
|
||||
"message": "Invalid sensor for thermostat, available options are: {options}"
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2025.9.1"]
|
||||
"requirements": ["aioecowitt==2025.9.2"]
|
||||
}
|
||||
|
11
homeassistant/components/esphome/analytics.py
Normal file
11
homeassistant/components/esphome/analytics.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Analytics platform."""
|
||||
|
||||
from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant, analytics_input: AnalyticsInput
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
return AnalyticsModifications(remove=True)
|
27
homeassistant/components/firefly_iii/__init__.py
Normal file
27
homeassistant/components/firefly_iii/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""The Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
|
||||
"""Set up Firefly III from a config entry."""
|
||||
|
||||
coordinator = FireflyDataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
97
homeassistant/components/firefly_iii/config_flow.py
Normal file
97
homeassistant/components/firefly_iii/config_flow.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Config flow for the Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyfirefly import (
|
||||
Firefly,
|
||||
FireflyAuthenticationError,
|
||||
FireflyConnectionError,
|
||||
FireflyTimeoutError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
try:
|
||||
client = Firefly(
|
||||
api_url=data[CONF_URL],
|
||||
api_key=data[CONF_API_KEY],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
await client.get_about()
|
||||
except FireflyAuthenticationError:
|
||||
raise InvalidAuth from None
|
||||
except FireflyConnectionError as err:
|
||||
raise CannotConnect from err
|
||||
except FireflyTimeoutError as err:
|
||||
raise FireflyClientTimeout from err
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Firefly III."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
try:
|
||||
await _validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except FireflyClientTimeout:
|
||||
errors["base"] = "timeout_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_URL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class FireflyClientTimeout(HomeAssistantError):
|
||||
"""Error to indicate a timeout occurred."""
|
6
homeassistant/components/firefly_iii/const.py
Normal file
6
homeassistant/components/firefly_iii/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Firefly III integration."""
|
||||
|
||||
DOMAIN = "firefly_iii"
|
||||
|
||||
MANUFACTURER = "Firefly III"
|
||||
NAME = "Firefly III"
|
137
homeassistant/components/firefly_iii/coordinator.py
Normal file
137
homeassistant/components/firefly_iii/coordinator.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Data Update Coordinator for Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyfirefly import (
|
||||
Firefly,
|
||||
FireflyAuthenticationError,
|
||||
FireflyConnectionError,
|
||||
FireflyTimeoutError,
|
||||
)
|
||||
from pyfirefly.models import Account, Bill, Budget, Category, Currency
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FireflyCoordinatorData:
|
||||
"""Data structure for Firefly III coordinator data."""
|
||||
|
||||
accounts: list[Account]
|
||||
categories: list[Category]
|
||||
category_details: list[Category]
|
||||
budgets: list[Budget]
|
||||
bills: list[Bill]
|
||||
primary_currency: Currency
|
||||
|
||||
|
||||
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
|
||||
"""Coordinator to manage data updates for Firefly III integration."""
|
||||
|
||||
config_entry: FireflyConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
)
|
||||
self.firefly = Firefly(
|
||||
api_url=self.config_entry.data[CONF_URL],
|
||||
api_key=self.config_entry.data[CONF_API_KEY],
|
||||
session=async_create_clientsession(
|
||||
self.hass,
|
||||
self.config_entry.data[CONF_VERIFY_SSL],
|
||||
cookie_jar=CookieJar(unsafe=True),
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
try:
|
||||
await self.firefly.get_about()
|
||||
except FireflyAuthenticationError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except FireflyConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except FireflyTimeoutError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def _async_update_data(self) -> FireflyCoordinatorData:
|
||||
"""Fetch data from Firefly III API."""
|
||||
now = datetime.now()
|
||||
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_date = now
|
||||
|
||||
try:
|
||||
accounts = await self.firefly.get_accounts()
|
||||
categories = await self.firefly.get_categories()
|
||||
category_details = [
|
||||
await self.firefly.get_category(
|
||||
category_id=int(category.id), start=start_date, end=end_date
|
||||
)
|
||||
for category in categories
|
||||
]
|
||||
primary_currency = await self.firefly.get_currency_primary()
|
||||
budgets = await self.firefly.get_budgets()
|
||||
bills = await self.firefly.get_bills()
|
||||
except FireflyAuthenticationError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except FireflyConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except FireflyTimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return FireflyCoordinatorData(
|
||||
accounts=accounts,
|
||||
categories=categories,
|
||||
category_details=category_details,
|
||||
budgets=budgets,
|
||||
bills=bills,
|
||||
primary_currency=primary_currency,
|
||||
)
|
40
homeassistant/components/firefly_iii/entity.py
Normal file
40
homeassistant/components/firefly_iii/entity.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Base entity for Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import FireflyDataUpdateCoordinator
|
||||
|
||||
|
||||
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
|
||||
"""Base class for Firefly III entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Firefly entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = entity_description
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
|
||||
)
|
||||
},
|
||||
)
|
18
homeassistant/components/firefly_iii/icons.json
Normal file
18
homeassistant/components/firefly_iii/icons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"account_type": {
|
||||
"default": "mdi:bank",
|
||||
"state": {
|
||||
"expense": "mdi:cash-minus",
|
||||
"revenue": "mdi:cash-plus",
|
||||
"asset": "mdi:account-cash",
|
||||
"liability": "mdi:hand-coin"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"default": "mdi:label"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
homeassistant/components/firefly_iii/manifest.json
Normal file
10
homeassistant/components/firefly_iii/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "firefly_iii",
|
||||
"name": "Firefly III",
|
||||
"codeowners": ["@erwindouna"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.6"]
|
||||
}
|
68
homeassistant/components/firefly_iii/quality_scale.yaml
Normal file
68
homeassistant/components/firefly_iii/quality_scale.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
No explicit parallel updates are defined.
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
No reauthentication flow is defined. It will be done in a next iteration.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
142
homeassistant/components/firefly_iii/sensor.py
Normal file
142
homeassistant/components/firefly_iii/sensor.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Sensor platform for Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Category
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
||||
from .entity import FireflyBaseEntity
|
||||
|
||||
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="account_type",
|
||||
translation_key="account",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
)
|
||||
|
||||
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="category",
|
||||
translation_key="category",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FireflyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Firefly III sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[SensorEntity] = [
|
||||
FireflyAccountEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=description,
|
||||
account=account,
|
||||
)
|
||||
for account in coordinator.data.accounts
|
||||
for description in ACCOUNT_SENSORS
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
FireflyCategoryEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=description,
|
||||
category=category,
|
||||
)
|
||||
for category in coordinator.data.category_details
|
||||
for description in CATEGORY_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III account."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
account: Account,
|
||||
) -> None:
|
||||
"""Initialize Firefly account entity."""
|
||||
super().__init__(coordinator, entity_description)
|
||||
self._account = account
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
|
||||
self._attr_name = account.attributes.name
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
# Account type state doesn't go well with the icons.json. Need to fix it.
|
||||
if account.attributes.type == "expense":
|
||||
self._attr_icon = "mdi:cash-minus"
|
||||
elif account.attributes.type == "asset":
|
||||
self._attr_icon = "mdi:account-cash"
|
||||
elif account.attributes.type == "revenue":
|
||||
self._attr_icon = "mdi:cash-plus"
|
||||
elif account.attributes.type == "liability":
|
||||
self._attr_icon = "mdi:hand-coin"
|
||||
else:
|
||||
self._attr_icon = "mdi:bank"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self._account.attributes.current_balance
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
"""Return extra state attributes for the account entity."""
|
||||
return {
|
||||
"account_role": self._account.attributes.account_role or "",
|
||||
"account_type": self._account.attributes.type or "",
|
||||
"current_balance": str(self._account.attributes.current_balance or ""),
|
||||
}
|
||||
|
||||
|
||||
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III category."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
category: Category,
|
||||
) -> None:
|
||||
"""Initialize Firefly category entity."""
|
||||
super().__init__(coordinator, entity_description)
|
||||
self._category = category
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
|
||||
self._attr_name = category.attributes.name
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
spent_items = self._category.attributes.spent or []
|
||||
earned_items = self._category.attributes.earned or []
|
||||
|
||||
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
|
||||
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
|
||||
|
||||
if spent == 0 and earned == 0:
|
||||
return None
|
||||
return spent + earned
|
39
homeassistant/components/firefly_iii/strings.json
Normal file
39
homeassistant/components/firefly_iii/strings.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "The API key for authenticating with Firefly",
|
||||
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
|
||||
},
|
||||
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "An error occurred while trying to connect to the Firefly instance: {error}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "An error occurred while trying to authenticate: {error}"
|
||||
},
|
||||
"timeout_connect": {
|
||||
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
|
||||
}
|
||||
}
|
||||
}
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250926.0"]
|
||||
"requirements": ["home-assistant-frontend==20251001.0"]
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
"""Coordinator module for managing Growatt data fetching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
@@ -145,7 +147,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return self.data.get("currency")
|
||||
|
||||
def get_data(
|
||||
self, entity_description: "GrowattSensorEntityDescription"
|
||||
self, entity_description: GrowattSensorEntityDescription
|
||||
) -> str | int | float | None:
|
||||
"""Get the data."""
|
||||
variable = entity_description.api_key
|
||||
|
@@ -4,9 +4,14 @@ from uuid import UUID
|
||||
|
||||
from habiticalib import Habitica
|
||||
|
||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
@@ -27,6 +32,7 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.CALENDAR,
|
||||
Platform.IMAGE,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TODO,
|
||||
@@ -46,6 +52,7 @@ async def async_setup_entry(
|
||||
"""Set up habitica from a config entry."""
|
||||
party_added_by_this_entry: UUID | None = None
|
||||
device_reg = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
session = async_get_clientsession(
|
||||
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
|
||||
@@ -96,6 +103,15 @@ async def async_setup_entry(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
notify_entities = [
|
||||
entry.entity_id
|
||||
for entry in entity_registry.entities.values()
|
||||
if entry.domain == NOTIFY_DOMAIN
|
||||
and entry.config_entry_id == config_entry.entry_id
|
||||
]
|
||||
for entity_id in notify_entities:
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
hass.config_entries.async_schedule_reload(config_entry.entry_id)
|
||||
|
||||
coordinator.async_add_listener(_party_update_listener)
|
||||
|
@@ -121,4 +121,4 @@ class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""If the binary sensor is on."""
|
||||
return self.coordinator.data.quest.active
|
||||
return self.coordinator.data.party.quest.active
|
||||
|
@@ -9,6 +9,7 @@ from datetime import timedelta
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
@@ -48,6 +49,14 @@ class HabiticaData:
|
||||
tasks: list[TaskData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HabiticaPartyData:
|
||||
"""Habitica party data."""
|
||||
|
||||
party: GroupData
|
||||
members: dict[UUID, UserData]
|
||||
|
||||
|
||||
type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
|
||||
|
||||
|
||||
@@ -192,11 +201,19 @@ class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]):
|
||||
return png.getvalue()
|
||||
|
||||
|
||||
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]):
|
||||
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]):
|
||||
"""Habitica Party Coordinator."""
|
||||
|
||||
_update_interval = timedelta(minutes=15)
|
||||
|
||||
async def _update_data(self) -> GroupData:
|
||||
async def _update_data(self) -> HabiticaPartyData:
|
||||
"""Fetch the latest party data."""
|
||||
return (await self.habitica.get_group()).data
|
||||
|
||||
return HabiticaPartyData(
|
||||
party=(await self.habitica.get_group()).data,
|
||||
members={
|
||||
member.id: member
|
||||
for member in (await self.habitica.get_group_members()).data
|
||||
if member.id
|
||||
},
|
||||
)
|
||||
|
@@ -68,14 +68,14 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]):
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert config_entry.unique_id
|
||||
unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}"
|
||||
unique_id = f"{config_entry.unique_id}_{coordinator.data.party.id!s}"
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=NAME,
|
||||
name=coordinator.data.summary,
|
||||
name=coordinator.data.party.summary,
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
via_device=(DOMAIN, config_entry.unique_id),
|
||||
)
|
||||
|
@@ -194,6 +194,11 @@
|
||||
"quest_running": {
|
||||
"default": "mdi:script-text-play"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"party_chat": {
|
||||
"default": "mdi:forum"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
@@ -128,7 +128,7 @@ class HabiticaPartyImage(HabiticaPartyBase, ImageEntity):
|
||||
"""Return URL of image."""
|
||||
return (
|
||||
f"{ASSETS_URL}quest_{key}.png"
|
||||
if (key := self.coordinator.data.quest.key)
|
||||
if (key := self.coordinator.data.party.quest.key)
|
||||
else None
|
||||
)
|
||||
|
||||
|
202
homeassistant/components/habitica/notify.py
Normal file
202
homeassistant/components/habitica/notify.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Notify platform for the Habitica integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
GroupData,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
NotFoundError,
|
||||
TooManyRequestsError,
|
||||
UserData,
|
||||
)
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
NotifyEntity,
|
||||
NotifyEntityDescription,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HABITICA_KEY
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
|
||||
PARALLEL_UPDATES = 10
|
||||
|
||||
|
||||
class HabiticaNotify(StrEnum):
|
||||
"""Habitica Notifier."""
|
||||
|
||||
PARTY_CHAT = "party_chat"
|
||||
PRIVATE_MESSAGE = "private_message"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HabiticaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the notify entity platform."""
|
||||
members_added: set[UUID] = set()
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
if party := coordinator.data.user.party.id:
|
||||
party_coordinator = hass.data[HABITICA_KEY][party]
|
||||
async_add_entities(
|
||||
[HabiticaPartyChatNotifyEntity(coordinator, party_coordinator.data.party)]
|
||||
)
|
||||
|
||||
@callback
|
||||
def add_entities() -> None:
|
||||
nonlocal members_added
|
||||
|
||||
new_members = set(party_coordinator.data.members.keys()) - members_added
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.data.user.id
|
||||
new_members.discard(coordinator.data.user.id)
|
||||
if new_members:
|
||||
async_add_entities(
|
||||
HabiticaPrivateMessageNotifyEntity(
|
||||
coordinator, party_coordinator.data.members[member]
|
||||
)
|
||||
for member in new_members
|
||||
)
|
||||
members_added |= new_members
|
||||
|
||||
delete_members = members_added - set(party_coordinator.data.members.keys())
|
||||
for member in delete_members:
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
NOTIFY_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.unique_id}_{member!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
members_added.discard(member)
|
||||
|
||||
party_coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
|
||||
class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
|
||||
"""Habitica base notify entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize a Habitica entity."""
|
||||
super().__init__(coordinator, self.entity_description)
|
||||
|
||||
@abstractmethod
|
||||
async def _send_message(self, message: str) -> None:
|
||||
"""Send a Habitica message."""
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message."""
|
||||
try:
|
||||
await self._send_message(message)
|
||||
except NotAuthorizedError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_forbidden",
|
||||
translation_placeholders={
|
||||
**self.translation_placeholders,
|
||||
"reason": e.error.message,
|
||||
},
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_not_found",
|
||||
translation_placeholders={
|
||||
**self.translation_placeholders,
|
||||
"reason": e.error.message,
|
||||
},
|
||||
) from e
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
||||
) from e
|
||||
except HabiticaException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": e.error.message},
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
|
||||
|
||||
class HabiticaPartyChatNotifyEntity(HabiticaBaseNotifyEntity):
|
||||
"""Representation of a Habitica party chat notify entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
party: GroupData,
|
||||
) -> None:
|
||||
"""Initialize a Habitica entity."""
|
||||
self._attr_translation_placeholders = {CONF_NAME: party.name}
|
||||
|
||||
self.entity_description = NotifyEntityDescription(
|
||||
key=HabiticaNotify.PARTY_CHAT,
|
||||
translation_key=HabiticaNotify.PARTY_CHAT,
|
||||
)
|
||||
self.party = party
|
||||
super().__init__(coordinator)
|
||||
|
||||
async def _send_message(self, message: str) -> None:
|
||||
"""Send a Habitica party chat message."""
|
||||
|
||||
await self.coordinator.habitica.send_group_message(
|
||||
message=message,
|
||||
group_id=self.party.id,
|
||||
)
|
||||
|
||||
|
||||
class HabiticaPrivateMessageNotifyEntity(HabiticaBaseNotifyEntity):
|
||||
"""Representation of a Habitica private message notify entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
member: UserData,
|
||||
) -> None:
|
||||
"""Initialize a Habitica entity."""
|
||||
self._attr_translation_placeholders = {CONF_NAME: member.profile.name or ""}
|
||||
self.entity_description = NotifyEntityDescription(
|
||||
key=f"{member.id!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
|
||||
translation_key=HabiticaNotify.PRIVATE_MESSAGE,
|
||||
)
|
||||
self.member = member
|
||||
super().__init__(coordinator)
|
||||
|
||||
async def _send_message(self, message: str) -> None:
|
||||
"""Send a Habitica private message."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.member.id
|
||||
await self.coordinator.habitica.send_private_message(
|
||||
message=message,
|
||||
to_user_id=self.member.id,
|
||||
)
|
@@ -445,7 +445,9 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the device."""
|
||||
|
||||
return self.entity_description.value_fn(self.coordinator.data, self.content)
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data.party, self.content
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
@@ -453,7 +455,9 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
||||
pic = self.entity_description.entity_picture
|
||||
|
||||
entity_picture = (
|
||||
pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data)
|
||||
pic
|
||||
if isinstance(pic, str) or pic is None
|
||||
else pic(self.coordinator.data.party)
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -468,5 +472,5 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return entity specific state attributes."""
|
||||
if func := self.entity_description.attributes_fn:
|
||||
return func(self.coordinator.data, self.content)
|
||||
return func(self.coordinator.data.party, self.content)
|
||||
return None
|
||||
|
@@ -264,6 +264,14 @@
|
||||
"name": "[%key:component::habitica::common::quest_name%]"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"party_chat": {
|
||||
"name": "Party chat"
|
||||
},
|
||||
"private_message": {
|
||||
"name": "Private message: {name}"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"display_name": {
|
||||
"name": "Display name",
|
||||
@@ -572,6 +580,12 @@
|
||||
},
|
||||
"frequency_not_monthly": {
|
||||
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
|
||||
},
|
||||
"send_message_forbidden": {
|
||||
"message": "You are not allowed to send messages to {name}. ({reason})"
|
||||
},
|
||||
"send_message_not_found": {
|
||||
"message": "Unable to send message, {name} not found. ({reason})"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
@@ -68,7 +68,6 @@ EVENT_HEALTH_CHANGED = "health_changed"
|
||||
EVENT_SUPPORTED_CHANGED = "supported_changed"
|
||||
EVENT_ISSUE_CHANGED = "issue_changed"
|
||||
EVENT_ISSUE_REMOVED = "issue_removed"
|
||||
EVENT_JOB = "job"
|
||||
|
||||
UPDATE_KEY_SUPERVISOR = "supervisor"
|
||||
|
||||
|
@@ -56,7 +56,6 @@ from .const import (
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .jobs import SupervisorJobs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .issues import SupervisorIssues
|
||||
@@ -312,7 +311,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
lambda: defaultdict(set)
|
||||
)
|
||||
self.supervisor_client = get_supervisor_client(hass)
|
||||
self.jobs = SupervisorJobs(hass)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update data via library."""
|
||||
@@ -487,9 +485,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
)
|
||||
|
||||
# Refresh jobs data
|
||||
await self.jobs.refresh_data(first_update)
|
||||
|
||||
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Update single addon stats."""
|
||||
try:
|
||||
|
@@ -1,157 +0,0 @@
|
||||
"""Track Supervisor job data and allow subscription to updates."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from aiohasupervisor.models import Job
|
||||
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
is_callback_check_partial,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from .const import (
|
||||
ATTR_DATA,
|
||||
ATTR_UPDATE_KEY,
|
||||
ATTR_WS_EVENT,
|
||||
EVENT_JOB,
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
EVENT_SUPERVISOR_UPDATE,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class JobSubscription:
|
||||
"""Subscribe for updates on jobs which match filters.
|
||||
|
||||
UUID is preferred match but only available in cases of a background API that
|
||||
returns the UUID before taking the action. Others are used to match jobs only
|
||||
if UUID is omitted. Either name or UUID is required to be able to match.
|
||||
|
||||
event_callback must be safe annotated as a homeassistant.core.callback
|
||||
and safe to call in the event loop.
|
||||
"""
|
||||
|
||||
event_callback: Callable[[Job], Any]
|
||||
uuid: str | None = None
|
||||
name: str | None = None
|
||||
reference: str | None | type[Any] = Any
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate at least one filter option is present."""
|
||||
if not self.name and not self.uuid:
|
||||
raise ValueError("Either name or uuid must be provided!")
|
||||
if not is_callback_check_partial(self.event_callback):
|
||||
raise ValueError("event_callback must be a homeassistant.core.callback!")
|
||||
|
||||
def matches(self, job: Job) -> bool:
|
||||
"""Return true if job matches subscription filters."""
|
||||
if self.uuid:
|
||||
return job.uuid == self.uuid
|
||||
return job.name == self.name and self.reference in (Any, job.reference)
|
||||
|
||||
|
||||
class SupervisorJobs:
|
||||
"""Manage access to Supervisor jobs."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize object."""
|
||||
self._hass = hass
|
||||
self._supervisor_client = get_supervisor_client(hass)
|
||||
self._jobs: dict[UUID, Job] = {}
|
||||
self._subscriptions: set[JobSubscription] = set()
|
||||
|
||||
@property
|
||||
def current_jobs(self) -> list[Job]:
|
||||
"""Return current jobs."""
|
||||
return list(self._jobs.values())
|
||||
|
||||
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
|
||||
"""Subscribe to updates for job. Return callback is used to unsubscribe.
|
||||
|
||||
If any jobs match the subscription at the time this is called, creates
|
||||
tasks to run their callback on it.
|
||||
"""
|
||||
self._subscriptions.add(subscription)
|
||||
|
||||
# As these are callbacks they are safe to run in the event loop
|
||||
# We wrap these in an asyncio task so subscribing does not wait on the logic
|
||||
if matches := [job for job in self._jobs.values() if subscription.matches(job)]:
|
||||
|
||||
async def event_callback_async(job: Job) -> Any:
|
||||
return subscription.event_callback(job)
|
||||
|
||||
for match in matches:
|
||||
self._hass.async_create_task(event_callback_async(match))
|
||||
|
||||
return partial(self._subscriptions.discard, subscription)
|
||||
|
||||
async def refresh_data(self, first_update: bool = False) -> None:
|
||||
"""Refresh job data."""
|
||||
job_data = await self._supervisor_client.jobs.info()
|
||||
job_queue: list[Job] = job_data.jobs.copy()
|
||||
new_jobs: dict[UUID, Job] = {}
|
||||
changed_jobs: list[Job] = []
|
||||
|
||||
# Rebuild our job cache from new info and compare to find changes
|
||||
while job_queue:
|
||||
job = job_queue.pop(0)
|
||||
job_queue.extend(job.child_jobs)
|
||||
job = replace(job, child_jobs=[])
|
||||
|
||||
if job.uuid not in self._jobs or job != self._jobs[job.uuid]:
|
||||
changed_jobs.append(job)
|
||||
new_jobs[job.uuid] = replace(job, child_jobs=[])
|
||||
|
||||
# For any jobs that disappeared which weren't done, tell subscribers they
|
||||
# changed to done. We don't know what else happened to them so leave the
|
||||
# rest of their state as is rather then guessing
|
||||
changed_jobs.extend(
|
||||
[
|
||||
replace(job, done=True)
|
||||
for uuid, job in self._jobs.items()
|
||||
if uuid not in new_jobs and job.done is False
|
||||
]
|
||||
)
|
||||
|
||||
# Replace our cache and inform subscribers of all changes
|
||||
self._jobs = new_jobs
|
||||
for job in changed_jobs:
|
||||
self._process_job_change(job)
|
||||
|
||||
# If this is the first update register to receive Supervisor events
|
||||
if first_update:
|
||||
async_dispatcher_connect(
|
||||
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
|
||||
)
|
||||
|
||||
@callback
|
||||
def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None:
|
||||
"""Update job data cache from supervisor events."""
|
||||
if ATTR_WS_EVENT not in event:
|
||||
return
|
||||
|
||||
if (
|
||||
event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
|
||||
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
|
||||
):
|
||||
self._hass.async_create_task(self.refresh_data())
|
||||
|
||||
elif event[ATTR_WS_EVENT] == EVENT_JOB:
|
||||
job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []})
|
||||
self._jobs[job.uuid] = job
|
||||
self._process_job_change(job)
|
||||
|
||||
def _process_job_change(self, job: Job) -> None:
|
||||
"""Process a job change by triggering callbacks on subscribers."""
|
||||
for sub in self._subscriptions:
|
||||
if sub.matches(job):
|
||||
sub.event_callback(job)
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.3.3b0"],
|
||||
"requirements": ["aiohasupervisor==0.3.3"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -6,7 +6,6 @@ import re
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import Job
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
|
||||
|
||||
from homeassistant.components.update import (
|
||||
@@ -16,7 +15,7 @@ from homeassistant.components.update import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ICON, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -36,7 +35,6 @@ from .entity import (
|
||||
HassioOSEntity,
|
||||
HassioSupervisorEntity,
|
||||
)
|
||||
from .jobs import JobSubscription
|
||||
from .update_helper import update_addon, update_core, update_os
|
||||
|
||||
ENTITY_DESCRIPTION = UpdateEntityDescription(
|
||||
@@ -91,7 +89,6 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
UpdateEntityFeature.INSTALL
|
||||
| UpdateEntityFeature.BACKUP
|
||||
| UpdateEntityFeature.RELEASE_NOTES
|
||||
| UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -157,30 +154,6 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@callback
|
||||
def _update_job_changed(self, job: Job) -> None:
|
||||
"""Process update for this entity's update job."""
|
||||
if job.done is False:
|
||||
self._attr_in_progress = True
|
||||
self._attr_update_percentage = job.progress
|
||||
else:
|
||||
self._attr_in_progress = False
|
||||
self._attr_update_percentage = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to progress updates."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.jobs.subscribe(
|
||||
JobSubscription(
|
||||
self._update_job_changed,
|
||||
name="addon_manager_update",
|
||||
reference=self._addon_slug,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
|
||||
"""Update entity to handle updates for the Home Assistant Operating System."""
|
||||
@@ -277,7 +250,6 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
UpdateEntityFeature.INSTALL
|
||||
| UpdateEntityFeature.SPECIFIC_VERSION
|
||||
| UpdateEntityFeature.BACKUP
|
||||
| UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
_attr_title = "Home Assistant Core"
|
||||
|
||||
@@ -309,25 +281,3 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
await update_core(self.hass, version, backup)
|
||||
|
||||
@callback
|
||||
def _update_job_changed(self, job: Job) -> None:
|
||||
"""Process update for this entity's update job."""
|
||||
if job.done is False:
|
||||
self._attr_in_progress = True
|
||||
self._attr_update_percentage = job.progress
|
||||
else:
|
||||
self._attr_in_progress = False
|
||||
self._attr_update_percentage = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to progress updates."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.jobs.subscribe(
|
||||
JobSubscription(
|
||||
self._update_job_changed, name="home_assistant_core_update"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@@ -10,6 +10,7 @@ from homeassistant.components.homeassistant_hardware import firmware_config_flow
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
@@ -67,6 +68,11 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
|
||||
context: ConfigFlowContext
|
||||
|
||||
# `rts_dtr` targets older adapters, `baudrate` works for newer ones. The reason we
|
||||
# try them in this order is that on older adapters `baudrate` entered the ESP32-S3
|
||||
# bootloader instead of the MG24 bootloader.
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -156,7 +157,7 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Connect ZBT-2 firmware update entity."""
|
||||
|
||||
bootloader_reset_type = None
|
||||
bootloader_reset_methods = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -39,6 +39,7 @@ from .util import (
|
||||
FirmwareInfo,
|
||||
OwningAddon,
|
||||
OwningIntegration,
|
||||
ResetTarget,
|
||||
async_flash_silabs_firmware,
|
||||
get_otbr_addon_manager,
|
||||
guess_firmware_info,
|
||||
@@ -79,6 +80,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Base flow to install firmware."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
|
||||
@@ -155,34 +158,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def _probe_firmware_info(
|
||||
self,
|
||||
probe_methods: tuple[ApplicationType, ...] = (
|
||||
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
|
||||
ApplicationType.GECKO_BOOTLOADER,
|
||||
ApplicationType.EZSP,
|
||||
ApplicationType.SPINEL,
|
||||
ApplicationType.CPC,
|
||||
),
|
||||
) -> bool:
|
||||
"""Probe the firmware currently on the device."""
|
||||
assert self._device is not None
|
||||
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
probe_methods=probe_methods,
|
||||
)
|
||||
|
||||
return (
|
||||
self._probed_firmware_info is not None
|
||||
and self._probed_firmware_info.firmware_type
|
||||
in (
|
||||
ApplicationType.EZSP,
|
||||
ApplicationType.SPINEL,
|
||||
ApplicationType.CPC,
|
||||
)
|
||||
)
|
||||
|
||||
async def _install_firmware_step(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
@@ -236,12 +211,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
) -> None:
|
||||
"""Install firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
raise AbortFlow(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
assert self._device is not None
|
||||
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
@@ -250,6 +219,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
)
|
||||
@@ -301,12 +272,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
|
||||
await async_flash_silabs_firmware(
|
||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
@@ -314,15 +285,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
async def _configure_and_start_otbr_addon(self) -> None:
|
||||
"""Configure and start the OTBR addon."""
|
||||
|
||||
# Before we start the addon, confirm that the correct firmware is running
|
||||
# and populate `self._probed_firmware_info` with the correct information
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
|
||||
raise AbortFlow(
|
||||
"unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
@@ -444,12 +406,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
return await self.async_step_prepare_thread_installation()
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_prepare_thread_installation(
|
||||
async def async_step_finish_thread_installation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Prepare for Thread installation by stopping the OTBR addon if needed."""
|
||||
"""Finish Thread installation by starting the OTBR addon."""
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio_thread",
|
||||
@@ -459,22 +421,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
# Stop the addon before continuing to flash firmware
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_finish_thread_installation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Finish Thread installation by starting the OTBR addon."""
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return await self.async_step_start_otbr_addon()
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
@@ -511,12 +463,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
||||
return self._async_flow_finished()
|
||||
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"universal-silabs-flasher==0.0.32",
|
||||
"universal-silabs-flasher==0.0.34",
|
||||
"ha-silabs-firmware-client==0.2.0"
|
||||
]
|
||||
}
|
||||
|
@@ -22,7 +22,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import FirmwareUpdateCoordinator
|
||||
from .helpers import async_register_firmware_info_callback
|
||||
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
async_flash_silabs_firmware,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,7 +86,7 @@ class BaseFirmwareUpdateEntity(
|
||||
|
||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||
entity_description: FirmwareUpdateEntityDescription
|
||||
bootloader_reset_type: str | None = None
|
||||
bootloader_reset_methods: list[ResetTarget] = []
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
@@ -268,7 +273,7 @@ class BaseFirmwareUpdateEntity(
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_type=self.bootloader_reset_type,
|
||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
||||
progress_callback=self._update_progress,
|
||||
)
|
||||
finally:
|
||||
|
@@ -4,13 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
|
||||
from universal_silabs_flasher.const import (
|
||||
ApplicationType as FlasherApplicationType,
|
||||
ResetTarget as FlasherResetTarget,
|
||||
)
|
||||
from universal_silabs_flasher.firmware import parse_firmware_image
|
||||
from universal_silabs_flasher.flasher import Flasher
|
||||
|
||||
@@ -42,9 +45,9 @@ class ApplicationType(StrEnum):
|
||||
"""Application type running on a device."""
|
||||
|
||||
GECKO_BOOTLOADER = "bootloader"
|
||||
CPC = "cpc"
|
||||
EZSP = "ezsp"
|
||||
SPINEL = "spinel"
|
||||
CPC = "cpc"
|
||||
ROUTER = "router"
|
||||
|
||||
@classmethod
|
||||
@@ -59,6 +62,18 @@ class ApplicationType(StrEnum):
|
||||
return FlasherApplicationType(self.value)
|
||||
|
||||
|
||||
class ResetTarget(StrEnum):
|
||||
"""Methods to reset a device into bootloader mode."""
|
||||
|
||||
RTS_DTR = "rts_dtr"
|
||||
BAUDRATE = "baudrate"
|
||||
YELLOW = "yellow"
|
||||
|
||||
def as_flasher_reset_target(self) -> FlasherResetTarget:
|
||||
"""Convert the reset target enum into one compatible with USF."""
|
||||
return FlasherResetTarget(self.value)
|
||||
|
||||
|
||||
@singleton(OTBR_ADDON_MANAGER_DATA)
|
||||
@callback
|
||||
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
||||
@@ -342,7 +357,7 @@ async def async_flash_silabs_firmware(
|
||||
device: str,
|
||||
fw_data: bytes,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
bootloader_reset_type: str | None = None,
|
||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> FirmwareInfo:
|
||||
"""Flash firmware to the SiLabs device."""
|
||||
@@ -359,7 +374,9 @@ async def async_flash_silabs_firmware(
|
||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||
ApplicationType.CPC.as_flasher_application_type(),
|
||||
),
|
||||
bootloader_reset=bootloader_reset_type,
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
),
|
||||
)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
|
@@ -168,7 +168,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""SkyConnect firmware update entity."""
|
||||
|
||||
bootloader_reset_type = None
|
||||
# The ZBT-1 does not have a hardware bootloader trigger
|
||||
bootloader_reset_methods = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -27,6 +27,8 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
probe_silabs_firmware_info,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HARDWARE,
|
||||
@@ -82,6 +84,8 @@ else:
|
||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -141,8 +145,10 @@ class HomeAssistantYellowConfigFlow(
|
||||
self, data: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
assert self._device is not None
|
||||
|
||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||
await self._probe_firmware_info()
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
|
||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||
if (
|
||||
|
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -173,7 +174,7 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Yellow firmware update entity."""
|
||||
|
||||
bootloader_reset_type = "yellow" # Triggers a GPIO reset
|
||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
|
||||
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
|
||||
|
||||
INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
|
||||
type INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
1
homeassistant/components/konnected_esphome/__init__.py
Normal file
1
homeassistant/components/konnected_esphome/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Konnected ESPHome."""
|
6
homeassistant/components/konnected_esphome/manifest.json
Normal file
6
homeassistant/components/konnected_esphome/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "konnected_esphome",
|
||||
"name": "Konnected",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "esphome"
|
||||
}
|
@@ -13,28 +13,16 @@ from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ( # noqa: F401
|
||||
_DEPRECATED_STATE_JAMMED,
|
||||
_DEPRECATED_STATE_LOCKED,
|
||||
_DEPRECATED_STATE_LOCKING,
|
||||
_DEPRECATED_STATE_UNLOCKED,
|
||||
_DEPRECATED_STATE_UNLOCKING,
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE,
|
||||
ATTR_CODE_FORMAT,
|
||||
SERVICE_LOCK,
|
||||
SERVICE_OPEN,
|
||||
SERVICE_UNLOCK,
|
||||
STATE_OPEN,
|
||||
STATE_OPENING,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.deprecation import (
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
@@ -317,11 +305,3 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return
|
||||
|
||||
self._lock_option_default_code = ""
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = ft.partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
|
||||
TIMEOUT = 10
|
||||
|
||||
TokenManager = Callable[[], Awaitable[str]]
|
||||
type TokenManager = Callable[[], Awaitable[str]]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
@@ -55,12 +55,6 @@ from homeassistant.const import ( # noqa: F401
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.network import get_url
|
||||
@@ -75,26 +69,6 @@ from .browse_media import ( # noqa: F401
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from .const import ( # noqa: F401
|
||||
_DEPRECATED_MEDIA_CLASS_DIRECTORY,
|
||||
_DEPRECATED_SUPPORT_BROWSE_MEDIA,
|
||||
_DEPRECATED_SUPPORT_CLEAR_PLAYLIST,
|
||||
_DEPRECATED_SUPPORT_GROUPING,
|
||||
_DEPRECATED_SUPPORT_NEXT_TRACK,
|
||||
_DEPRECATED_SUPPORT_PAUSE,
|
||||
_DEPRECATED_SUPPORT_PLAY,
|
||||
_DEPRECATED_SUPPORT_PLAY_MEDIA,
|
||||
_DEPRECATED_SUPPORT_PREVIOUS_TRACK,
|
||||
_DEPRECATED_SUPPORT_REPEAT_SET,
|
||||
_DEPRECATED_SUPPORT_SEEK,
|
||||
_DEPRECATED_SUPPORT_SELECT_SOUND_MODE,
|
||||
_DEPRECATED_SUPPORT_SELECT_SOURCE,
|
||||
_DEPRECATED_SUPPORT_SHUFFLE_SET,
|
||||
_DEPRECATED_SUPPORT_STOP,
|
||||
_DEPRECATED_SUPPORT_TURN_OFF,
|
||||
_DEPRECATED_SUPPORT_TURN_ON,
|
||||
_DEPRECATED_SUPPORT_VOLUME_MUTE,
|
||||
_DEPRECATED_SUPPORT_VOLUME_SET,
|
||||
_DEPRECATED_SUPPORT_VOLUME_STEP,
|
||||
ATTR_APP_ID,
|
||||
ATTR_APP_NAME,
|
||||
ATTR_ENTITY_PICTURE_LOCAL,
|
||||
@@ -188,17 +162,6 @@ class MediaPlayerDeviceClass(StrEnum):
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
|
||||
|
||||
|
||||
# DEVICE_CLASS* below are deprecated as of 2021.12
|
||||
# use the MediaPlayerDeviceClass enum instead.
|
||||
_DEPRECATED_DEVICE_CLASS_TV = DeprecatedConstantEnum(
|
||||
MediaPlayerDeviceClass.TV, "2025.10"
|
||||
)
|
||||
_DEPRECATED_DEVICE_CLASS_SPEAKER = DeprecatedConstantEnum(
|
||||
MediaPlayerDeviceClass.SPEAKER, "2025.10"
|
||||
)
|
||||
_DEPRECATED_DEVICE_CLASS_RECEIVER = DeprecatedConstantEnum(
|
||||
MediaPlayerDeviceClass.RECEIVER, "2025.10"
|
||||
)
|
||||
DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass]
|
||||
|
||||
|
||||
@@ -1196,6 +1159,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
media_content_id: str | None = None,
|
||||
media_filter_classes: list[MediaClass] | None = None,
|
||||
) -> SearchMedia:
|
||||
"""Search for media."""
|
||||
return await self.async_search_media(
|
||||
query=SearchMediaQuery(
|
||||
search_query=search_query,
|
||||
@@ -1510,13 +1474,3 @@ async def async_fetch_image(
|
||||
logger.warning("Error retrieving proxied image from %s", url)
|
||||
|
||||
return content, content_type
|
||||
|
||||
|
||||
# As we import deprecated constants from the const module, we need to add these two functions
|
||||
# otherwise this module will be logged for using deprecated constants and not the custom component
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = ft.partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
@@ -1,15 +1,8 @@
|
||||
"""Provides the constants needed for component."""
|
||||
|
||||
from enum import IntFlag, StrEnum
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
EnumWithDeprecatedMembers,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers
|
||||
|
||||
# How long our auth signature on the content should be valid for
|
||||
CONTENT_AUTH_EXPIRY_TIME = 3600 * 24
|
||||
@@ -94,38 +87,6 @@ class MediaClass(StrEnum):
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
# These MEDIA_CLASS_* constants are deprecated as of Home Assistant 2022.10.
|
||||
# Please use the MediaClass enum instead.
|
||||
_DEPRECATED_MEDIA_CLASS_ALBUM = DeprecatedConstantEnum(MediaClass.ALBUM, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_APP = DeprecatedConstantEnum(MediaClass.APP, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_ARTIST = DeprecatedConstantEnum(MediaClass.ARTIST, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_CHANNEL = DeprecatedConstantEnum(MediaClass.CHANNEL, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_COMPOSER = DeprecatedConstantEnum(
|
||||
MediaClass.COMPOSER, "2025.10"
|
||||
)
|
||||
_DEPRECATED_MEDIA_CLASS_CONTRIBUTING_ARTIST = DeprecatedConstantEnum(
|
||||
MediaClass.CONTRIBUTING_ARTIST, "2025.10"
|
||||
)
|
||||
_DEPRECATED_MEDIA_CLASS_DIRECTORY = DeprecatedConstantEnum(
|
||||
MediaClass.DIRECTORY, "2025.10"
|
||||
)
|
||||
_DEPRECATED_MEDIA_CLASS_EPISODE = DeprecatedConstantEnum(MediaClass.EPISODE, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_GAME = DeprecatedConstantEnum(MediaClass.GAME, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_GENRE = DeprecatedConstantEnum(MediaClass.GENRE, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_IMAGE = DeprecatedConstantEnum(MediaClass.IMAGE, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_MOVIE = DeprecatedConstantEnum(MediaClass.MOVIE, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_MUSIC = DeprecatedConstantEnum(MediaClass.MUSIC, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_PLAYLIST = DeprecatedConstantEnum(
|
||||
MediaClass.PLAYLIST, "2025.10"
|
||||
)
|
||||
_DEPRECATED_MEDIA_CLASS_PODCAST = DeprecatedConstantEnum(MediaClass.PODCAST, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_SEASON = DeprecatedConstantEnum(MediaClass.SEASON, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_TRACK = DeprecatedConstantEnum(MediaClass.TRACK, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_TV_SHOW = DeprecatedConstantEnum(MediaClass.TV_SHOW, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_URL = DeprecatedConstantEnum(MediaClass.URL, "2025.10")
|
||||
_DEPRECATED_MEDIA_CLASS_VIDEO = DeprecatedConstantEnum(MediaClass.VIDEO, "2025.10")
|
||||
|
||||
|
||||
class MediaType(StrEnum):
|
||||
"""Media type for media player entities."""
|
||||
|
||||
@@ -152,33 +113,6 @@ class MediaType(StrEnum):
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
# These MEDIA_TYPE_* constants are deprecated as of Home Assistant 2022.10.
|
||||
# Please use the MediaType enum instead.
|
||||
_DEPRECATED_MEDIA_TYPE_ALBUM = DeprecatedConstantEnum(MediaType.ALBUM, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_APP = DeprecatedConstantEnum(MediaType.APP, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_APPS = DeprecatedConstantEnum(MediaType.APPS, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_ARTIST = DeprecatedConstantEnum(MediaType.ARTIST, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_CHANNEL = DeprecatedConstantEnum(MediaType.CHANNEL, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_CHANNELS = DeprecatedConstantEnum(MediaType.CHANNELS, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_COMPOSER = DeprecatedConstantEnum(MediaType.COMPOSER, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_CONTRIBUTING_ARTIST = DeprecatedConstantEnum(
|
||||
MediaType.CONTRIBUTING_ARTIST, "2025.10"
|
||||
)
|
||||
_DEPRECATED_MEDIA_TYPE_EPISODE = DeprecatedConstantEnum(MediaType.EPISODE, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_GAME = DeprecatedConstantEnum(MediaType.GAME, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_GENRE = DeprecatedConstantEnum(MediaType.GENRE, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_IMAGE = DeprecatedConstantEnum(MediaType.IMAGE, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_MOVIE = DeprecatedConstantEnum(MediaType.MOVIE, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_MUSIC = DeprecatedConstantEnum(MediaType.MUSIC, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_PLAYLIST = DeprecatedConstantEnum(MediaType.PLAYLIST, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_PODCAST = DeprecatedConstantEnum(MediaType.PODCAST, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_SEASON = DeprecatedConstantEnum(MediaType.SEASON, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_TRACK = DeprecatedConstantEnum(MediaType.TRACK, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_TVSHOW = DeprecatedConstantEnum(MediaType.TVSHOW, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_URL = DeprecatedConstantEnum(MediaType.URL, "2025.10")
|
||||
_DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10")
|
||||
|
||||
|
||||
SERVICE_CLEAR_PLAYLIST = "clear_playlist"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_PLAY_MEDIA = "play_media"
|
||||
@@ -197,11 +131,6 @@ class RepeatMode(StrEnum):
|
||||
ONE = "one"
|
||||
|
||||
|
||||
# These REPEAT_MODE_* constants are deprecated as of Home Assistant 2022.10.
|
||||
# Please use the RepeatMode enum instead.
|
||||
_DEPRECATED_REPEAT_MODE_ALL = DeprecatedConstantEnum(RepeatMode.ALL, "2025.10")
|
||||
_DEPRECATED_REPEAT_MODE_OFF = DeprecatedConstantEnum(RepeatMode.OFF, "2025.10")
|
||||
_DEPRECATED_REPEAT_MODE_ONE = DeprecatedConstantEnum(RepeatMode.ONE, "2025.10")
|
||||
REPEAT_MODES = [cls.value for cls in RepeatMode]
|
||||
|
||||
|
||||
@@ -231,71 +160,3 @@ class MediaPlayerEntityFeature(IntFlag):
|
||||
MEDIA_ANNOUNCE = 1048576
|
||||
MEDIA_ENQUEUE = 2097152
|
||||
SEARCH_MEDIA = 4194304
|
||||
|
||||
|
||||
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
|
||||
# Please use the MediaPlayerEntityFeature enum instead.
|
||||
_DEPRECATED_SUPPORT_PAUSE = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.PAUSE, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_SEEK = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.SEEK, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_VOLUME_SET = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.VOLUME_SET, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_VOLUME_MUTE = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_PREVIOUS_TRACK = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_NEXT_TRACK = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.NEXT_TRACK, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_TURN_ON = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.TURN_ON, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_TURN_OFF = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.TURN_OFF, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_PLAY_MEDIA = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.PLAY_MEDIA, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_VOLUME_STEP = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.VOLUME_STEP, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_SELECT_SOURCE = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.STOP, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_CLEAR_PLAYLIST = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.CLEAR_PLAYLIST, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_PLAY = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.PLAY, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_SHUFFLE_SET = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.SHUFFLE_SET, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_SELECT_SOUND_MODE = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.SELECT_SOUND_MODE, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_BROWSE_MEDIA = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_REPEAT_SET = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.REPEAT_SET, "2025.10"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_GROUPING = DeprecatedConstantEnum(
|
||||
MediaPlayerEntityFeature.GROUPING, "2025.10"
|
||||
)
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
@@ -14,6 +14,7 @@ from homeassistant.const import (
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
@@ -27,6 +28,7 @@ from .browse_media import SearchMedia
|
||||
from .const import (
|
||||
ATTR_MEDIA_FILTER_CLASSES,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
SERVICE_SEARCH_MEDIA,
|
||||
@@ -39,6 +41,8 @@ INTENT_MEDIA_PAUSE = "HassMediaPause"
|
||||
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
|
||||
INTENT_MEDIA_NEXT = "HassMediaNext"
|
||||
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
|
||||
INTENT_PLAYER_MUTE = "HassMediaPlayerMute"
|
||||
INTENT_PLAYER_UNMUTE = "HassMediaPlayerUnmute"
|
||||
INTENT_SET_VOLUME = "HassSetVolume"
|
||||
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
|
||||
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
|
||||
@@ -130,6 +134,8 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
),
|
||||
)
|
||||
intent.async_register(hass, MediaSetVolumeRelativeHandler())
|
||||
intent.async_register(hass, MediaPlayerMuteUnmuteHandler(True))
|
||||
intent.async_register(hass, MediaPlayerMuteUnmuteHandler(False))
|
||||
intent.async_register(hass, MediaSearchAndPlayHandler())
|
||||
|
||||
|
||||
@@ -231,6 +237,42 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler):
|
||||
)
|
||||
|
||||
|
||||
class MediaPlayerMuteUnmuteHandler(intent.ServiceIntentHandler):
|
||||
"""Handle Mute/Unmute intents."""
|
||||
|
||||
def __init__(self, is_volume_muted: bool) -> None:
|
||||
"""Initialize the mute/unmute handler objects."""
|
||||
|
||||
super().__init__(
|
||||
(INTENT_PLAYER_MUTE if is_volume_muted else INTENT_PLAYER_UNMUTE),
|
||||
DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
required_domains={DOMAIN},
|
||||
required_features=MediaPlayerEntityFeature.VOLUME_MUTE,
|
||||
optional_slots={
|
||||
ATTR_MEDIA_VOLUME_MUTED: intent.IntentSlotInfo(
|
||||
description="Whether the media player should be muted or unmuted",
|
||||
value_schema=vol.Boolean(),
|
||||
),
|
||||
},
|
||||
description=(
|
||||
"Mutes a media player" if is_volume_muted else "Unmutes a media player"
|
||||
),
|
||||
platforms={DOMAIN},
|
||||
device_classes={MediaPlayerDeviceClass},
|
||||
)
|
||||
self.is_volume_muted = is_volume_muted
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
|
||||
intent_obj.slots["is_volume_muted"] = {
|
||||
"value": self.is_volume_muted,
|
||||
"text": str(self.is_volume_muted),
|
||||
}
|
||||
return await super().async_handle(intent_obj)
|
||||
|
||||
|
||||
class MediaSearchAndPlayHandler(intent.IntentHandler):
|
||||
"""Handle HassMediaSearchAndPlay intents."""
|
||||
|
||||
|
27
homeassistant/components/meteo_lt/__init__.py
Normal file
27
homeassistant/components/meteo_lt/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""The Meteo.lt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_PLACE_CODE, PLATFORMS
|
||||
from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
|
||||
"""Set up Meteo.lt from a config entry."""
|
||||
|
||||
coordinator = MeteoLtUpdateCoordinator(hass, entry.data[CONF_PLACE_CODE], entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
78
homeassistant/components/meteo_lt/config_flow.py
Normal file
78
homeassistant/components/meteo_lt/config_flow.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Config flow for Meteo.lt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from meteo_lt import MeteoLtAPI, Place
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import CONF_PLACE_CODE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeteoLtConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Meteo.lt."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._api = MeteoLtAPI()
|
||||
self._places: list[Place] = []
|
||||
self._selected_place: Place | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
place_code = user_input[CONF_PLACE_CODE]
|
||||
self._selected_place = next(
|
||||
(place for place in self._places if place.code == place_code),
|
||||
None,
|
||||
)
|
||||
if self._selected_place:
|
||||
await self.async_set_unique_id(self._selected_place.code)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self._selected_place.name,
|
||||
data={
|
||||
CONF_PLACE_CODE: self._selected_place.code,
|
||||
},
|
||||
)
|
||||
errors["base"] = "invalid_location"
|
||||
|
||||
if not self._places:
|
||||
try:
|
||||
await self._api.fetch_places()
|
||||
self._places = self._api.places
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
_LOGGER.error("Error fetching places: %s", err)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
if not self._places:
|
||||
return self.async_abort(reason="no_places_found")
|
||||
|
||||
places_options = {
|
||||
place.code: f"{place.name} ({place.administrative_division})"
|
||||
for place in self._places
|
||||
}
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PLACE_CODE): vol.In(places_options),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
17
homeassistant/components/meteo_lt/const.py
Normal file
17
homeassistant/components/meteo_lt/const.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Constants for the Meteo.lt integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "meteo_lt"
|
||||
PLATFORMS = [Platform.WEATHER]
|
||||
|
||||
MANUFACTURER = "Lithuanian Hydrometeorological Service"
|
||||
MODEL = "Weather Station"
|
||||
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30)
|
||||
|
||||
CONF_PLACE_CODE = "place_code"
|
||||
|
||||
ATTRIBUTION = "Data provided by Lithuanian Hydrometeorological Service (LHMT)"
|
61
homeassistant/components/meteo_lt/coordinator.py
Normal file
61
homeassistant/components/meteo_lt/coordinator.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""DataUpdateCoordinator for Meteo.lt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from meteo_lt import Forecast as MeteoLtForecast, MeteoLtAPI
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type MeteoLtConfigEntry = ConfigEntry[MeteoLtUpdateCoordinator]
|
||||
|
||||
|
||||
class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]):
|
||||
"""Class to manage fetching Meteo.lt data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
place_code: str,
|
||||
config_entry: MeteoLtConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.client = MeteoLtAPI()
|
||||
self.place_code = place_code
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=DEFAULT_UPDATE_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> MeteoLtForecast:
|
||||
"""Fetch data from Meteo.lt API."""
|
||||
try:
|
||||
forecast = await self.client.get_forecast(self.place_code)
|
||||
except aiohttp.ClientResponseError as err:
|
||||
raise UpdateFailed(
|
||||
f"API returned error status {err.status}: {err.message}"
|
||||
) from err
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
raise UpdateFailed(f"Cannot connect to API: {err}") from err
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
# Check if forecast data is available
|
||||
if not forecast.forecast_timestamps:
|
||||
raise UpdateFailed(
|
||||
f"No forecast data available for {self.place_code} - API returned empty timestamps"
|
||||
)
|
||||
|
||||
return forecast
|
11
homeassistant/components/meteo_lt/manifest.json
Normal file
11
homeassistant/components/meteo_lt/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "meteo_lt",
|
||||
"name": "Meteo.lt",
|
||||
"codeowners": ["@xE1H"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/meteo_lt",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["meteo-lt-pkg==0.2.4"]
|
||||
}
|
86
homeassistant/components/meteo_lt/quality_scale.yaml
Normal file
86
homeassistant/components/meteo_lt/quality_scale.yaml
Normal file
@@ -0,0 +1,86 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not provide custom service actions to document.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Weather entities do not require event subscriptions.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom service actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Public weather service that does not require authentication.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Weather stations cannot be automatically discovered.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Single weather entity per config entry, no dynamic device addition.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Weather entities are primary entities and do not require categories.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: Weather entities have implicit device class from the platform.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Primary weather entity should be enabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Weather entities use standard condition-based icons.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: No dynamic device management required.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
25
homeassistant/components/meteo_lt/strings.json
Normal file
25
homeassistant/components/meteo_lt/strings.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Select station",
|
||||
"data": {
|
||||
"place_code": "Station"
|
||||
},
|
||||
"data_description": {
|
||||
"place_code": "Weather station to get data from"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Meteo.lt API",
|
||||
"invalid_location": "Selected station is invalid",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Station is already configured",
|
||||
"cannot_connect": "Failed to connect to Meteo.lt API",
|
||||
"no_places_found": "No stations found from the API"
|
||||
}
|
||||
}
|
||||
}
|
190
homeassistant/components/meteo_lt/weather.py
Normal file
190
homeassistant/components/meteo_lt/weather.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Weather platform for Meteo.lt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
Forecast,
|
||||
WeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL
|
||||
from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MeteoLtConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the weather platform."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([MeteoLtWeatherEntity(coordinator)])
|
||||
|
||||
|
||||
class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherEntity):
|
||||
"""Weather entity for Meteo.lt."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
_attr_native_pressure_unit = UnitOfPressure.HPA
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
|
||||
_attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: MeteoLtUpdateCoordinator) -> None:
|
||||
"""Initialize the weather entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._place_code = coordinator.place_code
|
||||
self._attr_unique_id = str(self._place_code)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, self._place_code)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODEL,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data.current_conditions.temperature
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float | None:
|
||||
"""Return the apparent temperature."""
|
||||
return self.coordinator.data.current_conditions.apparent_temperature
|
||||
|
||||
@property
|
||||
def humidity(self) -> int | None:
|
||||
"""Return the humidity."""
|
||||
return self.coordinator.data.current_conditions.humidity
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float | None:
|
||||
"""Return the pressure."""
|
||||
return self.coordinator.data.current_conditions.pressure
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the wind speed."""
|
||||
return self.coordinator.data.current_conditions.wind_speed
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> int | None:
|
||||
"""Return the wind bearing."""
|
||||
return self.coordinator.data.current_conditions.wind_bearing
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float | None:
|
||||
"""Return the wind gust speed."""
|
||||
return self.coordinator.data.current_conditions.wind_gust_speed
|
||||
|
||||
@property
|
||||
def cloud_coverage(self) -> int | None:
|
||||
"""Return the cloud coverage."""
|
||||
return self.coordinator.data.current_conditions.cloud_coverage
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return self.coordinator.data.current_conditions.condition
|
||||
|
||||
def _convert_forecast_data(
|
||||
self, forecast_data: Any, include_templow: bool = False
|
||||
) -> Forecast:
|
||||
"""Convert forecast timestamp data to Forecast object."""
|
||||
return Forecast(
|
||||
datetime=forecast_data.datetime,
|
||||
native_temperature=forecast_data.temperature,
|
||||
native_templow=forecast_data.temperature_low if include_templow else None,
|
||||
native_apparent_temperature=forecast_data.apparent_temperature,
|
||||
condition=forecast_data.condition,
|
||||
native_precipitation=forecast_data.precipitation,
|
||||
precipitation_probability=None, # Not provided by API
|
||||
native_wind_speed=forecast_data.wind_speed,
|
||||
wind_bearing=forecast_data.wind_bearing,
|
||||
cloud_coverage=forecast_data.cloud_coverage,
|
||||
)
|
||||
|
||||
async def async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast."""
|
||||
# Using hourly data to create daily summaries, since daily data is not provided directly
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
forecasts_by_date = defaultdict(list)
|
||||
for timestamp in self.coordinator.data.forecast_timestamps:
|
||||
date = datetime.fromisoformat(timestamp.datetime).date()
|
||||
forecasts_by_date[date].append(timestamp)
|
||||
|
||||
daily_forecasts = []
|
||||
for date in sorted(forecasts_by_date.keys())[:5]:
|
||||
day_forecasts = forecasts_by_date[date]
|
||||
if not day_forecasts:
|
||||
continue
|
||||
|
||||
temps = [
|
||||
ts.temperature for ts in day_forecasts if ts.temperature is not None
|
||||
]
|
||||
max_temp = max(temps) if temps else None
|
||||
min_temp = min(temps) if temps else None
|
||||
|
||||
midday_forecast = min(
|
||||
day_forecasts,
|
||||
key=lambda ts: abs(datetime.fromisoformat(ts.datetime).hour - 12),
|
||||
)
|
||||
|
||||
daily_forecast = Forecast(
|
||||
datetime=day_forecasts[0].datetime,
|
||||
native_temperature=max_temp,
|
||||
native_templow=min_temp,
|
||||
native_apparent_temperature=midday_forecast.apparent_temperature,
|
||||
condition=midday_forecast.condition,
|
||||
# Calculate precipitation: sum if any values, else None
|
||||
native_precipitation=(
|
||||
sum(
|
||||
ts.precipitation
|
||||
for ts in day_forecasts
|
||||
if ts.precipitation is not None
|
||||
)
|
||||
if any(ts.precipitation is not None for ts in day_forecasts)
|
||||
else None
|
||||
),
|
||||
precipitation_probability=None,
|
||||
native_wind_speed=midday_forecast.wind_speed,
|
||||
wind_bearing=midday_forecast.wind_bearing,
|
||||
cloud_coverage=midday_forecast.cloud_coverage,
|
||||
)
|
||||
daily_forecasts.append(daily_forecast)
|
||||
|
||||
return daily_forecasts
|
||||
|
||||
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Return the hourly forecast."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
return [
|
||||
self._convert_forecast_data(forecast_data)
|
||||
for forecast_data in self.coordinator.data.forecast_timestamps[:24]
|
||||
]
|
@@ -208,7 +208,7 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
|
||||
|
||||
def __process_raw_value(self, entry: float | str | bytes) -> str | None:
|
||||
"""Process value from sensor with NaN handling, scaling, offset, min/max etc."""
|
||||
if self._nan_value and entry in (self._nan_value, -self._nan_value):
|
||||
if self._nan_value is not None and entry in (self._nan_value, -self._nan_value):
|
||||
return None
|
||||
if isinstance(entry, bytes):
|
||||
return entry.decode()
|
||||
|
@@ -46,6 +46,14 @@ from homeassistant.components.light import (
|
||||
VALID_COLOR_MODES,
|
||||
valid_supported_color_modes,
|
||||
)
|
||||
from homeassistant.components.number import (
|
||||
DEFAULT_MAX_VALUE,
|
||||
DEFAULT_MIN_VALUE,
|
||||
DEFAULT_STEP,
|
||||
DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS,
|
||||
NumberDeviceClass,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
DEVICE_CLASS_UNITS,
|
||||
@@ -79,6 +87,7 @@ from homeassistant.const import (
|
||||
CONF_EFFECT,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_HOST,
|
||||
CONF_MODE,
|
||||
CONF_NAME,
|
||||
CONF_OPTIMISTIC,
|
||||
CONF_PASSWORD,
|
||||
@@ -211,7 +220,9 @@ from .const import (
|
||||
CONF_IMAGE_TOPIC,
|
||||
CONF_KEEPALIVE,
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_MAX,
|
||||
CONF_MAX_KELVIN,
|
||||
CONF_MIN,
|
||||
CONF_MIN_KELVIN,
|
||||
CONF_MODE_COMMAND_TEMPLATE,
|
||||
CONF_MODE_COMMAND_TOPIC,
|
||||
@@ -293,6 +304,7 @@ from .const import (
|
||||
CONF_STATE_UNLOCKED,
|
||||
CONF_STATE_UNLOCKING,
|
||||
CONF_STATE_VALUE_TEMPLATE,
|
||||
CONF_STEP,
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION,
|
||||
CONF_SUPPORTED_COLOR_MODES,
|
||||
CONF_SUPPORTED_FEATURES,
|
||||
@@ -444,6 +456,8 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
@@ -679,6 +693,24 @@ LIGHT_SCHEMA_SELECTOR = SelectSelector(
|
||||
translation_key="light_schema",
|
||||
)
|
||||
)
|
||||
MIN_MAX_SELECTOR = NumberSelector(NumberSelectorConfig(step=1e-3))
|
||||
NUMBER_DEVICE_CLASS_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_class.value for device_class in NumberDeviceClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
# The number device classes are all shared with the sensor device classes
|
||||
translation_key="device_class_sensor",
|
||||
sort=True,
|
||||
)
|
||||
)
|
||||
NUMBER_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[mode.value for mode in NumberMode],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="number_mode",
|
||||
sort=True,
|
||||
)
|
||||
)
|
||||
ON_COMMAND_TYPE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=VALUES_ON_COMMAND_TYPE,
|
||||
@@ -726,6 +758,7 @@ SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
|
||||
translation_key=CONF_STATE_CLASS,
|
||||
)
|
||||
)
|
||||
STEP_SELECTOR = NumberSelector(NumberSelectorConfig(min=1e-3, step=1e-3))
|
||||
SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[platform.value for platform in VALID_COLOR_MODES],
|
||||
@@ -882,6 +915,23 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def number_unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
|
||||
"""Return a context based unit of measurement selector for number entities."""
|
||||
|
||||
if (
|
||||
device_class := user_data.get(CONF_DEVICE_CLASS)
|
||||
) is None or device_class not in NUMBER_DEVICE_CLASS_UNITS:
|
||||
return TEXT_SELECTOR
|
||||
return SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[str(uom) for uom in NUMBER_DEVICE_CLASS_UNITS[device_class]],
|
||||
sort=True,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
||||
"""Run validator, then return the unmodified input."""
|
||||
@@ -1005,6 +1055,29 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
|
||||
return errors
|
||||
|
||||
|
||||
@callback
|
||||
def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate MQTT number configuration."""
|
||||
errors: dict[str, Any] = {}
|
||||
if (
|
||||
CONF_MIN in config
|
||||
and CONF_MAX in config
|
||||
and config[CONF_MIN] > config[CONF_MAX]
|
||||
):
|
||||
errors[CONF_MIN] = "max_below_min"
|
||||
errors[CONF_MAX] = "max_below_min"
|
||||
|
||||
if (
|
||||
(device_class := config.get(CONF_DEVICE_CLASS)) is not None
|
||||
and device_class in NUMBER_DEVICE_CLASS_UNITS
|
||||
and config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
not in NUMBER_DEVICE_CLASS_UNITS[device_class]
|
||||
):
|
||||
errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@callback
|
||||
def validate_sensor_platform_config(
|
||||
config: dict[str, Any],
|
||||
@@ -1067,6 +1140,8 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
Platform.LIGHT.value: validate_light_platform_config,
|
||||
Platform.LOCK.value: None,
|
||||
Platform.NOTIFY.value: None,
|
||||
Platform.NUMBER.value: validate_number_platform_config,
|
||||
Platform.SELECT: None,
|
||||
Platform.SENSOR.value: validate_sensor_platform_config,
|
||||
Platform.SWITCH.value: None,
|
||||
}
|
||||
@@ -1282,6 +1357,18 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
|
||||
},
|
||||
Platform.LOCK.value: {},
|
||||
Platform.NOTIFY.value: {},
|
||||
Platform.NUMBER: {
|
||||
CONF_DEVICE_CLASS: PlatformField(
|
||||
selector=NUMBER_DEVICE_CLASS_SELECTOR,
|
||||
required=False,
|
||||
),
|
||||
CONF_UNIT_OF_MEASUREMENT: PlatformField(
|
||||
selector=number_unit_of_measurement_selector,
|
||||
required=False,
|
||||
custom_filtering=True,
|
||||
),
|
||||
},
|
||||
Platform.SELECT.value: {},
|
||||
Platform.SENSOR.value: {
|
||||
CONF_DEVICE_CLASS: PlatformField(
|
||||
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
|
||||
@@ -2966,6 +3053,86 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
|
||||
),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
Platform.NUMBER.value: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
),
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_MIN: PlatformField(
|
||||
selector=MIN_MAX_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_MIN_VALUE,
|
||||
),
|
||||
CONF_MAX: PlatformField(
|
||||
selector=MIN_MAX_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_MAX_VALUE,
|
||||
),
|
||||
CONF_STEP: PlatformField(
|
||||
selector=STEP_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_STEP,
|
||||
),
|
||||
CONF_MODE: PlatformField(
|
||||
selector=NUMBER_MODE_SELECTOR,
|
||||
required=True,
|
||||
default=NumberMode.AUTO.value,
|
||||
),
|
||||
CONF_PAYLOAD_RESET: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
default=DEFAULT_PAYLOAD_RESET,
|
||||
),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
Platform.SELECT.value: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
),
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_OPTIONS: PlatformField(selector=OPTIONS_SELECTOR, required=True),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
Platform.SENSOR.value: {
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
|
@@ -120,8 +120,10 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
|
||||
CONF_HUMIDITY_MAX = "max_humidity"
|
||||
CONF_HUMIDITY_MIN = "min_humidity"
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
|
||||
CONF_MAX = "max"
|
||||
CONF_MAX_KELVIN = "max_kelvin"
|
||||
CONF_MAX_MIREDS = "max_mireds"
|
||||
CONF_MIN = "min"
|
||||
CONF_MIN_KELVIN = "min_kelvin"
|
||||
CONF_MIN_MIREDS = "min_mireds"
|
||||
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
|
||||
@@ -196,6 +198,7 @@ CONF_STATE_OPENING = "state_opening"
|
||||
CONF_STATE_STOPPED = "state_stopped"
|
||||
CONF_STATE_UNLOCKED = "state_unlocked"
|
||||
CONF_STATE_UNLOCKING = "state_unlocking"
|
||||
CONF_STEP = "step"
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
|
||||
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
|
||||
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
|
||||
|
@@ -37,8 +37,12 @@ from .config import MQTT_RW_SCHEMA
|
||||
from .const import (
|
||||
CONF_COMMAND_TEMPLATE,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_MAX,
|
||||
CONF_MIN,
|
||||
CONF_PAYLOAD_RESET,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_STEP,
|
||||
DEFAULT_PAYLOAD_RESET,
|
||||
)
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import (
|
||||
@@ -53,12 +57,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CONF_MIN = "min"
|
||||
CONF_MAX = "max"
|
||||
CONF_STEP = "step"
|
||||
|
||||
DEFAULT_NAME = "MQTT Number"
|
||||
DEFAULT_PAYLOAD_RESET = "None"
|
||||
|
||||
MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
|
||||
{
|
||||
|
@@ -298,7 +298,7 @@
|
||||
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
|
||||
"supported_features": "The features that the entity supports.",
|
||||
"temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.",
|
||||
"unit_of_measurement": "Defines the unit of measurement of the sensor, if any."
|
||||
"unit_of_measurement": "Defines the unit of measurement, if any."
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
@@ -334,6 +334,9 @@
|
||||
"image_encoding": "Image encoding",
|
||||
"image_topic": "Image topic",
|
||||
"last_reset_value_template": "Last reset value template",
|
||||
"max": "Maximum",
|
||||
"min": "Minimum",
|
||||
"mode": "Mode",
|
||||
"modes": "Supported operation modes",
|
||||
"mode_command_topic": "Operation mode command topic",
|
||||
"mode_command_template": "Operation mode command template",
|
||||
@@ -341,9 +344,11 @@
|
||||
"mode_state_template": "Operation mode value template",
|
||||
"on_command_type": "ON command type",
|
||||
"optimistic": "Optimistic",
|
||||
"options": "Set options",
|
||||
"payload_off": "Payload \"off\"",
|
||||
"payload_on": "Payload \"on\"",
|
||||
"payload_press": "Payload \"press\"",
|
||||
"payload_reset": "Payload \"reset\"",
|
||||
"qos": "QoS",
|
||||
"red_template": "Red template",
|
||||
"retain": "Retain",
|
||||
@@ -352,6 +357,7 @@
|
||||
"state_template": "State template",
|
||||
"state_topic": "State topic",
|
||||
"state_value_template": "State value template",
|
||||
"step": "Step",
|
||||
"supported_color_modes": "Supported color modes",
|
||||
"url_template": "URL template",
|
||||
"url_topic": "URL topic",
|
||||
@@ -376,6 +382,9 @@
|
||||
"image_encoding": "Select the encoding of the received image data",
|
||||
"image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)",
|
||||
"last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
|
||||
"max": "Maximum value. [Learn more.]({url}#max)",
|
||||
"min": "Minimum value. [Learn more.]({url}#min)",
|
||||
"mode": "Control how the number should be displayed in the UI. [Learn more.]({url}#mode)",
|
||||
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
|
||||
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
|
||||
"mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)",
|
||||
@@ -383,9 +392,11 @@
|
||||
"mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)",
|
||||
"on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.",
|
||||
"optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)",
|
||||
"options": "List of options that can be selected.",
|
||||
"payload_off": "The payload that represents the \"off\" state.",
|
||||
"payload_on": "The payload that represents the \"on\" state.",
|
||||
"payload_press": "The payload to send when the button is triggered.",
|
||||
"payload_reset": "The payload received at the state topic that resets the entity to an unknown state.",
|
||||
"qos": "The QoS value a {platform} entity should use.",
|
||||
"red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.",
|
||||
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.",
|
||||
@@ -393,6 +404,7 @@
|
||||
"state_on": "The incoming payload that represents the \"on\" state. Use only when the value that represents \"on\" state in the state topic is different from value that should be sent to the command topic to turn the device on.",
|
||||
"state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.",
|
||||
"state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)",
|
||||
"step": "Step value. Smallest value 0.001.",
|
||||
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
|
||||
"url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
|
||||
"url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
|
||||
@@ -995,6 +1007,7 @@
|
||||
"invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list",
|
||||
"invalid_url": "Invalid URL",
|
||||
"last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only",
|
||||
"max_below_min": "Max value should be greater or equal to min value",
|
||||
"max_below_min_humidity": "Max humidity value should be greater than min humidity value",
|
||||
"max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value",
|
||||
"max_below_min_temperature": "Max temperature value should be greater than min temperature value",
|
||||
@@ -1294,6 +1307,13 @@
|
||||
"template": "Template"
|
||||
}
|
||||
},
|
||||
"number_mode": {
|
||||
"options": {
|
||||
"auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]",
|
||||
"box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]",
|
||||
"slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]"
|
||||
}
|
||||
},
|
||||
"on_command_type": {
|
||||
"options": {
|
||||
"brightness": "Brightness",
|
||||
@@ -1313,6 +1333,8 @@
|
||||
"light": "[%key:component::light::title%]",
|
||||
"lock": "[%key:component::lock::title%]",
|
||||
"notify": "[%key:component::notify::title%]",
|
||||
"number": "[%key:component::number::title%]",
|
||||
"select": "[%key:component::select::title%]",
|
||||
"sensor": "[%key:component::sensor::title%]",
|
||||
"switch": "[%key:component::switch::title%]"
|
||||
}
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ntfy",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aionfty"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiontfy==0.6.0"]
|
||||
}
|
||||
|
@@ -3,9 +3,7 @@ rules:
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: only entity actions
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: the integration does not poll
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
@@ -40,26 +38,28 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: the service cannot be discovered
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: the service cannot be discovered
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: the integration is a service
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: devices are added manually as subentries
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: no suitable device class for the notify entity
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: only one entity
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: the notify entity uses the device name as entity name, no translation required
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
|
@@ -115,9 +115,7 @@ class PandoraMediaPlayer(MediaPlayerEntity):
|
||||
async def _start_pianobar(self) -> bool:
|
||||
pianobar = pexpect.spawn("pianobar", encoding="utf-8")
|
||||
pianobar.delaybeforesend = None
|
||||
# mypy thinks delayafterread must be a float but that is not what pexpect says
|
||||
# https://github.com/pexpect/pexpect/blob/4.9/pexpect/expect.py#L170
|
||||
pianobar.delayafterread = None # type: ignore[assignment]
|
||||
pianobar.delayafterread = None
|
||||
pianobar.delayafterclose = 0
|
||||
pianobar.delayafterterminate = 0
|
||||
_LOGGER.debug("Started pianobar subprocess")
|
||||
|
@@ -57,4 +57,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry)
|
||||
data[CONF_API_TOKEN] = data.pop(CONF_API_KEY)
|
||||
hass.config_entries.async_update_entry(entry=entry, data=data, version=2)
|
||||
|
||||
if entry.version < 3:
|
||||
data = dict(entry.data)
|
||||
data[CONF_VERIFY_SSL] = True
|
||||
hass.config_entries.async_update_entry(entry=entry, data=data, version=3)
|
||||
|
||||
return True
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/portainer",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==0.1.7"]
|
||||
"requirements": ["pyportainer==1.0.2"]
|
||||
}
|
||||
|
@@ -215,6 +215,7 @@ def create_coordinator_container_vm(
|
||||
return DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=None,
|
||||
name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
|
@@ -39,6 +39,7 @@ SENSOR_TYPE_ALL_TORRENTS = "all_torrents"
|
||||
SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents"
|
||||
SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents"
|
||||
SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents"
|
||||
SENSOR_TYPE_ERRORED_TORRENTS = "errored_torrents"
|
||||
|
||||
|
||||
def get_state(coordinator: QBittorrentDataCoordinator) -> str:
|
||||
@@ -221,6 +222,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
|
||||
coordinator, ["stoppedDL", "stoppedUP"]
|
||||
),
|
||||
),
|
||||
QBittorrentSensorEntityDescription(
|
||||
key=SENSOR_TYPE_ERRORED_TORRENTS,
|
||||
translation_key="errored_torrents",
|
||||
value_fn=lambda coordinator: count_torrents_in_states(
|
||||
coordinator, ["error", "missingFiles"]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -18,6 +18,7 @@ get_torrents:
|
||||
- "all"
|
||||
- "seeding"
|
||||
- "started"
|
||||
- "errored"
|
||||
get_all_torrents:
|
||||
fields:
|
||||
torrent_filter:
|
||||
@@ -33,3 +34,4 @@ get_all_torrents:
|
||||
- "all"
|
||||
- "seeding"
|
||||
- "started"
|
||||
- "errored"
|
||||
|
@@ -70,6 +70,10 @@
|
||||
"name": "Paused torrents",
|
||||
"unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
|
||||
},
|
||||
"errored_torrents": {
|
||||
"name": "Errored torrents",
|
||||
"unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
|
||||
},
|
||||
"all_torrents": {
|
||||
"name": "All torrents",
|
||||
"unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
|
||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
from typing import Generic, TypeVar, cast
|
||||
from typing import cast
|
||||
|
||||
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
|
||||
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
|
||||
@@ -20,8 +20,6 @@ from .coordinator import QbusControllerCoordinator
|
||||
|
||||
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
|
||||
|
||||
StateT = TypeVar("StateT", bound=QbusMqttState)
|
||||
|
||||
|
||||
def create_new_entities(
|
||||
coordinator: QbusControllerCoordinator,
|
||||
@@ -78,7 +76,7 @@ def create_unique_id(serial_number: str, suffix: str) -> str:
|
||||
return f"ctd_{serial_number}_{suffix}"
|
||||
|
||||
|
||||
class QbusEntity(Entity, ABC, Generic[StateT]):
|
||||
class QbusEntity[StateT: QbusMqttState](Entity, ABC):
|
||||
"""Representation of a Qbus entity."""
|
||||
|
||||
_state_cls: type[StateT] = cast(type[StateT], QbusMqttState)
|
||||
|
@@ -1127,9 +1127,6 @@ class Recorder(threading.Thread):
|
||||
else:
|
||||
states_manager.add_pending(entity_id, dbstate)
|
||||
|
||||
if states_meta_manager.active:
|
||||
dbstate.entity_id = None
|
||||
|
||||
if entity_id is None or not (
|
||||
shared_attrs_bytes := state_attributes_manager.serialize_from_event(event)
|
||||
):
|
||||
@@ -1140,7 +1137,7 @@ class Recorder(threading.Thread):
|
||||
dbstate.states_meta_rel = pending_states_meta
|
||||
elif metadata_id := states_meta_manager.get(entity_id, session, True):
|
||||
dbstate.metadata_id = metadata_id
|
||||
elif states_meta_manager.active and entity_removed:
|
||||
elif entity_removed:
|
||||
# If the entity was removed, we don't need to add it to the
|
||||
# StatesMeta table or record it in the pending commit
|
||||
# if it does not have a metadata_id allocated to it as
|
||||
|
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Final, Protocol, Self, cast
|
||||
from typing import Any, Final, Protocol, Self
|
||||
|
||||
import ciso8601
|
||||
from fnv_hash_fast import fnv1a_32
|
||||
@@ -45,14 +45,9 @@ from homeassistant.const import (
|
||||
MAX_LENGTH_STATE_ENTITY_ID,
|
||||
MAX_LENGTH_STATE_STATE,
|
||||
)
|
||||
from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State
|
||||
from homeassistant.core import Event, EventStateChangedData
|
||||
from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.json import (
|
||||
JSON_DECODE_EXCEPTIONS,
|
||||
json_loads,
|
||||
json_loads_object,
|
||||
)
|
||||
|
||||
from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
|
||||
from .models import (
|
||||
@@ -60,8 +55,6 @@ from .models import (
|
||||
StatisticDataTimestamp,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
bytes_to_ulid_or_none,
|
||||
bytes_to_uuid_hex_or_none,
|
||||
datetime_to_timestamp_or_none,
|
||||
process_timestamp,
|
||||
ulid_to_bytes_or_none,
|
||||
@@ -251,9 +244,6 @@ class JSONLiteral(JSON):
|
||||
return process
|
||||
|
||||
|
||||
EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote]
|
||||
|
||||
|
||||
class Events(Base):
|
||||
"""Event history data."""
|
||||
|
||||
@@ -333,28 +323,6 @@ class Events(Base):
|
||||
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
|
||||
)
|
||||
|
||||
def to_native(self, validate_entity_id: bool = True) -> Event | None:
|
||||
"""Convert to a native HA Event."""
|
||||
context = Context(
|
||||
id=bytes_to_ulid_or_none(self.context_id_bin),
|
||||
user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin),
|
||||
parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin),
|
||||
)
|
||||
try:
|
||||
return Event(
|
||||
self.event_type or "",
|
||||
json_loads_object(self.event_data) if self.event_data else {},
|
||||
EventOrigin(self.origin)
|
||||
if self.origin
|
||||
else EVENT_ORIGIN_ORDER[self.origin_idx or 0],
|
||||
self.time_fired_ts or 0,
|
||||
context=context,
|
||||
)
|
||||
except JSON_DECODE_EXCEPTIONS:
|
||||
# When json_loads fails
|
||||
_LOGGER.exception("Error converting to event: %s", self)
|
||||
return None
|
||||
|
||||
|
||||
class LegacyEvents(LegacyBase):
|
||||
"""Event history data with event_id, used for schema migration."""
|
||||
@@ -410,17 +378,6 @@ class EventData(Base):
|
||||
"""Return the hash of json encoded shared data."""
|
||||
return fnv1a_32(shared_data_bytes)
|
||||
|
||||
def to_native(self) -> dict[str, Any]:
|
||||
"""Convert to an event data dictionary."""
|
||||
shared_data = self.shared_data
|
||||
if shared_data is None:
|
||||
return {}
|
||||
try:
|
||||
return cast(dict[str, Any], json_loads(shared_data))
|
||||
except JSON_DECODE_EXCEPTIONS:
|
||||
_LOGGER.exception("Error converting row to event data: %s", self)
|
||||
return {}
|
||||
|
||||
|
||||
class EventTypes(Base):
|
||||
"""Event type history."""
|
||||
@@ -537,7 +494,7 @@ class States(Base):
|
||||
context = event.context
|
||||
return States(
|
||||
state=state_value,
|
||||
entity_id=event.data["entity_id"],
|
||||
entity_id=None,
|
||||
attributes=None,
|
||||
context_id=None,
|
||||
context_id_bin=ulid_to_bytes_or_none(context.id),
|
||||
@@ -553,44 +510,6 @@ class States(Base):
|
||||
last_reported_ts=last_reported_ts,
|
||||
)
|
||||
|
||||
def to_native(self, validate_entity_id: bool = True) -> State | None:
|
||||
"""Convert to an HA state object."""
|
||||
context = Context(
|
||||
id=bytes_to_ulid_or_none(self.context_id_bin),
|
||||
user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin),
|
||||
parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin),
|
||||
)
|
||||
try:
|
||||
attrs = json_loads_object(self.attributes) if self.attributes else {}
|
||||
except JSON_DECODE_EXCEPTIONS:
|
||||
# When json_loads fails
|
||||
_LOGGER.exception("Error converting row to state: %s", self)
|
||||
return None
|
||||
last_updated = dt_util.utc_from_timestamp(self.last_updated_ts or 0)
|
||||
if self.last_changed_ts is None or self.last_changed_ts == self.last_updated_ts:
|
||||
last_changed = dt_util.utc_from_timestamp(self.last_updated_ts or 0)
|
||||
else:
|
||||
last_changed = dt_util.utc_from_timestamp(self.last_changed_ts or 0)
|
||||
if (
|
||||
self.last_reported_ts is None
|
||||
or self.last_reported_ts == self.last_updated_ts
|
||||
):
|
||||
last_reported = dt_util.utc_from_timestamp(self.last_updated_ts or 0)
|
||||
else:
|
||||
last_reported = dt_util.utc_from_timestamp(self.last_reported_ts or 0)
|
||||
return State(
|
||||
self.entity_id or "",
|
||||
self.state, # type: ignore[arg-type]
|
||||
# Join the state_attributes table on attributes_id to get the attributes
|
||||
# for newer states
|
||||
attrs,
|
||||
last_changed=last_changed,
|
||||
last_reported=last_reported,
|
||||
last_updated=last_updated,
|
||||
context=context,
|
||||
validate_entity_id=validate_entity_id,
|
||||
)
|
||||
|
||||
|
||||
class LegacyStates(LegacyBase):
|
||||
"""State change history with entity_id, used for schema migration."""
|
||||
@@ -675,18 +594,6 @@ class StateAttributes(Base):
|
||||
"""Return the hash of json encoded shared attributes."""
|
||||
return fnv1a_32(shared_attrs_bytes)
|
||||
|
||||
def to_native(self) -> dict[str, Any]:
|
||||
"""Convert to a state attributes dictionary."""
|
||||
shared_attrs = self.shared_attrs
|
||||
if shared_attrs is None:
|
||||
return {}
|
||||
try:
|
||||
return cast(dict[str, Any], json_loads(shared_attrs))
|
||||
except JSON_DECODE_EXCEPTIONS:
|
||||
# When json_loads fails
|
||||
_LOGGER.exception("Error converting row to state attributes: %s", self)
|
||||
return {}
|
||||
|
||||
|
||||
class StatesMeta(Base):
|
||||
"""Metadata for states."""
|
||||
@@ -903,10 +810,6 @@ class RecorderRuns(Base):
|
||||
f" created='{self.created.isoformat(sep=' ', timespec='seconds')}')>"
|
||||
)
|
||||
|
||||
def to_native(self, validate_entity_id: bool = True) -> Self:
|
||||
"""Return self, native format is this model."""
|
||||
return self
|
||||
|
||||
|
||||
class MigrationChanges(Base):
|
||||
"""Representation of migration changes."""
|
||||
|
@@ -61,15 +61,6 @@ def update_states_metadata(
|
||||
) -> None:
|
||||
"""Update the states metadata table when an entity is renamed."""
|
||||
states_meta_manager = instance.states_meta_manager
|
||||
if not states_meta_manager.active:
|
||||
_LOGGER.warning(
|
||||
"Cannot rename entity_id `%s` to `%s` "
|
||||
"because the states meta manager is not yet active",
|
||||
entity_id,
|
||||
new_entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
with session_scope(
|
||||
session=instance.get_session(),
|
||||
exception_filter=filter_unique_constraint_integrity_error(instance, "state"),
|
||||
|
@@ -8,7 +8,6 @@ from typing import Any
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.recorder import get_instance
|
||||
|
||||
from ..filters import Filters
|
||||
from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS
|
||||
@@ -44,15 +43,7 @@ def get_full_significant_states_with_session(
|
||||
no_attributes: bool = False,
|
||||
) -> dict[str, list[State]]:
|
||||
"""Return a dict of significant states during a time period."""
|
||||
if not get_instance(hass).states_meta_manager.active:
|
||||
from .legacy import ( # noqa: PLC0415
|
||||
get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session,
|
||||
)
|
||||
|
||||
_target = _legacy_get_full_significant_states_with_session
|
||||
else:
|
||||
_target = _modern_get_full_significant_states_with_session
|
||||
return _target(
|
||||
return _modern_get_full_significant_states_with_session(
|
||||
hass,
|
||||
session,
|
||||
start_time,
|
||||
@@ -69,15 +60,7 @@ def get_last_state_changes(
|
||||
hass: HomeAssistant, number_of_states: int, entity_id: str
|
||||
) -> dict[str, list[State]]:
|
||||
"""Return the last number_of_states."""
|
||||
if not get_instance(hass).states_meta_manager.active:
|
||||
from .legacy import ( # noqa: PLC0415
|
||||
get_last_state_changes as _legacy_get_last_state_changes,
|
||||
)
|
||||
|
||||
_target = _legacy_get_last_state_changes
|
||||
else:
|
||||
_target = _modern_get_last_state_changes
|
||||
return _target(hass, number_of_states, entity_id)
|
||||
return _modern_get_last_state_changes(hass, number_of_states, entity_id)
|
||||
|
||||
|
||||
def get_significant_states(
|
||||
@@ -93,15 +76,7 @@ def get_significant_states(
|
||||
compressed_state_format: bool = False,
|
||||
) -> dict[str, list[State | dict[str, Any]]]:
|
||||
"""Return a dict of significant states during a time period."""
|
||||
if not get_instance(hass).states_meta_manager.active:
|
||||
from .legacy import ( # noqa: PLC0415
|
||||
get_significant_states as _legacy_get_significant_states,
|
||||
)
|
||||
|
||||
_target = _legacy_get_significant_states
|
||||
else:
|
||||
_target = _modern_get_significant_states
|
||||
return _target(
|
||||
return _modern_get_significant_states(
|
||||
hass,
|
||||
start_time,
|
||||
end_time,
|
||||
@@ -129,15 +104,7 @@ def get_significant_states_with_session(
|
||||
compressed_state_format: bool = False,
|
||||
) -> dict[str, list[State | dict[str, Any]]]:
|
||||
"""Return a dict of significant states during a time period."""
|
||||
if not get_instance(hass).states_meta_manager.active:
|
||||
from .legacy import ( # noqa: PLC0415
|
||||
get_significant_states_with_session as _legacy_get_significant_states_with_session,
|
||||
)
|
||||
|
||||
_target = _legacy_get_significant_states_with_session
|
||||
else:
|
||||
_target = _modern_get_significant_states_with_session
|
||||
return _target(
|
||||
return _modern_get_significant_states_with_session(
|
||||
hass,
|
||||
session,
|
||||
start_time,
|
||||
@@ -163,15 +130,7 @@ def state_changes_during_period(
|
||||
include_start_time_state: bool = True,
|
||||
) -> dict[str, list[State]]:
|
||||
"""Return a list of states that changed during a time period."""
|
||||
if not get_instance(hass).states_meta_manager.active:
|
||||
from .legacy import ( # noqa: PLC0415
|
||||
state_changes_during_period as _legacy_state_changes_during_period,
|
||||
)
|
||||
|
||||
_target = _legacy_state_changes_during_period
|
||||
else:
|
||||
_target = _modern_state_changes_during_period
|
||||
return _target(
|
||||
return _modern_state_changes_during_period(
|
||||
hass,
|
||||
start_time,
|
||||
end_time,
|
||||
|
@@ -1,664 +0,0 @@
|
||||
"""Provide pre-made queries on top of the recorder component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
from datetime import datetime
|
||||
from itertools import groupby
|
||||
from operator import attrgetter
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select
|
||||
from sqlalchemy.engine.row import Row
|
||||
from sqlalchemy.orm.properties import MappedColumn
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql.expression import literal
|
||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||
|
||||
from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.recorder import get_instance
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..db_schema import StateAttributes, States
|
||||
from ..filters import Filters
|
||||
from ..models import process_timestamp_to_utc_isoformat
|
||||
from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state
|
||||
from ..util import execute_stmt_lambda_element, session_scope
|
||||
from .const import (
|
||||
LAST_CHANGED_KEY,
|
||||
NEED_ATTRIBUTE_DOMAINS,
|
||||
SIGNIFICANT_DOMAINS,
|
||||
SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE,
|
||||
STATE_KEY,
|
||||
)
|
||||
|
||||
_BASE_STATES = (
|
||||
States.entity_id,
|
||||
States.state,
|
||||
States.last_changed_ts,
|
||||
States.last_updated_ts,
|
||||
)
|
||||
_BASE_STATES_NO_LAST_CHANGED = (
|
||||
States.entity_id,
|
||||
States.state,
|
||||
literal(value=None).label("last_changed_ts"),
|
||||
States.last_updated_ts,
|
||||
)
|
||||
_QUERY_STATE_NO_ATTR = (
|
||||
*_BASE_STATES,
|
||||
literal(value=None, type_=Text).label("attributes"),
|
||||
literal(value=None, type_=Text).label("shared_attrs"),
|
||||
)
|
||||
_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (
|
||||
*_BASE_STATES_NO_LAST_CHANGED,
|
||||
literal(value=None, type_=Text).label("attributes"),
|
||||
literal(value=None, type_=Text).label("shared_attrs"),
|
||||
)
|
||||
_BASE_STATES_PRE_SCHEMA_31 = (
|
||||
States.entity_id,
|
||||
States.state,
|
||||
States.last_changed,
|
||||
States.last_updated,
|
||||
)
|
||||
_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31 = (
|
||||
States.entity_id,
|
||||
States.state,
|
||||
literal(value=None, type_=Text).label("last_changed"),
|
||||
States.last_updated,
|
||||
)
|
||||
_QUERY_STATE_NO_ATTR_PRE_SCHEMA_31 = (
|
||||
*_BASE_STATES_PRE_SCHEMA_31,
|
||||
literal(value=None, type_=Text).label("attributes"),
|
||||
literal(value=None, type_=Text).label("shared_attrs"),
|
||||
)
|
||||
_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED_PRE_SCHEMA_31 = (
|
||||
*_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31,
|
||||
literal(value=None, type_=Text).label("attributes"),
|
||||
literal(value=None, type_=Text).label("shared_attrs"),
|
||||
)
|
||||
# Remove QUERY_STATES_PRE_SCHEMA_25
|
||||
# and the migration_in_progress check
|
||||
# once schema 26 is created
|
||||
_QUERY_STATES_PRE_SCHEMA_25 = (
|
||||
*_BASE_STATES_PRE_SCHEMA_31,
|
||||
States.attributes,
|
||||
literal(value=None, type_=Text).label("shared_attrs"),
|
||||
)
|
||||
_QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED = (
|
||||
*_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31,
|
||||
States.attributes,
|
||||
literal(value=None, type_=Text).label("shared_attrs"),
|
||||
)
|
||||
_QUERY_STATES_PRE_SCHEMA_31 = (
|
||||
*_BASE_STATES_PRE_SCHEMA_31,
|
||||
# Remove States.attributes once all attributes are in StateAttributes.shared_attrs
|
||||
States.attributes,
|
||||
StateAttributes.shared_attrs,
|
||||
)
|
||||
_QUERY_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31 = (
|
||||
*_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31,
|
||||
# Remove States.attributes once all attributes are in StateAttributes.shared_attrs
|
||||
States.attributes,
|
||||
StateAttributes.shared_attrs,
|
||||
)
|
||||
_QUERY_STATES = (
|
||||
*_BASE_STATES,
|
||||
# Remove States.attributes once all attributes are in StateAttributes.shared_attrs
|
||||
States.attributes,
|
||||
StateAttributes.shared_attrs,
|
||||
)
|
||||
_QUERY_STATES_NO_LAST_CHANGED = (
|
||||
*_BASE_STATES_NO_LAST_CHANGED,
|
||||
# Remove States.attributes once all attributes are in StateAttributes.shared_attrs
|
||||
States.attributes,
|
||||
StateAttributes.shared_attrs,
|
||||
)
|
||||
_FIELD_MAP = {
|
||||
cast(MappedColumn, field).name: idx
|
||||
for idx, field in enumerate(_QUERY_STATE_NO_ATTR)
|
||||
}
|
||||
_FIELD_MAP_PRE_SCHEMA_31 = {
|
||||
cast(MappedColumn, field).name: idx
|
||||
for idx, field in enumerate(_QUERY_STATES_PRE_SCHEMA_31)
|
||||
}
|
||||
|
||||
|
||||
def _lambda_stmt_and_join_attributes(
|
||||
no_attributes: bool, include_last_changed: bool = True
|
||||
) -> tuple[StatementLambdaElement, bool]:
|
||||
"""Return the lambda_stmt and if StateAttributes should be joined.
|
||||
|
||||
Because these are lambda_stmt the values inside the lambdas need
|
||||
to be explicitly written out to avoid caching the wrong values.
|
||||
"""
|
||||
# If no_attributes was requested we do the query
|
||||
# without the attributes fields and do not join the
|
||||
# state_attributes table
|
||||
if no_attributes:
|
||||
if include_last_changed:
|
||||
return (
|
||||
lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)),
|
||||
False,
|
||||
)
|
||||
return (
|
||||
lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)),
|
||||
False,
|
||||
)
|
||||
|
||||
if include_last_changed:
|
||||
return lambda_stmt(lambda: select(*_QUERY_STATES)), True
|
||||
return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True
|
||||
|
||||
|
||||
def get_significant_states(
|
||||
hass: HomeAssistant,
|
||||
start_time: datetime,
|
||||
end_time: datetime | None = None,
|
||||
entity_ids: list[str] | None = None,
|
||||
filters: Filters | None = None,
|
||||
include_start_time_state: bool = True,
|
||||
significant_changes_only: bool = True,
|
||||
minimal_response: bool = False,
|
||||
no_attributes: bool = False,
|
||||
compressed_state_format: bool = False,
|
||||
) -> dict[str, list[State | dict[str, Any]]]:
|
||||
"""Wrap get_significant_states_with_session with an sql session."""
|
||||
with session_scope(hass=hass, read_only=True) as session:
|
||||
return get_significant_states_with_session(
|
||||
hass,
|
||||
session,
|
||||
start_time,
|
||||
end_time,
|
||||
entity_ids,
|
||||
filters,
|
||||
include_start_time_state,
|
||||
significant_changes_only,
|
||||
minimal_response,
|
||||
no_attributes,
|
||||
compressed_state_format,
|
||||
)
|
||||
|
||||
|
||||
def _significant_states_stmt(
|
||||
start_time: datetime,
|
||||
end_time: datetime | None,
|
||||
entity_ids: list[str],
|
||||
significant_changes_only: bool,
|
||||
no_attributes: bool,
|
||||
) -> StatementLambdaElement:
|
||||
"""Query the database for significant state changes."""
|
||||
stmt, join_attributes = _lambda_stmt_and_join_attributes(
|
||||
no_attributes, include_last_changed=not significant_changes_only
|
||||
)
|
||||
if (
|
||||
len(entity_ids) == 1
|
||||
and significant_changes_only
|
||||
and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS
|
||||
):
|
||||
stmt += lambda q: q.filter(
|
||||
(States.last_changed_ts == States.last_updated_ts)
|
||||
| States.last_changed_ts.is_(None)
|
||||
)
|
||||
elif significant_changes_only:
|
||||
stmt += lambda q: q.filter(
|
||||
or_(
|
||||
*[
|
||||
States.entity_id.like(entity_domain)
|
||||
for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE
|
||||
],
|
||||
(
|
||||
(States.last_changed_ts == States.last_updated_ts)
|
||||
| States.last_changed_ts.is_(None)
|
||||
),
|
||||
)
|
||||
)
|
||||
stmt += lambda q: q.filter(States.entity_id.in_(entity_ids))
|
||||
|
||||
start_time_ts = start_time.timestamp()
|
||||
stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts)
|
||||
if end_time:
|
||||
end_time_ts = end_time.timestamp()
|
||||
stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts)
|
||||
|
||||
if join_attributes:
|
||||
stmt += lambda q: q.outerjoin(
|
||||
StateAttributes, States.attributes_id == StateAttributes.attributes_id
|
||||
)
|
||||
stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts)
|
||||
return stmt
|
||||
|
||||
|
||||
def get_significant_states_with_session(
|
||||
hass: HomeAssistant,
|
||||
session: Session,
|
||||
start_time: datetime,
|
||||
end_time: datetime | None = None,
|
||||
entity_ids: list[str] | None = None,
|
||||
filters: Filters | None = None,
|
||||
include_start_time_state: bool = True,
|
||||
significant_changes_only: bool = True,
|
||||
minimal_response: bool = False,
|
||||
no_attributes: bool = False,
|
||||
compressed_state_format: bool = False,
|
||||
) -> dict[str, list[State | dict[str, Any]]]:
|
||||
"""Return states changes during UTC period start_time - end_time.
|
||||
|
||||
entity_ids is an optional iterable of entities to include in the results.
|
||||
|
||||
filters is an optional SQLAlchemy filter which will be applied to the database
|
||||
queries unless entity_ids is given, in which case its ignored.
|
||||
|
||||
Significant states are all states where there is a state change,
|
||||
as well as all states from certain domains (for instance
|
||||
thermostat so that we get current temperature in our graphs).
|
||||
"""
|
||||
if filters is not None:
|
||||
raise NotImplementedError("Filters are no longer supported")
|
||||
if not entity_ids:
|
||||
raise ValueError("entity_ids must be provided")
|
||||
stmt = _significant_states_stmt(
|
||||
start_time,
|
||||
end_time,
|
||||
entity_ids,
|
||||
significant_changes_only,
|
||||
no_attributes,
|
||||
)
|
||||
states = execute_stmt_lambda_element(session, stmt, None, end_time)
|
||||
return _sorted_states_to_dict(
|
||||
hass,
|
||||
session,
|
||||
states,
|
||||
start_time,
|
||||
entity_ids,
|
||||
include_start_time_state,
|
||||
minimal_response,
|
||||
no_attributes,
|
||||
compressed_state_format,
|
||||
)
|
||||
|
||||
|
||||
def get_full_significant_states_with_session(
|
||||
hass: HomeAssistant,
|
||||
session: Session,
|
||||
start_time: datetime,
|
||||
end_time: datetime | None = None,
|
||||
entity_ids: list[str] | None = None,
|
||||
filters: Filters | None = None,
|
||||
include_start_time_state: bool = True,
|
||||
significant_changes_only: bool = True,
|
||||
no_attributes: bool = False,
|
||||
) -> dict[str, list[State]]:
|
||||
"""Variant of get_significant_states_with_session.
|
||||
|
||||
Difference with get_significant_states_with_session is that it does not
|
||||
return minimal responses.
|
||||
"""
|
||||
return cast(
|
||||
dict[str, list[State]],
|
||||
get_significant_states_with_session(
|
||||
hass=hass,
|
||||
session=session,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
entity_ids=entity_ids,
|
||||
filters=filters,
|
||||
include_start_time_state=include_start_time_state,
|
||||
significant_changes_only=significant_changes_only,
|
||||
minimal_response=False,
|
||||
no_attributes=no_attributes,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _state_changed_during_period_stmt(
|
||||
start_time: datetime,
|
||||
end_time: datetime | None,
|
||||
entity_id: str,
|
||||
no_attributes: bool,
|
||||
descending: bool,
|
||||
limit: int | None,
|
||||
) -> StatementLambdaElement:
|
||||
stmt, join_attributes = _lambda_stmt_and_join_attributes(
|
||||
no_attributes, include_last_changed=False
|
||||
)
|
||||
start_time_ts = start_time.timestamp()
|
||||
stmt += lambda q: q.filter(
|
||||
(
|
||||
(States.last_changed_ts == States.last_updated_ts)
|
||||
| States.last_changed_ts.is_(None)
|
||||
)
|
||||
& (States.last_updated_ts > start_time_ts)
|
||||
)
|
||||
if end_time:
|
||||
end_time_ts = end_time.timestamp()
|
||||
stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts)
|
||||
stmt += lambda q: q.filter(States.entity_id == entity_id)
|
||||
if join_attributes:
|
||||
stmt += lambda q: q.outerjoin(
|
||||
StateAttributes, States.attributes_id == StateAttributes.attributes_id
|
||||
)
|
||||
if descending:
|
||||
stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts.desc())
|
||||
else:
|
||||
stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts)
|
||||
|
||||
if limit:
|
||||
stmt += lambda q: q.limit(limit)
|
||||
return stmt
|
||||
|
||||
|
||||
def state_changes_during_period(
|
||||
hass: HomeAssistant,
|
||||
start_time: datetime,
|
||||
end_time: datetime | None = None,
|
||||
entity_id: str | None = None,
|
||||
no_attributes: bool = False,
|
||||
descending: bool = False,
|
||||
limit: int | None = None,
|
||||
include_start_time_state: bool = True,
|
||||
) -> dict[str, list[State]]:
|
||||
"""Return states changes during UTC period start_time - end_time."""
|
||||
if not entity_id:
|
||||
raise ValueError("entity_id must be provided")
|
||||
entity_ids = [entity_id.lower()]
|
||||
with session_scope(hass=hass, read_only=True) as session:
|
||||
stmt = _state_changed_during_period_stmt(
|
||||
start_time,
|
||||
end_time,
|
||||
entity_id,
|
||||
no_attributes,
|
||||
descending,
|
||||
limit,
|
||||
)
|
||||
states = execute_stmt_lambda_element(session, stmt, None, end_time)
|
||||
return cast(
|
||||
dict[str, list[State]],
|
||||
_sorted_states_to_dict(
|
||||
hass,
|
||||
session,
|
||||
states,
|
||||
start_time,
|
||||
entity_ids,
|
||||
include_start_time_state=include_start_time_state,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_last_state_changes_stmt(
|
||||
number_of_states: int, entity_id: str
|
||||
) -> StatementLambdaElement:
|
||||
stmt, join_attributes = _lambda_stmt_and_join_attributes(
|
||||
False, include_last_changed=False
|
||||
)
|
||||
stmt += lambda q: q.where(
|
||||
States.state_id
|
||||
== (
|
||||
select(States.state_id)
|
||||
.filter(States.entity_id == entity_id)
|
||||
.order_by(States.last_updated_ts.desc())
|
||||
.limit(number_of_states)
|
||||
.subquery()
|
||||
).c.state_id
|
||||
)
|
||||
if join_attributes:
|
||||
stmt += lambda q: q.outerjoin(
|
||||
StateAttributes, States.attributes_id == StateAttributes.attributes_id
|
||||
)
|
||||
|
||||
stmt += lambda q: q.order_by(States.state_id.desc())
|
||||
return stmt
|
||||
|
||||
|
||||
def get_last_state_changes(
|
||||
hass: HomeAssistant, number_of_states: int, entity_id: str
|
||||
) -> dict[str, list[State]]:
|
||||
"""Return the last number_of_states."""
|
||||
entity_id_lower = entity_id.lower()
|
||||
entity_ids = [entity_id_lower]
|
||||
|
||||
with session_scope(hass=hass, read_only=True) as session:
|
||||
stmt = _get_last_state_changes_stmt(number_of_states, entity_id_lower)
|
||||
states = list(execute_stmt_lambda_element(session, stmt))
|
||||
return cast(
|
||||
dict[str, list[State]],
|
||||
_sorted_states_to_dict(
|
||||
hass,
|
||||
session,
|
||||
reversed(states),
|
||||
dt_util.utcnow(),
|
||||
entity_ids,
|
||||
include_start_time_state=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_states_for_entities_stmt(
|
||||
run_start_ts: float,
|
||||
utc_point_in_time: datetime,
|
||||
entity_ids: list[str],
|
||||
no_attributes: bool,
|
||||
) -> StatementLambdaElement:
|
||||
"""Baked query to get states for specific entities."""
|
||||
stmt, join_attributes = _lambda_stmt_and_join_attributes(
|
||||
no_attributes, include_last_changed=True
|
||||
)
|
||||
# We got an include-list of entities, accelerate the query by filtering already
|
||||
# in the inner query.
|
||||
utc_point_in_time_ts = utc_point_in_time.timestamp()
|
||||
stmt += lambda q: q.join(
|
||||
(
|
||||
most_recent_states_for_entities_by_date := (
|
||||
select(
|
||||
States.entity_id.label("max_entity_id"),
|
||||
func.max(States.last_updated_ts).label("max_last_updated"),
|
||||
)
|
||||
.filter(
|
||||
(States.last_updated_ts >= run_start_ts)
|
||||
& (States.last_updated_ts < utc_point_in_time_ts)
|
||||
)
|
||||
.filter(States.entity_id.in_(entity_ids))
|
||||
.group_by(States.entity_id)
|
||||
.subquery()
|
||||
)
|
||||
),
|
||||
and_(
|
||||
States.entity_id == most_recent_states_for_entities_by_date.c.max_entity_id,
|
||||
States.last_updated_ts
|
||||
== most_recent_states_for_entities_by_date.c.max_last_updated,
|
||||
),
|
||||
)
|
||||
if join_attributes:
|
||||
stmt += lambda q: q.outerjoin(
|
||||
StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
def _get_rows_with_session(
|
||||
hass: HomeAssistant,
|
||||
session: Session,
|
||||
utc_point_in_time: datetime,
|
||||
entity_ids: list[str],
|
||||
*,
|
||||
no_attributes: bool = False,
|
||||
) -> Iterable[Row]:
|
||||
"""Return the states at a specific point in time."""
|
||||
if len(entity_ids) == 1:
|
||||
return execute_stmt_lambda_element(
|
||||
session,
|
||||
_get_single_entity_states_stmt(
|
||||
utc_point_in_time, entity_ids[0], no_attributes
|
||||
),
|
||||
)
|
||||
|
||||
oldest_ts = get_instance(hass).states_manager.oldest_ts
|
||||
|
||||
if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp():
|
||||
# We don't have any states for the requested time
|
||||
return []
|
||||
|
||||
# We have more than one entity to look at so we need to do a query on states
|
||||
# since the last recorder run started.
|
||||
stmt = _get_states_for_entities_stmt(
|
||||
oldest_ts, utc_point_in_time, entity_ids, no_attributes
|
||||
)
|
||||
return execute_stmt_lambda_element(session, stmt)
|
||||
|
||||
|
||||
def _get_single_entity_states_stmt(
|
||||
utc_point_in_time: datetime,
|
||||
entity_id: str,
|
||||
no_attributes: bool = False,
|
||||
) -> StatementLambdaElement:
|
||||
# Use an entirely different (and extremely fast) query if we only
|
||||
# have a single entity id
|
||||
stmt, join_attributes = _lambda_stmt_and_join_attributes(
|
||||
no_attributes, include_last_changed=True
|
||||
)
|
||||
utc_point_in_time_ts = utc_point_in_time.timestamp()
|
||||
stmt += (
|
||||
lambda q: q.filter(
|
||||
States.last_updated_ts < utc_point_in_time_ts,
|
||||
States.entity_id == entity_id,
|
||||
)
|
||||
.order_by(States.last_updated_ts.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if join_attributes:
|
||||
stmt += lambda q: q.outerjoin(
|
||||
StateAttributes, States.attributes_id == StateAttributes.attributes_id
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
def _sorted_states_to_dict(
|
||||
hass: HomeAssistant,
|
||||
session: Session,
|
||||
states: Iterable[Row],
|
||||
start_time: datetime,
|
||||
entity_ids: list[str],
|
||||
include_start_time_state: bool = True,
|
||||
minimal_response: bool = False,
|
||||
no_attributes: bool = False,
|
||||
compressed_state_format: bool = False,
|
||||
) -> dict[str, list[State | dict[str, Any]]]:
|
||||
"""Convert SQL results into JSON friendly data structure.
|
||||
|
||||
This takes our state list and turns it into a JSON friendly data
|
||||
structure {'entity_id': [list of states], 'entity_id2': [list of states]}
|
||||
|
||||
States must be sorted by entity_id and last_updated
|
||||
|
||||
We also need to go back and create a synthetic zero data point for
|
||||
each list of states, otherwise our graphs won't start on the Y
|
||||
axis correctly.
|
||||
"""
|
||||
state_class: Callable[
|
||||
[Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any]
|
||||
]
|
||||
if compressed_state_format:
|
||||
state_class = legacy_row_to_compressed_state
|
||||
attr_time = COMPRESSED_STATE_LAST_UPDATED
|
||||
attr_state = COMPRESSED_STATE_STATE
|
||||
else:
|
||||
state_class = LegacyLazyState
|
||||
attr_time = LAST_CHANGED_KEY
|
||||
attr_state = STATE_KEY
|
||||
|
||||
result: dict[str, list[State | dict[str, Any]]] = defaultdict(list)
|
||||
# Set all entity IDs to empty lists in result set to maintain the order
|
||||
for ent_id in entity_ids:
|
||||
result[ent_id] = []
|
||||
|
||||
# Get the states at the start time
|
||||
time.perf_counter()
|
||||
initial_states: dict[str, Row] = {}
|
||||
if include_start_time_state:
|
||||
initial_states = {
|
||||
row.entity_id: row
|
||||
for row in _get_rows_with_session(
|
||||
hass,
|
||||
session,
|
||||
start_time,
|
||||
entity_ids,
|
||||
no_attributes=no_attributes,
|
||||
)
|
||||
}
|
||||
|
||||
if len(entity_ids) == 1:
|
||||
states_iter: Iterable[tuple[str, Iterator[Row]]] = (
|
||||
(entity_ids[0], iter(states)),
|
||||
)
|
||||
else:
|
||||
key_func = attrgetter("entity_id")
|
||||
states_iter = groupby(states, key_func)
|
||||
|
||||
# Append all changes to it
|
||||
for ent_id, group in states_iter:
|
||||
attr_cache: dict[str, dict[str, Any]] = {}
|
||||
prev_state: Column | str
|
||||
ent_results = result[ent_id]
|
||||
if row := initial_states.pop(ent_id, None):
|
||||
prev_state = row.state
|
||||
ent_results.append(state_class(row, attr_cache, start_time))
|
||||
|
||||
if not minimal_response or split_entity_id(ent_id)[0] in NEED_ATTRIBUTE_DOMAINS:
|
||||
ent_results.extend(
|
||||
state_class(db_state, attr_cache, None) for db_state in group
|
||||
)
|
||||
continue
|
||||
|
||||
# With minimal response we only provide a native
|
||||
# State for the first and last response. All the states
|
||||
# in-between only provide the "state" and the
|
||||
# "last_changed".
|
||||
if not ent_results:
|
||||
if (first_state := next(group, None)) is None:
|
||||
continue
|
||||
prev_state = first_state.state
|
||||
ent_results.append(state_class(first_state, attr_cache, None))
|
||||
|
||||
state_idx = _FIELD_MAP["state"]
|
||||
|
||||
#
|
||||
# minimal_response only makes sense with last_updated == last_updated
|
||||
#
|
||||
# We use last_updated for for last_changed since its the same
|
||||
#
|
||||
# With minimal response we do not care about attribute
|
||||
# changes so we can filter out duplicate states
|
||||
last_updated_ts_idx = _FIELD_MAP["last_updated_ts"]
|
||||
if compressed_state_format:
|
||||
for row in group:
|
||||
if (state := row[state_idx]) != prev_state:
|
||||
ent_results.append(
|
||||
{
|
||||
attr_state: state,
|
||||
attr_time: row[last_updated_ts_idx],
|
||||
}
|
||||
)
|
||||
prev_state = state
|
||||
continue
|
||||
|
||||
for row in group:
|
||||
if (state := row[state_idx]) != prev_state:
|
||||
ent_results.append(
|
||||
{
|
||||
attr_state: state,
|
||||
attr_time: process_timestamp_to_utc_isoformat(
|
||||
dt_util.utc_from_timestamp(row[last_updated_ts_idx])
|
||||
),
|
||||
}
|
||||
)
|
||||
prev_state = state
|
||||
|
||||
# If there are no states beyond the initial state,
|
||||
# the state a was never popped from initial_states
|
||||
for ent_id, row in initial_states.items():
|
||||
result[ent_id].append(state_class(row, {}, start_time))
|
||||
|
||||
# Filter out the empty lists if some states had 0 results.
|
||||
return {key: val for key, val in result.items() if val}
|
@@ -1,167 +0,0 @@
|
||||
"""Models for Recorder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.engine.row import Row
|
||||
|
||||
from homeassistant.const import (
|
||||
COMPRESSED_STATE_ATTRIBUTES,
|
||||
COMPRESSED_STATE_LAST_CHANGED,
|
||||
COMPRESSED_STATE_LAST_UPDATED,
|
||||
COMPRESSED_STATE_STATE,
|
||||
)
|
||||
from homeassistant.core import Context, State
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .state_attributes import decode_attributes_from_source
|
||||
from .time import process_timestamp
|
||||
|
||||
|
||||
class LegacyLazyState(State):
|
||||
"""A lazy version of core State after schema 31."""
|
||||
|
||||
__slots__ = [
|
||||
"_attributes",
|
||||
"_context",
|
||||
"_last_changed_ts",
|
||||
"_last_reported_ts",
|
||||
"_last_updated_ts",
|
||||
"_row",
|
||||
"attr_cache",
|
||||
]
|
||||
|
||||
def __init__( # pylint: disable=super-init-not-called
|
||||
self,
|
||||
row: Row,
|
||||
attr_cache: dict[str, dict[str, Any]],
|
||||
start_time: datetime | None,
|
||||
entity_id: str | None = None,
|
||||
) -> None:
|
||||
"""Init the lazy state."""
|
||||
self._row = row
|
||||
self.entity_id = entity_id or self._row.entity_id
|
||||
self.state = self._row.state or ""
|
||||
self._attributes: dict[str, Any] | None = None
|
||||
self._last_updated_ts: float | None = self._row.last_updated_ts or (
|
||||
start_time.timestamp() if start_time else None
|
||||
)
|
||||
self._last_changed_ts: float | None = (
|
||||
self._row.last_changed_ts or self._last_updated_ts
|
||||
)
|
||||
self._last_reported_ts: float | None = self._last_updated_ts
|
||||
self._context: Context | None = None
|
||||
self.attr_cache = attr_cache
|
||||
|
||||
@property # type: ignore[override]
|
||||
def attributes(self) -> dict[str, Any]:
|
||||
"""State attributes."""
|
||||
if self._attributes is None:
|
||||
self._attributes = decode_attributes_from_row_legacy(
|
||||
self._row, self.attr_cache
|
||||
)
|
||||
return self._attributes
|
||||
|
||||
@attributes.setter
|
||||
def attributes(self, value: dict[str, Any]) -> None:
|
||||
"""Set attributes."""
|
||||
self._attributes = value
|
||||
|
||||
@property
|
||||
def context(self) -> Context:
|
||||
"""State context."""
|
||||
if self._context is None:
|
||||
self._context = Context(id=None)
|
||||
return self._context
|
||||
|
||||
@context.setter
|
||||
def context(self, value: Context) -> None:
|
||||
"""Set context."""
|
||||
self._context = value
|
||||
|
||||
@property
|
||||
def last_changed(self) -> datetime:
|
||||
"""Last changed datetime."""
|
||||
assert self._last_changed_ts is not None
|
||||
return dt_util.utc_from_timestamp(self._last_changed_ts)
|
||||
|
||||
@last_changed.setter
|
||||
def last_changed(self, value: datetime) -> None:
|
||||
"""Set last changed datetime."""
|
||||
self._last_changed_ts = process_timestamp(value).timestamp()
|
||||
|
||||
@property
|
||||
def last_reported(self) -> datetime:
|
||||
"""Last reported datetime."""
|
||||
assert self._last_reported_ts is not None
|
||||
return dt_util.utc_from_timestamp(self._last_reported_ts)
|
||||
|
||||
@last_reported.setter
|
||||
def last_reported(self, value: datetime) -> None:
|
||||
"""Set last reported datetime."""
|
||||
self._last_reported_ts = process_timestamp(value).timestamp()
|
||||
|
||||
@property
|
||||
def last_updated(self) -> datetime:
|
||||
"""Last updated datetime."""
|
||||
assert self._last_updated_ts is not None
|
||||
return dt_util.utc_from_timestamp(self._last_updated_ts)
|
||||
|
||||
@last_updated.setter
|
||||
def last_updated(self, value: datetime) -> None:
|
||||
"""Set last updated datetime."""
|
||||
self._last_updated_ts = process_timestamp(value).timestamp()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]: # type: ignore[override]
|
||||
"""Return a dict representation of the LazyState.
|
||||
|
||||
Async friendly.
|
||||
To be used for JSON serialization.
|
||||
"""
|
||||
last_updated_isoformat = self.last_updated.isoformat()
|
||||
if self._last_changed_ts == self._last_updated_ts:
|
||||
last_changed_isoformat = last_updated_isoformat
|
||||
else:
|
||||
last_changed_isoformat = self.last_changed.isoformat()
|
||||
return {
|
||||
"entity_id": self.entity_id,
|
||||
"state": self.state,
|
||||
"attributes": self._attributes or self.attributes,
|
||||
"last_changed": last_changed_isoformat,
|
||||
"last_updated": last_updated_isoformat,
|
||||
}
|
||||
|
||||
|
||||
def legacy_row_to_compressed_state(
|
||||
row: Row,
|
||||
attr_cache: dict[str, dict[str, Any]],
|
||||
start_time: datetime | None,
|
||||
entity_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a database row to a compressed state schema 31 and later."""
|
||||
comp_state = {
|
||||
COMPRESSED_STATE_STATE: row.state,
|
||||
COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache),
|
||||
}
|
||||
if start_time:
|
||||
comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp()
|
||||
else:
|
||||
row_last_updated_ts: float = row.last_updated_ts
|
||||
comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts
|
||||
if (
|
||||
row_last_changed_ts := row.last_changed_ts
|
||||
) and row_last_updated_ts != row_last_changed_ts:
|
||||
comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts
|
||||
return comp_state
|
||||
|
||||
|
||||
def decode_attributes_from_row_legacy(
|
||||
row: Row, attr_cache: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""Decode attributes from a database row."""
|
||||
return decode_attributes_from_source(
|
||||
getattr(row, "shared_attrs", None) or getattr(row, "attributes", None),
|
||||
attr_cache,
|
||||
)
|
@@ -116,9 +116,7 @@ def purge_old_data(
|
||||
# This purge cycle is finished, clean up old event types and
|
||||
# recorder runs
|
||||
_purge_old_event_types(instance, session)
|
||||
|
||||
if instance.states_meta_manager.active:
|
||||
_purge_old_entity_ids(instance, session)
|
||||
_purge_old_entity_ids(instance, session)
|
||||
|
||||
_purge_old_recorder_runs(instance, session, purge_before)
|
||||
with session_scope(session=instance.get_session(), read_only=True) as session:
|
||||
|
@@ -24,8 +24,6 @@ CACHE_SIZE = 8192
|
||||
class StatesMetaManager(BaseLRUTableManager[StatesMeta]):
|
||||
"""Manage the StatesMeta table."""
|
||||
|
||||
active = True
|
||||
|
||||
def __init__(self, recorder: Recorder) -> None:
|
||||
"""Initialize the states meta manager."""
|
||||
self._did_first_load = False
|
||||
|
@@ -110,9 +110,7 @@ SUNDAY_WEEKDAY = 6
|
||||
DAYS_IN_WEEK = 7
|
||||
|
||||
|
||||
def execute(
|
||||
qry: Query, to_native: bool = False, validate_entity_ids: bool = True
|
||||
) -> list[Row]:
|
||||
def execute(qry: Query) -> list[Row]:
|
||||
"""Query the database and convert the objects to HA native form.
|
||||
|
||||
This method also retries a few times in the case of stale connections.
|
||||
@@ -122,33 +120,15 @@ def execute(
|
||||
try:
|
||||
if debug:
|
||||
timer_start = time.perf_counter()
|
||||
|
||||
if to_native:
|
||||
result = [
|
||||
row
|
||||
for row in (
|
||||
row.to_native(validate_entity_id=validate_entity_ids)
|
||||
for row in qry
|
||||
)
|
||||
if row is not None
|
||||
]
|
||||
else:
|
||||
result = qry.all()
|
||||
result = qry.all()
|
||||
|
||||
if debug:
|
||||
elapsed = time.perf_counter() - timer_start
|
||||
if to_native:
|
||||
_LOGGER.debug(
|
||||
"converting %d rows to native objects took %fs",
|
||||
len(result),
|
||||
elapsed,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"querying %d rows took %fs",
|
||||
len(result),
|
||||
elapsed,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"querying %d rows took %fs",
|
||||
len(result),
|
||||
elapsed,
|
||||
)
|
||||
|
||||
except SQLAlchemyError as err:
|
||||
_LOGGER.error("Error executing query: %s", err)
|
||||
|
@@ -377,8 +377,10 @@
|
||||
"max": "Max",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"intense": "Intense",
|
||||
"extreme": "Extreme",
|
||||
"custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]",
|
||||
"custom_water_flow": "Custom water flow",
|
||||
"vac_followed_by_mop": "Vacuum followed by mop",
|
||||
"smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]"
|
||||
}
|
||||
},
|
||||
|
@@ -23,11 +23,11 @@
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"title": "Retrieve Password",
|
||||
"title": "Retrieve password",
|
||||
"description": "Make sure that the iRobot app is not running on any device. Press and hold the Home button (or both Home and Spot buttons) on {name} until the device generates a sound (about two seconds), then submit within 30 seconds."
|
||||
},
|
||||
"link_manual": {
|
||||
"title": "Enter Password",
|
||||
"title": "Enter password",
|
||||
"description": "The password could not be retrieved from the device automatically. Please make sure that the iRobot app is not open on any device while trying to retrieve the password. Please follow the steps outlined in the documentation at: {auth_help_url}",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
|
@@ -552,8 +552,15 @@ def percentage_to_brightness(percentage: int) -> int:
|
||||
|
||||
def mac_address_from_name(name: str) -> str | None:
|
||||
"""Convert a name to a mac address."""
|
||||
mac = name.partition(".")[0].partition("-")[-1]
|
||||
return mac.upper() if len(mac) == 12 else None
|
||||
base = name.split(".", 1)[0]
|
||||
if "-" not in base:
|
||||
return None
|
||||
|
||||
mac = base.rsplit("-", 1)[-1]
|
||||
if len(mac) != 12 or not all(char in "0123456789abcdefABCDEF" for char in mac):
|
||||
return None
|
||||
|
||||
return mac.upper()
|
||||
|
||||
|
||||
def get_release_url(gen: int, model: str, beta: bool) -> str | None:
|
||||
|
@@ -63,12 +63,14 @@ async def async_setup_entry(
|
||||
SERVICE_SET_AUTO_OFF_NAME,
|
||||
SERVICE_SET_AUTO_OFF_SCHEMA,
|
||||
"async_set_auto_off_service",
|
||||
entity_device_classes=(SwitchDeviceClass.SWITCH,),
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_TURN_ON_WITH_TIMER_NAME,
|
||||
SERVICE_TURN_ON_WITH_TIMER_SCHEMA,
|
||||
"async_turn_on_with_timer_service",
|
||||
entity_device_classes=(SwitchDeviceClass.SWITCH,),
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -135,22 +137,6 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity):
|
||||
self._attr_is_on = self.control_result = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_auto_off_service(self, auto_off: timedelta) -> None:
|
||||
"""Use for handling setting device auto-off service calls."""
|
||||
_LOGGER.warning(
|
||||
"Service '%s' is not supported by %s",
|
||||
SERVICE_SET_AUTO_OFF_NAME,
|
||||
self.coordinator.name,
|
||||
)
|
||||
|
||||
async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None:
|
||||
"""Use for turning device on with a timer service calls."""
|
||||
_LOGGER.warning(
|
||||
"Service '%s' is not supported by %s",
|
||||
SERVICE_TURN_ON_WITH_TIMER_NAME,
|
||||
self.coordinator.name,
|
||||
)
|
||||
|
||||
|
||||
class SwitcherPowerPlugSwitchEntity(SwitcherBaseSwitchEntity):
|
||||
"""Representation of a Switcher power plug switch entity."""
|
||||
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from togrill_bluetooth.client import Client
|
||||
@@ -39,8 +38,6 @@ type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PacketType = TypeVar("PacketType", bound=Packet)
|
||||
|
||||
|
||||
def get_version_string(packet: PacketA0Notify) -> str:
|
||||
"""Construct a version string from packet data."""
|
||||
@@ -179,9 +176,9 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
|
||||
self.client = await self._connect_and_update_registry()
|
||||
return self.client
|
||||
|
||||
def get_packet(
|
||||
self, packet_type: type[PacketType], probe=None
|
||||
) -> PacketType | None:
|
||||
def get_packet[PacketT: Packet](
|
||||
self, packet_type: type[PacketT], probe=None
|
||||
) -> PacketT | None:
|
||||
"""Get a cached packet of a certain type."""
|
||||
|
||||
if packet := self.data.get((packet_type.type, probe)):
|
||||
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
from time import monotonic
|
||||
from typing import Any, Final, Generic, Protocol, TypeVar
|
||||
from typing import Any, Final, Protocol
|
||||
|
||||
from aiohttp import web
|
||||
import mutagen
|
||||
@@ -628,10 +628,7 @@ class HasLastUsed(Protocol):
|
||||
last_used: float
|
||||
|
||||
|
||||
T = TypeVar("T", bound=HasLastUsed)
|
||||
|
||||
|
||||
class DictCleaning(Generic[T]):
|
||||
class DictCleaning[T: HasLastUsed]:
|
||||
"""Helper to clean up the stale sessions."""
|
||||
|
||||
unsub: CALLBACK_TYPE | None = None
|
||||
|
@@ -4,6 +4,6 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "vegehub"
|
||||
NAME = "VegeHub"
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
MANUFACTURER = "vegetronix"
|
||||
MODEL = "VegeHub"
|
||||
|
@@ -39,6 +39,11 @@
|
||||
"battery_volts": {
|
||||
"name": "Battery voltage"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"switch": {
|
||||
"name": "Actuator {index}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
80
homeassistant/components/vegehub/switch.py
Normal file
80
homeassistant/components/vegehub/switch.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Switch configuration for VegeHub integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import VegeHubConfigEntry, VegeHubCoordinator
|
||||
from .entity import VegeHubEntity
|
||||
|
||||
SWITCH_TYPES: dict[str, SwitchEntityDescription] = {
|
||||
"switch": SwitchEntityDescription(
|
||||
key="switch",
|
||||
translation_key="switch",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VegeHubConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up VegeHub switches from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
VegeHubSwitch(
|
||||
index=i,
|
||||
duration=600, # Default duration of 10 minutes
|
||||
coordinator=coordinator,
|
||||
description=SWITCH_TYPES["switch"],
|
||||
)
|
||||
for i in range(coordinator.vegehub.num_actuators)
|
||||
)
|
||||
|
||||
|
||||
class VegeHubSwitch(VegeHubEntity, SwitchEntity):
|
||||
"""Class for VegeHub Switches."""
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
index: int,
|
||||
duration: int,
|
||||
coordinator: VegeHubCoordinator,
|
||||
description: SwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
# Set unique ID for pulling data from the coordinator
|
||||
self.data_key = f"actuator_{index}"
|
||||
self._attr_unique_id = f"{self._mac_address}_{self.data_key}"
|
||||
self._attr_translation_placeholders = {"index": str(index + 1)}
|
||||
self._attr_available = False
|
||||
self.index = index
|
||||
self.duration = duration
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the switch is on."""
|
||||
if self.coordinator.data is None or self._attr_unique_id is None:
|
||||
return False
|
||||
return self.coordinator.data.get(self.data_key, 0) > 0
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.coordinator.vegehub.set_actuator(1, self.index, self.duration)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.coordinator.vegehub.set_actuator(0, self.index, self.duration)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user