mirror of
https://github.com/home-assistant/core.git
synced 2026-02-04 06:15:47 +01:00
Compare commits
69 Commits
bump_otbr_
...
2026.2.0b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3369459d41 | ||
|
|
8536472fe9 | ||
|
|
ad4fda7bb4 | ||
|
|
36e1b86952 | ||
|
|
0c9834e4ca | ||
|
|
360af74519 | ||
|
|
d099ac457d | ||
|
|
fc330ce165 | ||
|
|
b52dd5fc05 | ||
|
|
b517ce132f | ||
|
|
acec35846c | ||
|
|
af661898c2 | ||
|
|
e2f5a4849c | ||
|
|
399b7f6223 | ||
|
|
782f7af332 | ||
|
|
66af6565bf | ||
|
|
8a00aa8550 | ||
|
|
b07adc03d2 | ||
|
|
a978e3c199 | ||
|
|
bb3c977448 | ||
|
|
8057de408e | ||
|
|
0be4ee71e7 | ||
|
|
7ff5f14748 | ||
|
|
d5e58c817d | ||
|
|
8a08016fb9 | ||
|
|
d45ddd3762 | ||
|
|
0e98e8c893 | ||
|
|
84a09bec0e | ||
|
|
6fd27ec7ec | ||
|
|
91e2a318a5 | ||
|
|
1221c5bcad | ||
|
|
8e3befc301 | ||
|
|
2df62385f1 | ||
|
|
9f3b13dfa1 | ||
|
|
9c27e1233e | ||
|
|
825da95550 | ||
|
|
18bda2dbbe | ||
|
|
630a9b4896 | ||
|
|
e6399d2bfe | ||
|
|
4bae0d15ec | ||
|
|
760a75d1f1 | ||
|
|
c08912fc78 | ||
|
|
316d804336 | ||
|
|
d3658a52dd | ||
|
|
b3e42a1f07 | ||
|
|
dee07b25a2 | ||
|
|
f460bf36fe | ||
|
|
020d122799 | ||
|
|
699b4b12da | ||
|
|
3ec96f21d1 | ||
|
|
c6c5970864 | ||
|
|
570146c4a6 | ||
|
|
75b7f80f6c | ||
|
|
1c1a99e5ae | ||
|
|
0203f6e6f1 | ||
|
|
66612f97ec | ||
|
|
6d215c284c | ||
|
|
8e9e406341 | ||
|
|
b6772c4104 | ||
|
|
d6a830da1a | ||
|
|
2f7a895e28 | ||
|
|
5cb5b0eb45 | ||
|
|
33ae951030 | ||
|
|
1cb56216ba | ||
|
|
6409574ecf | ||
|
|
a94d39e493 | ||
|
|
fec008c589 | ||
|
|
358e58ea85 | ||
|
|
e8bbc9598f |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -254,7 +254,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
|
||||
uses: j178/prek-action@564dda4cfa5e96aafdc4a5696c4bf7b46baae5ac # v1.1.0
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -288,6 +288,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cloud/ @home-assistant/cloud
|
||||
/homeassistant/components/cloudflare/ @ludeeus @ctalkington
|
||||
/tests/components/cloudflare/ @ludeeus @ctalkington
|
||||
/homeassistant/components/cloudflare_r2/ @corrreia
|
||||
/tests/components/cloudflare_r2/ @corrreia
|
||||
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/tests/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/homeassistant/components/coinbase/ @tombrien
|
||||
@@ -1263,6 +1265,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/powerfox/ @klaasnicolaas
|
||||
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/components/prana/ @prana-dev-official
|
||||
/tests/components/prana/ @prana-dev-official
|
||||
/homeassistant/components/private_ble_device/ @Jc2k
|
||||
/tests/components/private_ble_device/ @Jc2k
|
||||
/homeassistant/components/probe_plus/ @pantherale0
|
||||
|
||||
5
homeassistant/brands/cloudflare.json
Normal file
5
homeassistant/brands/cloudflare.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "cloudflare",
|
||||
"name": "Cloudflare",
|
||||
"integrations": ["cloudflare", "cloudflare_r2"]
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class AbodeLight(AbodeDevice, LightEntity):
|
||||
return _hs
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
if self.hs_color is not None:
|
||||
@@ -110,7 +110,7 @@ class AbodeLight(AbodeDevice, LightEntity):
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
"""Flag supported color modes."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
return {ColorMode.COLOR_TEMP, ColorMode.HS}
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -18,7 +18,13 @@ from .analytics import (
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
|
||||
from .const import (
|
||||
ATTR_ONBOARDED,
|
||||
ATTR_PREFERENCES,
|
||||
ATTR_SNAPSHOTS,
|
||||
DOMAIN,
|
||||
PREFERENCE_SCHEMA,
|
||||
)
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
@@ -44,29 +50,55 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
# For now we want to enable device analytics only if the url option
|
||||
# is explicitly listed in YAML.
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
disable_snapshots = False
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
disable_snapshots = True
|
||||
snapshots_url = None
|
||||
|
||||
analytics = Analytics(hass, snapshots_url, disable_snapshots)
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
started = False
|
||||
|
||||
async def _async_handle_labs_update(
|
||||
event: Event[labs.EventLabsUpdatedData],
|
||||
) -> None:
|
||||
"""Handle labs feature toggle."""
|
||||
await analytics.save_preferences({ATTR_SNAPSHOTS: event.data["enabled"]})
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
@callback
|
||||
def _async_labs_event_filter(event_data: labs.EventLabsUpdatedData) -> bool:
|
||||
"""Filter labs events for this integration's snapshot feature."""
|
||||
return (
|
||||
event_data["domain"] == DOMAIN
|
||||
and event_data["preview_feature"] == LABS_SNAPSHOT_FEATURE
|
||||
)
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
hass.bus.async_listen(
|
||||
labs.EVENT_LABS_UPDATED,
|
||||
_async_handle_labs_update,
|
||||
event_filter=_async_labs_event_filter,
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.energy import (
|
||||
DOMAIN as ENERGY_DOMAIN,
|
||||
is_configured as energy_is_configured,
|
||||
)
|
||||
from homeassistant.components.labs import async_is_preview_feature_enabled
|
||||
from homeassistant.components.recorder import (
|
||||
DOMAIN as RECORDER_DOMAIN,
|
||||
get_instance as get_recorder_instance,
|
||||
@@ -241,12 +242,10 @@ class Analytics:
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
snapshots_url: str | None = None,
|
||||
disable_snapshots: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the Analytics class."""
|
||||
self._hass: HomeAssistant = hass
|
||||
self._snapshots_url = snapshots_url
|
||||
self._disable_snapshots = disable_snapshots
|
||||
|
||||
self._session = async_get_clientsession(hass)
|
||||
self._data = AnalyticsData(False, {})
|
||||
@@ -258,15 +257,13 @@ class Analytics:
|
||||
def preferences(self) -> dict:
|
||||
"""Return the current active preferences."""
|
||||
preferences = self._data.preferences
|
||||
result = {
|
||||
return {
|
||||
ATTR_BASE: preferences.get(ATTR_BASE, False),
|
||||
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
|
||||
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
|
||||
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
|
||||
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
|
||||
}
|
||||
if not self._disable_snapshots:
|
||||
result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False)
|
||||
return result
|
||||
|
||||
@property
|
||||
def onboarded(self) -> bool:
|
||||
@@ -291,6 +288,11 @@ class Analytics:
|
||||
"""Return bool if a supervisor is present."""
|
||||
return is_hassio(self._hass)
|
||||
|
||||
@property
|
||||
def _snapshots_enabled(self) -> bool:
|
||||
"""Check if snapshots feature is enabled via labs."""
|
||||
return async_is_preview_feature_enabled(self._hass, DOMAIN, "snapshots")
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Load preferences."""
|
||||
stored = await self._store.async_load()
|
||||
@@ -645,7 +647,10 @@ class Analytics:
|
||||
),
|
||||
)
|
||||
|
||||
if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots:
|
||||
if (
|
||||
not self.preferences.get(ATTR_SNAPSHOTS, False)
|
||||
or not self._snapshots_enabled
|
||||
):
|
||||
LOGGER.debug("Snapshot analytics not scheduled")
|
||||
if self._snapshot_scheduled:
|
||||
self._snapshot_scheduled()
|
||||
|
||||
@@ -7,5 +7,11 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"feedback_url": "https://forms.gle/GqvRmgmghSDco8M46",
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
10
homeassistant/components/analytics/strings.json
Normal file
10
homeassistant/components/analytics/strings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
|
||||
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
|
||||
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
|
||||
"name": "Device database"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_TRACKED_INTEGRATIONS
|
||||
from .const import CONF_TRACKED_APPS, CONF_TRACKED_INTEGRATIONS
|
||||
from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
@@ -59,6 +60,30 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate to a new version."""
|
||||
# Migration for switching add-ons to apps
|
||||
if entry.version < 2:
|
||||
ent_reg = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if not entity_entry.unique_id.startswith("addon_"):
|
||||
continue
|
||||
|
||||
ent_reg.async_update_entity(
|
||||
entity_entry.entity_id,
|
||||
new_unique_id=entity_entry.unique_id.replace("addon_", "app_"),
|
||||
)
|
||||
|
||||
options = dict(entry.options)
|
||||
options[CONF_TRACKED_APPS] = options.pop("tracked_addons", [])
|
||||
|
||||
hass.config_entries.async_update_entry(entry, version=2, options=options)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
|
||||
) -> bool:
|
||||
|
||||
@@ -26,7 +26,7 @@ from homeassistant.helpers.selector import (
|
||||
|
||||
from . import AnalyticsInsightsConfigEntry
|
||||
from .const import (
|
||||
CONF_TRACKED_ADDONS,
|
||||
CONF_TRACKED_APPS,
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
@@ -43,6 +43,8 @@ INTEGRATION_TYPES_WITHOUT_ANALYTICS = (
|
||||
class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Homeassistant Analytics."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -59,7 +61,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
if all(
|
||||
[
|
||||
not user_input.get(CONF_TRACKED_ADDONS),
|
||||
not user_input.get(CONF_TRACKED_APPS),
|
||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
||||
]
|
||||
@@ -70,7 +72,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title="Home Assistant Analytics Insights",
|
||||
data={},
|
||||
options={
|
||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
||||
CONF_TRACKED_APPS: user_input.get(CONF_TRACKED_APPS, []),
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
@@ -84,7 +86,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
addons = await client.get_addons()
|
||||
apps = await client.get_addons()
|
||||
integrations = await client.get_integrations(Environment.NEXT)
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
@@ -107,9 +109,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
||||
vol.Optional(CONF_TRACKED_APPS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(addons),
|
||||
options=list(apps),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
@@ -144,7 +146,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
if user_input is not None:
|
||||
if all(
|
||||
[
|
||||
not user_input.get(CONF_TRACKED_ADDONS),
|
||||
not user_input.get(CONF_TRACKED_APPS),
|
||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
||||
]
|
||||
@@ -154,7 +156,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
||||
CONF_TRACKED_APPS: user_input.get(CONF_TRACKED_APPS, []),
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
@@ -168,7 +170,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
addons = await client.get_addons()
|
||||
apps = await client.get_addons()
|
||||
integrations = await client.get_integrations(Environment.NEXT)
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
@@ -189,9 +191,9 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
||||
vol.Optional(CONF_TRACKED_APPS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(addons),
|
||||
options=list(apps),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
DOMAIN = "analytics_insights"
|
||||
|
||||
CONF_TRACKED_ADDONS = "tracked_addons"
|
||||
CONF_TRACKED_APPS = "tracked_apps"
|
||||
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_ADDONS,
|
||||
CONF_TRACKED_APPS,
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
@@ -35,7 +35,7 @@ class AnalyticsData:
|
||||
|
||||
active_installations: int
|
||||
reports_integrations: int
|
||||
addons: dict[str, int]
|
||||
apps: dict[str, int]
|
||||
core_integrations: dict[str, int]
|
||||
custom_integrations: dict[str, int]
|
||||
|
||||
@@ -60,7 +60,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
update_interval=timedelta(hours=12),
|
||||
)
|
||||
self._client = client
|
||||
self._tracked_addons = self.config_entry.options.get(CONF_TRACKED_ADDONS, [])
|
||||
self._tracked_apps = self.config_entry.options.get(CONF_TRACKED_APPS, [])
|
||||
self._tracked_integrations = self.config_entry.options[
|
||||
CONF_TRACKED_INTEGRATIONS
|
||||
]
|
||||
@@ -70,7 +70,9 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
|
||||
async def _async_update_data(self) -> AnalyticsData:
|
||||
try:
|
||||
addons_data = await self._client.get_addons()
|
||||
apps_data = (
|
||||
await self._client.get_addons()
|
||||
) # Still add method name. Needs library update
|
||||
data = await self._client.get_current_analytics()
|
||||
custom_data = await self._client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError as err:
|
||||
@@ -79,9 +81,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
) from err
|
||||
except HomeassistantAnalyticsNotModifiedError:
|
||||
return self.data
|
||||
addons = {
|
||||
addon: get_addon_value(addons_data, addon) for addon in self._tracked_addons
|
||||
}
|
||||
apps = {app: get_app_value(apps_data, app) for app in self._tracked_apps}
|
||||
core_integrations = {
|
||||
integration: data.integrations.get(integration, 0)
|
||||
for integration in self._tracked_integrations
|
||||
@@ -93,14 +93,14 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
return AnalyticsData(
|
||||
data.active_installations,
|
||||
data.reports_integrations,
|
||||
addons,
|
||||
apps,
|
||||
core_integrations,
|
||||
custom_integrations,
|
||||
)
|
||||
|
||||
|
||||
def get_addon_value(data: dict[str, Addon], name_slug: str) -> int:
|
||||
"""Get addon value."""
|
||||
def get_app_value(data: dict[str, Addon], name_slug: str) -> int:
|
||||
"""Get app value."""
|
||||
if name_slug in data:
|
||||
return data[name_slug].total
|
||||
return 0
|
||||
|
||||
@@ -29,17 +29,17 @@ class AnalyticsSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[AnalyticsData], StateType]
|
||||
|
||||
|
||||
def get_addon_entity_description(
|
||||
def get_app_entity_description(
|
||||
name_slug: str,
|
||||
) -> AnalyticsSensorEntityDescription:
|
||||
"""Get addon entity description."""
|
||||
"""Get app entity description."""
|
||||
return AnalyticsSensorEntityDescription(
|
||||
key=f"addon_{name_slug}_active_installations",
|
||||
translation_key="addons",
|
||||
key=f"app_{name_slug}_active_installations",
|
||||
translation_key="apps",
|
||||
name=name_slug,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement="active installations",
|
||||
value_fn=lambda data: data.addons.get(name_slug),
|
||||
value_fn=lambda data: data.apps.get(name_slug),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,9 +106,9 @@ async def async_setup_entry(
|
||||
entities.extend(
|
||||
HomeassistantAnalyticsSensor(
|
||||
coordinator,
|
||||
get_addon_entity_description(addon_name_slug),
|
||||
get_app_entity_description(app_name_slug),
|
||||
)
|
||||
for addon_name_slug in coordinator.data.addons
|
||||
for app_name_slug in coordinator.data.apps
|
||||
)
|
||||
entities.extend(
|
||||
HomeassistantAnalyticsSensor(
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"tracked_addons": "Add-ons",
|
||||
"tracked_apps": "Apps",
|
||||
"tracked_custom_integrations": "Custom integrations",
|
||||
"tracked_integrations": "Integrations"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_addons": "Select the add-ons you want to track",
|
||||
"tracked_apps": "Select the apps you want to track",
|
||||
"tracked_custom_integrations": "Select the custom integrations you want to track",
|
||||
"tracked_integrations": "Select the integrations you want to track"
|
||||
}
|
||||
@@ -45,12 +45,12 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data::tracked_addons%]",
|
||||
"tracked_apps": "[%key:component::analytics_insights::config::step::user::data::tracked_apps%]",
|
||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]",
|
||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data_description::tracked_addons%]",
|
||||
"tracked_apps": "[%key:component::analytics_insights::config::step::user::data_description::tracked_apps%]",
|
||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]",
|
||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -25,18 +25,11 @@ from homeassistant.const import (
|
||||
CONF_ACTIONS,
|
||||
CONF_ALIAS,
|
||||
CONF_CONDITIONS,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT_DATA,
|
||||
CONF_ID,
|
||||
CONF_MODE,
|
||||
CONF_OPTIONS,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
CONF_TRIGGERS,
|
||||
CONF_VARIABLES,
|
||||
CONF_ZONE,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
SERVICE_RELOAD,
|
||||
SERVICE_TOGGLE,
|
||||
@@ -53,10 +46,13 @@ from homeassistant.core import (
|
||||
ServiceCall,
|
||||
callback,
|
||||
split_entity_id,
|
||||
valid_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
|
||||
from homeassistant.helpers import condition as condition_helper, config_validation as cv
|
||||
from homeassistant.helpers import (
|
||||
condition as condition_helper,
|
||||
config_validation as cv,
|
||||
trigger as trigger_helper,
|
||||
)
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -86,7 +82,6 @@ from homeassistant.helpers.trace import (
|
||||
trace_get,
|
||||
trace_path,
|
||||
)
|
||||
from homeassistant.helpers.trigger import async_initialize_triggers
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.dt import parse_datetime
|
||||
@@ -618,7 +613,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
|
||||
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_LABEL_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
@@ -633,7 +628,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
|
||||
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_FLOOR_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
@@ -646,7 +641,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
referenced |= condition_helper.async_extract_targets(conf, ATTR_AREA_ID)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
|
||||
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_AREA_ID))
|
||||
return referenced
|
||||
|
||||
@property
|
||||
@@ -666,7 +661,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
referenced |= condition_helper.async_extract_devices(conf)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_trigger_extract_devices(conf))
|
||||
referenced |= set(trigger_helper.async_extract_devices(conf))
|
||||
|
||||
return referenced
|
||||
|
||||
@@ -680,7 +675,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
referenced |= condition_helper.async_extract_entities(conf)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
for entity_id in _trigger_extract_entities(conf):
|
||||
for entity_id in trigger_helper.async_extract_entities(conf):
|
||||
referenced.add(entity_id)
|
||||
|
||||
return referenced
|
||||
@@ -954,7 +949,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
self._logger.error("Error rendering trigger variables: %s", err)
|
||||
return None
|
||||
|
||||
return await async_initialize_triggers(
|
||||
return await trigger_helper.async_initialize_triggers(
|
||||
self.hass,
|
||||
self._trigger_config,
|
||||
self._async_trigger_if_enabled,
|
||||
@@ -1238,78 +1233,6 @@ async def _async_process_if(
|
||||
return result
|
||||
|
||||
|
||||
@callback
|
||||
def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
|
||||
"""Extract devices from a trigger config."""
|
||||
if trigger_conf[CONF_PLATFORM] == "device":
|
||||
return [trigger_conf[CONF_DEVICE_ID]]
|
||||
|
||||
if (
|
||||
trigger_conf[CONF_PLATFORM] == "event"
|
||||
and CONF_EVENT_DATA in trigger_conf
|
||||
and CONF_DEVICE_ID in trigger_conf[CONF_EVENT_DATA]
|
||||
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID], str)
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
|
||||
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
|
||||
|
||||
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
|
||||
return target_devices
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
"""Extract entities from a trigger config."""
|
||||
if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"):
|
||||
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "calendar":
|
||||
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "zone":
|
||||
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "geo_location":
|
||||
return [trigger_conf[CONF_ZONE]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "sun":
|
||||
return ["sun.sun"]
|
||||
|
||||
if (
|
||||
trigger_conf[CONF_PLATFORM] == "event"
|
||||
and CONF_EVENT_DATA in trigger_conf
|
||||
and CONF_ENTITY_ID in trigger_conf[CONF_EVENT_DATA]
|
||||
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID], str)
|
||||
and valid_entity_id(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID])
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
|
||||
|
||||
if target_entities := _get_targets_from_trigger_config(
|
||||
trigger_conf, CONF_ENTITY_ID
|
||||
):
|
||||
return target_entities
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _get_targets_from_trigger_config(
|
||||
config: dict,
|
||||
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
|
||||
) -> list[str]:
|
||||
"""Extract targets from a target config."""
|
||||
if not (target_conf := config.get(CONF_TARGET)):
|
||||
return []
|
||||
if not (targets := target_conf.get(target)):
|
||||
return []
|
||||
|
||||
return [targets] if isinstance(targets, str) else targets
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
|
||||
def websocket_config(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -13,14 +13,7 @@ from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .const import ATTR_MASTER, DOMAIN, SERVICE_JOIN, SERVICE_UNJOIN
|
||||
from .coordinator import (
|
||||
BluesoundConfigEntry,
|
||||
BluesoundCoordinator,
|
||||
@@ -37,22 +30,6 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Bluesound."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_increase_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_clear_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -5,7 +5,5 @@ INTEGRATION_TITLE = "Bluesound"
|
||||
ATTR_BLUESOUND_GROUP = "bluesound_group"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"clear_sleep_timer": {
|
||||
"service": "mdi:sleep-off"
|
||||
},
|
||||
"join": {
|
||||
"service": "mdi:link-variant"
|
||||
},
|
||||
"set_sleep_timer": {
|
||||
"service": "mdi:sleep"
|
||||
},
|
||||
"unjoin": {
|
||||
"service": "mdi:link-variant-off"
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ from .const import (
|
||||
ATTR_BLUESOUND_GROUP,
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .coordinator import BluesoundCoordinator
|
||||
@@ -603,42 +601,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
"""Remove follower to leader."""
|
||||
await self._player.remove_follower(host, port)
|
||||
|
||||
async def async_increase_timer(self) -> int:
|
||||
"""Increase sleep time on player."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_SET_TIMER}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_set_sleep_timer",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
return await self._player.sleep_timer()
|
||||
|
||||
async def async_clear_timer(self) -> None:
|
||||
"""Clear sleep timer on player."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_CLEAR_TIMER}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_clear_sleep_timer",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
sleep = 1
|
||||
while sleep > 0:
|
||||
sleep = await self._player.sleep_timer()
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable or disable shuffle mode."""
|
||||
await self._player.shuffle(shuffle)
|
||||
|
||||
@@ -19,19 +19,3 @@ unjoin:
|
||||
entity:
|
||||
integration: bluesound
|
||||
domain: media_player
|
||||
|
||||
set_sleep_timer:
|
||||
fields:
|
||||
entity_id:
|
||||
selector:
|
||||
entity:
|
||||
integration: bluesound
|
||||
domain: media_player
|
||||
|
||||
clear_sleep_timer:
|
||||
fields:
|
||||
entity_id:
|
||||
selector:
|
||||
entity:
|
||||
integration: bluesound
|
||||
domain: media_player
|
||||
|
||||
@@ -37,34 +37,16 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_service_clear_sleep_timer": {
|
||||
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.clear_sleep_timer"
|
||||
},
|
||||
"deprecated_service_join": {
|
||||
"description": "Use the `media_player.join` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.join"
|
||||
},
|
||||
"deprecated_service_set_sleep_timer": {
|
||||
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.set_sleep_timer"
|
||||
},
|
||||
"deprecated_service_unjoin": {
|
||||
"description": "Use the `media_player.unjoin` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.unjoin"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clear_sleep_timer": {
|
||||
"description": "Clears a Bluesound timer.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Name(s) of entities that will have the timer cleared.",
|
||||
"name": "Entity"
|
||||
}
|
||||
},
|
||||
"name": "Clear sleep timer"
|
||||
},
|
||||
"join": {
|
||||
"description": "Groups players together under a single master speaker.",
|
||||
"fields": {
|
||||
@@ -79,16 +61,6 @@
|
||||
},
|
||||
"name": "Join"
|
||||
},
|
||||
"set_sleep_timer": {
|
||||
"description": "Sets a Bluesound timer that will turn off the speaker. It will increase in steps: 15, 30, 45, 60, 90, 0.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Name(s) of entities that will have a timer set.",
|
||||
"name": "Entity"
|
||||
}
|
||||
},
|
||||
"name": "Set sleep timer"
|
||||
},
|
||||
"unjoin": {
|
||||
"description": "Separates a player from a group.",
|
||||
"fields": {
|
||||
|
||||
87
homeassistant/components/cloudflare_r2/__init__.py
Normal file
87
homeassistant/components/cloudflare_r2/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""The Cloudflare R2 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiobotocore.client import AioBaseClient as S3Client
|
||||
from aiobotocore.session import AioSession
|
||||
from botocore.exceptions import (
|
||||
ClientError,
|
||||
ConnectionError,
|
||||
EndpointConnectionError,
|
||||
ParamValidationError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type R2ConfigEntry = ConfigEntry[S3Client]
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: R2ConfigEntry) -> bool:
|
||||
"""Set up Cloudflare R2 from a config entry."""
|
||||
|
||||
data = cast(dict, entry.data)
|
||||
try:
|
||||
session = AioSession()
|
||||
# pylint: disable-next=unnecessary-dunder-call
|
||||
client = await session.create_client(
|
||||
"s3",
|
||||
endpoint_url=data.get(CONF_ENDPOINT_URL),
|
||||
aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY],
|
||||
aws_access_key_id=data[CONF_ACCESS_KEY_ID],
|
||||
).__aenter__()
|
||||
await client.head_bucket(Bucket=data[CONF_BUCKET])
|
||||
except ClientError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
) from err
|
||||
except ParamValidationError as err:
|
||||
if "Invalid bucket name" in str(err):
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
) from err
|
||||
except (ConnectionError, EndpointConnectionError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
def notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: R2ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
client = entry.runtime_data
|
||||
await client.__aexit__(None, None, None)
|
||||
return True
|
||||
346
homeassistant/components/cloudflare_r2/backup.py
Normal file
346
homeassistant/components/cloudflare_r2/backup.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Backup platform for the Cloudflare R2 integration."""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import R2ConfigEntry
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CACHE_TTL = 300
|
||||
|
||||
# S3 part size requirements: 5 MiB to 5 GiB per part
|
||||
# We set the threshold to 20 MiB to avoid too many parts.
|
||||
# Note that each part is allocated in the memory.
|
||||
MULTIPART_MIN_PART_SIZE_BYTES = 20 * 2**20
|
||||
|
||||
|
||||
def handle_boto_errors[T](
|
||||
func: Callable[..., Coroutine[Any, Any, T]],
|
||||
) -> Callable[..., Coroutine[Any, Any, T]]:
|
||||
"""Handle BotoCoreError exceptions by converting them to BackupAgentError."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||
"""Catch BotoCoreError and raise BackupAgentError."""
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except BotoCoreError as err:
|
||||
error_msg = f"Failed during {func.__name__}"
|
||||
raise BackupAgentError(error_msg) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries: list[R2ConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return [R2BackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed.
|
||||
|
||||
:return: A function to unregister the listener.
|
||||
"""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata files."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
class R2BackupAgent(BackupAgent):
|
||||
"""Backup agent for the Cloudflare R2 integration."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: R2ConfigEntry) -> None:
|
||||
"""Initialize the R2 agent."""
|
||||
super().__init__()
|
||||
self._client = entry.runtime_data
|
||||
self._bucket: str = entry.data[CONF_BUCKET]
|
||||
self.name = entry.title
|
||||
self.unique_id = entry.entry_id
|
||||
self._backup_cache: dict[str, AgentBackup] = {}
|
||||
self._cache_expiration = time()
|
||||
self._prefix: str = entry.data.get(CONF_PREFIX, "").strip("/")
|
||||
|
||||
def _with_prefix(self, key: str) -> str:
|
||||
if not self._prefix:
|
||||
return key
|
||||
return f"{self._prefix}/{key}"
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file.
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
:return: An async iterator that yields bytes.
|
||||
"""
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
tar_filename, _ = suggested_filenames(backup)
|
||||
|
||||
response = await self._client.get_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
|
||||
)
|
||||
return response["Body"].iter_chunks()
|
||||
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
:param backup: Metadata about the backup that should be uploaded.
|
||||
"""
|
||||
tar_filename, metadata_filename = suggested_filenames(backup)
|
||||
|
||||
try:
|
||||
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
await self._upload_simple(tar_filename, open_stream)
|
||||
else:
|
||||
await self._upload_multipart(tar_filename, open_stream)
|
||||
|
||||
# Upload the metadata file
|
||||
metadata_content = json.dumps(backup.as_dict())
|
||||
await self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(metadata_filename),
|
||||
Body=metadata_content,
|
||||
)
|
||||
except BotoCoreError as err:
|
||||
raise BackupAgentError("Failed to upload backup") from err
|
||||
else:
|
||||
# Reset cache after successful upload
|
||||
self._cache_expiration = time()
|
||||
|
||||
async def _upload_simple(
|
||||
self,
|
||||
tar_filename: str,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
) -> None:
|
||||
"""Upload a small file using simple upload.
|
||||
|
||||
:param tar_filename: The target filename for the backup.
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
"""
|
||||
_LOGGER.debug("Starting simple upload for %s", tar_filename)
|
||||
stream = await open_stream()
|
||||
file_data = bytearray()
|
||||
async for chunk in stream:
|
||||
file_data.extend(chunk)
|
||||
|
||||
await self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
Body=bytes(file_data),
|
||||
)
|
||||
|
||||
async def _upload_multipart(
|
||||
self,
|
||||
tar_filename: str,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
):
|
||||
"""Upload a large file using multipart upload.
|
||||
|
||||
:param tar_filename: The target filename for the backup.
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
"""
|
||||
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
|
||||
multipart_upload = await self._client.create_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
)
|
||||
upload_id = multipart_upload["UploadId"]
|
||||
try:
|
||||
parts = []
|
||||
part_number = 1
|
||||
buffer_size = 0 # bytes
|
||||
buffer: list[bytes] = []
|
||||
|
||||
stream = await open_stream()
|
||||
async for chunk in stream:
|
||||
buffer_size += len(chunk)
|
||||
buffer.append(chunk)
|
||||
|
||||
# If buffer size meets minimum part size, upload it as a part
|
||||
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
_LOGGER.debug(
|
||||
"Uploading part number %d, size %d", part_number, buffer_size
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
part_number += 1
|
||||
buffer_size = 0
|
||||
buffer = []
|
||||
|
||||
# Upload the final buffer as the last part (no minimum size requirement)
|
||||
if buffer:
|
||||
_LOGGER.debug(
|
||||
"Uploading final part number %d, size %d", part_number, buffer_size
|
||||
)
|
||||
part = await self._client.upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=b"".join(buffer),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
|
||||
await self._client.complete_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
UploadId=upload_id,
|
||||
MultipartUpload={"Parts": parts},
|
||||
)
|
||||
|
||||
except BotoCoreError:
|
||||
try:
|
||||
await self._client.abort_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
UploadId=upload_id,
|
||||
)
|
||||
except BotoCoreError:
|
||||
_LOGGER.exception("Failed to abort multipart upload")
|
||||
raise
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file.
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
"""
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
tar_filename, metadata_filename = suggested_filenames(backup)
|
||||
|
||||
# Delete both the backup file and its metadata file
|
||||
await self._client.delete_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
|
||||
)
|
||||
await self._client.delete_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(metadata_filename)
|
||||
)
|
||||
|
||||
# Reset cache after successful deletion
|
||||
self._cache_expiration = time()
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups = await self._list_backups()
|
||||
return list(backups.values())
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup:
|
||||
"""Return a backup."""
|
||||
return await self._find_backup_by_id(backup_id)
|
||||
|
||||
async def _find_backup_by_id(self, backup_id: str) -> AgentBackup:
|
||||
"""Find a backup by its backup ID."""
|
||||
backups = await self._list_backups()
|
||||
if backup := backups.get(backup_id):
|
||||
return backup
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
async def _list_backups(self) -> dict[str, AgentBackup]:
|
||||
"""List backups, using a cache if possible."""
|
||||
if time() <= self._cache_expiration:
|
||||
return self._backup_cache
|
||||
|
||||
backups = {}
|
||||
# Only pass Prefix if a prefix is configured; some S3-compatible APIs
|
||||
# (and type checkers) do not like Prefix=None.
|
||||
list_kwargs = {"Bucket": self._bucket}
|
||||
if self._prefix:
|
||||
list_kwargs["Prefix"] = self._prefix + "/"
|
||||
response = await self._client.list_objects_v2(**list_kwargs)
|
||||
|
||||
# Filter for metadata files only
|
||||
metadata_files = [
|
||||
obj
|
||||
for obj in response.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
]
|
||||
|
||||
for metadata_file in metadata_files:
|
||||
try:
|
||||
# Download and parse metadata file
|
||||
metadata_response = await self._client.get_object(
|
||||
Bucket=self._bucket, Key=metadata_file["Key"]
|
||||
)
|
||||
metadata_content = await metadata_response["Body"].read()
|
||||
metadata_json = json.loads(metadata_content)
|
||||
except (BotoCoreError, json.JSONDecodeError) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to process metadata file %s: %s",
|
||||
metadata_file["Key"],
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backup = AgentBackup.from_dict(metadata_json)
|
||||
backups[backup.backup_id] = backup
|
||||
|
||||
self._backup_cache = backups
|
||||
self._cache_expiration = time() + CACHE_TTL
|
||||
|
||||
return self._backup_cache
|
||||
113
homeassistant/components/cloudflare_r2/config_flow.py
Normal file
113
homeassistant/components/cloudflare_r2/config_flow.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Config flow for the Cloudflare R2 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiobotocore.session import AioSession
|
||||
from botocore.exceptions import (
|
||||
ClientError,
|
||||
ConnectionError,
|
||||
EndpointConnectionError,
|
||||
ParamValidationError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CLOUDFLARE_R2_DOMAIN,
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCESS_KEY_ID): cv.string,
|
||||
vol.Required(CONF_SECRET_ACCESS_KEY): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_BUCKET): cv.string,
|
||||
vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
vol.Optional(CONF_PREFIX, default=""): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class R2ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cloudflare R2."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_BUCKET: user_input[CONF_BUCKET],
|
||||
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
|
||||
}
|
||||
)
|
||||
|
||||
parsed = urlparse(user_input[CONF_ENDPOINT_URL])
|
||||
if not parsed.hostname or not parsed.hostname.endswith(
|
||||
CLOUDFLARE_R2_DOMAIN
|
||||
):
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
else:
|
||||
try:
|
||||
session = AioSession()
|
||||
async with session.create_client(
|
||||
"s3",
|
||||
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
|
||||
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
|
||||
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
|
||||
) as client:
|
||||
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
|
||||
except ClientError:
|
||||
errors["base"] = "invalid_credentials"
|
||||
except ParamValidationError as err:
|
||||
if "Invalid bucket name" in str(err):
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except ValueError:
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
except EndpointConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
except ConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
else:
|
||||
# Do not persist empty optional values
|
||||
data = dict(user_input)
|
||||
if not data.get(CONF_PREFIX):
|
||||
data.pop(CONF_PREFIX, None)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_BUCKET], data=data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"auth_docs_url": DESCRIPTION_R2_AUTH_DOCS_URL,
|
||||
},
|
||||
)
|
||||
26
homeassistant/components/cloudflare_r2/const.py
Normal file
26
homeassistant/components/cloudflare_r2/const.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Constants for the Cloudflare R2 integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "cloudflare_r2"
|
||||
|
||||
CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
# R2 is S3-compatible. Endpoint should be like:
|
||||
# https://<accountid>.r2.cloudflarestorage.com
|
||||
CLOUDFLARE_R2_DOMAIN: Final = "r2.cloudflarestorage.com"
|
||||
DEFAULT_ENDPOINT_URL: Final = "https://ACCOUNT_ID." + CLOUDFLARE_R2_DOMAIN + "/"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
|
||||
|
||||
DESCRIPTION_R2_AUTH_DOCS_URL: Final = "https://developers.cloudflare.com/r2/api/tokens/"
|
||||
12
homeassistant/components/cloudflare_r2/manifest.json
Normal file
12
homeassistant/components/cloudflare_r2/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "cloudflare_r2",
|
||||
"name": "Cloudflare R2",
|
||||
"codeowners": ["@corrreia"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloudflare_r2",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiobotocore"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiobotocore==2.21.1"]
|
||||
}
|
||||
112
homeassistant/components/cloudflare_r2/quality_scale.yaml
Normal file
112
homeassistant/components/cloudflare_r2/quality_scale.yaml
Normal file
@@ -0,0 +1,112 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not have any custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities of this integration do not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
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 actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Cloudflare R2 is a cloud service that is not discovered on the network.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Cloudflare R2 is a cloud service that is not discovered on the network.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: The integration extends core functionality and does not require examples.
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: No known limitations.
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: This integration does not support physical devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting:
|
||||
status: exempt
|
||||
comment: There are no more detailed troubleshooting instructions available than what is already included in strings.json.
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration does not have devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not use icons.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There are no issues which can be repaired.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This integration does not have devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
46
homeassistant/components/cloudflare_r2/strings.json
Normal file
46
homeassistant/components/cloudflare_r2/strings.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:component::cloudflare_r2::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::cloudflare_r2::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::cloudflare_r2::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "[%key:component::cloudflare_r2::exceptions::invalid_endpoint_url::message%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"access_key_id": "Access key ID",
|
||||
"bucket": "Bucket name",
|
||||
"endpoint_url": "Endpoint URL",
|
||||
"prefix": "Folder prefix (optional)",
|
||||
"secret_access_key": "Secret access key"
|
||||
},
|
||||
"data_description": {
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2 (this is your Account ID)",
|
||||
"bucket": "Bucket must already exist and be writable by the provided credentials.",
|
||||
"endpoint_url": "Cloudflare R2 S3-compatible endpoint.",
|
||||
"prefix": "Optional folder path inside the bucket. Example: backups/homeassistant",
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Docs]({auth_docs_url})"
|
||||
},
|
||||
"title": "Add Cloudflare R2 bucket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
"invalid_bucket_name": {
|
||||
"message": "Invalid bucket name"
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Bucket cannot be accessed using provided access key ID and secret."
|
||||
},
|
||||
"invalid_endpoint_url": {
|
||||
"message": "Invalid endpoint URL. Please enter a valid Cloudflare R2 endpoint URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
|
||||
105
homeassistant/components/compit/icons.json
Normal file
105
homeassistant/components/compit/icons.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"aero_by_pass": {
|
||||
"default": "mdi:valve",
|
||||
"state": {
|
||||
"off": "mdi:valve-closed",
|
||||
"on": "mdi:valve-open"
|
||||
}
|
||||
},
|
||||
"buffer_mode": {
|
||||
"default": "mdi:database",
|
||||
"state": {
|
||||
"disabled": "mdi:water-boiler-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"dhw_circulation": {
|
||||
"default": "mdi:pump",
|
||||
"state": {
|
||||
"disabled": "mdi:pump-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"heating_source_of_correction": {
|
||||
"default": "mdi:tune-variant",
|
||||
"state": {
|
||||
"disabled": "mdi:cancel",
|
||||
"nano_nr_1": "mdi:thermostat-box",
|
||||
"nano_nr_2": "mdi:thermostat-box",
|
||||
"nano_nr_3": "mdi:thermostat-box",
|
||||
"nano_nr_4": "mdi:thermostat-box",
|
||||
"nano_nr_5": "mdi:thermostat-box",
|
||||
"no_corrections": "mdi:cancel",
|
||||
"schedule": "mdi:calendar-clock",
|
||||
"thermostat": "mdi:thermostat"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"default": "mdi:translate"
|
||||
},
|
||||
"mixer_mode": {
|
||||
"default": "mdi:valve",
|
||||
"state": {
|
||||
"disabled": "mdi:cancel",
|
||||
"nano_nr_1": "mdi:thermostat-box",
|
||||
"nano_nr_2": "mdi:thermostat-box",
|
||||
"nano_nr_3": "mdi:thermostat-box",
|
||||
"nano_nr_4": "mdi:thermostat-box",
|
||||
"nano_nr_5": "mdi:thermostat-box",
|
||||
"schedule": "mdi:calendar-clock",
|
||||
"thermostat": "mdi:thermostat"
|
||||
}
|
||||
},
|
||||
"mixer_mode_zone": {
|
||||
"default": "mdi:valve",
|
||||
"state": {
|
||||
"disabled": "mdi:cancel",
|
||||
"nano_nr_1": "mdi:thermostat-box",
|
||||
"nano_nr_2": "mdi:thermostat-box",
|
||||
"nano_nr_3": "mdi:thermostat-box",
|
||||
"nano_nr_4": "mdi:thermostat-box",
|
||||
"nano_nr_5": "mdi:thermostat-box",
|
||||
"schedule": "mdi:calendar-clock",
|
||||
"thermostat": "mdi:thermostat"
|
||||
}
|
||||
},
|
||||
"nano_work_mode": {
|
||||
"default": "mdi:cog-outline",
|
||||
"state": {
|
||||
"christmas": "mdi:pine-tree",
|
||||
"manual_0": "mdi:home-floor-0",
|
||||
"manual_1": "mdi:home-floor-1",
|
||||
"manual_2": "mdi:home-floor-2",
|
||||
"manual_3": "mdi:home-floor-3",
|
||||
"out_of_home": "mdi:home-export-outline",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"operating_mode": {
|
||||
"default": "mdi:cog",
|
||||
"state": {
|
||||
"disabled": "mdi:cog-off",
|
||||
"eco": "mdi:leaf"
|
||||
}
|
||||
},
|
||||
"solarcomp_operating_mode": {
|
||||
"default": "mdi:heating-coil",
|
||||
"state": {
|
||||
"de_icing": "mdi:snowflake-melt",
|
||||
"disabled": "mdi:cancel",
|
||||
"holiday": "mdi:beach"
|
||||
}
|
||||
},
|
||||
"work_mode": {
|
||||
"default": "mdi:cog-outline",
|
||||
"state": {
|
||||
"cooling": "mdi:snowflake-thermometer",
|
||||
"summer": "mdi:weather-sunny",
|
||||
"winter": "mdi:snowflake"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,7 @@ rules:
|
||||
This integration does not have any entities that should disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no need for icon translations.
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
432
homeassistant/components/compit/select.py
Normal file
432
homeassistant/components/compit/select.py
Normal file
@@ -0,0 +1,432 @@
|
||||
"""Select platform for Compit integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from compit_inext_api.consts import CompitParameter
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CompitDeviceDescription:
|
||||
"""Class to describe a Compit device."""
|
||||
|
||||
name: str
|
||||
"""Name of the device."""
|
||||
|
||||
parameters: dict[CompitParameter, SelectEntityDescription]
|
||||
"""Parameters of the device."""
|
||||
|
||||
|
||||
DESCRIPTIONS: dict[CompitParameter, SelectEntityDescription] = {
|
||||
CompitParameter.LANGUAGE: SelectEntityDescription(
|
||||
key=CompitParameter.LANGUAGE.value,
|
||||
translation_key="language",
|
||||
options=[
|
||||
"polish",
|
||||
"english",
|
||||
],
|
||||
),
|
||||
CompitParameter.AEROKONFBYPASS: SelectEntityDescription(
|
||||
key=CompitParameter.AEROKONFBYPASS.value,
|
||||
translation_key="aero_by_pass",
|
||||
options=[
|
||||
"off",
|
||||
"auto",
|
||||
"on",
|
||||
],
|
||||
),
|
||||
CompitParameter.NANO_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.NANO_MODE.value,
|
||||
translation_key="nano_work_mode",
|
||||
options=[
|
||||
"manual_3",
|
||||
"manual_2",
|
||||
"manual_1",
|
||||
"manual_0",
|
||||
"schedule",
|
||||
"christmas",
|
||||
"out_of_home",
|
||||
],
|
||||
),
|
||||
CompitParameter.R900_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R900_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"eco",
|
||||
"hybrid",
|
||||
],
|
||||
),
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.SOLAR_COMP_OPERATING_MODE.value,
|
||||
translation_key="solarcomp_operating_mode",
|
||||
options=[
|
||||
"auto",
|
||||
"de_icing",
|
||||
"holiday",
|
||||
"disabled",
|
||||
],
|
||||
),
|
||||
CompitParameter.R490_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R490_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"eco",
|
||||
"hybrid",
|
||||
],
|
||||
),
|
||||
CompitParameter.WORK_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.WORK_MODE.value,
|
||||
translation_key="work_mode",
|
||||
options=[
|
||||
"winter",
|
||||
"summer",
|
||||
"cooling",
|
||||
],
|
||||
),
|
||||
CompitParameter.R470_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R470_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"auto",
|
||||
"eco",
|
||||
],
|
||||
),
|
||||
CompitParameter.HEATING_SOURCE_OF_CORRECTION: SelectEntityDescription(
|
||||
key=CompitParameter.HEATING_SOURCE_OF_CORRECTION.value,
|
||||
translation_key="heating_source_of_correction",
|
||||
options=[
|
||||
"no_corrections",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
),
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: SelectEntityDescription(
|
||||
key=CompitParameter.BIOMAX_MIXER_MODE_ZONE_1.value,
|
||||
translation_key="mixer_mode_zone",
|
||||
options=[
|
||||
"disabled",
|
||||
"without_thermostat",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
translation_placeholders={"zone": "1"},
|
||||
),
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2: SelectEntityDescription(
|
||||
key=CompitParameter.BIOMAX_MIXER_MODE_ZONE_2.value,
|
||||
translation_key="mixer_mode_zone",
|
||||
options=[
|
||||
"disabled",
|
||||
"without_thermostat",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
translation_placeholders={"zone": "2"},
|
||||
),
|
||||
CompitParameter.DHW_CIRCULATION_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.DHW_CIRCULATION_MODE.value,
|
||||
translation_key="dhw_circulation",
|
||||
options=[
|
||||
"disabled",
|
||||
"constant",
|
||||
"schedule",
|
||||
],
|
||||
),
|
||||
CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION: SelectEntityDescription(
|
||||
key=CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION.value,
|
||||
translation_key="heating_source_of_correction",
|
||||
options=[
|
||||
"disabled",
|
||||
"no_corrections",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
),
|
||||
CompitParameter.MIXER_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.MIXER_MODE.value,
|
||||
translation_key="mixer_mode",
|
||||
options=[
|
||||
"no_corrections",
|
||||
"schedule",
|
||||
"thermostat",
|
||||
"nano_nr_1",
|
||||
"nano_nr_2",
|
||||
"nano_nr_3",
|
||||
"nano_nr_4",
|
||||
"nano_nr_5",
|
||||
],
|
||||
),
|
||||
CompitParameter.R480_OPERATING_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.R480_OPERATING_MODE.value,
|
||||
translation_key="operating_mode",
|
||||
options=[
|
||||
"disabled",
|
||||
"eco",
|
||||
"hybrid",
|
||||
],
|
||||
),
|
||||
CompitParameter.BUFFER_MODE: SelectEntityDescription(
|
||||
key=CompitParameter.BUFFER_MODE.value,
|
||||
translation_key="buffer_mode",
|
||||
options=[
|
||||
"schedule",
|
||||
"manual",
|
||||
"disabled",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
|
||||
223: CompitDeviceDescription(
|
||||
name="Nano Color 2",
|
||||
parameters={
|
||||
CompitParameter.LANGUAGE: DESCRIPTIONS[CompitParameter.LANGUAGE],
|
||||
CompitParameter.AEROKONFBYPASS: DESCRIPTIONS[
|
||||
CompitParameter.AEROKONFBYPASS
|
||||
],
|
||||
},
|
||||
),
|
||||
12: CompitDeviceDescription(
|
||||
name="Nano Color",
|
||||
parameters={
|
||||
CompitParameter.LANGUAGE: DESCRIPTIONS[CompitParameter.LANGUAGE],
|
||||
CompitParameter.AEROKONFBYPASS: DESCRIPTIONS[
|
||||
CompitParameter.AEROKONFBYPASS
|
||||
],
|
||||
},
|
||||
),
|
||||
7: CompitDeviceDescription(
|
||||
name="Nano One",
|
||||
parameters={
|
||||
CompitParameter.LANGUAGE: DESCRIPTIONS[CompitParameter.LANGUAGE],
|
||||
CompitParameter.NANO_MODE: DESCRIPTIONS[CompitParameter.NANO_MODE],
|
||||
},
|
||||
),
|
||||
224: CompitDeviceDescription(
|
||||
name="R 900",
|
||||
parameters={
|
||||
CompitParameter.R900_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R900_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
45: CompitDeviceDescription(
|
||||
name="SolarComp971",
|
||||
parameters={
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
99: CompitDeviceDescription(
|
||||
name="SolarComp971C",
|
||||
parameters={
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
44: CompitDeviceDescription(
|
||||
name="SolarComp 951",
|
||||
parameters={
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.SOLAR_COMP_OPERATING_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
92: CompitDeviceDescription(
|
||||
name="r490",
|
||||
parameters={
|
||||
CompitParameter.R490_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R490_OPERATING_MODE
|
||||
],
|
||||
CompitParameter.WORK_MODE: DESCRIPTIONS[CompitParameter.WORK_MODE],
|
||||
},
|
||||
),
|
||||
34: CompitDeviceDescription(
|
||||
name="r470",
|
||||
parameters={
|
||||
CompitParameter.R470_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R470_OPERATING_MODE
|
||||
],
|
||||
CompitParameter.HEATING_SOURCE_OF_CORRECTION: DESCRIPTIONS[
|
||||
CompitParameter.HEATING_SOURCE_OF_CORRECTION
|
||||
],
|
||||
},
|
||||
),
|
||||
201: CompitDeviceDescription(
|
||||
name="BioMax775",
|
||||
parameters={
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1
|
||||
],
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2
|
||||
],
|
||||
CompitParameter.DHW_CIRCULATION_MODE: DESCRIPTIONS[
|
||||
CompitParameter.DHW_CIRCULATION_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
36: CompitDeviceDescription(
|
||||
name="BioMax742",
|
||||
parameters={
|
||||
CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_HEATING_SOURCE_OF_CORRECTION
|
||||
],
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1
|
||||
],
|
||||
CompitParameter.DHW_CIRCULATION_MODE: DESCRIPTIONS[
|
||||
CompitParameter.DHW_CIRCULATION_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
75: CompitDeviceDescription(
|
||||
name="BioMax772",
|
||||
parameters={
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_1
|
||||
],
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2: DESCRIPTIONS[
|
||||
CompitParameter.BIOMAX_MIXER_MODE_ZONE_2
|
||||
],
|
||||
CompitParameter.DHW_CIRCULATION_MODE: DESCRIPTIONS[
|
||||
CompitParameter.DHW_CIRCULATION_MODE
|
||||
],
|
||||
},
|
||||
),
|
||||
5: CompitDeviceDescription(
|
||||
name="R350 T3",
|
||||
parameters={
|
||||
CompitParameter.MIXER_MODE: DESCRIPTIONS[CompitParameter.MIXER_MODE],
|
||||
},
|
||||
),
|
||||
215: CompitDeviceDescription(
|
||||
name="R480",
|
||||
parameters={
|
||||
CompitParameter.R480_OPERATING_MODE: DESCRIPTIONS[
|
||||
CompitParameter.R480_OPERATING_MODE
|
||||
],
|
||||
CompitParameter.BUFFER_MODE: DESCRIPTIONS[CompitParameter.BUFFER_MODE],
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Compit select entities from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
select_entities = []
|
||||
for device_id, device in coordinator.connector.all_devices.items():
|
||||
device_definition = DEVICE_DEFINITIONS.get(device.definition.code)
|
||||
|
||||
if not device_definition:
|
||||
continue
|
||||
|
||||
for code, entity_description in device_definition.parameters.items():
|
||||
param = next(
|
||||
(p for p in device.state.params if p.code == entity_description.key),
|
||||
None,
|
||||
)
|
||||
|
||||
if param is None:
|
||||
continue
|
||||
|
||||
select_entities.append(
|
||||
CompitSelect(
|
||||
coordinator,
|
||||
device_id,
|
||||
device_definition.name,
|
||||
code,
|
||||
entity_description,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_devices(select_entities)
|
||||
|
||||
|
||||
class CompitSelect(CoordinatorEntity[CompitDataUpdateCoordinator], SelectEntity):
|
||||
"""Representation of a Compit select entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
device_name: str,
|
||||
parameter_code: CompitParameter,
|
||||
entity_description: SelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self.device_id = device_id
|
||||
self.entity_description = entity_description
|
||||
self._attr_has_entity_name = True
|
||||
self._attr_unique_id = f"{device_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=device_name,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=device_name,
|
||||
)
|
||||
self.parameter_code = parameter_code
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.connector.get_device(self.device_id) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current option."""
|
||||
return self.coordinator.connector.get_current_option(
|
||||
self.device_id, self.parameter_code
|
||||
)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, self.parameter_code, option
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -31,5 +31,120 @@
|
||||
"title": "Connect to Compit iNext"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"aero_by_pass": {
|
||||
"name": "Bypass",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"buffer_mode": {
|
||||
"name": "Buffer mode",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"dhw_circulation": {
|
||||
"name": "Domestic hot water circulation",
|
||||
"state": {
|
||||
"constant": "Constant",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"heating_source_of_correction": {
|
||||
"name": "Heating source of correction",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"no_corrections": "No corrections",
|
||||
"schedule": "Schedule",
|
||||
"thermostat": "Thermostat"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"name": "Language",
|
||||
"state": {
|
||||
"english": "English",
|
||||
"polish": "Polish"
|
||||
}
|
||||
},
|
||||
"mixer_mode": {
|
||||
"name": "Mixer mode",
|
||||
"state": {
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"no_corrections": "No corrections",
|
||||
"schedule": "Schedule",
|
||||
"thermostat": "Thermostat"
|
||||
}
|
||||
},
|
||||
"mixer_mode_zone": {
|
||||
"name": "Zone {zone} mixer mode",
|
||||
"state": {
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"no_corrections": "No corrections",
|
||||
"schedule": "Schedule",
|
||||
"thermostat": "Thermostat",
|
||||
"without_thermostat": "Without thermostat"
|
||||
}
|
||||
},
|
||||
"nano_work_mode": {
|
||||
"name": "Nano work mode",
|
||||
"state": {
|
||||
"christmas": "Christmas",
|
||||
"manual_0": "Manual 0",
|
||||
"manual_1": "Manual 1",
|
||||
"manual_2": "Manual 2",
|
||||
"manual_3": "Manual 3",
|
||||
"out_of_home": "Out of home",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"operating_mode": {
|
||||
"name": "Operating mode",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"eco": "Eco",
|
||||
"hybrid": "Hybrid"
|
||||
}
|
||||
},
|
||||
"solarcomp_operating_mode": {
|
||||
"name": "Operating mode",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"de_icing": "De-icing",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"holiday": "Holiday"
|
||||
}
|
||||
},
|
||||
"work_mode": {
|
||||
"name": "Current season",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"summer": "Summer",
|
||||
"winter": "Winter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +90,14 @@ class CrownstoneLightEntity(CrownstoneEntity, LightEntity):
|
||||
return crownstone_state_to_hass(self.device.state) > 0
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self.device.abilities.get(DIMMING_ABILITY).is_enabled:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Flag supported color modes."""
|
||||
return {self.color_mode}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class CyncLightEntity(CyncBaseEntity, LightEntity):
|
||||
return self._device.rgb
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if (
|
||||
|
||||
@@ -244,7 +244,7 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
|
||||
self._attr_effect_list = XMAS_LIGHT_EFFECTS
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device.color_mode in DECONZ_TO_COLOR_MODE:
|
||||
color_mode = DECONZ_TO_COLOR_MODE[self._device.color_mode]
|
||||
|
||||
@@ -103,14 +103,14 @@ class DecoraWifiLight(LightEntity):
|
||||
self._attr_unique_id = switch.serial
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._switch.canSetLevel:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Flag supported color modes."""
|
||||
return {self.color_mode}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ class DemoLight(LightEntity):
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
"""Return the color mode of the light."""
|
||||
return self._color_mode
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from fnmatch import translate
|
||||
from functools import lru_cache, partial
|
||||
from functools import lru_cache
|
||||
from ipaddress import IPv4Address
|
||||
import itertools
|
||||
import logging
|
||||
@@ -50,12 +50,6 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
)
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstant,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.discovery_flow import DiscoveryKey
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -80,13 +74,6 @@ SCAN_INTERVAL = timedelta(minutes=60)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEPRECATED_DhcpServiceInfo = DeprecatedConstant(
|
||||
_DhcpServiceInfo,
|
||||
"homeassistant.helpers.service_info.dhcp.DhcpServiceInfo",
|
||||
"2026.2",
|
||||
)
|
||||
|
||||
|
||||
def async_index_integration_matchers(
|
||||
integration_matchers: list[DHCPMatcher],
|
||||
) -> DhcpMatchers:
|
||||
@@ -503,11 +490,3 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool:
|
||||
since the devices will not change frequently
|
||||
"""
|
||||
return bool(_compile_fnmatch(pattern).match(name))
|
||||
|
||||
|
||||
# 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())
|
||||
|
||||
@@ -12,6 +12,7 @@ from doorbirdpy import DoorBird
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
@@ -218,6 +219,9 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
if existing_entry.source == SOURCE_IGNORE:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Check if the host is actually changing
|
||||
if existing_entry.data.get(CONF_HOST) != host:
|
||||
await self._async_verify_existing_device_for_discovery(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.1.0"]
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class ElgatoLight(ElgatoEntity, LightEntity):
|
||||
return color_util.color_temperature_mired_to_kelvin(mired_temperature)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self.coordinator.data.state.hue is not None:
|
||||
return ColorMode.HS
|
||||
|
||||
@@ -59,13 +59,38 @@ class FlowToGridSourceType(TypedDict):
|
||||
number_energy_price: float | None # Price for energy ($/kWh)
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict):
|
||||
class PowerConfig(TypedDict, total=False):
|
||||
"""Dictionary holding power sensor configuration options.
|
||||
|
||||
Users can configure power sensors in three ways:
|
||||
1. Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
|
||||
2. Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
|
||||
3. Two sensors: separate positive sensors for each direction
|
||||
"""
|
||||
|
||||
# Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
|
||||
stat_rate: str
|
||||
|
||||
# Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
|
||||
stat_rate_inverted: str
|
||||
|
||||
# Two sensors: separate positive sensors for each direction
|
||||
# Result = stat_rate_from - stat_rate_to (positive when net outflow)
|
||||
stat_rate_from: str # Battery: discharge, Grid: consumption
|
||||
stat_rate_to: str # Battery: charge, Grid: return
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict, total=False):
|
||||
"""Dictionary holding the source of grid power consumption."""
|
||||
|
||||
# statistic_id of a power meter (kW)
|
||||
# negative values indicate grid return
|
||||
# This is either the original sensor or a generated template sensor
|
||||
stat_rate: str
|
||||
|
||||
# User's original power sensor configuration
|
||||
power_config: PowerConfig
|
||||
|
||||
|
||||
class GridSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid energy consumption."""
|
||||
@@ -97,8 +122,12 @@ class BatterySourceType(TypedDict):
|
||||
stat_energy_from: str
|
||||
stat_energy_to: str
|
||||
# positive when discharging, negative when charging
|
||||
# This is either the original sensor or a generated template sensor
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# User's original power sensor configuration
|
||||
power_config: NotRequired[PowerConfig]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
"""Dictionary holding the source of gas consumption."""
|
||||
@@ -211,10 +240,53 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_rate"): str,
|
||||
}
|
||||
|
||||
def _validate_power_config(val: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate power_config has exactly one configuration method."""
|
||||
if not val:
|
||||
raise vol.Invalid("power_config must have at least one option")
|
||||
|
||||
# Ensure only one configuration method is used
|
||||
has_single = "stat_rate" in val
|
||||
has_inverted = "stat_rate_inverted" in val
|
||||
has_combined = "stat_rate_from" in val
|
||||
|
||||
methods_count = sum([has_single, has_inverted, has_combined])
|
||||
if methods_count > 1:
|
||||
raise vol.Invalid(
|
||||
"power_config must use only one configuration method: "
|
||||
"stat_rate, stat_rate_inverted, or stat_rate_from/stat_rate_to"
|
||||
)
|
||||
|
||||
return val
|
||||
|
||||
|
||||
POWER_CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive("stat_rate", "power_source"): str,
|
||||
vol.Exclusive("stat_rate_inverted", "power_source"): str,
|
||||
# stat_rate_from/stat_rate_to: two sensors for bidirectional power
|
||||
# Battery: from=discharge (out), to=charge (in)
|
||||
# Grid: from=consumption, to=return
|
||||
vol.Inclusive("stat_rate_from", "two_sensors"): str,
|
||||
vol.Inclusive("stat_rate_to", "two_sensors"): str,
|
||||
}
|
||||
),
|
||||
_validate_power_config,
|
||||
)
|
||||
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
# stat_rate and power_config are both optional schema keys, but the validator
|
||||
# requires that at least one is provided; power_config takes precedence
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("stat_rate", "power_config"),
|
||||
)
|
||||
|
||||
|
||||
@@ -225,7 +297,7 @@ def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[di
|
||||
val: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Ensure that the user doesn't add duplicate values."""
|
||||
counts = Counter(flow_from[key] for flow_from in val)
|
||||
counts = Counter(item.get(key) for item in val if item.get(key) is not None)
|
||||
|
||||
for value, count in counts.items():
|
||||
if count > 1:
|
||||
@@ -267,7 +339,10 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Required("type"): "battery",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Required("stat_energy_to"): str,
|
||||
# Both stat_rate and power_config are optional
|
||||
# If power_config is provided, it takes precedence and stat_rate is overwritten
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
@@ -387,6 +462,12 @@ class EnergyManager:
|
||||
if key in update:
|
||||
data[key] = update[key]
|
||||
|
||||
# Process energy sources and set stat_rate for power configs
|
||||
if "energy_sources" in update:
|
||||
data["energy_sources"] = self._process_energy_sources(
|
||||
data["energy_sources"]
|
||||
)
|
||||
|
||||
self.data = data
|
||||
self._store.async_delay_save(lambda: data, 60)
|
||||
|
||||
@@ -395,6 +476,68 @@ class EnergyManager:
|
||||
|
||||
await asyncio.gather(*(listener() for listener in self._update_listeners))
|
||||
|
||||
def _process_energy_sources(self, sources: list[SourceType]) -> list[SourceType]:
|
||||
"""Process energy sources and set stat_rate for power configs."""
|
||||
from .helpers import generate_power_sensor_entity_id # noqa: PLC0415
|
||||
|
||||
processed: list[SourceType] = []
|
||||
for source in sources:
|
||||
if source["type"] == "battery":
|
||||
source = self._process_battery_power(
|
||||
source, generate_power_sensor_entity_id
|
||||
)
|
||||
elif source["type"] == "grid":
|
||||
source = self._process_grid_power(
|
||||
source, generate_power_sensor_entity_id
|
||||
)
|
||||
processed.append(source)
|
||||
return processed
|
||||
|
||||
def _process_battery_power(
|
||||
self,
|
||||
source: BatterySourceType,
|
||||
generate_entity_id: Callable[[str, PowerConfig], str],
|
||||
) -> BatterySourceType:
|
||||
"""Set stat_rate for battery if power_config is specified."""
|
||||
if "power_config" not in source:
|
||||
return source
|
||||
|
||||
config = source["power_config"]
|
||||
|
||||
# If power_config has stat_rate (standard), just use it directly
|
||||
if "stat_rate" in config:
|
||||
return {**source, "stat_rate": config["stat_rate"]}
|
||||
|
||||
# For inverted or two-sensor config, set stat_rate to the generated entity_id
|
||||
return {**source, "stat_rate": generate_entity_id("battery", config)}
|
||||
|
||||
def _process_grid_power(
|
||||
self,
|
||||
source: GridSourceType,
|
||||
generate_entity_id: Callable[[str, PowerConfig], str],
|
||||
) -> GridSourceType:
|
||||
"""Set stat_rate for grid power sources if power_config is specified."""
|
||||
if "power" not in source:
|
||||
return source
|
||||
|
||||
processed_power: list[GridPowerSourceType] = []
|
||||
for power in source["power"]:
|
||||
if "power_config" in power:
|
||||
config = power["power_config"]
|
||||
|
||||
# If power_config has stat_rate (standard), just use it directly
|
||||
if "stat_rate" in config:
|
||||
processed_power.append({**power, "stat_rate": config["stat_rate"]})
|
||||
else:
|
||||
# For inverted or two-sensor config, set stat_rate to generated entity_id
|
||||
processed_power.append(
|
||||
{**power, "stat_rate": generate_entity_id("grid", config)}
|
||||
)
|
||||
else:
|
||||
processed_power.append(power)
|
||||
|
||||
return {**source, "power": processed_power}
|
||||
|
||||
@callback
|
||||
def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None:
|
||||
"""Listen for data updates."""
|
||||
|
||||
42
homeassistant/components/energy/helpers.py
Normal file
42
homeassistant/components/energy/helpers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Helpers for the Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .data import PowerConfig
|
||||
|
||||
|
||||
def generate_power_sensor_unique_id(source_type: str, config: PowerConfig) -> str:
|
||||
"""Generate a unique ID for a power transform sensor."""
|
||||
if "stat_rate_inverted" in config:
|
||||
sensor_id = config["stat_rate_inverted"].replace(".", "_")
|
||||
return f"energy_power_{source_type}_inverted_{sensor_id}"
|
||||
if "stat_rate_from" in config and "stat_rate_to" in config:
|
||||
from_id = config["stat_rate_from"].replace(".", "_")
|
||||
to_id = config["stat_rate_to"].replace(".", "_")
|
||||
return f"energy_power_{source_type}_combined_{from_id}_{to_id}"
|
||||
# This case is impossible: schema validation (vol.Inclusive) ensures
|
||||
# stat_rate_from and stat_rate_to are always present together
|
||||
raise RuntimeError("Invalid power config: missing required keys")
|
||||
|
||||
|
||||
def generate_power_sensor_entity_id(source_type: str, config: PowerConfig) -> str:
|
||||
"""Generate an entity ID for a power transform sensor."""
|
||||
if "stat_rate_inverted" in config:
|
||||
# Use source sensor name with _inverted suffix
|
||||
source = config["stat_rate_inverted"]
|
||||
if source.startswith("sensor."):
|
||||
return f"{source}_inverted"
|
||||
return f"sensor.{source.replace('.', '_')}_inverted"
|
||||
if "stat_rate_from" in config and "stat_rate_to" in config:
|
||||
# Use both sensors in entity ID to ensure uniqueness when multiple
|
||||
# combined configs exist. The entity represents net power (from - to),
|
||||
# e.g., discharge - charge for battery.
|
||||
from_sensor = config["stat_rate_from"].removeprefix("sensor.")
|
||||
to_sensor = config["stat_rate_to"].removeprefix("sensor.")
|
||||
return f"sensor.energy_{source_type}_{from_sensor}_{to_sensor}_net_power"
|
||||
# This case is impossible: schema validation (vol.Inclusive) ensures
|
||||
# stat_rate_from and stat_rate_to are always present together
|
||||
raise RuntimeError("Invalid power config: missing required keys")
|
||||
@@ -19,7 +19,12 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import
|
||||
reset_detected,
|
||||
)
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
State,
|
||||
@@ -36,7 +41,8 @@ from homeassistant.util import dt as dt_util, unit_conversion
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import EnergyManager, async_get_manager
|
||||
from .data import EnergyManager, PowerConfig, async_get_manager
|
||||
from .helpers import generate_power_sensor_entity_id, generate_power_sensor_unique_id
|
||||
|
||||
SUPPORTED_STATE_CLASSES = {
|
||||
SensorStateClass.MEASUREMENT,
|
||||
@@ -137,6 +143,7 @@ class SensorManager:
|
||||
self.manager = manager
|
||||
self.async_add_entities = async_add_entities
|
||||
self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {}
|
||||
self.current_power_entities: dict[str, EnergyPowerSensor] = {}
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start."""
|
||||
@@ -147,8 +154,9 @@ class SensorManager:
|
||||
|
||||
async def _process_manager_data(self) -> None:
|
||||
"""Process manager data."""
|
||||
to_add: list[EnergyCostSensor] = []
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor] = []
|
||||
to_remove = dict(self.current_entities)
|
||||
power_to_remove = dict(self.current_power_entities)
|
||||
|
||||
async def finish() -> None:
|
||||
if to_add:
|
||||
@@ -159,6 +167,13 @@ class SensorManager:
|
||||
self.current_entities.pop(key)
|
||||
await entity.async_remove()
|
||||
|
||||
for power_key, power_entity in power_to_remove.items():
|
||||
self.current_power_entities.pop(power_key)
|
||||
await power_entity.async_remove()
|
||||
|
||||
# This guard is for the optional typing of EnergyManager.data.
|
||||
# In practice, data is always set to default preferences in async_update
|
||||
# before listeners are called, so this case should never happen.
|
||||
if not self.manager.data:
|
||||
await finish()
|
||||
return
|
||||
@@ -185,6 +200,13 @@ class SensorManager:
|
||||
to_remove,
|
||||
)
|
||||
|
||||
# Process power sensors for battery and grid sources
|
||||
self._process_power_sensor_data(
|
||||
energy_source,
|
||||
to_add,
|
||||
power_to_remove,
|
||||
)
|
||||
|
||||
await finish()
|
||||
|
||||
@callback
|
||||
@@ -192,7 +214,7 @@ class SensorManager:
|
||||
self,
|
||||
adapter: SourceAdapter,
|
||||
config: Mapping[str, Any],
|
||||
to_add: list[EnergyCostSensor],
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[tuple[str, str | None, str], EnergyCostSensor],
|
||||
) -> None:
|
||||
"""Process sensor data."""
|
||||
@@ -220,6 +242,64 @@ class SensorManager:
|
||||
)
|
||||
to_add.append(self.current_entities[key])
|
||||
|
||||
@callback
|
||||
def _process_power_sensor_data(
|
||||
self,
|
||||
energy_source: Mapping[str, Any],
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[str, EnergyPowerSensor],
|
||||
) -> None:
|
||||
"""Process power sensor data for battery and grid sources."""
|
||||
source_type = energy_source.get("type")
|
||||
|
||||
if source_type == "battery":
|
||||
power_config = energy_source.get("power_config")
|
||||
if power_config and self._needs_power_sensor(power_config):
|
||||
self._create_or_keep_power_sensor(
|
||||
source_type, power_config, to_add, to_remove
|
||||
)
|
||||
|
||||
elif source_type == "grid":
|
||||
for power in energy_source.get("power", []):
|
||||
power_config = power.get("power_config")
|
||||
if power_config and self._needs_power_sensor(power_config):
|
||||
self._create_or_keep_power_sensor(
|
||||
source_type, power_config, to_add, to_remove
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _needs_power_sensor(power_config: PowerConfig) -> bool:
|
||||
"""Check if power_config needs a transform sensor."""
|
||||
# Only create sensors for inverted or two-sensor configs
|
||||
# Standard stat_rate configs don't need a transform sensor
|
||||
return "stat_rate_inverted" in power_config or (
|
||||
"stat_rate_from" in power_config and "stat_rate_to" in power_config
|
||||
)
|
||||
|
||||
def _create_or_keep_power_sensor(
|
||||
self,
|
||||
source_type: str,
|
||||
power_config: PowerConfig,
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[str, EnergyPowerSensor],
|
||||
) -> None:
|
||||
"""Create a power sensor or keep an existing one."""
|
||||
unique_id = generate_power_sensor_unique_id(source_type, power_config)
|
||||
|
||||
# If entity already exists, keep it
|
||||
if unique_id in to_remove:
|
||||
to_remove.pop(unique_id)
|
||||
return
|
||||
|
||||
sensor = EnergyPowerSensor(
|
||||
source_type,
|
||||
power_config,
|
||||
unique_id,
|
||||
generate_power_sensor_entity_id(source_type, power_config),
|
||||
)
|
||||
self.current_power_entities[unique_id] = sensor
|
||||
to_add.append(sensor)
|
||||
|
||||
|
||||
def _set_result_unless_done(future: asyncio.Future[None]) -> None:
|
||||
"""Set the result of a future unless it is done."""
|
||||
@@ -495,3 +575,197 @@ class EnergyCostSensor(SensorEntity):
|
||||
prefix = self._config[self._adapter.stat_energy_key]
|
||||
|
||||
return f"{prefix}_{self._adapter.source_type}_{self._adapter.entity_id_suffix}"
|
||||
|
||||
|
||||
class EnergyPowerSensor(SensorEntity):
|
||||
"""Transform power sensor values (invert or combine two sensors).
|
||||
|
||||
This sensor handles non-standard power sensor configurations for the energy
|
||||
dashboard by either inverting polarity or combining two positive sensors.
|
||||
"""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_device_class = SensorDeviceClass.POWER
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_type: str,
|
||||
config: PowerConfig,
|
||||
unique_id: str,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__()
|
||||
self._source_type = source_type
|
||||
self._config: PowerConfig = config
|
||||
self._attr_unique_id = unique_id
|
||||
self.entity_id = entity_id
|
||||
self._source_sensors: list[str] = []
|
||||
self._is_inverted = "stat_rate_inverted" in config
|
||||
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
|
||||
|
||||
# Determine source sensors
|
||||
if self._is_inverted:
|
||||
self._source_sensors = [config["stat_rate_inverted"]]
|
||||
elif self._is_combined:
|
||||
self._source_sensors = [
|
||||
config["stat_rate_from"],
|
||||
config["stat_rate_to"],
|
||||
]
|
||||
|
||||
# add_finished is set when either async_added_to_hass or add_to_platform_abort
|
||||
# is called
|
||||
self.add_finished: asyncio.Future[None] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if self._is_inverted:
|
||||
source = self.hass.states.get(self._source_sensors[0])
|
||||
return source is not None and source.state not in (
|
||||
"unknown",
|
||||
"unavailable",
|
||||
)
|
||||
if self._is_combined:
|
||||
discharge = self.hass.states.get(self._source_sensors[0])
|
||||
charge = self.hass.states.get(self._source_sensors[1])
|
||||
return (
|
||||
discharge is not None
|
||||
and charge is not None
|
||||
and discharge.state not in ("unknown", "unavailable")
|
||||
and charge.state not in ("unknown", "unavailable")
|
||||
)
|
||||
return True
|
||||
|
||||
@callback
|
||||
def _update_state(self) -> None:
|
||||
"""Update the sensor state based on source sensors."""
|
||||
if self._is_inverted:
|
||||
source_state = self.hass.states.get(self._source_sensors[0])
|
||||
if source_state is None or source_state.state in ("unknown", "unavailable"):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
try:
|
||||
value = float(source_state.state)
|
||||
except ValueError:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
self._attr_native_value = value * -1
|
||||
|
||||
elif self._is_combined:
|
||||
discharge_state = self.hass.states.get(self._source_sensors[0])
|
||||
charge_state = self.hass.states.get(self._source_sensors[1])
|
||||
|
||||
if (
|
||||
discharge_state is None
|
||||
or charge_state is None
|
||||
or discharge_state.state in ("unknown", "unavailable")
|
||||
or charge_state.state in ("unknown", "unavailable")
|
||||
):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
try:
|
||||
discharge = float(discharge_state.state)
|
||||
charge = float(charge_state.state)
|
||||
except ValueError:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
# Get units from state attributes
|
||||
discharge_unit = discharge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
# Convert to Watts if units are present
|
||||
if discharge_unit:
|
||||
discharge = unit_conversion.PowerConverter.convert(
|
||||
discharge, discharge_unit, UnitOfPower.WATT
|
||||
)
|
||||
if charge_unit:
|
||||
charge = unit_conversion.PowerConverter.convert(
|
||||
charge, charge_unit, UnitOfPower.WATT
|
||||
)
|
||||
|
||||
self._attr_native_value = discharge - charge
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
# Set name based on source sensor(s)
|
||||
if self._source_sensors:
|
||||
entity_reg = er.async_get(self.hass)
|
||||
device_id = None
|
||||
source_name = None
|
||||
# Check first sensor
|
||||
if source_entry := entity_reg.async_get(self._source_sensors[0]):
|
||||
device_id = source_entry.device_id
|
||||
# For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit
|
||||
if self._is_combined:
|
||||
self._attr_native_unit_of_measurement = UnitOfPower.WATT
|
||||
else:
|
||||
self._attr_native_unit_of_measurement = (
|
||||
source_entry.unit_of_measurement
|
||||
)
|
||||
# Get source name from registry
|
||||
source_name = source_entry.name or source_entry.original_name
|
||||
# Assign power sensor to same device as source sensor(s)
|
||||
# Note: We use manual entity registry update instead of _attr_device_info
|
||||
# because device assignment depends on runtime information from the entity
|
||||
# registry (which source sensor has a device). This information isn't
|
||||
# available during __init__, and the entity is already registered before
|
||||
# async_added_to_hass runs, making the standard _attr_device_info pattern
|
||||
# incompatible with this use case.
|
||||
# If first sensor has no device and we have a second sensor, check it
|
||||
if not device_id and len(self._source_sensors) > 1:
|
||||
if source_entry := entity_reg.async_get(self._source_sensors[1]):
|
||||
device_id = source_entry.device_id
|
||||
# Update entity registry entry with device_id
|
||||
if device_id and (power_entry := entity_reg.async_get(self.entity_id)):
|
||||
entity_reg.async_update_entity(
|
||||
power_entry.entity_id, device_id=device_id
|
||||
)
|
||||
else:
|
||||
self._attr_has_entity_name = False
|
||||
|
||||
# Set name for inverted mode
|
||||
if self._is_inverted:
|
||||
if source_name:
|
||||
self._attr_name = f"{source_name} Inverted"
|
||||
else:
|
||||
# Fall back to entity_id if no name in registry
|
||||
sensor_name = split_entity_id(self._source_sensors[0])[1].replace(
|
||||
"_", " "
|
||||
)
|
||||
self._attr_name = f"{sensor_name.title()} Inverted"
|
||||
|
||||
# Set name for combined mode
|
||||
if self._is_combined:
|
||||
self._attr_name = f"{self._source_type.title()} Power"
|
||||
|
||||
self._update_state()
|
||||
|
||||
# Track state changes on all source sensors
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
self._source_sensors,
|
||||
self._async_state_changed_listener,
|
||||
)
|
||||
)
|
||||
_set_result_unless_done(self.add_finished)
|
||||
|
||||
@callback
|
||||
def _async_state_changed_listener(self, *_: Any) -> None:
|
||||
"""Handle source sensor state changes."""
|
||||
self._update_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def add_to_platform_abort(self) -> None:
|
||||
"""Abort adding an entity to a platform."""
|
||||
_set_result_unless_done(self.add_finished)
|
||||
super().add_to_platform_abort()
|
||||
|
||||
@@ -300,7 +300,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def color_mode(self) -> str:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if not self._supports_color_mode:
|
||||
supported_color_modes = self.supported_color_modes
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.light import (
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_WHITE,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
)
|
||||
@@ -235,7 +236,7 @@ class FluxLight(
|
||||
return self._device.rgbcw
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
return _flux_color_mode_to_hass(
|
||||
self._device.color_mode, self._device.color_modes
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv
|
||||
from .const import FLUX_COLOR_MODE_TO_HASS, MIN_RGB_BRIGHTNESS
|
||||
|
||||
|
||||
def _hass_color_modes(device: AIOWifiLedBulb) -> set[str]:
|
||||
def _hass_color_modes(device: AIOWifiLedBulb) -> set[ColorMode]:
|
||||
color_modes = device.color_modes
|
||||
if not color_modes:
|
||||
return {ColorMode.ONOFF}
|
||||
|
||||
@@ -462,6 +462,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Shopping list panel was replaced by todo panel in 2023.11
|
||||
hass.http.register_redirect("/shopping-list", "/todo")
|
||||
|
||||
# Developer tools moved to config panel in 2026.2
|
||||
for url in (
|
||||
"/developer-tools",
|
||||
"/developer-tools/yaml",
|
||||
"/developer-tools/state",
|
||||
"/developer-tools/action",
|
||||
"/developer-tools/template",
|
||||
"/developer-tools/event",
|
||||
"/developer-tools/statistics",
|
||||
"/developer-tools/assist",
|
||||
"/developer-tools/debug",
|
||||
):
|
||||
hass.http.register_redirect(url, f"/config{url}")
|
||||
|
||||
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
||||
|
||||
async_register_built_in_panel(
|
||||
@@ -495,14 +509,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
"developer-tools",
|
||||
require_admin=True,
|
||||
sidebar_title="developer_tools",
|
||||
sidebar_icon="mdi:hammer",
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_change_listener(
|
||||
resource_type: str,
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260107.2"]
|
||||
"requirements": ["home-assistant-frontend==20260128.1"]
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
return self._device.rgb_color
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode | str | None:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode."""
|
||||
if self._fixed_color_mode:
|
||||
# The light supports only a single color mode, return it
|
||||
|
||||
@@ -70,6 +70,8 @@ from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
@@ -135,6 +137,10 @@ SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
@@ -156,7 +162,7 @@ def valid_addon(value: Any) -> str:
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid add-on slug")
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
@@ -168,6 +174,12 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
@@ -186,7 +198,13 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -201,7 +219,13 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -221,12 +245,18 @@ class APIEndpointSettings(NamedTuple):
|
||||
|
||||
|
||||
MAP_SERVICE_API = {
|
||||
# Legacy addon services
|
||||
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STDIN: APIEndpointSettings(
|
||||
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
|
||||
),
|
||||
# New app services
|
||||
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
|
||||
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
|
||||
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
|
||||
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
|
||||
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
|
||||
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
|
||||
SERVICE_BACKUP_FULL: APIEndpointSettings(
|
||||
@@ -386,12 +416,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
api_endpoint = MAP_SERVICE_API[service.service]
|
||||
|
||||
data = service.data.copy()
|
||||
addon = data.pop(ATTR_ADDON, None)
|
||||
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
|
||||
slug = data.pop(ATTR_SLUG, None)
|
||||
|
||||
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
|
||||
data[ATTR_ADDONS] = addons
|
||||
|
||||
payload = None
|
||||
|
||||
# Pass data to Hass.io API
|
||||
if service.service == SERVICE_ADDON_STDIN:
|
||||
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
|
||||
payload = data[ATTR_INPUT]
|
||||
elif api_endpoint.pass_data:
|
||||
payload = data
|
||||
|
||||
@@ -125,7 +125,7 @@ class AddonManager:
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} add-on discovery info",
|
||||
"Failed to get the {addon_name} app discovery info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_get_addon_discovery_info(self) -> dict:
|
||||
@@ -140,12 +140,12 @@ class AddonManager:
|
||||
)
|
||||
|
||||
if not discovery_info:
|
||||
raise AddonError(f"Failed to get {self.addon_name} add-on discovery info")
|
||||
raise AddonError(f"Failed to get {self.addon_name} app discovery info")
|
||||
|
||||
return discovery_info.config
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} add-on info",
|
||||
"Failed to get the {addon_name} app info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_get_addon_info(self) -> AddonInfo:
|
||||
@@ -153,7 +153,7 @@ class AddonManager:
|
||||
addon_store_info = await self._supervisor_client.store.addon_info(
|
||||
self.addon_slug
|
||||
)
|
||||
self._logger.debug("Add-on store info: %s", addon_store_info.to_dict())
|
||||
self._logger.debug("App store info: %s", addon_store_info.to_dict())
|
||||
if not addon_store_info.installed:
|
||||
return AddonInfo(
|
||||
available=addon_store_info.available,
|
||||
@@ -190,7 +190,7 @@ class AddonManager:
|
||||
return addon_state
|
||||
|
||||
@api_error(
|
||||
"Failed to set the {addon_name} add-on options",
|
||||
"Failed to set the {addon_name} app options",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_set_addon_options(self, config: dict) -> None:
|
||||
@@ -202,10 +202,10 @@ class AddonManager:
|
||||
def _check_addon_available(self, addon_info: AddonInfo) -> None:
|
||||
"""Check if the managed add-on is available."""
|
||||
if not addon_info.available:
|
||||
raise AddonError(f"{self.addon_name} add-on is not available")
|
||||
raise AddonError(f"{self.addon_name} app is not available")
|
||||
|
||||
@api_error(
|
||||
"Failed to install the {addon_name} add-on", expected_error_type=SupervisorError
|
||||
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
@@ -216,14 +216,14 @@ class AddonManager:
|
||||
await self._supervisor_client.store.install_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to uninstall the {addon_name} add-on",
|
||||
"Failed to uninstall the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_uninstall_addon(self) -> None:
|
||||
"""Uninstall the managed add-on."""
|
||||
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
|
||||
|
||||
@api_error("Failed to update the {addon_name} add-on")
|
||||
@api_error("Failed to update the {addon_name} app")
|
||||
async def async_update_addon(self) -> None:
|
||||
"""Update the managed add-on if needed."""
|
||||
addon_info = await self.async_get_addon_info()
|
||||
@@ -231,7 +231,7 @@ class AddonManager:
|
||||
self._check_addon_available(addon_info)
|
||||
|
||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
||||
raise AddonError(f"{self.addon_name} add-on is not installed")
|
||||
raise AddonError(f"{self.addon_name} app is not installed")
|
||||
|
||||
if not addon_info.update_available:
|
||||
return
|
||||
@@ -242,28 +242,28 @@ class AddonManager:
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to start the {addon_name} add-on", expected_error_type=SupervisorError
|
||||
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
async def async_start_addon(self) -> None:
|
||||
"""Start the managed add-on."""
|
||||
await self._supervisor_client.addons.start_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to restart the {addon_name} add-on", expected_error_type=SupervisorError
|
||||
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
async def async_restart_addon(self) -> None:
|
||||
"""Restart the managed add-on."""
|
||||
await self._supervisor_client.addons.restart_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to stop the {addon_name} add-on", expected_error_type=SupervisorError
|
||||
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
async def async_stop_addon(self) -> None:
|
||||
"""Stop the managed add-on."""
|
||||
await self._supervisor_client.addons.stop_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to create a backup of the {addon_name} add-on",
|
||||
"Failed to create a backup of the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_create_backup(self) -> None:
|
||||
@@ -284,7 +284,7 @@ class AddonManager:
|
||||
addon_info = await self.async_get_addon_info()
|
||||
|
||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
||||
raise AddonError(f"{self.addon_name} add-on is not installed")
|
||||
raise AddonError(f"{self.addon_name} app is not installed")
|
||||
|
||||
if addon_config != addon_info.options:
|
||||
await self.async_set_addon_options(addon_config)
|
||||
@@ -297,7 +297,7 @@ class AddonManager:
|
||||
"""
|
||||
if not self._install_task or self._install_task.done():
|
||||
self._logger.info(
|
||||
"%s add-on is not installed. Installing add-on", self.addon_name
|
||||
"%s app is not installed. Installing app", self.addon_name
|
||||
)
|
||||
self._install_task = self._async_schedule_addon_operation(
|
||||
self.async_install_addon, catch_error=catch_error
|
||||
@@ -316,7 +316,7 @@ class AddonManager:
|
||||
"""
|
||||
if not self._install_task or self._install_task.done():
|
||||
self._logger.info(
|
||||
"%s add-on is not installed. Installing add-on", self.addon_name
|
||||
"%s app is not installed. Installing app", self.addon_name
|
||||
)
|
||||
self._install_task = self._async_schedule_addon_operation(
|
||||
self.async_install_addon,
|
||||
@@ -336,7 +336,7 @@ class AddonManager:
|
||||
Only schedule a new update task if the there's no running task.
|
||||
"""
|
||||
if not self._update_task or self._update_task.done():
|
||||
self._logger.info("Trying to update the %s add-on", self.addon_name)
|
||||
self._logger.info("Trying to update the %s app", self.addon_name)
|
||||
self._update_task = self._async_schedule_addon_operation(
|
||||
self.async_update_addon,
|
||||
catch_error=catch_error,
|
||||
@@ -350,9 +350,7 @@ class AddonManager:
|
||||
Only schedule a new start task if the there's no running task.
|
||||
"""
|
||||
if not self._start_task or self._start_task.done():
|
||||
self._logger.info(
|
||||
"%s add-on is not running. Starting add-on", self.addon_name
|
||||
)
|
||||
self._logger.info("%s app is not running. Starting app", self.addon_name)
|
||||
self._start_task = self._async_schedule_addon_operation(
|
||||
self.async_start_addon, catch_error=catch_error
|
||||
)
|
||||
@@ -365,7 +363,7 @@ class AddonManager:
|
||||
Only schedule a new restart task if the there's no running task.
|
||||
"""
|
||||
if not self._restart_task or self._restart_task.done():
|
||||
self._logger.info("Restarting %s add-on", self.addon_name)
|
||||
self._logger.info("Restarting %s app", self.addon_name)
|
||||
self._restart_task = self._async_schedule_addon_operation(
|
||||
self.async_restart_addon, catch_error=catch_error
|
||||
)
|
||||
@@ -382,9 +380,7 @@ class AddonManager:
|
||||
Only schedule a new setup task if there's no running task.
|
||||
"""
|
||||
if not self._start_task or self._start_task.done():
|
||||
self._logger.info(
|
||||
"%s add-on is not running. Starting add-on", self.addon_name
|
||||
)
|
||||
self._logger.info("%s app is not running. Starting app", self.addon_name)
|
||||
self._start_task = self._async_schedule_addon_operation(
|
||||
partial(
|
||||
self.async_configure_addon,
|
||||
|
||||
@@ -17,6 +17,8 @@ DOMAIN = "hassio"
|
||||
|
||||
ATTR_ADDON = "addon"
|
||||
ATTR_ADDONS = "addons"
|
||||
ATTR_APP = "app"
|
||||
ATTR_APPS = "apps"
|
||||
ATTR_ADMIN = "admin"
|
||||
ATTR_COMPRESSED = "compressed"
|
||||
ATTR_CONFIG = "config"
|
||||
@@ -174,7 +176,7 @@ EXTRA_PLACEHOLDERS = {
|
||||
class SupervisorEntityModel(StrEnum):
|
||||
"""Supervisor entity model."""
|
||||
|
||||
ADDON = "Home Assistant Add-on"
|
||||
ADDON = "Home Assistant App"
|
||||
OS = "Home Assistant Operating System"
|
||||
CORE = "Home Assistant Core"
|
||||
SUPERVISOR = "Home Assistant Supervisor"
|
||||
|
||||
@@ -117,7 +117,7 @@ class HassIODiscovery(HomeAssistantView):
|
||||
try:
|
||||
addon_info = await self._supervisor_client.addons.addon_info(data.addon)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Can't read add-on info: %s", err)
|
||||
_LOGGER.error("Can't read app info: %s", err)
|
||||
return
|
||||
|
||||
data.config[ATTR_ADDON] = addon_info.name
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
"addon_stop": {
|
||||
"service": "mdi:stop"
|
||||
},
|
||||
"app_restart": {
|
||||
"service": "mdi:restart"
|
||||
},
|
||||
"app_start": {
|
||||
"service": "mdi:play"
|
||||
},
|
||||
"app_stdin": {
|
||||
"service": "mdi:console"
|
||||
},
|
||||
"app_stop": {
|
||||
"service": "mdi:stop"
|
||||
},
|
||||
"backup_full": {
|
||||
"service": "mdi:content-save"
|
||||
},
|
||||
|
||||
@@ -30,6 +30,42 @@ addon_stop:
|
||||
selector:
|
||||
addon:
|
||||
|
||||
app_start:
|
||||
fields:
|
||||
app:
|
||||
required: true
|
||||
example: core_ssh
|
||||
selector:
|
||||
app:
|
||||
|
||||
app_restart:
|
||||
fields:
|
||||
app:
|
||||
required: true
|
||||
example: core_ssh
|
||||
selector:
|
||||
app:
|
||||
|
||||
app_stdin:
|
||||
fields:
|
||||
app:
|
||||
required: true
|
||||
example: core_ssh
|
||||
selector:
|
||||
app:
|
||||
input:
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
app_stop:
|
||||
fields:
|
||||
app:
|
||||
required: true
|
||||
example: core_ssh
|
||||
selector:
|
||||
app:
|
||||
|
||||
host_reboot:
|
||||
host_shutdown:
|
||||
backup_full:
|
||||
@@ -64,6 +100,10 @@ backup_partial:
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
apps:
|
||||
example: ["core_ssh", "core_samba", "core_mosquitto"]
|
||||
selector:
|
||||
object:
|
||||
addons:
|
||||
example: ["core_ssh", "core_samba", "core_mosquitto"]
|
||||
selector:
|
||||
@@ -113,6 +153,10 @@ restore_partial:
|
||||
example: ["homeassistant", "share"]
|
||||
selector:
|
||||
object:
|
||||
apps:
|
||||
example: ["core_ssh", "core_samba", "core_mosquitto"]
|
||||
selector:
|
||||
object:
|
||||
addons:
|
||||
example: ["core_ssh", "core_samba", "core_mosquitto"]
|
||||
selector:
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"step": {
|
||||
"fix_menu": {
|
||||
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
|
||||
"description": "App {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple apps. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
|
||||
"menu_options": {
|
||||
"addon_disable_boot": "[%key:common::action::disable%]",
|
||||
"addon_execute_start": "[%key:common::action::start%]"
|
||||
@@ -59,41 +59,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Add-on failed to start at boot"
|
||||
"title": "App failed to start at boot"
|
||||
},
|
||||
"issue_addon_deprecated_addon": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details."
|
||||
"apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details."
|
||||
},
|
||||
"step": {
|
||||
"addon_execute_remove": {
|
||||
"description": "Add-on {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the add-on to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
"description": "App {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the app to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Installed add-on is deprecated"
|
||||
"title": "Installed app is deprecated"
|
||||
},
|
||||
"issue_addon_detached_addon_missing": {
|
||||
"description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store.",
|
||||
"title": "Missing repository for an installed add-on"
|
||||
"description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.",
|
||||
"title": "Missing repository for an installed app"
|
||||
},
|
||||
"issue_addon_detached_addon_removed": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details."
|
||||
"apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details."
|
||||
},
|
||||
"step": {
|
||||
"addon_execute_remove": {
|
||||
"description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
"description": "App {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Installed add-on has been removed from repository"
|
||||
"title": "Installed app has been removed from repository"
|
||||
},
|
||||
"issue_addon_pwned": {
|
||||
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue.",
|
||||
"title": "Insecure secrets detected in add-on configuration"
|
||||
"description": "App {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue.",
|
||||
"title": "Insecure secrets detected in app configuration"
|
||||
},
|
||||
"issue_mount_mount_failed": {
|
||||
"fix_flow": {
|
||||
@@ -123,7 +123,7 @@
|
||||
},
|
||||
"step": {
|
||||
"system_execute_rebuild": {
|
||||
"description": "The default configuration for add-ons and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}"
|
||||
"description": "The default configuration for apps and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"step": {
|
||||
"system_execute_reboot": {
|
||||
"description": "Settings were changed which require a system reboot to take effect.\n\nThis fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period."
|
||||
"description": "Settings were changed which require a system reboot to take effect.\n\nThis fix will initiate a system reboot which will make Home Assistant and all the apps inaccessible for a brief period."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -203,7 +203,7 @@
|
||||
"title": "Unsupported system - {reason}"
|
||||
},
|
||||
"unsupported_apparmor": {
|
||||
"description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. For troubleshooting information, select Learn more.",
|
||||
"description": "System is unsupported because AppArmor is working incorrectly and apps are running in an unprotected and insecure way. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - AppArmor issues"
|
||||
},
|
||||
"unsupported_cgroup_version": {
|
||||
@@ -336,6 +336,50 @@
|
||||
},
|
||||
"name": "Stop add-on"
|
||||
},
|
||||
"app_restart": {
|
||||
"description": "Restarts a Home Assistant app.",
|
||||
"fields": {
|
||||
"app": {
|
||||
"description": "The app to restart.",
|
||||
"name": "[%key:component::hassio::services::app_start::fields::app::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Restart app"
|
||||
},
|
||||
"app_start": {
|
||||
"description": "Starts a Home Assistant app.",
|
||||
"fields": {
|
||||
"app": {
|
||||
"description": "The app to start.",
|
||||
"name": "App"
|
||||
}
|
||||
},
|
||||
"name": "Start app"
|
||||
},
|
||||
"app_stdin": {
|
||||
"description": "Writes data to the standard input of a Home Assistant app.",
|
||||
"fields": {
|
||||
"app": {
|
||||
"description": "The app to write to.",
|
||||
"name": "[%key:component::hassio::services::app_start::fields::app::name%]"
|
||||
},
|
||||
"input": {
|
||||
"description": "The data to write.",
|
||||
"name": "Input"
|
||||
}
|
||||
},
|
||||
"name": "Write data to app stdin"
|
||||
},
|
||||
"app_stop": {
|
||||
"description": "Stops a Home Assistant app.",
|
||||
"fields": {
|
||||
"app": {
|
||||
"description": "The app to stop.",
|
||||
"name": "[%key:component::hassio::services::app_start::fields::app::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Stop app"
|
||||
},
|
||||
"backup_full": {
|
||||
"description": "Creates a full backup.",
|
||||
"fields": {
|
||||
@@ -366,8 +410,12 @@
|
||||
"description": "Creates a partial backup.",
|
||||
"fields": {
|
||||
"addons": {
|
||||
"description": "List of add-ons to include in the backup. Use the name slug of each add-on.",
|
||||
"name": "Add-ons"
|
||||
"description": "List of apps (formerly add-ons) to include in the backup. Use the slug of each app. Legacy option - use apps instead.",
|
||||
"name": "Add-ons (legacy)"
|
||||
},
|
||||
"apps": {
|
||||
"description": "List of apps to include in the backup. Use the slug of each app.",
|
||||
"name": "Apps"
|
||||
},
|
||||
"compressed": {
|
||||
"description": "[%key:component::hassio::services::backup_full::fields::compressed::description%]",
|
||||
@@ -426,9 +474,13 @@
|
||||
"description": "Restores from a partial backup.",
|
||||
"fields": {
|
||||
"addons": {
|
||||
"description": "List of add-ons to restore from the backup. Use the name slug of each add-on.",
|
||||
"description": "List of apps (formerly add-ons) to restore from the backup. Use the slug of each app. Legacy option - use apps instead.",
|
||||
"name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]"
|
||||
},
|
||||
"apps": {
|
||||
"description": "List of apps to restore from the backup. Use the slug of each app.",
|
||||
"name": "[%key:component::hassio::services::backup_partial::fields::apps::name%]"
|
||||
},
|
||||
"folders": {
|
||||
"description": "List of directories to restore from the backup.",
|
||||
"name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]"
|
||||
@@ -458,7 +510,7 @@
|
||||
"docker_version": "Docker version",
|
||||
"healthy": "Healthy",
|
||||
"host_os": "Host operating system",
|
||||
"installed_addons": "Installed add-ons",
|
||||
"installed_addons": "Installed apps",
|
||||
"nameservers": "Nameservers",
|
||||
"supervisor_api": "Supervisor API",
|
||||
"supervisor_version": "Supervisor version",
|
||||
|
||||
@@ -131,7 +131,7 @@ class HomeKitLight(HomeKitEntity, LightEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
# aiohomekit does not keep track of the light's color mode, report
|
||||
# hs for light supporting both hs and ct
|
||||
|
||||
@@ -410,7 +410,7 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
return hue_brightness_to_hass(bri)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._fixed_color_mode:
|
||||
return self._fixed_color_mode
|
||||
|
||||
@@ -231,7 +231,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
@callback
|
||||
def _update_values(self) -> None:
|
||||
"""Set base values from underlying lights of a group."""
|
||||
supported_color_modes: set[ColorMode | str] = set()
|
||||
supported_color_modes: set[ColorMode] = set()
|
||||
lights_with_color_support = 0
|
||||
lights_with_color_temp_support = 0
|
||||
lights_with_dimming_support = 0
|
||||
|
||||
@@ -63,16 +63,14 @@ class HueBLELight(LightEntity):
|
||||
|
||||
self._api = light
|
||||
self._attr_unique_id = light.address
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
color_util.color_temperature_mired_to_kelvin(light.maximum_mireds)
|
||||
if light.maximum_mireds
|
||||
else None
|
||||
)
|
||||
self._attr_max_color_temp_kelvin = (
|
||||
color_util.color_temperature_mired_to_kelvin(light.minimum_mireds)
|
||||
if light.minimum_mireds
|
||||
else None
|
||||
)
|
||||
if light.maximum_mireds:
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
color_util.color_temperature_mired_to_kelvin(light.maximum_mireds)
|
||||
)
|
||||
if light.minimum_mireds:
|
||||
self._attr_max_color_temp_kelvin = (
|
||||
color_util.color_temperature_mired_to_kelvin(light.minimum_mireds)
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=light.name,
|
||||
connections={(CONNECTION_BLUETOOTH, light.address)},
|
||||
|
||||
@@ -47,7 +47,7 @@ class InsteonEntity(Entity):
|
||||
return str(self._insteon_device.address)
|
||||
|
||||
@property
|
||||
def group(self):
|
||||
def insteon_group(self):
|
||||
"""Return the INSTEON group that the entity responds to."""
|
||||
return self._insteon_device_group.group
|
||||
|
||||
@@ -76,7 +76,7 @@ class InsteonEntity(Entity):
|
||||
"""Provide attributes for display on device card."""
|
||||
return {
|
||||
"insteon_address": self.address,
|
||||
"insteon_group": self.group,
|
||||
"insteon_group": self.insteon_group,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -114,7 +114,7 @@ class InsteonEntity(Entity):
|
||||
_LOGGER.debug(
|
||||
"Tracking updates for device %s group %d name %s",
|
||||
self.address,
|
||||
self.group,
|
||||
self.insteon_group,
|
||||
self._insteon_device_group.name,
|
||||
)
|
||||
self._insteon_device_group.subscribe(self.async_entity_update)
|
||||
@@ -142,7 +142,7 @@ class InsteonEntity(Entity):
|
||||
_LOGGER.debug(
|
||||
"Remove tracking updates for device %s group %d name %s",
|
||||
self.address,
|
||||
self.group,
|
||||
self.insteon_group,
|
||||
self._insteon_device_group.name,
|
||||
)
|
||||
self._insteon_device_group.unsubscribe(self.async_entity_update)
|
||||
@@ -170,7 +170,7 @@ class InsteonEntity(Entity):
|
||||
if self._insteon_device_group.name in STATE_NAME_LABEL_MAP:
|
||||
label = STATE_NAME_LABEL_MAP[self._insteon_device_group.name]
|
||||
else:
|
||||
label = f"Group {self.group:d}"
|
||||
label = f"Group {self.insteon_group:d}"
|
||||
return label
|
||||
|
||||
async def _async_add_default_links(self):
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.14.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2026.1.15.112308"
|
||||
"knx-frontend==2026.1.28.162006"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -55,6 +56,7 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
|
||||
port=config_entry.data[CONF_PORT],
|
||||
username=config_entry.data.get(CONF_USERNAME),
|
||||
password=config_entry.data.get(CONF_PASSWORD),
|
||||
session=async_create_clientsession(hass),
|
||||
)
|
||||
device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry(
|
||||
registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id
|
||||
|
||||
@@ -74,5 +74,5 @@ rules:
|
||||
stale-devices: done
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
|
||||
@@ -123,8 +123,8 @@ def filter_supported_color_modes(color_modes: Iterable[ColorMode]) -> set[ColorM
|
||||
|
||||
|
||||
def valid_supported_color_modes(
|
||||
color_modes: Iterable[ColorMode | str],
|
||||
) -> set[ColorMode | str]:
|
||||
color_modes: Iterable[ColorMode],
|
||||
) -> set[ColorMode]:
|
||||
"""Validate the given color modes."""
|
||||
color_modes = set(color_modes)
|
||||
if (
|
||||
@@ -902,7 +902,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
entity_description: LightEntityDescription
|
||||
_attr_brightness: int | None = None
|
||||
_attr_color_mode: ColorMode | str | None = None
|
||||
_attr_color_mode: ColorMode | None = None
|
||||
_attr_color_temp_kelvin: int | None = None
|
||||
_attr_effect_list: list[str] | None = None
|
||||
_attr_effect: str | None = None
|
||||
@@ -915,7 +915,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_rgb_color: tuple[int, int, int] | None = None
|
||||
_attr_rgbw_color: tuple[int, int, int, int] | None = None
|
||||
_attr_rgbww_color: tuple[int, int, int, int, int] | None = None
|
||||
_attr_supported_color_modes: set[ColorMode] | set[str] | None = None
|
||||
_attr_supported_color_modes: set[ColorMode] | None = None
|
||||
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
|
||||
_attr_xy_color: tuple[float, float] | None = None
|
||||
|
||||
@@ -932,7 +932,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return self._attr_brightness
|
||||
|
||||
@cached_property
|
||||
def color_mode(self) -> ColorMode | str | None:
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
"""Return the color mode of the light."""
|
||||
return self._attr_color_mode
|
||||
|
||||
@@ -1233,7 +1233,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
def __validate_supported_color_modes(
|
||||
self,
|
||||
supported_color_modes: set[ColorMode] | set[str],
|
||||
supported_color_modes: set[ColorMode],
|
||||
) -> None:
|
||||
"""Validate the supported color modes."""
|
||||
if self.__color_mode_reported:
|
||||
@@ -1339,7 +1339,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return data
|
||||
|
||||
@property
|
||||
def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]:
|
||||
def _light_internal_supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Calculate supported color modes with backwards compatibility."""
|
||||
if (_supported_color_modes := self.supported_color_modes) is not None:
|
||||
self.__validate_supported_color_modes(_supported_color_modes)
|
||||
@@ -1379,7 +1379,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return supported_color_modes
|
||||
|
||||
@cached_property
|
||||
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
"""Flag supported color modes."""
|
||||
return self._attr_supported_color_modes
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ def state[_LimitlessLEDGroupT: LimitlessLEDGroup, **_P](
|
||||
pipeline = Pipeline()
|
||||
transition_time = DEFAULT_TRANSITION
|
||||
if self.effect == EFFECT_COLORLOOP:
|
||||
self.group.stop()
|
||||
self.led_group.stop()
|
||||
self._attr_effect = None
|
||||
# Set transition time.
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
@@ -205,7 +205,7 @@ def state[_LimitlessLEDGroupT: LimitlessLEDGroup, **_P](
|
||||
function(self, transition_time, pipeline, *args, **kwargs)
|
||||
# Update state.
|
||||
self._attr_is_on = new_state
|
||||
self.group.enqueue(pipeline)
|
||||
self.led_group.enqueue(pipeline)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
return wrapper
|
||||
@@ -250,7 +250,7 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
|
||||
ColorMode.HS,
|
||||
}
|
||||
|
||||
self.group = group
|
||||
self.led_group = group
|
||||
self._attr_name = group.name
|
||||
self.config = config
|
||||
self._attr_is_on = False
|
||||
@@ -275,7 +275,7 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity):
|
||||
return self._attr_brightness
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._fixed_color_mode:
|
||||
return self._fixed_color_mode
|
||||
|
||||
@@ -21,7 +21,6 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.frame import report_usage
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
@@ -109,34 +108,6 @@ class LovelaceData:
|
||||
resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection
|
||||
yaml_dashboards: dict[str | None, ConfigType]
|
||||
|
||||
def __getitem__(self, name: str) -> Any:
|
||||
"""Enable method for compatibility reason.
|
||||
|
||||
Following migration from an untyped dict to a dataclass in
|
||||
https://github.com/home-assistant/core/pull/136313
|
||||
"""
|
||||
report_usage(
|
||||
f"accessed lovelace_data['{name}'] instead of lovelace_data.{name}",
|
||||
breaks_in_ha_version="2026.2",
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
return getattr(self, name)
|
||||
|
||||
def get(self, name: str, default: Any = None) -> Any:
|
||||
"""Enable method for compatibility reason.
|
||||
|
||||
Following migration from an untyped dict to a dataclass in
|
||||
https://github.com/home-assistant/core/pull/136313
|
||||
"""
|
||||
report_usage(
|
||||
f"accessed lovelace_data.get('{name}') instead of lovelace_data.{name}",
|
||||
breaks_in_ha_version="2026.2",
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
if hasattr(self, name):
|
||||
return getattr(self, name)
|
||||
return default
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Lovelace commands."""
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.5.7"]
|
||||
"requirements": ["lunatone-rest-api-client==0.6.3"]
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ def account_meta(data: Account) -> Mapping[str, Any]:
|
||||
"display_name": data.display_name,
|
||||
"bio": data.note,
|
||||
"created": dt_util.as_local(data.created_at).date(),
|
||||
**{f.name: f.value for f in data.fields},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"addon_connection_failed": "Failed to connect to the {addon} add-on. Check the add-on status and try again later.",
|
||||
"addon_info_failed": "Failed get info for the {addon} add-on.",
|
||||
"addon_install_failed": "Failed to install the {addon} add-on.",
|
||||
"addon_start_failed": "Failed to start the {addon} add-on.",
|
||||
"addon_connection_failed": "Failed to connect to the {addon} app. Check the app status and try again later.",
|
||||
"addon_info_failed": "Failed get info for the {addon} app.",
|
||||
"addon_install_failed": "Failed to install the {addon} app.",
|
||||
"addon_start_failed": "Failed to start the {addon} app.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
@@ -68,11 +68,11 @@
|
||||
"description": "Please enter the connection information of your MQTT broker."
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?",
|
||||
"title": "MQTT broker via Home Assistant add-on"
|
||||
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the {addon} app?",
|
||||
"title": "MQTT broker via Home Assistant app"
|
||||
},
|
||||
"install_addon": {
|
||||
"title": "Installing add-on"
|
||||
"title": "Installing app"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
@@ -87,12 +87,12 @@
|
||||
"title": "Re-authentication required with the MQTT broker"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "Starting add-on"
|
||||
"title": "Starting app"
|
||||
},
|
||||
"user": {
|
||||
"description": "Please choose how you want to connect to the MQTT broker:",
|
||||
"menu_options": {
|
||||
"addon": "Use the official {addon} add-on.",
|
||||
"addon": "Use the official {addon} app.",
|
||||
"broker": "Manually enter the MQTT broker connection details"
|
||||
}
|
||||
}
|
||||
@@ -1085,7 +1085,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"addon_start_failed": {
|
||||
"message": "Failed to correctly start {addon} add-on."
|
||||
"message": "Failed to correctly start {addon} app."
|
||||
},
|
||||
"command_template_error": {
|
||||
"message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}."
|
||||
@@ -1120,12 +1120,8 @@
|
||||
"description": "Entity {entity_id} uses the `object_id` option which is deprecated. To fix the issue, replace the `object_id: {object_id}` option with `default_entity_id: {domain}.{object_id}` in your \"configuration.yaml\", and restart Home Assistant.",
|
||||
"title": "Deprecated option object_id used"
|
||||
},
|
||||
"deprecated_vacuum_battery_feature": {
|
||||
"description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant.",
|
||||
"title": "Deprecated battery feature used"
|
||||
},
|
||||
"invalid_platform_config": {
|
||||
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.",
|
||||
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.",
|
||||
"title": "Invalid config found for MQTT {domain} item"
|
||||
},
|
||||
"subentry_migration_discovery": {
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType
|
||||
@@ -25,15 +25,14 @@ from homeassistant.util.json import json_loads_object
|
||||
|
||||
from . import subscription
|
||||
from .config import MQTT_BASE_SCHEMA
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN
|
||||
from .entity import IssueSeverity, MqttEntity, async_setup_entity_entry_helper
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
from .util import learn_more_url, valid_publish_topic
|
||||
from .util import valid_publish_topic
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
BATTERY = "battery_level"
|
||||
FAN_SPEED = "fan_speed"
|
||||
STATE = "state"
|
||||
|
||||
@@ -84,9 +83,6 @@ SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = {
|
||||
VacuumEntityFeature.STOP: "stop",
|
||||
VacuumEntityFeature.RETURN_HOME: "return_home",
|
||||
VacuumEntityFeature.FAN_SPEED: "fan_speed",
|
||||
# Use of the battery feature was deprecated in HA Core 2025.8
|
||||
# and will be removed with HA Core 2026.2
|
||||
VacuumEntityFeature.BATTERY: "battery",
|
||||
VacuumEntityFeature.STATUS: "status",
|
||||
VacuumEntityFeature.SEND_COMMAND: "send_command",
|
||||
VacuumEntityFeature.LOCATE: "locate",
|
||||
@@ -134,8 +130,6 @@ _FEATURE_PAYLOADS = {
|
||||
|
||||
MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
|
||||
{
|
||||
vacuum.ATTR_BATTERY_ICON,
|
||||
vacuum.ATTR_BATTERY_LEVEL,
|
||||
vacuum.ATTR_FAN_SPEED,
|
||||
}
|
||||
)
|
||||
@@ -252,36 +246,10 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
)
|
||||
}
|
||||
|
||||
async def mqtt_async_added_to_hass(self) -> None:
|
||||
"""Check for use of deprecated battery features."""
|
||||
if self.supported_features & VacuumEntityFeature.BATTERY:
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_vacuum_battery_feature_{self.entity_id}",
|
||||
issue_domain=vacuum.DOMAIN,
|
||||
breaks_in_ha_version="2026.2",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url=learn_more_url(vacuum.DOMAIN),
|
||||
translation_placeholders={"entity_id": self.entity_id},
|
||||
translation_key="deprecated_vacuum_battery_feature",
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"MQTT vacuum entity %s implements the battery feature "
|
||||
"which is deprecated. This will stop working "
|
||||
"in Home Assistant 2026.2. Implement a separate entity "
|
||||
"for the battery status instead",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
def _update_state_attributes(self, payload: dict[str, Any]) -> None:
|
||||
"""Update the entity state attributes."""
|
||||
self._state_attrs.update(payload)
|
||||
self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0)
|
||||
# Use of the battery feature was deprecated in HA Core 2025.8
|
||||
# and will be removed with HA Core 2026.2
|
||||
self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0)))
|
||||
|
||||
@callback
|
||||
def _state_message_received(self, msg: ReceiveMessage) -> None:
|
||||
|
||||
@@ -96,7 +96,6 @@ async def validate_nibegw_input(
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
heatpump = HeatPump(Model[data[CONF_MODEL]])
|
||||
heatpump.word_swap = True
|
||||
await heatpump.initialize()
|
||||
|
||||
connection = NibeGW(
|
||||
@@ -114,6 +113,9 @@ async def validate_nibegw_input(
|
||||
"Address already in use", "listening_port", "address_in_use"
|
||||
) from exception
|
||||
|
||||
if heatpump.word_swap is None:
|
||||
heatpump.word_swap = True
|
||||
|
||||
try:
|
||||
await connection.verify_connectivity()
|
||||
except (ReadSendException, CoilWriteSendException) as exception:
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
|
||||
|
||||
114
homeassistant/components/openevse/number.py
Normal file
114
homeassistant/components/openevse/number.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Support for OpenEVSE number entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_SERIAL_NUMBER,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpenEVSENumberDescription(NumberEntityDescription):
|
||||
"""Describes an OpenEVSE number entity."""
|
||||
|
||||
value_fn: Callable[[OpenEVSE], float]
|
||||
min_value_fn: Callable[[OpenEVSE], float]
|
||||
max_value_fn: Callable[[OpenEVSE], float]
|
||||
set_value_fn: Callable[[OpenEVSE, float], Awaitable[Any]]
|
||||
|
||||
|
||||
NUMBER_TYPES: tuple[OpenEVSENumberDescription, ...] = (
|
||||
OpenEVSENumberDescription(
|
||||
key="charge_rate",
|
||||
translation_key="charge_rate",
|
||||
value_fn=lambda ev: ev.max_current_soft,
|
||||
min_value_fn=lambda ev: ev.min_amps,
|
||||
max_value_fn=lambda ev: ev.max_amps,
|
||||
set_value_fn=lambda ev, value: ev.set_current(value),
|
||||
native_step=1.0,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=NumberDeviceClass.CURRENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OpenEVSEConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up OpenEVSE sensors based on config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
identifier = entry.unique_id or entry.entry_id
|
||||
async_add_entities(
|
||||
OpenEVSENumber(coordinator, description, identifier, entry.unique_id)
|
||||
for description in NUMBER_TYPES
|
||||
)
|
||||
|
||||
|
||||
class OpenEVSENumber(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], NumberEntity):
|
||||
"""Implementation of an OpenEVSE sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: OpenEVSENumberDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OpenEVSEDataUpdateCoordinator,
|
||||
description: OpenEVSENumberDescription,
|
||||
identifier: str,
|
||||
unique_id: str | None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{identifier}-{description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
manufacturer="OpenEVSE",
|
||||
)
|
||||
if unique_id:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, unique_id)
|
||||
}
|
||||
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the number."""
|
||||
return self.entity_description.value_fn(self.coordinator.charger)
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return the minimum value."""
|
||||
return self.entity_description.min_value_fn(self.coordinator.charger)
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
return self.entity_description.max_value_fn(self.coordinator.charger)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.entity_description.set_value_fn(self.coordinator.charger, value)
|
||||
@@ -30,6 +30,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"charge_rate": {
|
||||
"name": "Charge rate"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ambient_temp": {
|
||||
"name": "Ambient temperature"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
"requirements": ["python-otbr-api==2.7.1"]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"get_get_border_agent_id_unsupported": {
|
||||
"description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR.",
|
||||
"description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router app or the Silicon Labs Multiprotocol app. Otherwise update your self-managed OTBR.",
|
||||
"title": "The OTBR does not support Border Agent ID"
|
||||
},
|
||||
"insecure_thread_network": {
|
||||
|
||||
35
homeassistant/components/prana/__init__.py
Normal file
35
homeassistant/components/prana/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Home Assistant Prana integration entry point.
|
||||
|
||||
Sets up the update coordinator and forwards platform setups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Keep platforms sorted alphabetically to satisfy lint rule
|
||||
PLATFORMS = [Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:
|
||||
"""Set up Prana from a config entry."""
|
||||
|
||||
coordinator = PranaCoordinator(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: PranaConfigEntry) -> bool:
|
||||
"""Unload Prana integration platforms and coordinator."""
|
||||
_LOGGER.info("Unloading Prana integration")
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
102
homeassistant/components/prana/config_flow.py
Normal file
102
homeassistant/components/prana/config_flow.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Configuration flow for Prana integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from prana_local_api_client.exceptions import PranaApiCommunicationError
|
||||
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
|
||||
from prana_local_api_client.prana_api_client import PranaLocalApiClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PranaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Prana config flow."""
|
||||
|
||||
_host: str
|
||||
_device_info: PranaDeviceInfo
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle Zeroconf discovery of a Prana device."""
|
||||
_LOGGER.debug("Discovered device via Zeroconf: %s", discovery_info)
|
||||
|
||||
friendly_name = discovery_info.properties.get("label", "")
|
||||
self.context["title_placeholders"] = {"name": friendly_name}
|
||||
self._host = discovery_info.host
|
||||
|
||||
try:
|
||||
self._device_info = await self._validate_device()
|
||||
except ValueError:
|
||||
return self.async_abort(reason="invalid_device")
|
||||
except PranaApiCommunicationError:
|
||||
return self.async_abort(reason="invalid_device_or_unreachable")
|
||||
|
||||
self._set_confirm_only()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Handle the user confirming a discovered Prana device."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._device_info.label,
|
||||
data={CONF_HOST: self._host},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={
|
||||
"name": self._device_info.label,
|
||||
"host": self._host,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Manual entry by IP address."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._host = user_input[CONF_HOST]
|
||||
try:
|
||||
self._device_info = await self._validate_device()
|
||||
except ValueError:
|
||||
return self.async_abort(reason="invalid_device")
|
||||
except PranaApiCommunicationError:
|
||||
errors = {"base": "invalid_device_or_unreachable"}
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._device_info.label,
|
||||
data={CONF_HOST: self._host},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=USER_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _validate_device(self) -> PranaDeviceInfo:
|
||||
"""Validate that a Prana device is reachable and valid."""
|
||||
client = PranaLocalApiClient(host=self._host, port=80)
|
||||
device_info = await client.get_device_info()
|
||||
|
||||
if not device_info.isValid:
|
||||
raise ValueError("invalid_device")
|
||||
|
||||
await self.async_set_unique_id(device_info.manufactureId)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return device_info
|
||||
3
homeassistant/components/prana/const.py
Normal file
3
homeassistant/components/prana/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for Prana integration."""
|
||||
|
||||
DOMAIN = "prana"
|
||||
67
homeassistant/components/prana/coordinator.py
Normal file
67
homeassistant/components/prana/coordinator.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Coordinator for Prana integration.
|
||||
|
||||
Responsible for polling the device REST endpoints and normalizing data for entities.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from prana_local_api_client.exceptions import (
|
||||
PranaApiCommunicationError,
|
||||
PranaApiUpdateFailed,
|
||||
)
|
||||
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
|
||||
from prana_local_api_client.models.prana_state import PranaState
|
||||
from prana_local_api_client.prana_api_client import PranaLocalApiClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=10)
|
||||
COORDINATOR_NAME = f"{DOMAIN} coordinator"
|
||||
|
||||
type PranaConfigEntry = ConfigEntry[PranaCoordinator]
|
||||
|
||||
|
||||
class PranaCoordinator(DataUpdateCoordinator[PranaState]):
|
||||
"""Universal coordinator for Prana (fan, switch, sensor, light data)."""
|
||||
|
||||
config_entry: PranaConfigEntry
|
||||
device_info: PranaDeviceInfo
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: PranaConfigEntry) -> None:
|
||||
"""Initialize the Prana data update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=COORDINATOR_NAME,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
self.api_client = PranaLocalApiClient(host=entry.data[CONF_HOST], port=80)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
self.device_info = await self.api_client.get_device_info()
|
||||
except PranaApiCommunicationError as err:
|
||||
raise ConfigEntryNotReady("Could not fetch device info") from err
|
||||
|
||||
async def _async_update_data(self) -> PranaState:
|
||||
"""Fetch and normalize device state for all platforms."""
|
||||
try:
|
||||
state = await self.api_client.get_state()
|
||||
except PranaApiUpdateFailed as err:
|
||||
raise UpdateFailed(f"HTTP error communicating with device: {err}") from err
|
||||
except PranaApiCommunicationError as err:
|
||||
raise UpdateFailed(
|
||||
f"Network error communicating with device: {err}"
|
||||
) from err
|
||||
return state
|
||||
50
homeassistant/components/prana/entity.py
Normal file
50
homeassistant/components/prana/entity.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Defines base Prana entity."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.switch import StrEnum
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import PranaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaEntityDescription(EntityDescription):
|
||||
"""Description for all Prana entities."""
|
||||
|
||||
key: StrEnum
|
||||
|
||||
|
||||
class PranaBaseEntity(CoordinatorEntity[PranaCoordinator]):
|
||||
"""Defines a base Prana entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_description: PranaEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PranaCoordinator,
|
||||
description: PranaEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Prana entity."""
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.config_entry.unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
|
||||
manufacturer="Prana",
|
||||
name=coordinator.device_info.label,
|
||||
model=coordinator.device_info.pranaModel,
|
||||
serial_number=coordinator.device_info.manufactureId,
|
||||
sw_version=str(coordinator.device_info.fwVersion),
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
self.entity_description = description
|
||||
21
homeassistant/components/prana/icons.json
Normal file
21
homeassistant/components/prana/icons.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"auto": {
|
||||
"default": "mdi:fan-auto"
|
||||
},
|
||||
"auto_plus": {
|
||||
"default": "mdi:fan-auto"
|
||||
},
|
||||
"bound": {
|
||||
"default": "mdi:link"
|
||||
},
|
||||
"heater": {
|
||||
"default": "mdi:radiator"
|
||||
},
|
||||
"winter": {
|
||||
"default": "mdi:snowflake"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
homeassistant/components/prana/manifest.json
Normal file
16
homeassistant/components/prana/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"domain": "prana",
|
||||
"name": "Prana",
|
||||
"codeowners": ["@prana-dev-official"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/prana",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["prana-api-client==0.10.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_prana._tcp.local."
|
||||
}
|
||||
]
|
||||
}
|
||||
76
homeassistant/components/prana/quality_scale.yaml
Normal file
76
homeassistant/components/prana/quality_scale.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
rules:
|
||||
# Bronze (must be satisfied or exempt)
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration registers no custom services/actions
|
||||
appropriate-polling: done # coordinator with 10s local interval (>5s min)
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration has no custom services/actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration has no entity-specific event handling
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done # coordinator stored in ConfigEntry.runtime_data
|
||||
test-before-configure: done # config flow validates discovery metadata
|
||||
test-before-setup: done # coordinator refreshed prior to platform setup
|
||||
unique-config-entry: done # unique_id based on device manufacturer id prevents duplicates
|
||||
|
||||
# Silver (future work)
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done # async_unload_entry implemented
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No configuration parameters
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done # codeowners present
|
||||
log-when-unavailable: done # logged in coordinator
|
||||
parallel-updates: done # PARALLEL_UPDATES defined in platforms
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration does not use authentication
|
||||
test-coverage: todo
|
||||
|
||||
# Gold (future work)
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: done # zeroconf implemented
|
||||
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: Device type integration
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done # strings + translation keys
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There is no to repair
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Device type integration
|
||||
|
||||
# Platinum (future work)
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
46
homeassistant/components/prana/strings.json
Normal file
46
homeassistant/components/prana/strings.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_device": "The device is invalid",
|
||||
"invalid_device_or_unreachable": "The device is invalid or unreachable"
|
||||
},
|
||||
"error": {
|
||||
"invalid_device_or_unreachable": "The device is invalid or unreachable"
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Set up {name} at {host}?",
|
||||
"title": "Confirm Prana device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address or hostname of your Prana device."
|
||||
},
|
||||
"title": "Add Prana device by IP"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"auto": {
|
||||
"name": "Auto"
|
||||
},
|
||||
"auto_plus": {
|
||||
"name": "Auto plus"
|
||||
},
|
||||
"bound": {
|
||||
"name": "Bound"
|
||||
},
|
||||
"heater": {
|
||||
"name": "Heater"
|
||||
},
|
||||
"winter": {
|
||||
"name": "Winter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
homeassistant/components/prana/switch.py
Normal file
100
homeassistant/components/prana/switch.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Switch platform for Prana integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import dataclass
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
StrEnum,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity, PranaEntityDescription
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class PranaSwitchType(StrEnum):
|
||||
"""Enumerates Prana switch types exposed by the device API."""
|
||||
|
||||
BOUND = "bound"
|
||||
HEATER = "heater"
|
||||
NIGHT = "night"
|
||||
BOOST = "boost"
|
||||
AUTO = "auto"
|
||||
AUTO_PLUS = "auto_plus"
|
||||
WINTER = "winter"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaSwitchEntityDescription(SwitchEntityDescription, PranaEntityDescription):
|
||||
"""Description of a Prana switch entity."""
|
||||
|
||||
value_fn: Callable[[PranaCoordinator], bool]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaEntityDescription, ...] = (
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.BOUND,
|
||||
translation_key="bound",
|
||||
value_fn=lambda coord: coord.data.bound,
|
||||
),
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.HEATER,
|
||||
translation_key="heater",
|
||||
value_fn=lambda coord: coord.data.heater,
|
||||
),
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.AUTO,
|
||||
translation_key="auto",
|
||||
value_fn=lambda coord: coord.data.auto,
|
||||
),
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.AUTO_PLUS,
|
||||
translation_key="auto_plus",
|
||||
value_fn=lambda coord: coord.data.auto_plus,
|
||||
),
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.WINTER,
|
||||
translation_key="winter",
|
||||
value_fn=lambda coord: coord.data.winter,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PranaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Prana switch entities from a config entry."""
|
||||
async_add_entities(
|
||||
PranaSwitch(entry.runtime_data, entity_description)
|
||||
for entity_description in ENTITIES
|
||||
)
|
||||
|
||||
|
||||
class PranaSwitch(PranaBaseEntity, SwitchEntity):
|
||||
"""Representation of a Prana switch (bound/heater/auto/etc)."""
|
||||
|
||||
entity_description: PranaSwitchEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return switch on/off state."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.coordinator.api_client.set_switch(self.entity_description.key, True)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.coordinator.api_client.set_switch(self.entity_description.key, False)
|
||||
await self.coordinator.async_refresh()
|
||||
@@ -1035,7 +1035,7 @@
|
||||
"title": "Reolink password too long"
|
||||
},
|
||||
"ssl": {
|
||||
"description": "Global SSL certificate configured in the [configuration.yaml under http]({ssl_link}) while a local HTTP address `{base_url}` is configured under \"Home Assistant URL\" in the [network settings]({network_link}). Therefore, the Reolink device can not reach Home Assistant to push its motion/AI events. Please make sure the local HTTP address is not covered by the SSL certificate, by for instance using [NGINX add-on]({nginx_link}) instead of a globally enforced SSL certificate.",
|
||||
"description": "Global SSL certificate configured in the [configuration.yaml under http]({ssl_link}) while a local HTTP address `{base_url}` is configured under \"Home Assistant URL\" in the [network settings]({network_link}). Therefore, the Reolink device cannot reach Home Assistant to push its motion/AI events. Please make sure the local HTTP address is not covered by the SSL certificate by, for instance, using the [NGINX app for Home Assistant]({nginx_link}) instead of a globally enforced SSL certificate.",
|
||||
"title": "Reolink incompatible with global SSL certificate"
|
||||
},
|
||||
"webhook_url": {
|
||||
|
||||
@@ -553,6 +553,8 @@ class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01):
|
||||
RoborockB01Props.REAL_CLEAN_TIME,
|
||||
RoborockB01Props.HYPA,
|
||||
RoborockB01Props.WIND,
|
||||
RoborockB01Props.WATER,
|
||||
RoborockB01Props.MODE,
|
||||
]
|
||||
|
||||
async def _async_update_data(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Support for Roborock select."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from roborock.data import RoborockDockDustCollectionModeCode
|
||||
from roborock import B01Props, CleanTypeMapping
|
||||
from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping
|
||||
from roborock.devices.traits.b01 import Q7PropertiesApi
|
||||
from roborock.devices.traits.v1 import PropertiesApi
|
||||
from roborock.devices.traits.v1.home import HomeTrait
|
||||
from roborock.devices.traits.v1.maps import MapsTrait
|
||||
@@ -18,8 +21,12 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MAP_SLEEP
|
||||
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
|
||||
from .entity import RoborockCoordinatedEntityV1
|
||||
from .coordinator import (
|
||||
RoborockB01Q7UpdateCoordinator,
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
)
|
||||
from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -44,6 +51,42 @@ class RoborockSelectDescription(SelectEntityDescription):
|
||||
"""Whether this entity is for the dock."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockB01SelectDescription(SelectEntityDescription):
|
||||
"""Class to describe a Roborock B01 select entity."""
|
||||
|
||||
api_fn: Callable[[Q7PropertiesApi, str], Awaitable[Any]]
|
||||
"""Function to call the API."""
|
||||
|
||||
value_fn: Callable[[B01Props], str | None]
|
||||
"""Function to get the current value of the select entity."""
|
||||
|
||||
options_lambda: Callable[[Q7PropertiesApi], list[str] | None]
|
||||
"""Function to get all options of the select entity or returns None if not supported."""
|
||||
|
||||
|
||||
B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [
|
||||
RoborockB01SelectDescription(
|
||||
key="water_flow",
|
||||
translation_key="water_flow",
|
||||
api_fn=lambda api, value: api.set_water_level(
|
||||
WaterLevelMapping.from_value(value)
|
||||
),
|
||||
value_fn=lambda data: data.water.value if data.water else None,
|
||||
options_lambda=lambda _: [option.value for option in WaterLevelMapping],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
RoborockB01SelectDescription(
|
||||
key="cleaning_mode",
|
||||
translation_key="cleaning_mode",
|
||||
api_fn=lambda api, value: api.set_mode(CleanTypeMapping.from_value(value)),
|
||||
value_fn=lambda data: data.mode.value if data.mode else None,
|
||||
options_lambda=lambda _: list(CleanTypeMapping.keys()),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
|
||||
RoborockSelectDescription(
|
||||
key="water_box_mode",
|
||||
@@ -114,6 +157,52 @@ async def async_setup_entry(
|
||||
if (home_trait := coordinator.properties_api.home) is not None
|
||||
if (map_trait := coordinator.properties_api.maps) is not None
|
||||
)
|
||||
async_add_entities(
|
||||
RoborockB01SelectEntity(coordinator, description, options)
|
||||
for coordinator in config_entry.runtime_data.b01
|
||||
for description in B01_SELECT_DESCRIPTIONS
|
||||
if isinstance(coordinator, RoborockB01Q7UpdateCoordinator)
|
||||
if (options := description.options_lambda(coordinator.api)) is not None
|
||||
)
|
||||
|
||||
|
||||
class RoborockB01SelectEntity(RoborockCoordinatedEntityB01, SelectEntity):
|
||||
"""Select entity for Roborock B01 devices."""
|
||||
|
||||
entity_description: RoborockB01SelectDescription
|
||||
coordinator: RoborockB01Q7UpdateCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockB01Q7UpdateCoordinator,
|
||||
entity_description: RoborockB01SelectDescription,
|
||||
options: list[str],
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(
|
||||
f"{entity_description.key}_{coordinator.duid_slug}", coordinator
|
||||
)
|
||||
self._attr_options = options
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the option."""
|
||||
try:
|
||||
await self.entity_description.api_fn(self.coordinator.api, option)
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={
|
||||
"command": self.entity_description.key,
|
||||
},
|
||||
) from err
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current option."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
|
||||
class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"cleaning_mode": {
|
||||
"name": "Cleaning mode",
|
||||
"state": {
|
||||
"mop": "Mop only",
|
||||
"vac_and_mop": "Vacuum and mop",
|
||||
"vacuum": "Vacuum only"
|
||||
}
|
||||
},
|
||||
"dust_collection_mode": {
|
||||
"name": "Empty mode",
|
||||
"state": {
|
||||
@@ -126,6 +134,14 @@
|
||||
},
|
||||
"selected_map": {
|
||||
"name": "Selected map"
|
||||
},
|
||||
"water_flow": {
|
||||
"name": "Water flow",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key of the SABnzbd server. This can be found in the SABnzbd web interface under Config cog (top right) > General > Security.",
|
||||
"url": "The full URL, including port, of the SABnzbd server. Example: `{sabnzbd_full_url_local}` or `{sabnzbd_full_url_addon}`, if you are using the add-on."
|
||||
"url": "The full URL, including port, of the SABnzbd server. Example: `{sabnzbd_full_url_local}` or `{sabnzbd_full_url_addon}`, if you are using the SABnzbd app for Home Assistant."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class BlockShellyLight(ShellyBlockAttributeEntity, LightEntity):
|
||||
"""Entity that controls a light on block based Shelly devices."""
|
||||
|
||||
entity_description: BlockLightDescription
|
||||
_attr_supported_color_modes: set[str]
|
||||
_attr_supported_color_modes: set[ColorMode]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sia",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pysiaalarm"],
|
||||
"requirements": ["pysiaalarm==3.1.1"]
|
||||
"requirements": ["pysiaalarm==3.2.2"]
|
||||
}
|
||||
|
||||
@@ -110,9 +110,7 @@ async def async_setup_entry(
|
||||
serial,
|
||||
version,
|
||||
sensor,
|
||||
description.native_unit_of_measurement,
|
||||
description.state_class,
|
||||
description.device_class,
|
||||
description,
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
@@ -131,17 +129,13 @@ class InverterSensorEntity(CoordinatorEntity, SensorEntity):
|
||||
serial: str,
|
||||
version: str,
|
||||
key: str,
|
||||
unit: str | None,
|
||||
state_class: SensorStateClass | str | None,
|
||||
device_class: SensorDeviceClass | None,
|
||||
entity_description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an inverter sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = uid
|
||||
self._attr_name = f"{manufacturer} {serial} {key}"
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_state_class = state_class
|
||||
self._attr_device_class = device_class
|
||||
self.entity_description = entity_description
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -8,30 +8,7 @@ from typing import Any
|
||||
|
||||
from homeassistant.core import HassJob, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstant,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_NT as _ATTR_NT,
|
||||
ATTR_ST as _ATTR_ST,
|
||||
ATTR_UPNP_DEVICE_TYPE as _ATTR_UPNP_DEVICE_TYPE,
|
||||
ATTR_UPNP_FRIENDLY_NAME as _ATTR_UPNP_FRIENDLY_NAME,
|
||||
ATTR_UPNP_MANUFACTURER as _ATTR_UPNP_MANUFACTURER,
|
||||
ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL,
|
||||
ATTR_UPNP_MODEL_DESCRIPTION as _ATTR_UPNP_MODEL_DESCRIPTION,
|
||||
ATTR_UPNP_MODEL_NAME as _ATTR_UPNP_MODEL_NAME,
|
||||
ATTR_UPNP_MODEL_NUMBER as _ATTR_UPNP_MODEL_NUMBER,
|
||||
ATTR_UPNP_MODEL_URL as _ATTR_UPNP_MODEL_URL,
|
||||
ATTR_UPNP_PRESENTATION_URL as _ATTR_UPNP_PRESENTATION_URL,
|
||||
ATTR_UPNP_SERIAL as _ATTR_UPNP_SERIAL,
|
||||
ATTR_UPNP_SERVICE_LIST as _ATTR_UPNP_SERVICE_LIST,
|
||||
ATTR_UPNP_UDN as _ATTR_UPNP_UDN,
|
||||
ATTR_UPNP_UPC as _ATTR_UPNP_UPC,
|
||||
SsdpServiceInfo as _SsdpServiceInfo,
|
||||
)
|
||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo as _SsdpServiceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_ssdp, bind_hass
|
||||
from homeassistant.util.logging import catch_log_exception
|
||||
@@ -56,93 +33,12 @@ ATTR_SSDP_EXT = "ssdp_ext"
|
||||
ATTR_SSDP_SERVER = "ssdp_server"
|
||||
ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG"
|
||||
ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG"
|
||||
# Attributes for accessing info from retrieved UPnP device description
|
||||
_DEPRECATED_ATTR_ST = DeprecatedConstant(
|
||||
_ATTR_ST,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_ST",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_NT = DeprecatedConstant(
|
||||
_ATTR_NT,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_NT",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_DEVICE_TYPE = DeprecatedConstant(
|
||||
_ATTR_UPNP_DEVICE_TYPE,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_FRIENDLY_NAME = DeprecatedConstant(
|
||||
_ATTR_UPNP_FRIENDLY_NAME,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_MANUFACTURER = DeprecatedConstant(
|
||||
_ATTR_UPNP_MANUFACTURER,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_MANUFACTURER_URL = DeprecatedConstant(
|
||||
_ATTR_UPNP_MANUFACTURER_URL,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_MODEL_DESCRIPTION = DeprecatedConstant(
|
||||
_ATTR_UPNP_MODEL_DESCRIPTION,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_MODEL_NAME = DeprecatedConstant(
|
||||
_ATTR_UPNP_MODEL_NAME,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_MODEL_NUMBER = DeprecatedConstant(
|
||||
_ATTR_UPNP_MODEL_NUMBER,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_MODEL_URL = DeprecatedConstant(
|
||||
_ATTR_UPNP_MODEL_URL,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_SERIAL = DeprecatedConstant(
|
||||
_ATTR_UPNP_SERIAL,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_SERVICE_LIST = DeprecatedConstant(
|
||||
_ATTR_UPNP_SERVICE_LIST,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_UDN = DeprecatedConstant(
|
||||
_ATTR_UPNP_UDN,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_UPC = DeprecatedConstant(
|
||||
_ATTR_UPNP_UPC,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC",
|
||||
"2026.2",
|
||||
)
|
||||
_DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant(
|
||||
_ATTR_UPNP_PRESENTATION_URL,
|
||||
"homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL",
|
||||
"2026.2",
|
||||
)
|
||||
|
||||
# Attributes for accessing info added by Home Assistant
|
||||
ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains"
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
_DEPRECATED_SsdpServiceInfo = DeprecatedConstant(
|
||||
_SsdpServiceInfo,
|
||||
"homeassistant.helpers.service_info.ssdp.SsdpServiceInfo",
|
||||
"2026.2",
|
||||
)
|
||||
|
||||
|
||||
def _format_err(name: str, *args: Any) -> str:
|
||||
"""Format error message."""
|
||||
@@ -217,11 +113,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
websocket_api.async_setup(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# 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())
|
||||
|
||||
@@ -50,13 +50,9 @@ class DaliCenterLight(DaliDeviceEntity, LightEntity):
|
||||
"""Representation of a Sunricher DALI Light."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_is_on: bool | None = None
|
||||
_attr_brightness: int | None = None
|
||||
_attr_min_color_temp_kelvin = 1000
|
||||
_attr_max_color_temp_kelvin = 8000
|
||||
_white_level: int | None = None
|
||||
_attr_color_mode: ColorMode | str | None = None
|
||||
_attr_color_temp_kelvin: int | None = None
|
||||
_attr_hs_color: tuple[float, float] | None = None
|
||||
_attr_rgbw_color: tuple[int, int, int, int] | None = None
|
||||
|
||||
def __init__(self, light: Device) -> None:
|
||||
"""Initialize the light entity."""
|
||||
@@ -69,8 +65,6 @@ class DaliCenterLight(DaliDeviceEntity, LightEntity):
|
||||
model=light.model,
|
||||
via_device=(DOMAIN, light.gw_sn),
|
||||
)
|
||||
self._attr_min_color_temp_kelvin = 1000
|
||||
self._attr_max_color_temp_kelvin = 8000
|
||||
|
||||
self._determine_features()
|
||||
|
||||
|
||||
@@ -95,10 +95,10 @@ class TasmotaLight(
|
||||
|
||||
def __init__(self, **kwds: Any) -> None:
|
||||
"""Initialize Tasmota light."""
|
||||
self._supported_color_modes: set[str] | None = None
|
||||
self._supported_color_modes: set[ColorMode] | None = None
|
||||
|
||||
self._brightness: int | None = None
|
||||
self._color_mode: str | None = None
|
||||
self._color_mode: ColorMode | None = None
|
||||
self._color_temp: int | None = None
|
||||
self._effect: str | None = None
|
||||
self._white_value: int | None = None
|
||||
@@ -195,7 +195,7 @@ class TasmotaLight(
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
"""Return the color mode of the light."""
|
||||
return self._color_mode
|
||||
|
||||
@@ -241,7 +241,7 @@ class TasmotaLight(
|
||||
return (hs_color[0], hs_color[1])
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
"""Flag supported color modes."""
|
||||
return self._supported_color_modes
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user