mirror of
https://github.com/home-assistant/core.git
synced 2025-11-21 00:36:54 +00:00
Compare commits
47 Commits
setpoint_c
...
edenhaus-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364e373e06 | ||
|
|
96a974dc94 | ||
|
|
e572f8d48f | ||
|
|
482b5d49a3 | ||
|
|
126fd217e7 | ||
|
|
0327b0e1ec | ||
|
|
3d5a7b4813 | ||
|
|
e0bb30f63b | ||
|
|
e5ae58c5df | ||
|
|
13e4bb4b93 | ||
|
|
d5fd27d2a2 | ||
|
|
0a034b9984 | ||
|
|
6a8106c0eb | ||
|
|
2cacfc7413 | ||
|
|
388ab5c16c | ||
|
|
81ea6f8c25 | ||
|
|
4f885994b7 | ||
|
|
25e2c9ee80 | ||
|
|
12c04f5571 | ||
|
|
3ad1c6a47a | ||
|
|
e7e13ecc74 | ||
|
|
991b8d2040 | ||
|
|
43fadbf6b4 | ||
|
|
ca79d37135 | ||
|
|
df8ef15535 | ||
|
|
249c1530d0 | ||
|
|
081b769abc | ||
|
|
b8b101d747 | ||
|
|
a19be192e0 | ||
|
|
92da82a200 | ||
|
|
820ba1dfba | ||
|
|
63c8962f09 | ||
|
|
c1a6996549 | ||
|
|
05253841af | ||
|
|
f2ef0503a0 | ||
|
|
938da38fc3 | ||
|
|
9311a87bf5 | ||
|
|
b45294ded3 | ||
|
|
82d3190016 | ||
|
|
d8cbcc1977 | ||
|
|
4b69543515 | ||
|
|
97ef4a35b9 | ||
|
|
f782c78650 | ||
|
|
139ed34c74 | ||
|
|
7f14d013ac | ||
|
|
963e27dda4 | ||
|
|
b8e3d57fea |
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -627,6 +627,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/guardian/ @bachya
|
||||
/homeassistant/components/habitica/ @tr4nt0r
|
||||
/tests/components/habitica/ @tr4nt0r
|
||||
/homeassistant/components/hanna/ @bestycame
|
||||
/tests/components/hanna/ @bestycame
|
||||
/homeassistant/components/hardkernel/ @home-assistant/core
|
||||
/tests/components/hardkernel/ @home-assistant/core
|
||||
/homeassistant/components/hardware/ @home-assistant/core
|
||||
@@ -846,6 +848,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/kraken/ @eifinger
|
||||
/homeassistant/components/kulersky/ @emlove
|
||||
/tests/components/kulersky/ @emlove
|
||||
/homeassistant/components/labs/ @home-assistant/core
|
||||
/tests/components/labs/ @home-assistant/core
|
||||
/homeassistant/components/lacrosse_view/ @IceBotYT
|
||||
/tests/components/lacrosse_view/ @IceBotYT
|
||||
/homeassistant/components/lamarzocco/ @zweckj
|
||||
@@ -1736,6 +1740,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_ble/ @rajlaud
|
||||
/tests/components/victron_ble/ @rajlaud
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -25,7 +25,7 @@ RUN \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
@@ -176,6 +176,8 @@ FRONTEND_INTEGRATIONS = {
|
||||
STAGE_0_INTEGRATIONS = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
||||
# Setup labs for preview features
|
||||
("labs", {"labs"}, STAGE_0_SUBSTAGE_TIMEOUT),
|
||||
# Setup frontend
|
||||
("frontend", FRONTEND_INTEGRATIONS, None),
|
||||
# Setup recorder
|
||||
@@ -212,6 +214,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"backup",
|
||||
"frontend",
|
||||
"hardware",
|
||||
"labs",
|
||||
"logger",
|
||||
"network",
|
||||
"system_health",
|
||||
|
||||
5
homeassistant/brands/victron.json
Normal file
5
homeassistant/brands/victron.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "victron",
|
||||
"name": "Victron",
|
||||
"integrations": ["victron_ble", "victron_remote_monitoring"]
|
||||
}
|
||||
@@ -6,9 +6,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -20,7 +19,7 @@ from .analytics import (
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
@@ -43,28 +42,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
@callback
|
||||
def start_schedule(_event: Event) -> None:
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
# Wait 15 min after started
|
||||
async_call_later(
|
||||
hass,
|
||||
900,
|
||||
HassJob(
|
||||
analytics.send_analytics,
|
||||
name="analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Send every day
|
||||
async_track_time_interval(
|
||||
hass,
|
||||
analytics.send_analytics,
|
||||
INTERVAL,
|
||||
name="analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
await analytics.async_schedule()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
@@ -111,7 +91,7 @@ async def websocket_analytics_preferences(
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.send_analytics()
|
||||
await analytics.async_schedule()
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
|
||||
@@ -7,6 +7,8 @@ from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Protocol
|
||||
import uuid
|
||||
|
||||
@@ -31,10 +33,18 @@ from homeassistant.const import (
|
||||
BASE_PLATFORMS,
|
||||
__version__ as HA_VERSION,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ReleaseChannel,
|
||||
callback,
|
||||
get_release_channel,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
@@ -51,6 +61,7 @@ from homeassistant.setup import async_get_loaded_integrations
|
||||
from .const import (
|
||||
ANALYTICS_ENDPOINT_URL,
|
||||
ANALYTICS_ENDPOINT_URL_DEV,
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
ATTR_ADDON_COUNT,
|
||||
ATTR_ADDONS,
|
||||
ATTR_ARCH,
|
||||
@@ -71,6 +82,7 @@ from .const import (
|
||||
ATTR_PROTECTED,
|
||||
ATTR_RECORDER,
|
||||
ATTR_SLUG,
|
||||
ATTR_SNAPSHOTS,
|
||||
ATTR_STATE_COUNT,
|
||||
ATTR_STATISTICS,
|
||||
ATTR_SUPERVISOR,
|
||||
@@ -80,8 +92,10 @@ from .const import (
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
DOMAIN,
|
||||
INTERVAL,
|
||||
LOGGER,
|
||||
PREFERENCE_SCHEMA,
|
||||
SNAPSHOT_VERSION,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
@@ -194,13 +208,18 @@ def gen_uuid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
RELEASE_CHANNEL = get_release_channel()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsData:
|
||||
"""Analytics data."""
|
||||
|
||||
onboarded: bool
|
||||
preferences: dict[str, bool]
|
||||
uuid: str | None
|
||||
uuid: str | None = None
|
||||
submission_identifier: str | None = None
|
||||
snapshot_submission_time: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
|
||||
@@ -209,6 +228,8 @@ class AnalyticsData:
|
||||
data["onboarded"],
|
||||
data["preferences"],
|
||||
data["uuid"],
|
||||
data.get("submission_identifier"),
|
||||
data.get("snapshot_submission_time"),
|
||||
)
|
||||
|
||||
|
||||
@@ -219,8 +240,10 @@ class Analytics:
|
||||
"""Initialize the Analytics class."""
|
||||
self.hass: HomeAssistant = hass
|
||||
self.session = async_get_clientsession(hass)
|
||||
self._data = AnalyticsData(False, {}, None)
|
||||
self._data = AnalyticsData(False, {})
|
||||
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
self._basic_scheduled: CALLBACK_TYPE | None = None
|
||||
self._snapshot_scheduled: CALLBACK_TYPE | None = None
|
||||
|
||||
@property
|
||||
def preferences(self) -> dict:
|
||||
@@ -228,6 +251,7 @@ class Analytics:
|
||||
preferences = self._data.preferences
|
||||
return {
|
||||
ATTR_BASE: preferences.get(ATTR_BASE, False),
|
||||
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
|
||||
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
|
||||
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
|
||||
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
|
||||
@@ -244,9 +268,9 @@ class Analytics:
|
||||
return self._data.uuid
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
def endpoint_basic(self) -> str:
|
||||
"""Return the endpoint that will receive the payload."""
|
||||
if HA_VERSION.endswith("0.dev0"):
|
||||
if RELEASE_CHANNEL is ReleaseChannel.DEV:
|
||||
# dev installations will contact the dev analytics environment
|
||||
return ANALYTICS_ENDPOINT_URL_DEV
|
||||
return ANALYTICS_ENDPOINT_URL
|
||||
@@ -277,13 +301,17 @@ class Analytics:
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||
|
||||
async def _save(self) -> None:
|
||||
"""Save data."""
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
|
||||
async def save_preferences(self, preferences: dict) -> None:
|
||||
"""Save preferences."""
|
||||
preferences = PREFERENCE_SCHEMA(preferences)
|
||||
self._data.preferences.update(preferences)
|
||||
self._data.onboarded = True
|
||||
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
await hassio.async_update_diagnostics(
|
||||
@@ -292,17 +320,16 @@ class Analytics:
|
||||
|
||||
async def send_analytics(self, _: datetime | None = None) -> None:
|
||||
"""Send analytics."""
|
||||
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
|
||||
return
|
||||
|
||||
hass = self.hass
|
||||
supervisor_info = None
|
||||
operating_system_info: dict[str, Any] = {}
|
||||
|
||||
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
|
||||
LOGGER.debug("Nothing to submit")
|
||||
return
|
||||
|
||||
if self._data.uuid is None:
|
||||
self._data.uuid = gen_uuid()
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
@@ -436,7 +463,7 @@ class Analytics:
|
||||
|
||||
try:
|
||||
async with timeout(30):
|
||||
response = await self.session.post(self.endpoint, json=payload)
|
||||
response = await self.session.post(self.endpoint_basic, json=payload)
|
||||
if response.status == 200:
|
||||
LOGGER.info(
|
||||
(
|
||||
@@ -449,7 +476,7 @@ class Analytics:
|
||||
LOGGER.warning(
|
||||
"Sending analytics failed with statuscode %s from %s",
|
||||
response.status,
|
||||
self.endpoint,
|
||||
self.endpoint_basic,
|
||||
)
|
||||
except TimeoutError:
|
||||
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
|
||||
@@ -489,6 +516,182 @@ class Analytics:
|
||||
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
|
||||
)
|
||||
|
||||
async def send_snapshot(self, _: datetime | None = None) -> None:
|
||||
"""Send a snapshot."""
|
||||
if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False):
|
||||
return
|
||||
|
||||
payload = await _async_snapshot_payload(self.hass)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"home-assistant/{HA_VERSION}",
|
||||
}
|
||||
if self._data.submission_identifier is not None:
|
||||
headers["X-Device-Database-Submission-Identifier"] = (
|
||||
self._data.submission_identifier
|
||||
)
|
||||
|
||||
try:
|
||||
async with timeout(30):
|
||||
response = await self.session.post(
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers
|
||||
)
|
||||
|
||||
if response.status == 200: # OK
|
||||
response_data = await response.json()
|
||||
new_identifier = response_data.get("submission_identifier")
|
||||
|
||||
if (
|
||||
new_identifier is not None
|
||||
and new_identifier != self._data.submission_identifier
|
||||
):
|
||||
self._data.submission_identifier = new_identifier
|
||||
await self._save()
|
||||
|
||||
LOGGER.info(
|
||||
"Submitted snapshot analytics to Home Assistant servers"
|
||||
)
|
||||
|
||||
elif response.status == 400: # Bad Request
|
||||
response_data = await response.json()
|
||||
error_kind = response_data.get("kind", "unknown")
|
||||
error_message = response_data.get("message", "Unknown error")
|
||||
|
||||
if error_kind == "invalid-submission-identifier":
|
||||
# Clear the invalid identifier and retry on next cycle
|
||||
LOGGER.warning(
|
||||
"Invalid submission identifier to %s, clearing: %s",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
error_message,
|
||||
)
|
||||
self._data.submission_identifier = None
|
||||
await self._save()
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Malformed snapshot analytics submission (%s) to %s: %s",
|
||||
error_kind,
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
error_message,
|
||||
)
|
||||
|
||||
elif response.status == 503: # Service Unavailable
|
||||
response_text = await response.text()
|
||||
LOGGER.warning(
|
||||
"Snapshot analytics service %s unavailable: %s",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
response_text,
|
||||
)
|
||||
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Unexpected status code %s when submitting snapshot analytics to %s",
|
||||
response.status,
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
LOGGER.error(
|
||||
"Timeout sending snapshot analytics to %s",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
LOGGER.error(
|
||||
"Error sending snapshot analytics to %s: %r",
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
||||
err,
|
||||
)
|
||||
|
||||
async def async_schedule(self) -> None:
|
||||
"""Schedule analytics."""
|
||||
if not self.onboarded:
|
||||
LOGGER.debug("Analytics not scheduled")
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
if self._snapshot_scheduled:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
return
|
||||
|
||||
if not self.preferences.get(ATTR_BASE, False):
|
||||
LOGGER.debug("Basic analytics not scheduled")
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
elif self._basic_scheduled is None:
|
||||
# Wait 15 min after started for basic analytics
|
||||
self._basic_scheduled = async_call_later(
|
||||
self.hass,
|
||||
900,
|
||||
HassJob(
|
||||
self._async_schedule_basic,
|
||||
name="basic analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in (
|
||||
ReleaseChannel.DEV,
|
||||
ReleaseChannel.NIGHTLY,
|
||||
):
|
||||
LOGGER.debug("Snapshot analytics not scheduled")
|
||||
if self._snapshot_scheduled:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
elif self._snapshot_scheduled is None:
|
||||
snapshot_submission_time = self._data.snapshot_submission_time
|
||||
|
||||
if snapshot_submission_time is None:
|
||||
# Randomize the submission time within the 24 hours
|
||||
snapshot_submission_time = random.uniform(0, 86400)
|
||||
self._data.snapshot_submission_time = snapshot_submission_time
|
||||
await self._save()
|
||||
LOGGER.debug(
|
||||
"Initialized snapshot submission time to %s",
|
||||
snapshot_submission_time,
|
||||
)
|
||||
|
||||
# Calculate delay until next submission
|
||||
current_time = time.time()
|
||||
delay = (snapshot_submission_time - current_time) % 86400
|
||||
|
||||
self._snapshot_scheduled = async_call_later(
|
||||
self.hass,
|
||||
delay,
|
||||
HassJob(
|
||||
self._async_schedule_snapshots,
|
||||
name="snapshot analytics schedule",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_schedule_basic(self, _: datetime | None = None) -> None:
|
||||
"""Schedule basic analytics."""
|
||||
await self.send_analytics()
|
||||
|
||||
# Send basic analytics every day
|
||||
self._basic_scheduled = async_track_time_interval(
|
||||
self.hass,
|
||||
self.send_analytics,
|
||||
INTERVAL,
|
||||
name="basic analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
|
||||
async def _async_schedule_snapshots(self, _: datetime | None = None) -> None:
|
||||
"""Schedule snapshot analytics."""
|
||||
await self.send_snapshot()
|
||||
|
||||
# Send snapshot analytics every day
|
||||
self._snapshot_scheduled = async_track_time_interval(
|
||||
self.hass,
|
||||
self.send_snapshot,
|
||||
INTERVAL,
|
||||
name="snapshot analytics daily",
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
|
||||
|
||||
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
"""Extract domains from the YAML configuration."""
|
||||
@@ -505,8 +708,8 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"""Return detailed information about entities and devices."""
|
||||
async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"""Return detailed information about entities and devices for a snapshot."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
@@ -711,8 +914,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return integrations_info
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices for a direct download."""
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
"version": f"home-assistant:{SNAPSHOT_VERSION}",
|
||||
"home_assistant": HA_VERSION,
|
||||
"integrations": integrations_info,
|
||||
"integrations": await _async_snapshot_payload(hass),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import voluptuous as vol
|
||||
|
||||
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
|
||||
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
|
||||
SNAPSHOT_VERSION = "1"
|
||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}"
|
||||
DOMAIN = "analytics"
|
||||
INTERVAL = timedelta(days=1)
|
||||
STORAGE_KEY = "core.analytics"
|
||||
@@ -38,6 +40,7 @@ ATTR_PREFERENCES = "preferences"
|
||||
ATTR_PROTECTED = "protected"
|
||||
ATTR_RECORDER = "recorder"
|
||||
ATTR_SLUG = "slug"
|
||||
ATTR_SNAPSHOTS = "snapshots"
|
||||
ATTR_STATE_COUNT = "state_count"
|
||||
ATTR_STATISTICS = "statistics"
|
||||
ATTR_SUPERVISOR = "supervisor"
|
||||
@@ -51,6 +54,7 @@ ATTR_VERSION = "version"
|
||||
PREFERENCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_BASE): bool,
|
||||
vol.Optional(ATTR_SNAPSHOTS): bool,
|
||||
vol.Optional(ATTR_DIAGNOSTICS): bool,
|
||||
vol.Optional(ATTR_STATISTICS): bool,
|
||||
vol.Optional(ATTR_USAGE): bool,
|
||||
|
||||
@@ -7,3 +7,26 @@ CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
"end apc": "date_deprecated",
|
||||
"date": "date_deprecated",
|
||||
"apcmodel": "available_via_device_info",
|
||||
"model": "available_via_device_info",
|
||||
"firmware": "available_via_device_info",
|
||||
"version": "available_via_device_info",
|
||||
"upsname": "available_via_device_info",
|
||||
"serialno": "available_via_device_info",
|
||||
}
|
||||
|
||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
||||
"apcmodel": "model",
|
||||
"model": "model",
|
||||
"firmware": "hw_version",
|
||||
"version": "sw_version",
|
||||
"upsname": "name",
|
||||
"serialno": "serial_number",
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -22,9 +24,11 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
|
||||
from .const import LAST_S_TEST
|
||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||
from .entity import APCUPSdEntity
|
||||
|
||||
@@ -528,3 +532,62 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to Home Assistant.
|
||||
|
||||
If this is a deprecated sensor entity, create a repair issue to guide
|
||||
the user to disable it.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
||||
if not reason:
|
||||
return
|
||||
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
if not automations and not scripts:
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
)
|
||||
for entity_id in entities
|
||||
if (entry := entity_registry.async_get(entity_id))
|
||||
]
|
||||
placeholders = {
|
||||
"entity_name": str(self.name or self.entity_id),
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items),
|
||||
}
|
||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
||||
placeholders["available_via_device_attr"] = via_attr
|
||||
if device_entry := self.device_entry:
|
||||
placeholders["device_id"] = device_entry.id
|
||||
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"{reason}_{self.entity_id}",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=reason,
|
||||
translation_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle when entity will be removed from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
||||
|
||||
@@ -241,5 +241,19 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to APC UPS Daemon."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"apc_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"available_via_device_info": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
},
|
||||
"date_deprecated": {
|
||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
||||
"title": "{entity_name} sensor is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
|
||||
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
|
||||
serial_number=coordinator.brother.serial,
|
||||
manufacturer="Brother",
|
||||
model=coordinator.brother.model,
|
||||
model_id=coordinator.brother.model,
|
||||
name=coordinator.brother.model,
|
||||
sw_version=coordinator.brother.firmware,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -345,12 +345,10 @@ class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_native_value = description.value(coordinator.data)
|
||||
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._attr_native_value = self.entity_description.value(self.coordinator.data)
|
||||
self.async_write_ha_state()
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.entity_description.value(self.coordinator.data)
|
||||
|
||||
@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
websocket_api.async_register_command(hass, websocket_create_area)
|
||||
websocket_api.async_register_command(hass, websocket_delete_area)
|
||||
websocket_api.async_register_command(hass, websocket_update_area)
|
||||
websocket_api.async_register_command(hass, websocket_reorder_areas)
|
||||
return True
|
||||
|
||||
|
||||
@@ -145,3 +146,27 @@ def websocket_update_area(
|
||||
connection.send_error(msg["id"], "invalid_info", str(err))
|
||||
else:
|
||||
connection.send_result(msg["id"], entry.json_fragment)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "config/area_registry/reorder",
|
||||
vol.Required("area_ids"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@callback
|
||||
def websocket_reorder_areas(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle reorder areas websocket command."""
|
||||
registry = ar.async_get(hass)
|
||||
|
||||
try:
|
||||
registry.async_reorder(msg["area_ids"])
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
|
||||
else:
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
websocket_api.async_register_command(hass, websocket_create_floor)
|
||||
websocket_api.async_register_command(hass, websocket_delete_floor)
|
||||
websocket_api.async_register_command(hass, websocket_update_floor)
|
||||
websocket_api.async_register_command(hass, websocket_reorder_floors)
|
||||
return True
|
||||
|
||||
|
||||
@@ -127,6 +128,28 @@ def websocket_update_floor(
|
||||
connection.send_result(msg["id"], _entry_dict(entry))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "config/floor_registry/reorder",
|
||||
vol.Required("floor_ids"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@callback
|
||||
def websocket_reorder_floors(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle reorder floors websocket command."""
|
||||
registry = fr.async_get(hass)
|
||||
|
||||
try:
|
||||
registry.async_reorder(msg["floor_ids"])
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
|
||||
else:
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@callback
|
||||
def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
|
||||
"""Convert entry to API format."""
|
||||
|
||||
@@ -11,11 +11,14 @@ import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.websocket_api import ActiveConnection
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
|
||||
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
|
||||
STORAGE_VERSION_USER_DATA = 1
|
||||
STORAGE_VERSION_SYSTEM_DATA = 1
|
||||
|
||||
|
||||
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
||||
@@ -23,6 +26,9 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_set_user_data)
|
||||
websocket_api.async_register_command(hass, websocket_get_user_data)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
|
||||
websocket_api.async_register_command(hass, websocket_set_system_data)
|
||||
websocket_api.async_register_command(hass, websocket_get_system_data)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_system_data)
|
||||
|
||||
|
||||
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
|
||||
@@ -83,6 +89,52 @@ class _UserStore(Store[dict[str, Any]]):
|
||||
)
|
||||
|
||||
|
||||
@singleton.singleton(DATA_SYSTEM_STORAGE, async_=True)
|
||||
async def async_system_store(hass: HomeAssistant) -> SystemStore:
|
||||
"""Access the system store."""
|
||||
store = SystemStore(hass)
|
||||
await store.async_load()
|
||||
return store
|
||||
|
||||
|
||||
class SystemStore:
|
||||
"""System store for frontend data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the system store."""
|
||||
self._store: Store[dict[str, Any]] = Store(
|
||||
hass,
|
||||
STORAGE_VERSION_SYSTEM_DATA,
|
||||
"frontend.system_data",
|
||||
)
|
||||
self.data: dict[str, Any] = {}
|
||||
self.subscriptions: dict[str, list[Callable[[], None]]] = {}
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the data from the store."""
|
||||
self.data = await self._store.async_load() or {}
|
||||
|
||||
async def async_set_item(self, key: str, value: Any) -> None:
|
||||
"""Set an item and save the store."""
|
||||
self.data[key] = value
|
||||
self._store.async_delay_save(lambda: self.data, 1.0)
|
||||
for cb in self.subscriptions.get(key, []):
|
||||
cb()
|
||||
|
||||
@callback
|
||||
def async_subscribe(
|
||||
self, key: str, on_update_callback: Callable[[], None]
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to store updates."""
|
||||
self.subscriptions.setdefault(key, []).append(on_update_callback)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from the store."""
|
||||
self.subscriptions[key].remove(on_update_callback)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
def with_user_store(
|
||||
orig_func: Callable[
|
||||
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
|
||||
@@ -107,6 +159,28 @@ def with_user_store(
|
||||
return with_user_store_func
|
||||
|
||||
|
||||
def with_system_store(
|
||||
orig_func: Callable[
|
||||
[HomeAssistant, ActiveConnection, dict[str, Any], SystemStore],
|
||||
Coroutine[Any, Any, None],
|
||||
],
|
||||
) -> Callable[
|
||||
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
|
||||
]:
|
||||
"""Decorate function to provide system store."""
|
||||
|
||||
@wraps(orig_func)
|
||||
async def with_system_store_func(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Provide system store to function."""
|
||||
store = await async_system_store(hass)
|
||||
|
||||
await orig_func(hass, connection, msg, store)
|
||||
|
||||
return with_system_store_func
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "frontend/set_user_data",
|
||||
@@ -169,3 +243,65 @@ async def websocket_subscribe_user_data(
|
||||
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
|
||||
on_data_update()
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "frontend/set_system_data",
|
||||
vol.Required("key"): str,
|
||||
vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
@with_system_store
|
||||
async def websocket_set_system_data(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
store: SystemStore,
|
||||
) -> None:
|
||||
"""Handle set system data command."""
|
||||
await store.async_set_item(msg["key"], msg["value"])
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "frontend/get_system_data", vol.Required("key"): str}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@with_system_store
|
||||
async def websocket_get_system_data(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
store: SystemStore,
|
||||
) -> None:
|
||||
"""Handle get system data command."""
|
||||
connection.send_result(msg["id"], {"value": store.data.get(msg["key"])})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "frontend/subscribe_system_data",
|
||||
vol.Required("key"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@with_system_store
|
||||
async def websocket_subscribe_system_data(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
store: SystemStore,
|
||||
) -> None:
|
||||
"""Handle subscribe to system data command."""
|
||||
key: str = msg["key"]
|
||||
|
||||
def on_data_update() -> None:
|
||||
"""Handle system data update."""
|
||||
connection.send_event(msg["id"], {"value": store.data.get(key)})
|
||||
|
||||
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
|
||||
on_data_update()
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp import ClientSession, UnixConnector
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||
from awesomeversion import AwesomeVersion
|
||||
from go2rtc_client import Go2RtcRestClient
|
||||
@@ -52,6 +53,7 @@ from .const import (
|
||||
CONF_DEBUG_UI,
|
||||
DEBUG_UI_URL_MESSAGE,
|
||||
DOMAIN,
|
||||
HA_MANAGED_UNIX_SOCKET,
|
||||
HA_MANAGED_URL,
|
||||
RECOMMENDED_VERSION,
|
||||
)
|
||||
@@ -60,35 +62,6 @@ from .server import Server
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_FFMPEG = "ffmpeg"
|
||||
_SUPPORTED_STREAMS = frozenset(
|
||||
(
|
||||
"bubble",
|
||||
"dvrip",
|
||||
"expr",
|
||||
_FFMPEG,
|
||||
"gopro",
|
||||
"homekit",
|
||||
"http",
|
||||
"https",
|
||||
"httpx",
|
||||
"isapi",
|
||||
"ivideon",
|
||||
"kasa",
|
||||
"nest",
|
||||
"onvif",
|
||||
"roborock",
|
||||
"rtmp",
|
||||
"rtmps",
|
||||
"rtmpx",
|
||||
"rtsp",
|
||||
"rtsps",
|
||||
"rtspx",
|
||||
"tapo",
|
||||
"tcp",
|
||||
"webrtc",
|
||||
"webtorrent",
|
||||
)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -102,7 +75,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
|
||||
_DATA_GO2RTC: HassKey[Go2RtcConfig] = HassKey(DOMAIN)
|
||||
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
|
||||
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
|
||||
|
||||
@@ -129,8 +102,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return False
|
||||
|
||||
# HA will manage the binary
|
||||
session = ClientSession(connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET))
|
||||
server = Server(
|
||||
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
|
||||
hass,
|
||||
binary,
|
||||
session,
|
||||
enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False),
|
||||
)
|
||||
try:
|
||||
await server.start()
|
||||
@@ -140,12 +117,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def on_stop(event: Event) -> None:
|
||||
await server.stop()
|
||||
await session.close()
|
||||
|
||||
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
|
||||
|
||||
url = HA_MANAGED_URL
|
||||
else:
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
hass.data[_DATA_GO2RTC] = url
|
||||
hass.data[_DATA_GO2RTC] = Go2RtcConfig(url, session)
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
@@ -161,8 +141,9 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
|
||||
"""Set up go2rtc from a config entry."""
|
||||
|
||||
url = hass.data[_DATA_GO2RTC]
|
||||
session = async_get_clientsession(hass)
|
||||
config = hass.data[_DATA_GO2RTC]
|
||||
url = config.url
|
||||
session = config.session
|
||||
client = Go2RtcRestClient(session, url)
|
||||
# Validate the server URL
|
||||
try:
|
||||
@@ -197,6 +178,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
|
||||
return False
|
||||
|
||||
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
||||
await provider.initialize()
|
||||
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
||||
return True
|
||||
|
||||
@@ -228,16 +210,21 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
self._session = session
|
||||
self._rest_client = rest_client
|
||||
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||
self._supported_schemes: set[str] = set()
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
"""Return the integration domain of the provider."""
|
||||
return DOMAIN
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the provider."""
|
||||
self._supported_schemes = await self._rest_client.schemes.list()
|
||||
|
||||
@callback
|
||||
def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Return if this provider is supports the Camera as source."""
|
||||
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
|
||||
return stream_source.partition(":")[0] in self._supported_schemes
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self,
|
||||
@@ -365,3 +352,11 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
for ws_client in self._sessions.values():
|
||||
await ws_client.close()
|
||||
self._sessions.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Go2RtcConfig:
|
||||
"""Go2rtc configuration."""
|
||||
|
||||
url: str
|
||||
session: ClientSession
|
||||
|
||||
@@ -6,4 +6,5 @@ CONF_DEBUG_UI = "debug_ui"
|
||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||
HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
RECOMMENDED_VERSION = "1.9.11"
|
||||
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
|
||||
RECOMMENDED_VERSION = "1.9.12"
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["go2rtc-client==0.2.1"],
|
||||
"requirements": ["go2rtc-client==0.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ from contextlib import suppress
|
||||
import logging
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from go2rtc_client import Go2RtcRestClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
|
||||
from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_TERMINATE_TIMEOUT = 5
|
||||
@@ -23,14 +23,26 @@ _LOG_BUFFER_SIZE = 512
|
||||
_RESPAWN_COOLDOWN = 1
|
||||
|
||||
# Default configuration for HA
|
||||
# - Api is listening only on localhost
|
||||
# - Unix socket for secure local communication
|
||||
# - HTTP API only enabled when UI is enabled
|
||||
# - Enable rtsp for localhost only as ffmpeg needs it
|
||||
# - Clear default ice servers
|
||||
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
||||
# Do not edit it manually
|
||||
|
||||
app:
|
||||
modules: {app_modules}
|
||||
|
||||
api:
|
||||
listen: "{api_ip}:{api_port}"
|
||||
listen: "{listen_config}"
|
||||
unix_listen: "{unix_socket}"
|
||||
allow_paths: {api_allow_paths}
|
||||
|
||||
# ffmpeg needs the exec module
|
||||
# Restrict execution to only ffmpeg binary
|
||||
exec:
|
||||
allow_paths:
|
||||
- ffmpeg
|
||||
|
||||
rtsp:
|
||||
listen: "127.0.0.1:18554"
|
||||
@@ -40,6 +52,43 @@ webrtc:
|
||||
ice_servers: []
|
||||
"""
|
||||
|
||||
_APP_MODULES = (
|
||||
"api",
|
||||
"exec", # Execution module for ffmpeg
|
||||
"ffmpeg",
|
||||
"http",
|
||||
"mjpeg",
|
||||
"onvif",
|
||||
"rtmp",
|
||||
"rtsp",
|
||||
"srtp",
|
||||
"webrtc",
|
||||
"ws",
|
||||
)
|
||||
|
||||
_API_ALLOW_PATHS = (
|
||||
"/", # UI static page and version control
|
||||
"/api", # Main API path
|
||||
"/api/frame.jpeg", # Snapshot functionality
|
||||
"/api/schemes", # Supported stream schemes
|
||||
"/api/streams", # Stream management
|
||||
"/api/webrtc", # Webrtc functionality
|
||||
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
|
||||
)
|
||||
|
||||
# Additional modules when UI is enabled
|
||||
_UI_APP_MODULES = (
|
||||
*_APP_MODULES,
|
||||
"debug",
|
||||
)
|
||||
# Additional api paths when UI is enabled
|
||||
_UI_API_ALLOW_PATHS = (
|
||||
*_API_ALLOW_PATHS,
|
||||
"/api/config", # UI config view
|
||||
"/api/log", # UI log view
|
||||
"/api/streams.dot", # UI network view
|
||||
)
|
||||
|
||||
_LOG_LEVEL_MAP = {
|
||||
"TRC": logging.DEBUG,
|
||||
"DBG": logging.DEBUG,
|
||||
@@ -61,14 +110,38 @@ class Go2RTCWatchdogError(HomeAssistantError):
|
||||
"""Raised on watchdog error."""
|
||||
|
||||
|
||||
def _create_temp_file(api_ip: str) -> str:
|
||||
def _format_list_for_yaml(items: tuple[str, ...]) -> str:
|
||||
"""Format a list of strings for yaml config."""
|
||||
if not items:
|
||||
return "[]"
|
||||
formatted_items = ",".join(f'"{item}"' for item in items)
|
||||
return f"[{formatted_items}]"
|
||||
|
||||
|
||||
def _create_temp_file(enable_ui: bool) -> str:
|
||||
"""Create temporary config file."""
|
||||
app_modules: tuple[str, ...] = _APP_MODULES
|
||||
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
|
||||
|
||||
if enable_ui:
|
||||
app_modules = _UI_APP_MODULES
|
||||
api_paths = _UI_API_ALLOW_PATHS
|
||||
# Listen on all interfaces for allowing access from all ips
|
||||
listen_config = f":{HA_MANAGED_API_PORT}"
|
||||
else:
|
||||
# Disable HTTP listening when UI is not enabled
|
||||
# as HA does not use it.
|
||||
listen_config = ""
|
||||
|
||||
# Set delete=False to prevent the file from being deleted when the file is closed
|
||||
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
||||
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
||||
file.write(
|
||||
_GO2RTC_CONFIG_FORMAT.format(
|
||||
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
|
||||
listen_config=listen_config,
|
||||
unix_socket=HA_MANAGED_UNIX_SOCKET,
|
||||
app_modules=_format_list_for_yaml(app_modules),
|
||||
api_allow_paths=_format_list_for_yaml(api_paths),
|
||||
).encode()
|
||||
)
|
||||
return file.name
|
||||
@@ -78,18 +151,21 @@ class Server:
|
||||
"""Go2rtc server."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, binary: str, *, enable_ui: bool = False
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
binary: str,
|
||||
session: ClientSession,
|
||||
*,
|
||||
enable_ui: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the server."""
|
||||
self._hass = hass
|
||||
self._binary = binary
|
||||
self._session = session
|
||||
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._startup_complete = asyncio.Event()
|
||||
self._api_ip = _LOCALHOST_IP
|
||||
if enable_ui:
|
||||
# Listen on all interfaces for allowing access from all ips
|
||||
self._api_ip = ""
|
||||
self._enable_ui = enable_ui
|
||||
self._watchdog_task: asyncio.Task | None = None
|
||||
self._watchdog_tasks: list[asyncio.Task] = []
|
||||
|
||||
@@ -104,7 +180,7 @@ class Server:
|
||||
"""Start the server."""
|
||||
_LOGGER.debug("Starting go2rtc server")
|
||||
config_file = await self._hass.async_add_executor_job(
|
||||
_create_temp_file, self._api_ip
|
||||
_create_temp_file, self._enable_ui
|
||||
)
|
||||
|
||||
self._startup_complete.clear()
|
||||
@@ -133,7 +209,7 @@ class Server:
|
||||
raise Go2RTCServerStartError from err
|
||||
|
||||
# Check the server version
|
||||
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
|
||||
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
|
||||
await client.validate_server_version()
|
||||
|
||||
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
|
||||
@@ -205,7 +281,7 @@ class Server:
|
||||
|
||||
async def _monitor_api(self) -> None:
|
||||
"""Raise if the go2rtc process terminates."""
|
||||
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
|
||||
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
|
||||
|
||||
_LOGGER.debug("Monitoring go2rtc API")
|
||||
try:
|
||||
|
||||
54
homeassistant/components/hanna/__init__.py
Normal file
54
homeassistant/components/hanna/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""The Hanna Instruments integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hanna_cloud import HannaCloudClient
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HannaConfigEntry, HannaDataCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
def _authenticate_and_get_devices(
|
||||
api_client: HannaCloudClient,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Authenticate and get devices in a single executor job."""
|
||||
api_client.authenticate(email, password)
|
||||
return api_client.get_devices()
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool:
|
||||
"""Set up Hanna Instruments from a config entry."""
|
||||
api_client = HannaCloudClient()
|
||||
devices = await hass.async_add_executor_job(
|
||||
_authenticate_and_get_devices,
|
||||
api_client,
|
||||
entry.data[CONF_EMAIL],
|
||||
entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
# Create device coordinators
|
||||
device_coordinators = {}
|
||||
for device in devices:
|
||||
coordinator = HannaDataCoordinator(hass, entry, device, api_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
device_coordinators[coordinator.device_identifier] = coordinator
|
||||
|
||||
# Set runtime data
|
||||
entry.runtime_data = device_coordinators
|
||||
|
||||
# Forward the setup to the platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
62
homeassistant/components/hanna/config_flow.py
Normal file
62
homeassistant/components/hanna/config_flow.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Config flow for Hanna Instruments integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hanna_cloud import AuthenticationError, HannaCloudClient
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HannaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Hanna Instruments."""
|
||||
|
||||
VERSION = 1
|
||||
data_schema = vol.Schema(
|
||||
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the setup flow."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_EMAIL])
|
||||
self._abort_if_unique_id_configured()
|
||||
client = HannaCloudClient()
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
client.authenticate,
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
except (Timeout, RequestsConnectionError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
self.data_schema, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
3
homeassistant/components/hanna/const.py
Normal file
3
homeassistant/components/hanna/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Hanna integration."""
|
||||
|
||||
DOMAIN = "hanna"
|
||||
72
homeassistant/components/hanna/coordinator.py
Normal file
72
homeassistant/components/hanna/coordinator.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Hanna Instruments data coordinator for Home Assistant.
|
||||
|
||||
This module provides the data coordinator for fetching and managing Hanna Instruments
|
||||
sensor data.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hanna_cloud import HannaCloudClient
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type HannaConfigEntry = ConfigEntry[dict[str, HannaDataCoordinator]]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HannaDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for fetching Hanna sensor data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HannaConfigEntry,
|
||||
device: dict[str, Any],
|
||||
api_client: HannaCloudClient,
|
||||
) -> None:
|
||||
"""Initialize the Hanna data coordinator."""
|
||||
self.api_client = api_client
|
||||
self.device_data = device
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_{self.device_identifier}",
|
||||
config_entry=config_entry,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
@property
|
||||
def device_identifier(self) -> str:
|
||||
"""Return the device identifier."""
|
||||
return self.device_data["DID"]
|
||||
|
||||
def get_parameters(self) -> list[dict[str, Any]]:
|
||||
"""Get all parameters from the sensor data."""
|
||||
return self.api_client.parameters
|
||||
|
||||
def get_parameter_value(self, key: str) -> Any:
|
||||
"""Get the value for a specific parameter."""
|
||||
for parameter in self.get_parameters():
|
||||
if parameter["name"] == key:
|
||||
return parameter["value"]
|
||||
return None
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch latest sensor data from the Hanna API."""
|
||||
try:
|
||||
readings = await self.hass.async_add_executor_job(
|
||||
self.api_client.get_last_device_reading, self.device_identifier
|
||||
)
|
||||
except RequestException as e:
|
||||
raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e
|
||||
except (KeyError, IndexError) as e:
|
||||
raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e
|
||||
return readings
|
||||
28
homeassistant/components/hanna/entity.py
Normal file
28
homeassistant/components/hanna/entity.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Hanna Instruments entity base class for Home Assistant.
|
||||
|
||||
This module provides the base entity class for Hanna Instruments entities.
|
||||
"""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HannaDataCoordinator
|
||||
|
||||
|
||||
class HannaEntity(CoordinatorEntity[HannaDataCoordinator]):
|
||||
"""Base class for Hanna entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: HannaDataCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.device_identifier)},
|
||||
manufacturer=coordinator.device_data.get("manufacturer"),
|
||||
model=coordinator.device_data.get("DM"),
|
||||
name=coordinator.device_data.get("name"),
|
||||
serial_number=coordinator.device_data.get("serial_number"),
|
||||
sw_version=coordinator.device_data.get("sw_version"),
|
||||
)
|
||||
10
homeassistant/components/hanna/manifest.json
Normal file
10
homeassistant/components/hanna/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "hanna",
|
||||
"name": "Hanna",
|
||||
"codeowners": ["@bestycame"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hanna",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hanna-cloud==0.0.6"]
|
||||
}
|
||||
70
homeassistant/components/hanna/quality_scale.yaml
Normal file
70
homeassistant/components/hanna/quality_scale.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't add actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
106
homeassistant/components/hanna/sensor.py
Normal file
106
homeassistant/components/hanna/sensor.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Hanna Instruments sensor integration for Home Assistant.
|
||||
|
||||
This module provides sensor entities for various Hanna Instruments devices,
|
||||
including pH, ORP, temperature, and chemical sensors. It uses the Hanna API
|
||||
to fetch readings and updates them periodically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import HannaConfigEntry, HannaDataCoordinator
|
||||
from .entity import HannaEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_DESCRIPTIONS = [
|
||||
SensorEntityDescription(
|
||||
key="ph",
|
||||
translation_key="ph_value",
|
||||
device_class=SensorDeviceClass.PH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="orp",
|
||||
translation_key="chlorine_orp_value",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temp",
|
||||
translation_key="water_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="airTemp",
|
||||
translation_key="air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="acidBase",
|
||||
translation_key="ph_acid_base_flow_rate",
|
||||
icon="mdi:chemical-weapon",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="cl",
|
||||
translation_key="chlorine_flow_rate",
|
||||
icon="mdi:chemical-weapon",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HannaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Hanna sensors from a config entry."""
|
||||
device_coordinators = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HannaSensor(coordinator, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
for coordinator in device_coordinators.values()
|
||||
)
|
||||
|
||||
|
||||
class HannaSensor(HannaEntity, SensorEntity):
|
||||
"""Representation of a Hanna sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HannaDataCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Hanna sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.coordinator.get_parameter_value(self.entity_description.key)
|
||||
44
homeassistant/components/hanna/strings.json
Normal file
44
homeassistant/components/hanna/strings.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Email address for your Hanna Cloud account",
|
||||
"password": "Password for your Hanna Cloud account"
|
||||
},
|
||||
"description": "Enter your Hanna Cloud credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_temperature": {
|
||||
"name": "Air temperature"
|
||||
},
|
||||
"chlorine_flow_rate": {
|
||||
"name": "Chlorine flow rate"
|
||||
},
|
||||
"chlorine_orp_value": {
|
||||
"name": "Chlorine ORP value"
|
||||
},
|
||||
"ph_acid_base_flow_rate": {
|
||||
"name": "pH Acid/Base flow rate"
|
||||
},
|
||||
"water_temperature": {
|
||||
"name": "Water temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"universal-silabs-flasher==0.1.0",
|
||||
"universal-silabs-flasher==0.1.2",
|
||||
"ha-silabs-firmware-client==0.3.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.0"]
|
||||
"requirements": ["aioautomower==2.7.1"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ from random import random
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EVENT_LABS_UPDATED,
|
||||
EventLabsUpdatedData,
|
||||
async_is_preview_feature_enabled,
|
||||
)
|
||||
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
@@ -30,10 +35,14 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import (
|
||||
@@ -110,6 +119,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Notify backup listeners
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
|
||||
# Subscribe to labs feature updates for kitchen_sink preview repair
|
||||
@callback
|
||||
def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == "kitchen_sink"
|
||||
and event.data["preview_feature"] == "special_repair"
|
||||
):
|
||||
_async_update_special_repair(hass)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated)
|
||||
)
|
||||
|
||||
# Check if lab feature is currently enabled and create repair if so
|
||||
_async_update_special_repair(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -137,6 +163,27 @@ async def async_remove_config_entry_device(
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def _async_update_special_repair(hass: HomeAssistant) -> None:
|
||||
"""Create or delete the special repair issue.
|
||||
|
||||
Creates a repair issue when the special_repair lab feature is enabled,
|
||||
and deletes it when disabled. This demonstrates how lab features can interact
|
||||
with Home Assistant's repair system.
|
||||
"""
|
||||
if async_is_preview_feature_enabled(hass, DOMAIN, "special_repair"):
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"kitchen_sink_special_repair_issue",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="special_repair",
|
||||
)
|
||||
else:
|
||||
async_delete_issue(hass, DOMAIN, "kitchen_sink_special_repair_issue")
|
||||
|
||||
|
||||
async def _notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/kitchen_sink",
|
||||
"iot_class": "calculated",
|
||||
"preview_features": {
|
||||
"special_repair": {
|
||||
"feedback_url": "https://community.home-assistant.io",
|
||||
"learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
},
|
||||
"title": "The blinker fluid is empty and needs to be refilled"
|
||||
},
|
||||
"special_repair": {
|
||||
"description": "This is a special repair created by a preview feature! This demonstrates how lab features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.",
|
||||
"title": "Special repair feature preview"
|
||||
},
|
||||
"transmogrifier_deprecated": {
|
||||
"description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API",
|
||||
"title": "The transmogrifier component is deprecated"
|
||||
@@ -103,6 +107,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"special_repair": {
|
||||
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.",
|
||||
"disable_confirmation": "This will remove the special repair issue. Don't worry, this is just a demonstration feature.",
|
||||
"enable_confirmation": "This will create a special repair issue to demonstrate Labs preview features. This is just an example and won't affect your actual system.",
|
||||
"name": "Special repair"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"test_service_1": {
|
||||
"description": "Fake action for testing",
|
||||
|
||||
310
homeassistant/components/labs/__init__.py
Normal file
310
homeassistant/components/labs/__init__.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""The Home Assistant Labs integration.
|
||||
|
||||
This integration provides preview features that can be toggled on/off by users.
|
||||
Integrations can register lab preview features in their manifest.json which will appear
|
||||
in the Home Assistant Labs UI for users to enable or disable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.backup import async_get_manager
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_LABS_UPDATED,
|
||||
LABS_DATA,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
EventLabsUpdatedData,
|
||||
LabPreviewFeature,
|
||||
LabsData,
|
||||
LabsStoreData,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
__all__ = [
|
||||
"EVENT_LABS_UPDATED",
|
||||
"EventLabsUpdatedData",
|
||||
"async_is_preview_feature_enabled",
|
||||
]
|
||||
|
||||
|
||||
class LabsStorage(Store[LabsStoreData]):
|
||||
"""Custom Store for Labs that converts between runtime and storage formats.
|
||||
|
||||
Runtime format: {"preview_feature_status": {(domain, preview_feature)}}
|
||||
Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]}
|
||||
|
||||
Only enabled features are saved to storage - if stored, it's enabled.
|
||||
"""
|
||||
|
||||
async def _async_load_data(self) -> LabsStoreData | None:
|
||||
"""Load data and convert from storage format to runtime format."""
|
||||
raw_data = await super()._async_load_data()
|
||||
if raw_data is None:
|
||||
return None
|
||||
|
||||
status_list = raw_data.get("preview_feature_status", [])
|
||||
|
||||
# Convert list of objects to runtime set - if stored, it's enabled
|
||||
return {
|
||||
"preview_feature_status": {
|
||||
(item["domain"], item["preview_feature"]) for item in status_list
|
||||
}
|
||||
}
|
||||
|
||||
def _write_data(self, path: str, data: dict) -> None:
|
||||
"""Convert from runtime format to storage format and write.
|
||||
|
||||
Only saves enabled features - disabled is the default.
|
||||
"""
|
||||
# Extract the actual data (has version/key wrapper)
|
||||
actual_data = data.get("data", data)
|
||||
|
||||
# Check if this is Labs data (has preview_feature_status key)
|
||||
if "preview_feature_status" not in actual_data:
|
||||
# Not Labs data, write as-is
|
||||
super()._write_data(path, data)
|
||||
return
|
||||
|
||||
preview_status = actual_data["preview_feature_status"]
|
||||
|
||||
# Convert from runtime format (set of tuples) to storage format (list of dicts)
|
||||
status_list = [
|
||||
{"domain": domain, "preview_feature": preview_feature}
|
||||
for domain, preview_feature in preview_status
|
||||
]
|
||||
|
||||
# Build the final data structure with converted format
|
||||
data_copy = data.copy()
|
||||
data_copy["data"] = {"preview_feature_status": status_list}
|
||||
|
||||
super()._write_data(path, data_copy)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Labs component."""
|
||||
store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
|
||||
data = await store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {"preview_feature_status": set()}
|
||||
|
||||
# Scan ALL integrations for lab preview features (loaded or not)
|
||||
lab_preview_features = await _async_scan_all_preview_features(hass)
|
||||
|
||||
# Clean up preview features that no longer exist
|
||||
if lab_preview_features:
|
||||
valid_keys = {
|
||||
(pf.domain, pf.preview_feature) for pf in lab_preview_features.values()
|
||||
}
|
||||
stale_keys = data["preview_feature_status"] - valid_keys
|
||||
|
||||
if stale_keys:
|
||||
_LOGGER.debug(
|
||||
"Removing %d stale preview features: %s",
|
||||
len(stale_keys),
|
||||
stale_keys,
|
||||
)
|
||||
data["preview_feature_status"] -= stale_keys
|
||||
|
||||
await store.async_save(data)
|
||||
|
||||
hass.data[LABS_DATA] = LabsData(
|
||||
store=store,
|
||||
data=data,
|
||||
preview_features=lab_preview_features,
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_list_preview_features)
|
||||
websocket_api.async_register_command(hass, websocket_update_preview_feature)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _populate_preview_features(
|
||||
preview_features: dict[str, LabPreviewFeature],
|
||||
domain: str,
|
||||
labs_preview_features: dict[str, dict[str, str]],
|
||||
is_built_in: bool = True,
|
||||
) -> None:
|
||||
"""Populate preview features dictionary from integration preview_features.
|
||||
|
||||
Args:
|
||||
preview_features: Dictionary to populate
|
||||
domain: Integration domain
|
||||
labs_preview_features: Dictionary of preview feature definitions from manifest
|
||||
is_built_in: Whether this is a built-in integration
|
||||
"""
|
||||
for preview_feature_key, preview_feature_data in labs_preview_features.items():
|
||||
preview_feature = LabPreviewFeature(
|
||||
domain=domain,
|
||||
preview_feature=preview_feature_key,
|
||||
is_built_in=is_built_in,
|
||||
feedback_url=preview_feature_data.get("feedback_url"),
|
||||
learn_more_url=preview_feature_data.get("learn_more_url"),
|
||||
report_issue_url=preview_feature_data.get("report_issue_url"),
|
||||
)
|
||||
preview_features[preview_feature.full_key] = preview_feature
|
||||
|
||||
|
||||
async def _async_scan_all_preview_features(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, LabPreviewFeature]:
|
||||
"""Scan ALL available integrations for lab preview features (loaded or not)."""
|
||||
preview_features: dict[str, LabPreviewFeature] = {}
|
||||
|
||||
# Load pre-generated built-in lab preview features (already includes all data)
|
||||
for domain, domain_preview_features in LABS_PREVIEW_FEATURES.items():
|
||||
_populate_preview_features(
|
||||
preview_features, domain, domain_preview_features, is_built_in=True
|
||||
)
|
||||
|
||||
# Scan custom components
|
||||
custom_integrations = await async_get_custom_components(hass)
|
||||
_LOGGER.debug(
|
||||
"Loaded %d built-in + scanning %d custom integrations for lab preview features",
|
||||
len(preview_features),
|
||||
len(custom_integrations),
|
||||
)
|
||||
|
||||
for integration in custom_integrations.values():
|
||||
if labs_preview_features := integration.preview_features:
|
||||
_populate_preview_features(
|
||||
preview_features,
|
||||
integration.domain,
|
||||
labs_preview_features,
|
||||
is_built_in=False,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Loaded %d total lab preview features", len(preview_features))
|
||||
return preview_features
|
||||
|
||||
|
||||
@callback
|
||||
def async_is_preview_feature_enabled(
|
||||
hass: HomeAssistant, domain: str, preview_feature: str
|
||||
) -> bool:
|
||||
"""Check if a lab preview feature is enabled.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
|
||||
Returns:
|
||||
True if the preview feature is enabled, False otherwise
|
||||
"""
|
||||
if LABS_DATA not in hass.data:
|
||||
return False
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
|
||||
def websocket_list_preview_features(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List all lab preview features filtered by loaded integrations."""
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
loaded_components = hass.config.components
|
||||
|
||||
preview_features: list[dict[str, Any]] = [
|
||||
preview_feature.to_dict(
|
||||
(preview_feature.domain, preview_feature.preview_feature)
|
||||
in labs_data.data["preview_feature_status"]
|
||||
)
|
||||
for preview_feature_key, preview_feature in labs_data.preview_features.items()
|
||||
if preview_feature.domain in loaded_components
|
||||
]
|
||||
|
||||
connection.send_result(msg["id"], {"features": preview_features})
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "labs/update",
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("preview_feature"): str,
|
||||
vol.Required("enabled"): bool,
|
||||
vol.Optional("create_backup", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_preview_feature(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update a lab preview feature state."""
|
||||
domain = msg["domain"]
|
||||
preview_feature = msg["preview_feature"]
|
||||
enabled = msg["enabled"]
|
||||
create_backup = msg["create_backup"]
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
|
||||
# Build preview_feature_id for lookup
|
||||
preview_feature_id = f"{domain}.{preview_feature}"
|
||||
|
||||
# Validate preview feature exists
|
||||
if preview_feature_id not in labs_data.preview_features:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_FOUND,
|
||||
f"Preview feature {preview_feature_id} not found",
|
||||
)
|
||||
return
|
||||
|
||||
# Create backup if requested and enabling
|
||||
if create_backup and enabled:
|
||||
try:
|
||||
backup_manager = async_get_manager(hass)
|
||||
await backup_manager.async_create_automatic_backup()
|
||||
except Exception as err: # noqa: BLE001 - websocket handlers can catch broad exceptions
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_UNKNOWN_ERROR,
|
||||
f"Error creating backup: {err}",
|
||||
)
|
||||
return
|
||||
|
||||
# Update storage (only store enabled features, remove if disabled)
|
||||
if enabled:
|
||||
labs_data.data["preview_feature_status"].add((domain, preview_feature))
|
||||
else:
|
||||
labs_data.data["preview_feature_status"].discard((domain, preview_feature))
|
||||
|
||||
# Save changes immediately
|
||||
await labs_data.store.async_save(labs_data.data)
|
||||
|
||||
# Fire event
|
||||
event_data: EventLabsUpdatedData = {
|
||||
"domain": domain,
|
||||
"preview_feature": preview_feature,
|
||||
"enabled": enabled,
|
||||
}
|
||||
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
77
homeassistant/components/labs/const.py
Normal file
77
homeassistant/components/labs/const.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Constants for the Home Assistant Labs integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
DOMAIN = "labs"
|
||||
|
||||
STORAGE_KEY = "core.labs"
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
EVENT_LABS_UPDATED = "labs_updated"
|
||||
|
||||
|
||||
class EventLabsUpdatedData(TypedDict):
|
||||
"""Event data for labs_updated event."""
|
||||
|
||||
domain: str
|
||||
preview_feature: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class LabPreviewFeature:
|
||||
"""Lab preview feature definition."""
|
||||
|
||||
domain: str
|
||||
preview_feature: str
|
||||
is_built_in: bool = True
|
||||
feedback_url: str | None = None
|
||||
learn_more_url: str | None = None
|
||||
report_issue_url: str | None = None
|
||||
|
||||
@property
|
||||
def full_key(self) -> str:
|
||||
"""Return the full key for the preview feature (domain.preview_feature)."""
|
||||
return f"{self.domain}.{self.preview_feature}"
|
||||
|
||||
def to_dict(self, enabled: bool) -> dict[str, str | bool | None]:
|
||||
"""Return a serialized version of the preview feature.
|
||||
|
||||
Args:
|
||||
enabled: Whether the preview feature is currently enabled
|
||||
|
||||
Returns:
|
||||
Dictionary with preview feature data including enabled status
|
||||
"""
|
||||
return {
|
||||
"preview_feature": self.preview_feature,
|
||||
"domain": self.domain,
|
||||
"enabled": enabled,
|
||||
"is_built_in": self.is_built_in,
|
||||
"feedback_url": self.feedback_url,
|
||||
"learn_more_url": self.learn_more_url,
|
||||
"report_issue_url": self.report_issue_url,
|
||||
}
|
||||
|
||||
|
||||
type LabsStoreData = dict[str, set[tuple[str, str]]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabsData:
|
||||
"""Storage class for Labs global data."""
|
||||
|
||||
store: Store[LabsStoreData]
|
||||
data: LabsStoreData
|
||||
preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict)
|
||||
|
||||
|
||||
LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN)
|
||||
9
homeassistant/components/labs/manifest.json
Normal file
9
homeassistant/components/labs/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "labs",
|
||||
"name": "Home Assistant Labs",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/labs",
|
||||
"integration_type": "system",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
3
homeassistant/components/labs/strings.json
Normal file
3
homeassistant/components/labs/strings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Home Assistant Labs"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from asyncio import Task
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -44,7 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
_default_update_interval = SCAN_INTERVAL
|
||||
config_entry: LaMarzoccoConfigEntry
|
||||
websocket_terminated = True
|
||||
_websocket_task: Task | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -64,6 +65,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
self.device = device
|
||||
self.cloud_client = cloud_client
|
||||
|
||||
@property
|
||||
def websocket_terminated(self) -> bool:
|
||||
"""Return True if the websocket task is terminated or not running."""
|
||||
if self._websocket_task is None:
|
||||
return True
|
||||
return self._websocket_task.done()
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Do the data update."""
|
||||
try:
|
||||
@@ -95,13 +103,14 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
# ensure token stays valid; does nothing if token is still valid
|
||||
await self.cloud_client.async_get_access_token()
|
||||
|
||||
if self.device.websocket.connected:
|
||||
# Only skip websocket reconnection if it's currently connected and the task is still running
|
||||
if self.device.websocket.connected and not self.websocket_terminated:
|
||||
return
|
||||
|
||||
await self.device.get_dashboard()
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
|
||||
self.config_entry.async_create_background_task(
|
||||
self._websocket_task = self.config_entry.async_create_background_task(
|
||||
hass=self.hass,
|
||||
target=self.connect_websocket(),
|
||||
name="lm_websocket_task",
|
||||
@@ -120,7 +129,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
|
||||
_LOGGER.debug("Init WebSocket in background task")
|
||||
|
||||
self.websocket_terminated = False
|
||||
self.async_update_listeners()
|
||||
|
||||
await self.device.connect_dashboard_websocket(
|
||||
@@ -129,7 +137,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
disconnect_callback=self.async_update_listeners,
|
||||
)
|
||||
|
||||
self.websocket_terminated = True
|
||||
self.async_update_listeners()
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import pypck
|
||||
from pypck.connection import (
|
||||
@@ -48,7 +49,6 @@ from .const import (
|
||||
)
|
||||
from .helpers import (
|
||||
AddressType,
|
||||
InputType,
|
||||
LcnConfigEntry,
|
||||
LcnRuntimeData,
|
||||
async_update_config_entry,
|
||||
@@ -285,7 +285,7 @@ def _async_fire_access_control_event(
|
||||
hass: HomeAssistant,
|
||||
device: dr.DeviceEntry | None,
|
||||
address: AddressType,
|
||||
inp: InputType,
|
||||
inp: pypck.inputs.ModStatusAccessControl,
|
||||
) -> None:
|
||||
"""Fire access control event (transponder, transmitter, fingerprint, codelock)."""
|
||||
event_data = {
|
||||
@@ -299,7 +299,11 @@ def _async_fire_access_control_event(
|
||||
|
||||
if inp.periphery == pypck.lcn_defs.AccessControlPeriphery.TRANSMITTER:
|
||||
event_data.update(
|
||||
{"level": inp.level, "key": inp.key, "action": inp.action.value}
|
||||
{
|
||||
"level": inp.level,
|
||||
"key": inp.key,
|
||||
"action": cast(pypck.lcn_defs.KeyAction, inp.action).value,
|
||||
}
|
||||
)
|
||||
|
||||
event_name = f"lcn_{inp.periphery.value.lower()}"
|
||||
@@ -310,7 +314,7 @@ def _async_fire_send_keys_event(
|
||||
hass: HomeAssistant,
|
||||
device: dr.DeviceEntry | None,
|
||||
address: AddressType,
|
||||
inp: InputType,
|
||||
inp: pypck.inputs.ModSendKeysHost,
|
||||
) -> None:
|
||||
"""Fire send_keys event."""
|
||||
for table, action in enumerate(inp.actions):
|
||||
|
||||
@@ -100,8 +100,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP]
|
||||
self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP]
|
||||
|
||||
self._current_temperature = None
|
||||
self._target_temperature = None
|
||||
self._is_on = True
|
||||
|
||||
self._attr_hvac_modes = [HVACMode.HEAT]
|
||||
@@ -121,16 +119,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool mode.
|
||||
@@ -166,7 +154,7 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
):
|
||||
return
|
||||
self._is_on = False
|
||||
self._target_temperature = None
|
||||
self._attr_target_temperature = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
@@ -178,7 +166,7 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
self.setpoint, temperature, self.unit
|
||||
):
|
||||
return
|
||||
self._target_temperature = temperature
|
||||
self._attr_target_temperature = temperature
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
@@ -198,10 +186,14 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
return
|
||||
|
||||
if input_obj.get_var() == self.variable:
|
||||
self._current_temperature = input_obj.get_value().to_var_unit(self.unit)
|
||||
self._attr_current_temperature = float(
|
||||
input_obj.get_value().to_var_unit(self.unit)
|
||||
)
|
||||
elif input_obj.get_var() == self.setpoint:
|
||||
self._is_on = not input_obj.get_value().is_locked_regulator()
|
||||
if self._is_on:
|
||||
self._target_temperature = input_obj.get_value().to_var_unit(self.unit)
|
||||
self._attr_target_temperature = float(
|
||||
input_obj.get_value().to_var_unit(self.unit)
|
||||
)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -120,7 +120,7 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors={CONF_BASE: error},
|
||||
)
|
||||
|
||||
data: dict = {
|
||||
data: dict[str, Any] = {
|
||||
**user_input,
|
||||
CONF_DEVICES: [],
|
||||
CONF_ENTITIES: [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for LCN covers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Coroutine, Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
@@ -81,6 +81,8 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
|
||||
_attr_is_opening = False
|
||||
_attr_assumed_state = True
|
||||
|
||||
reverse_time: pypck.lcn_defs.MotorReverseTime | None
|
||||
|
||||
def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None:
|
||||
"""Initialize the LCN cover."""
|
||||
super().__init__(config, config_entry)
|
||||
@@ -255,7 +257,15 @@ class LcnRelayCover(LcnEntity, CoverEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
coros = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
|
||||
coros: list[
|
||||
Coroutine[
|
||||
Any,
|
||||
Any,
|
||||
pypck.inputs.ModStatusRelays
|
||||
| pypck.inputs.ModStatusMotorPositionBS4
|
||||
| None,
|
||||
]
|
||||
] = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
|
||||
if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4:
|
||||
coros.append(
|
||||
self.device_connection.request_status_motor_position(
|
||||
@@ -283,7 +293,7 @@ class LcnRelayCover(LcnEntity, CoverEntity):
|
||||
)
|
||||
and input_obj.motor == self.motor.value
|
||||
):
|
||||
self._attr_current_cover_position = input_obj.position
|
||||
self._attr_current_cover_position = int(input_obj.position)
|
||||
if self._attr_current_cover_position in [0, 100]:
|
||||
self._attr_is_opening = False
|
||||
self._attr_is_closing = False
|
||||
|
||||
@@ -35,7 +35,7 @@ class LcnEntity(Entity):
|
||||
self.config = config
|
||||
self.config_entry = config_entry
|
||||
self.address: AddressType = config[CONF_ADDRESS]
|
||||
self._unregister_for_inputs: Callable | None = None
|
||||
self._unregister_for_inputs: Callable[[], None] | None = None
|
||||
self._name: str = config[CONF_NAME]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
|
||||
@@ -61,7 +61,7 @@ type LcnConfigEntry = ConfigEntry[LcnRuntimeData]
|
||||
|
||||
type AddressType = tuple[int, int, bool]
|
||||
|
||||
type InputType = type[pypck.inputs.Input]
|
||||
type InputType = pypck.inputs.Input
|
||||
|
||||
# Regex for address validation
|
||||
PATTERN_ADDRESS = re.compile(
|
||||
@@ -269,10 +269,10 @@ async def async_update_device_config(
|
||||
if device_config[CONF_NAME] != "":
|
||||
return
|
||||
|
||||
device_name = ""
|
||||
device_name: str | None = None
|
||||
if not is_group:
|
||||
device_name = await device_connection.request_name()
|
||||
if is_group or device_name == "":
|
||||
if is_group or device_name is None:
|
||||
module_type = "Group" if is_group else "Module"
|
||||
device_name = (
|
||||
f"{module_type} "
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pypck==0.9.3", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.5", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -74,4 +74,4 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration is not making any HTTP requests.
|
||||
strict-typing: todo
|
||||
strict-typing: done
|
||||
|
||||
@@ -156,6 +156,8 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
|
||||
class LcnLedLogicSensor(LcnEntity, SensorEntity):
|
||||
"""Representation of a LCN sensor for leds and logicops."""
|
||||
|
||||
source: pypck.lcn_defs.LedPort | pypck.lcn_defs.LogicOpPort
|
||||
|
||||
def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None:
|
||||
"""Initialize the LCN sensor."""
|
||||
super().__init__(config, config_entry)
|
||||
|
||||
@@ -104,7 +104,9 @@ def get_config_entry(
|
||||
|
||||
@wraps(func)
|
||||
async def get_entry(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get config_entry."""
|
||||
if not (config_entry := hass.config_entries.async_get_entry(msg["entry_id"])):
|
||||
@@ -124,7 +126,7 @@ def get_config_entry(
|
||||
async def websocket_get_device_configs(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Get device configs."""
|
||||
@@ -144,7 +146,7 @@ async def websocket_get_device_configs(
|
||||
async def websocket_get_entity_configs(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Get entities configs."""
|
||||
@@ -175,7 +177,7 @@ async def websocket_get_entity_configs(
|
||||
async def websocket_scan_devices(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Scan for new devices."""
|
||||
@@ -207,7 +209,7 @@ async def websocket_scan_devices(
|
||||
async def websocket_add_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Add a device."""
|
||||
@@ -253,7 +255,7 @@ async def websocket_add_device(
|
||||
async def websocket_delete_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Delete a device."""
|
||||
@@ -315,7 +317,7 @@ async def websocket_delete_device(
|
||||
async def websocket_add_entity(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Add an entity."""
|
||||
@@ -381,7 +383,7 @@ async def websocket_add_entity(
|
||||
async def websocket_delete_entity(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
msg: dict[str, Any],
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Delete an entity."""
|
||||
@@ -451,7 +453,7 @@ async def async_create_or_update_device_in_config_entry(
|
||||
|
||||
|
||||
def get_entity_entry(
|
||||
hass: HomeAssistant, entity_config: dict, config_entry: LcnConfigEntry
|
||||
hass: HomeAssistant, entity_config: dict[str, Any], config_entry: LcnConfigEntry
|
||||
) -> er.RegistryEntry | None:
|
||||
"""Get entity RegistryEntry from entity_config."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pylutron_caseta"],
|
||||
"requirements": ["pylutron-caseta==0.25.0"],
|
||||
"requirements": ["pylutron-caseta==0.26.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -183,13 +183,6 @@ PUMP_CONTROL_MODE_MAP = {
|
||||
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
|
||||
}
|
||||
|
||||
SETPOINT_CHANGE_SOURCE_MAP = {
|
||||
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
|
||||
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
|
||||
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
|
||||
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
|
||||
}
|
||||
|
||||
HUMIDITY_SCALING_FACTOR = 100
|
||||
TEMPERATURE_SCALING_FACTOR = 100
|
||||
|
||||
@@ -1495,47 +1488,4 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="SetpointChangeSource",
|
||||
translation_key="setpoint_change_source",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
state_class=None,
|
||||
# convert to set first to remove the duplicate unknown value
|
||||
options=[x for x in SETPOINT_CHANGE_SOURCE_MAP.values() if x is not None],
|
||||
device_to_ha=lambda x: SETPOINT_CHANGE_SOURCE_MAP[x],
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="SetpointChangeSourceTimestamp",
|
||||
translation_key="setpoint_change_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
state_class=None,
|
||||
device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(
|
||||
clusters.Thermostat.Attributes.SetpointChangeSourceTimestamp,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThermostatSetpointChangeAmount",
|
||||
translation_key="setpoint_change_amount",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeAmount,),
|
||||
device_type=(device_types.Thermostat,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -223,9 +223,6 @@
|
||||
"pump_setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"setpoint_change_source_timestamp": {
|
||||
"name": "Last change"
|
||||
},
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
@@ -521,20 +518,6 @@
|
||||
"rms_voltage": {
|
||||
"name": "Effective voltage"
|
||||
},
|
||||
"setpoint_change_amount": {
|
||||
"name": "Last change amount"
|
||||
},
|
||||
"setpoint_change_source": {
|
||||
"name": "Last change source",
|
||||
"state": {
|
||||
"external": "External",
|
||||
"manual": "Manual",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"setpoint_change_timestamp": {
|
||||
"name": "Last change"
|
||||
},
|
||||
"switch_current_position": {
|
||||
"name": "Current switch position"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,8 +36,8 @@ from .const import (
|
||||
COFFEE_SYSTEM_PROFILE,
|
||||
DISABLED_TEMP_ENTITIES,
|
||||
DOMAIN,
|
||||
PROGRAM_IDS,
|
||||
PROGRAM_PHASE,
|
||||
STATE_PROGRAM_ID,
|
||||
STATE_STATUS_TAGS,
|
||||
MieleAppliance,
|
||||
PlatePowerStep,
|
||||
@@ -979,21 +979,16 @@ class MieleProgramIdSensor(MieleSensor):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
ret_val = STATE_PROGRAM_ID.get(self.device.device_type, {}).get(
|
||||
self.device.state_program_id
|
||||
return (
|
||||
PROGRAM_IDS[self.device.device_type](self.device.state_program_id).name
|
||||
if self.device.device_type in PROGRAM_IDS
|
||||
else None
|
||||
)
|
||||
if ret_val is None:
|
||||
_LOGGER.debug(
|
||||
"Unknown program id: %s on device type: %s",
|
||||
self.device.state_program_id,
|
||||
self.device.device_type,
|
||||
)
|
||||
return ret_val
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return the options list for the actual device type."""
|
||||
return sorted(set(STATE_PROGRAM_ID.get(self.device.device_type, {}).values()))
|
||||
return sorted(PROGRAM_IDS.get(self.device.device_type, {}).keys())
|
||||
|
||||
|
||||
class MieleTimeSensor(MieleRestorableSensor):
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"custom_program_9": "Custom program 9",
|
||||
"dark_garments": "Dark garments",
|
||||
"dark_mixed_grain_bread": "Dark mixed grain bread",
|
||||
"decrystallise_honey": "Decrystallise honey",
|
||||
"decrystallise_honey": "Decrystallize honey",
|
||||
"defrost": "Defrost",
|
||||
"defrosting_with_microwave": "Defrosting with microwave",
|
||||
"defrosting_with_steam": "Defrosting with steam",
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import (
|
||||
OhmeAdvancedSettingsCoordinator,
|
||||
OhmeChargeSessionCoordinator,
|
||||
OhmeConfigEntry,
|
||||
OhmeDeviceInfoCoordinator,
|
||||
@@ -56,7 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool
|
||||
|
||||
coordinators = (
|
||||
OhmeChargeSessionCoordinator(hass, entry, client),
|
||||
OhmeAdvancedSettingsCoordinator(hass, entry, client),
|
||||
OhmeDeviceInfoCoordinator(hass, entry, client),
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import logging
|
||||
from ohme import ApiException, OhmeApiClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -23,7 +23,6 @@ class OhmeRuntimeData:
|
||||
"""Dataclass to hold ohme coordinators."""
|
||||
|
||||
charge_session_coordinator: OhmeChargeSessionCoordinator
|
||||
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
|
||||
device_info_coordinator: OhmeDeviceInfoCoordinator
|
||||
|
||||
|
||||
@@ -78,31 +77,6 @@ class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
|
||||
await self.client.async_get_charge_session()
|
||||
|
||||
|
||||
class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
|
||||
"""Coordinator to pull settings and charger state from the API."""
|
||||
|
||||
coordinator_name = "Advanced Settings"
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
|
||||
) -> None:
|
||||
"""Initialise coordinator."""
|
||||
super().__init__(hass, config_entry, client)
|
||||
|
||||
@callback
|
||||
def _dummy_listener() -> None:
|
||||
pass
|
||||
|
||||
# This coordinator is used by the API library to determine whether the
|
||||
# charger is online and available. It is therefore required even if no
|
||||
# entities are using it.
|
||||
self.async_add_listener(_dummy_listener)
|
||||
|
||||
async def _internal_update_data(self) -> None:
|
||||
"""Fetch data from API endpoint."""
|
||||
await self.client.async_get_advanced_settings()
|
||||
|
||||
|
||||
class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
|
||||
"""Coordinator to pull device info and charger settings from the API."""
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.5.2"]
|
||||
"requirements": ["ohme==1.6.0"]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
|
||||
value_fn: Callable[[OhmeApiClient], str | int | float | None]
|
||||
|
||||
|
||||
SENSOR_CHARGE_SESSION = [
|
||||
SENSORS = [
|
||||
OhmeSensorDescription(
|
||||
key="status",
|
||||
translation_key="status",
|
||||
@@ -91,18 +91,6 @@ SENSOR_CHARGE_SESSION = [
|
||||
),
|
||||
]
|
||||
|
||||
SENSOR_ADVANCED_SETTINGS = [
|
||||
OhmeSensorDescription(
|
||||
key="ct_current",
|
||||
translation_key="ct_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
value_fn=lambda client: client.power.ct_amps,
|
||||
is_supported_fn=lambda client: client.ct_connected,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -110,16 +98,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors."""
|
||||
coordinators = config_entry.runtime_data
|
||||
coordinator_map = [
|
||||
(SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
|
||||
(SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
|
||||
]
|
||||
coordinator = config_entry.runtime_data.charge_session_coordinator
|
||||
|
||||
async_add_entities(
|
||||
OhmeSensor(coordinator, description)
|
||||
for entities, coordinator in coordinator_map
|
||||
for description in entities
|
||||
for description in SENSORS
|
||||
if description.is_supported_fn(coordinator.client)
|
||||
)
|
||||
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.0.16"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.17"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
|
||||
from aioshelly.ble import get_name_from_model_id
|
||||
from aioshelly.ble.manufacturer_data import (
|
||||
has_rpc_over_ble,
|
||||
parse_shelly_manufacturer_data,
|
||||
)
|
||||
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
|
||||
from aioshelly.block_device import BlockDevice
|
||||
from aioshelly.common import ConnectionOptions, get_info
|
||||
@@ -358,8 +362,35 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle bluetooth discovery."""
|
||||
# Parse MAC address from the Bluetooth device name
|
||||
if not (mac := mac_address_from_name(discovery_info.name)):
|
||||
# Try to parse MAC address from the Bluetooth device name
|
||||
# If not found, try to get it from manufacturer data
|
||||
device_name = discovery_info.name
|
||||
if (
|
||||
not (mac := mac_address_from_name(device_name))
|
||||
and (
|
||||
parsed := parse_shelly_manufacturer_data(
|
||||
discovery_info.manufacturer_data
|
||||
)
|
||||
)
|
||||
and (mac_with_colons := parsed.get("mac"))
|
||||
and isinstance(mac_with_colons, str)
|
||||
):
|
||||
# parse_shelly_manufacturer_data returns MAC with colons (e.g., "CC:BA:97:C2:D6:72")
|
||||
# Convert to format without colons to match mac_address_from_name output
|
||||
mac = mac_with_colons.replace(":", "")
|
||||
# For devices without a Shelly name, use model name from model ID if available
|
||||
# Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR"
|
||||
if (
|
||||
(model_id := parsed.get("model_id"))
|
||||
and isinstance(model_id, int)
|
||||
and (model_name := get_name_from_model_id(model_id))
|
||||
):
|
||||
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
|
||||
device_name = f"{model_name.replace(' ', '')}-{mac}"
|
||||
else:
|
||||
device_name = f"Shelly-{mac}"
|
||||
|
||||
if not mac:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
|
||||
@@ -381,10 +412,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not self.ble_device:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.device_name = discovery_info.name
|
||||
self.device_name = device_name
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": discovery_info.name},
|
||||
"title_placeholders": {"name": device_name},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "Shelly*"
|
||||
},
|
||||
{
|
||||
"manufacturer_id": 2985
|
||||
}
|
||||
],
|
||||
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
|
||||
@@ -14,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioshelly==13.17.0"],
|
||||
"requirements": ["aioshelly==13.19.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "shelly*",
|
||||
|
||||
@@ -66,8 +66,6 @@ class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor):
|
||||
if webhook_id == self.webhook_id and data[ATTR_EVENT] in (
|
||||
"alarm_snooze_clicked",
|
||||
"alarm_snooze_canceled",
|
||||
"alarm_alert_start",
|
||||
"alarm_alert_dismiss",
|
||||
"alarm_skip_next",
|
||||
"show_skip_next_alarm",
|
||||
"alarm_rescheduled",
|
||||
|
||||
@@ -8,20 +8,17 @@ from dataclasses import dataclass
|
||||
from pysmartthings import Attribute, Capability, Category, SmartThings, Status
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import INVALID_SWITCH_CATEGORIES, MAIN
|
||||
from .entity import SmartThingsEntity
|
||||
from .util import deprecate_entity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -31,11 +28,14 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
is_on_key: str
|
||||
category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None
|
||||
category: set[Category] | None = None
|
||||
exists_fn: Callable[[str], bool] | None = None
|
||||
exists_fn: (
|
||||
Callable[
|
||||
[str, dict[str, dict[Capability | str, dict[Attribute | str, Status]]]],
|
||||
bool,
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
component_translation_key: dict[str, str] | None = None
|
||||
deprecated_fn: Callable[
|
||||
[dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None
|
||||
] = lambda _: None
|
||||
|
||||
|
||||
CAPABILITY_TO_SENSORS: dict[
|
||||
@@ -59,17 +59,16 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
Category.DOOR: BinarySensorDeviceClass.DOOR,
|
||||
Category.WINDOW: BinarySensorDeviceClass.WINDOW,
|
||||
},
|
||||
exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"},
|
||||
exists_fn=lambda component, status: (
|
||||
not ("freezer" in status and "cooler" in status)
|
||||
if component == MAIN
|
||||
else True
|
||||
),
|
||||
component_translation_key={
|
||||
"freezer": "freezer_door",
|
||||
"cooler": "cooler_door",
|
||||
"cvroom": "cool_select_plus_door",
|
||||
},
|
||||
deprecated_fn=(
|
||||
lambda status: "fridge_door"
|
||||
if "freezer" in status and "cooler" in status
|
||||
else None
|
||||
),
|
||||
)
|
||||
},
|
||||
Capability.CUSTOM_DRYER_WRINKLE_PREVENT: {
|
||||
@@ -155,15 +154,6 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
},
|
||||
Capability.VALVE: {
|
||||
Attribute.VALVE: SmartThingsBinarySensorEntityDescription(
|
||||
key=Attribute.VALVE,
|
||||
translation_key="valve",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
is_on_key="open",
|
||||
deprecated_fn=lambda _: "valve",
|
||||
)
|
||||
},
|
||||
Capability.WATER_SENSOR: {
|
||||
Attribute.WATER: SmartThingsBinarySensorEntityDescription(
|
||||
key=Attribute.WATER,
|
||||
@@ -204,64 +194,39 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add binary sensors for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
entities = []
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
|
||||
for capability, attribute_map in CAPABILITY_TO_SENSORS.items():
|
||||
for attribute, description in attribute_map.items():
|
||||
for component in device.status:
|
||||
if (
|
||||
capability in device.status[component]
|
||||
and (
|
||||
component == MAIN
|
||||
or (
|
||||
description.exists_fn is not None
|
||||
and description.exists_fn(component)
|
||||
)
|
||||
)
|
||||
and (
|
||||
not description.category
|
||||
or get_main_component_category(device)
|
||||
in description.category
|
||||
)
|
||||
):
|
||||
if (
|
||||
component == MAIN
|
||||
and (issue := description.deprecated_fn(device.status))
|
||||
is not None
|
||||
):
|
||||
if deprecate_entity(
|
||||
hass,
|
||||
entity_registry,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}",
|
||||
f"deprecated_binary_{issue}",
|
||||
):
|
||||
entities.append(
|
||||
SmartThingsBinarySensor(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
capability,
|
||||
attribute,
|
||||
component,
|
||||
)
|
||||
)
|
||||
continue
|
||||
entities.append(
|
||||
SmartThingsBinarySensor(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
capability,
|
||||
attribute,
|
||||
component,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
SmartThingsBinarySensor(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
capability,
|
||||
attribute,
|
||||
component,
|
||||
)
|
||||
for device in entry_data.devices.values()
|
||||
for capability, attribute_map in CAPABILITY_TO_SENSORS.items()
|
||||
for attribute, description in attribute_map.items()
|
||||
for component in device.status
|
||||
if (
|
||||
capability in device.status[component]
|
||||
and (
|
||||
component == MAIN
|
||||
or (
|
||||
description.component_translation_key is not None
|
||||
and component in description.component_translation_key
|
||||
)
|
||||
)
|
||||
and (
|
||||
description.exists_fn is None
|
||||
or description.exists_fn(component, device.status)
|
||||
)
|
||||
and (
|
||||
not description.category
|
||||
or get_main_component_category(device) in description.category
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
|
||||
|
||||
@@ -667,22 +667,6 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_binary_fridge_door": {
|
||||
"description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue.",
|
||||
"title": "Refrigerator door binary sensor deprecated"
|
||||
},
|
||||
"deprecated_binary_fridge_door_scripts": {
|
||||
"description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue.",
|
||||
"title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]"
|
||||
},
|
||||
"deprecated_binary_valve": {
|
||||
"description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue.",
|
||||
"title": "Valve binary sensor deprecated"
|
||||
},
|
||||
"deprecated_binary_valve_scripts": {
|
||||
"description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue.",
|
||||
"title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]"
|
||||
},
|
||||
"deprecated_dhw": {
|
||||
"description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nPlease update your dashboards and templates to use the new water heater entity and disable the sensor to fix this issue.",
|
||||
"title": "Water heater sensors deprecated"
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
|
||||
from .types import DaliCenterConfigEntry, DaliCenterData
|
||||
@@ -47,12 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -
|
||||
"You can try to delete the gateway and add it again"
|
||||
) from exc
|
||||
|
||||
def on_online_status(dev_id: str, available: bool) -> None:
|
||||
signal = f"{DOMAIN}_update_available_{dev_id}"
|
||||
hass.add_job(async_dispatcher_send, hass, signal, available)
|
||||
|
||||
gateway.on_online_status = on_online_status
|
||||
|
||||
try:
|
||||
devices = await gateway.discover_devices()
|
||||
except DaliGatewayError as exc:
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from PySrDaliGateway import Device
|
||||
from PySrDaliGateway import CallbackEventType, Device
|
||||
from PySrDaliGateway.helper import is_light_device
|
||||
from PySrDaliGateway.types import LightStatus
|
||||
|
||||
@@ -19,10 +19,6 @@ from homeassistant.components.light import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
@@ -40,15 +36,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Sunricher DALI light entities from config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
gateway = runtime_data.gateway
|
||||
devices = runtime_data.devices
|
||||
|
||||
def _on_light_status(dev_id: str, status: LightStatus) -> None:
|
||||
signal = f"{DOMAIN}_update_{dev_id}"
|
||||
hass.add_job(async_dispatcher_send, hass, signal, status)
|
||||
|
||||
gateway.on_light_status = _on_light_status
|
||||
|
||||
async_add_entities(
|
||||
DaliCenterLight(device)
|
||||
for device in devices
|
||||
@@ -123,14 +112,16 @@ class DaliCenterLight(LightEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity addition to Home Assistant."""
|
||||
|
||||
signal = f"{DOMAIN}_update_{self._attr_unique_id}"
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, signal, self._handle_device_update)
|
||||
self._light.register_listener(
|
||||
CallbackEventType.LIGHT_STATUS, self._handle_device_update
|
||||
)
|
||||
)
|
||||
|
||||
signal = f"{DOMAIN}_update_available_{self._attr_unique_id}"
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, signal, self._handle_availability)
|
||||
self._light.register_listener(
|
||||
CallbackEventType.ONLINE_STATUS, self._handle_availability
|
||||
)
|
||||
)
|
||||
|
||||
# read_status() only queues a request on the gateway and relies on the
|
||||
@@ -187,4 +178,4 @@ class DaliCenterLight(LightEntity):
|
||||
):
|
||||
self._attr_rgbw_color = status["rgbw_color"]
|
||||
|
||||
self.async_write_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["PySrDaliGateway==0.13.1"]
|
||||
"requirements": ["PySrDaliGateway==0.16.2"]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -26,7 +26,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeIntegerWrapper, IntegerTypeData, find_dpcode
|
||||
from .models import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
IntegerTypeData,
|
||||
find_dpcode,
|
||||
)
|
||||
from .util import get_dpcode
|
||||
|
||||
TUYA_HVAC_TO_HA = {
|
||||
@@ -110,6 +116,17 @@ async def async_setup_entry(
|
||||
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_CURRENT
|
||||
),
|
||||
fan_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device,
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
prefer_function=True,
|
||||
),
|
||||
hvac_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH, prefer_function=True
|
||||
),
|
||||
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_SET, prefer_function=True
|
||||
),
|
||||
@@ -140,8 +157,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
description: TuyaClimateEntityDescription,
|
||||
system_temperature_unit: UnitOfTemperature,
|
||||
*,
|
||||
current_humidity_wrapper: _RoundedIntegerWrapper | None = None,
|
||||
target_humidity_wrapper: _RoundedIntegerWrapper | None = None,
|
||||
current_humidity_wrapper: _RoundedIntegerWrapper | None,
|
||||
fan_mode_wrapper: DPCodeEnumWrapper | None,
|
||||
hvac_mode_wrapper: DPCodeEnumWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
target_humidity_wrapper: _RoundedIntegerWrapper | None,
|
||||
) -> None:
|
||||
"""Determine which values to use."""
|
||||
self._attr_target_temperature_step = 1.0
|
||||
@@ -149,6 +169,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
super().__init__(device, device_manager)
|
||||
self._current_humidity_wrapper = current_humidity_wrapper
|
||||
self._fan_mode_wrapper = fan_mode_wrapper
|
||||
self._hvac_mode_wrapper = hvac_mode_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
self._target_humidity_wrapper = target_humidity_wrapper
|
||||
|
||||
# If both temperature values for celsius and fahrenheit are present,
|
||||
@@ -222,12 +245,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
# Determine HVAC modes
|
||||
self._attr_hvac_modes: list[HVACMode] = []
|
||||
self._hvac_to_tuya = {}
|
||||
if enum_type := find_dpcode(
|
||||
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if hvac_mode_wrapper:
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
unknown_hvac_modes: list[str] = []
|
||||
for tuya_mode in enum_type.range:
|
||||
for tuya_mode in hvac_mode_wrapper.type_information.range:
|
||||
if tuya_mode in TUYA_HVAC_TO_HA:
|
||||
ha_mode = TUYA_HVAC_TO_HA[tuya_mode]
|
||||
self._hvac_to_tuya[ha_mode] = tuya_mode
|
||||
@@ -239,7 +260,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
|
||||
self._attr_preset_modes = unknown_hvac_modes
|
||||
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
|
||||
elif get_dpcode(self.device, DPCode.SWITCH):
|
||||
elif switch_wrapper:
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
description.switch_only_hvac_mode,
|
||||
@@ -256,16 +277,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
# Determine fan modes
|
||||
self._fan_mode_dp_code: str | None = None
|
||||
if enum_type := find_dpcode(
|
||||
self.device,
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
):
|
||||
if fan_mode_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
|
||||
self._attr_fan_modes = enum_type.range
|
||||
self._fan_mode_dp_code = enum_type.dpcode
|
||||
self._attr_fan_modes = fan_mode_wrapper.type_information.range
|
||||
|
||||
# Determine swing modes
|
||||
if get_dpcode(
|
||||
@@ -288,32 +302,35 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
if get_dpcode(self.device, DPCode.SWITCH_VERTICAL):
|
||||
self._attr_swing_modes.append(SWING_VERTICAL)
|
||||
|
||||
if DPCode.SWITCH in self.device.function:
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
commands = [{"code": DPCode.SWITCH, "value": hvac_mode != HVACMode.OFF}]
|
||||
if hvac_mode in self._hvac_to_tuya:
|
||||
commands = []
|
||||
if self._switch_wrapper:
|
||||
commands.append(
|
||||
{"code": DPCode.MODE, "value": self._hvac_to_tuya[hvac_mode]}
|
||||
self._switch_wrapper.get_update_command(
|
||||
self.device, hvac_mode != HVACMode.OFF
|
||||
)
|
||||
)
|
||||
self._send_command(commands)
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
|
||||
commands.append(
|
||||
self._hvac_mode_wrapper.get_update_command(
|
||||
self.device, self._hvac_to_tuya[hvac_mode]
|
||||
)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
commands = [{"code": DPCode.MODE, "value": preset_mode}]
|
||||
self._send_command(commands)
|
||||
await self._async_send_dpcode_update(self._hvac_mode_wrapper, preset_mode)
|
||||
|
||||
def set_fan_mode(self, fan_mode: str) -> None:
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
if TYPE_CHECKING:
|
||||
# guarded by ClimateEntityFeature.FAN_MODE
|
||||
assert self._fan_mode_dp_code is not None
|
||||
|
||||
self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}])
|
||||
await self._async_send_dpcode_update(self._fan_mode_wrapper, fan_mode)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
@@ -405,34 +422,29 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac mode."""
|
||||
# If the switch off, hvac mode is off as well. Unless the switch
|
||||
# the switch is on or doesn't exists of course...
|
||||
if not self.device.status.get(DPCode.SWITCH, True):
|
||||
# If the switch is off, hvac mode is off as well.
|
||||
# Unless the switch doesn't exists of course...
|
||||
if (switch_status := self._read_wrapper(self._switch_wrapper)) is False:
|
||||
return HVACMode.OFF
|
||||
|
||||
if DPCode.MODE not in self.device.function:
|
||||
if self.device.status.get(DPCode.SWITCH, False):
|
||||
return self.entity_description.switch_only_hvac_mode
|
||||
return HVACMode.OFF
|
||||
# If the mode is known and maps to an HVAC mode, return it.
|
||||
if (mode := self._read_wrapper(self._hvac_mode_wrapper)) and (
|
||||
hvac_mode := TUYA_HVAC_TO_HA.get(mode)
|
||||
):
|
||||
return hvac_mode
|
||||
|
||||
if (
|
||||
mode := self.device.status.get(DPCode.MODE)
|
||||
) is not None and mode in TUYA_HVAC_TO_HA:
|
||||
return TUYA_HVAC_TO_HA[mode]
|
||||
|
||||
# If the switch is on, and the mode does not match any hvac mode.
|
||||
if self.device.status.get(DPCode.SWITCH, False):
|
||||
# If hvac_mode is unknown, return the switch only mode.
|
||||
if switch_status:
|
||||
return self.entity_description.switch_only_hvac_mode
|
||||
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return preset mode."""
|
||||
if DPCode.MODE not in self.device.function:
|
||||
if self._hvac_mode_wrapper is None:
|
||||
return None
|
||||
|
||||
mode = self.device.status.get(DPCode.MODE)
|
||||
mode = self._read_wrapper(self._hvac_mode_wrapper)
|
||||
if mode in TUYA_HVAC_TO_HA:
|
||||
return None
|
||||
|
||||
@@ -441,11 +453,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return fan mode."""
|
||||
return (
|
||||
self.device.status.get(self._fan_mode_dp_code)
|
||||
if self._fan_mode_dp_code
|
||||
else None
|
||||
)
|
||||
return self._read_wrapper(self._fan_mode_wrapper)
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
@@ -466,10 +474,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
return SWING_OFF
|
||||
|
||||
def turn_on(self) -> None:
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
self._send_command([{"code": DPCode.SWITCH, "value": True}])
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, True)
|
||||
|
||||
def turn_off(self) -> None:
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
self._send_command([{"code": DPCode.SWITCH, "value": False}])
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
|
||||
@@ -20,9 +20,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeIntegerWrapper, find_dpcode
|
||||
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper
|
||||
from .util import get_dpcode
|
||||
|
||||
|
||||
@@ -73,6 +73,60 @@ class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper):
|
||||
return device.status.get(DPCode.CONTROL_BACK_MODE) != "back"
|
||||
|
||||
|
||||
class _InstructionWrapper:
|
||||
"""Default wrapper for sending open/close/stop instructions."""
|
||||
|
||||
def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
class _InstructionBooleanWrapper(DPCodeBooleanWrapper, _InstructionWrapper):
|
||||
"""Wrapper for boolean-based open/close instructions."""
|
||||
|
||||
def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return {"code": self.dpcode, "value": True}
|
||||
|
||||
def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
return {"code": self.dpcode, "value": False}
|
||||
|
||||
|
||||
class _InstructionEnumWrapper(DPCodeEnumWrapper, _InstructionWrapper):
|
||||
"""Wrapper for enum-based open/close/stop instructions."""
|
||||
|
||||
open_instruction = "open"
|
||||
close_instruction = "close"
|
||||
stop_instruction = "stop"
|
||||
|
||||
def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
if self.open_instruction in self.type_information.range:
|
||||
return {"code": self.dpcode, "value": self.open_instruction}
|
||||
return None
|
||||
|
||||
def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
if self.close_instruction in self.type_information.range:
|
||||
return {"code": self.dpcode, "value": self.close_instruction}
|
||||
return None
|
||||
|
||||
def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
if self.stop_instruction in self.type_information.range:
|
||||
return {"code": self.dpcode, "value": self.stop_instruction}
|
||||
return None
|
||||
|
||||
|
||||
class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper):
|
||||
"""Wrapper for enum-based instructions with special values (FZ/ZZ/STOP)."""
|
||||
|
||||
open_instruction = "FZ"
|
||||
close_instruction = "ZZ"
|
||||
stop_instruction = "STOP"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaCoverEntityDescription(CoverEntityDescription):
|
||||
"""Describe an Tuya cover entity."""
|
||||
@@ -80,13 +134,11 @@ class TuyaCoverEntityDescription(CoverEntityDescription):
|
||||
current_state: DPCode | tuple[DPCode, ...] | None = None
|
||||
current_state_inverse: bool = False
|
||||
current_position: DPCode | tuple[DPCode, ...] | None = None
|
||||
instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper
|
||||
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
|
||||
_InvertedPercentageMappingWrapper
|
||||
)
|
||||
set_position: DPCode | None = None
|
||||
open_instruction_value: str = "open"
|
||||
close_instruction_value: str = "close"
|
||||
stop_instruction_value: str = "stop"
|
||||
|
||||
|
||||
COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
@@ -147,9 +199,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
current_position=DPCode.POSITION,
|
||||
set_position=DPCode.POSITION,
|
||||
device_class=CoverDeviceClass.CURTAIN,
|
||||
open_instruction_value="FZ",
|
||||
close_instruction_value="ZZ",
|
||||
stop_instruction_value="STOP",
|
||||
instruction_wrapper=_SpecialInstructionEnumWrapper,
|
||||
),
|
||||
# switch_1 is an undocumented code that behaves identically to control
|
||||
# It is used by the Kogan Smart Blinds Driver
|
||||
@@ -192,6 +242,21 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
}
|
||||
|
||||
|
||||
def _get_instruction_wrapper(
|
||||
device: CustomerDevice, description: TuyaCoverEntityDescription
|
||||
) -> _InstructionWrapper | None:
|
||||
"""Get the instruction wrapper for the cover entity."""
|
||||
if enum_wrapper := description.instruction_wrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
):
|
||||
return enum_wrapper
|
||||
|
||||
# Fallback to a boolean wrapper if available
|
||||
return _InstructionBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -215,6 +280,9 @@ async def async_setup_entry(
|
||||
current_position=description.position_wrapper.find_dpcode(
|
||||
device, description.current_position
|
||||
),
|
||||
instruction_wrapper=_get_instruction_wrapper(
|
||||
device, description
|
||||
),
|
||||
set_position=description.position_wrapper.find_dpcode(
|
||||
device, description.set_position, prefer_function=True
|
||||
),
|
||||
@@ -253,6 +321,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
description: TuyaCoverEntityDescription,
|
||||
*,
|
||||
current_position: _DPCodePercentageMappingWrapper | None = None,
|
||||
instruction_wrapper: _InstructionWrapper | None = None,
|
||||
set_position: _DPCodePercentageMappingWrapper | None = None,
|
||||
tilt_position: _DPCodePercentageMappingWrapper | None = None,
|
||||
) -> None:
|
||||
@@ -263,24 +332,17 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
self._attr_supported_features = CoverEntityFeature(0)
|
||||
|
||||
self._current_position = current_position or set_position
|
||||
self._instruction_wrapper = instruction_wrapper
|
||||
self._set_position = set_position
|
||||
self._tilt_position = tilt_position
|
||||
|
||||
# Check if this cover is based on a switch or has controls
|
||||
if get_dpcode(self.device, description.key):
|
||||
if device.function[description.key].type == "Boolean":
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
elif enum_type := find_dpcode(
|
||||
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if description.open_instruction_value in enum_type.range:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if description.close_instruction_value in enum_type.range:
|
||||
self._attr_supported_features |= CoverEntityFeature.CLOSE
|
||||
if description.stop_instruction_value in enum_type.range:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
if instruction_wrapper:
|
||||
if instruction_wrapper.get_open_command(device) is not None:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if instruction_wrapper.get_close_command(device) is not None:
|
||||
self._attr_supported_features |= CoverEntityFeature.CLOSE
|
||||
if instruction_wrapper.get_stop_command(device) is not None:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
self._current_state = get_dpcode(self.device, description.current_state)
|
||||
|
||||
@@ -321,60 +383,42 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
return None
|
||||
|
||||
def open_cover(self, **kwargs: Any) -> None:
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
value: bool | str = True
|
||||
if find_dpcode(
|
||||
self.device,
|
||||
self.entity_description.key,
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
if self._instruction_wrapper and (
|
||||
command := self._instruction_wrapper.get_open_command(self.device)
|
||||
):
|
||||
value = self.entity_description.open_instruction_value
|
||||
|
||||
commands: list[dict[str, str | int]] = [
|
||||
{"code": self.entity_description.key, "value": value}
|
||||
]
|
||||
await self._async_send_commands([command])
|
||||
return
|
||||
|
||||
if self._set_position is not None:
|
||||
commands.append(self._set_position.get_update_command(self.device, 100))
|
||||
await self._async_send_commands(
|
||||
[self._set_position.get_update_command(self.device, 100)]
|
||||
)
|
||||
|
||||
self._send_command(commands)
|
||||
|
||||
def close_cover(self, **kwargs: Any) -> None:
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
value: bool | str = False
|
||||
if find_dpcode(
|
||||
self.device,
|
||||
self.entity_description.key,
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
if self._instruction_wrapper and (
|
||||
command := self._instruction_wrapper.get_close_command(self.device)
|
||||
):
|
||||
value = self.entity_description.close_instruction_value
|
||||
|
||||
commands: list[dict[str, str | int]] = [
|
||||
{"code": self.entity_description.key, "value": value}
|
||||
]
|
||||
await self._async_send_commands([command])
|
||||
return
|
||||
|
||||
if self._set_position is not None:
|
||||
commands.append(self._set_position.get_update_command(self.device, 0))
|
||||
|
||||
self._send_command(commands)
|
||||
await self._async_send_commands(
|
||||
[self._set_position.get_update_command(self.device, 0)]
|
||||
)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION])
|
||||
|
||||
def stop_cover(self, **kwargs: Any) -> None:
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
self._send_command(
|
||||
[
|
||||
{
|
||||
"code": self.entity_description.key,
|
||||
"value": self.entity_description.stop_instruction_value,
|
||||
}
|
||||
]
|
||||
)
|
||||
if self._instruction_wrapper and (
|
||||
command := self._instruction_wrapper.get_stop_command(self.device)
|
||||
):
|
||||
await self._async_send_commands([command])
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
|
||||
@@ -66,6 +66,10 @@ class TuyaEntity(Entity):
|
||||
LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands)
|
||||
self.device_manager.send_commands(self.device.id, commands)
|
||||
|
||||
async def _async_send_commands(self, commands: list[dict[str, Any]]) -> None:
|
||||
"""Send a list of commands to the device."""
|
||||
await self.hass.async_add_executor_job(self._send_command, commands)
|
||||
|
||||
def _read_wrapper(self, dpcode_wrapper: DPCodeWrapper | None) -> Any | None:
|
||||
"""Read the wrapper device status."""
|
||||
if dpcode_wrapper is None:
|
||||
|
||||
@@ -23,10 +23,17 @@ from homeassistant.util.percentage import (
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .entity import TuyaEntity
|
||||
from .models import EnumTypeData, IntegerTypeData, find_dpcode
|
||||
from .models import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
EnumTypeData,
|
||||
IntegerTypeData,
|
||||
find_dpcode,
|
||||
)
|
||||
from .util import get_dpcode
|
||||
|
||||
_DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,)
|
||||
_MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE)
|
||||
_OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL)
|
||||
_SPEED_DPCODES = (
|
||||
DPCode.FAN_SPEED_PERCENT,
|
||||
@@ -74,7 +81,18 @@ async def async_setup_entry(
|
||||
for device_id in device_ids:
|
||||
device = manager.device_map[device_id]
|
||||
if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device):
|
||||
entities.append(TuyaFanEntity(device, manager))
|
||||
entities.append(
|
||||
TuyaFanEntity(
|
||||
device,
|
||||
manager,
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, _MODE_DPCODES, prefer_function=True
|
||||
),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, _SWITCH_DPCODES, prefer_function=True
|
||||
),
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
async_discover_device([*manager.device_map])
|
||||
@@ -89,32 +107,25 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
|
||||
_direction: EnumTypeData | None = None
|
||||
_oscillate: DPCode | None = None
|
||||
_presets: EnumTypeData | None = None
|
||||
_speed: IntegerTypeData | None = None
|
||||
_speeds: EnumTypeData | None = None
|
||||
_switch: DPCode | None = None
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
*,
|
||||
mode_wrapper: DPCodeEnumWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya Fan Device."""
|
||||
super().__init__(device, device_manager)
|
||||
|
||||
self._switch = get_dpcode(self.device, _SWITCH_DPCODES)
|
||||
|
||||
self._attr_preset_modes = []
|
||||
if enum_type := find_dpcode(
|
||||
self.device,
|
||||
(DPCode.FAN_MODE, DPCode.MODE),
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
):
|
||||
self._presets = enum_type
|
||||
self._mode_wrapper = mode_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
if mode_wrapper:
|
||||
self._attr_supported_features |= FanEntityFeature.PRESET_MODE
|
||||
self._attr_preset_modes = enum_type.range
|
||||
self._attr_preset_modes = mode_wrapper.type_information.range
|
||||
|
||||
# Find speed controls, can be either percentage or a set of speeds
|
||||
if int_type := find_dpcode(
|
||||
@@ -137,16 +148,14 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
):
|
||||
self._direction = enum_type
|
||||
self._attr_supported_features |= FanEntityFeature.DIRECTION
|
||||
if self._switch is not None:
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode of the fan."""
|
||||
if self._presets is None:
|
||||
return
|
||||
self._send_command([{"code": self._presets.dpcode, "value": preset_mode}])
|
||||
await self._async_send_dpcode_update(self._mode_wrapper, preset_mode)
|
||||
|
||||
def set_direction(self, direction: str) -> None:
|
||||
"""Set the direction of the fan."""
|
||||
@@ -179,22 +188,22 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
]
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
self._send_command([{"code": self._switch, "value": False}])
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
|
||||
def turn_on(
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if self._switch is None:
|
||||
if self._switch_wrapper is None:
|
||||
return
|
||||
|
||||
commands: list[dict[str, str | bool | int]] = [
|
||||
{"code": self._switch, "value": True}
|
||||
self._switch_wrapper.get_update_command(self.device, True)
|
||||
]
|
||||
|
||||
if percentage is not None and self._speed is not None:
|
||||
@@ -215,10 +224,11 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
}
|
||||
)
|
||||
|
||||
if preset_mode is not None and self._presets is not None:
|
||||
commands.append({"code": self._presets.dpcode, "value": preset_mode})
|
||||
|
||||
self._send_command(commands)
|
||||
if preset_mode is not None and self._mode_wrapper:
|
||||
commands.append(
|
||||
self._mode_wrapper.get_update_command(self.device, preset_mode)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
def oscillate(self, oscillating: bool) -> None:
|
||||
"""Oscillate the fan."""
|
||||
@@ -229,9 +239,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if fan is on."""
|
||||
if self._switch is None:
|
||||
return None
|
||||
return self.device.status.get(self._switch)
|
||||
return self._read_wrapper(self._switch_wrapper)
|
||||
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
@@ -260,9 +268,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset_mode."""
|
||||
if self._presets is None:
|
||||
return None
|
||||
return self.device.status.get(self._presets.dpcode)
|
||||
return self._read_wrapper(self._mode_wrapper)
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
|
||||
@@ -851,11 +851,16 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
key=DPCode.EXCRETION_TIME_DAY,
|
||||
translation_key="excretion_time_day",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.EXCRETION_TIMES_DAY,
|
||||
translation_key="excretion_times_day",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.STATUS,
|
||||
translation_key="cat_litter_box_status",
|
||||
),
|
||||
),
|
||||
DeviceCategory.MZJ: (
|
||||
TuyaSensorEntityDescription(
|
||||
|
||||
@@ -617,6 +617,17 @@
|
||||
"carbon_monoxide": {
|
||||
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
|
||||
},
|
||||
"cat_litter_box_status": {
|
||||
"name": "[%key:component::tuya::entity::sensor::status::name%]",
|
||||
"state": {
|
||||
"clean": "Cleaning",
|
||||
"empty": "Emptying",
|
||||
"level": "Leveling",
|
||||
"sleep": "Sleeping",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"uv": "Sanitizing"
|
||||
}
|
||||
},
|
||||
"cat_weight": {
|
||||
"name": "Cat weight"
|
||||
},
|
||||
@@ -645,7 +656,7 @@
|
||||
"name": "Duster cloth lifetime"
|
||||
},
|
||||
"excretion_time_day": {
|
||||
"name": "Excretion time (day)"
|
||||
"name": "Excretion duration"
|
||||
},
|
||||
"excretion_times_day": {
|
||||
"name": "Excretion times (day)"
|
||||
|
||||
@@ -19,7 +19,6 @@ from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper
|
||||
from .util import get_dpcode
|
||||
|
||||
TUYA_MODE_RETURN_HOME = "chargego"
|
||||
TUYA_STATUS_TO_HA = {
|
||||
@@ -80,6 +79,12 @@ async def async_setup_entry(
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
pause_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.PAUSE
|
||||
),
|
||||
status_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.STATUS
|
||||
),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.POWER_GO, prefer_function=True
|
||||
),
|
||||
@@ -108,6 +113,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
fan_speed_wrapper: DPCodeEnumWrapper | None,
|
||||
locate_wrapper: DPCodeBooleanWrapper | None,
|
||||
mode_wrapper: DPCodeEnumWrapper | None,
|
||||
pause_wrapper: DPCodeBooleanWrapper | None,
|
||||
status_wrapper: DPCodeEnumWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya vacuum."""
|
||||
@@ -116,13 +123,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._fan_speed_wrapper = fan_speed_wrapper
|
||||
self._locate_wrapper = locate_wrapper
|
||||
self._mode_wrapper = mode_wrapper
|
||||
self._pause_wrapper = pause_wrapper
|
||||
self._status_wrapper = status_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
self._attr_fan_speed_list = []
|
||||
self._attr_supported_features = (
|
||||
VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE
|
||||
)
|
||||
if get_dpcode(self.device, DPCode.PAUSE):
|
||||
if pause_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
|
||||
if charge_wrapper or (
|
||||
@@ -151,13 +160,12 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
@property
|
||||
def activity(self) -> VacuumActivity | None:
|
||||
"""Return Tuya vacuum device state."""
|
||||
if self.device.status.get(DPCode.PAUSE) and not (
|
||||
self.device.status.get(DPCode.STATUS)
|
||||
):
|
||||
if (status := self._read_wrapper(self._status_wrapper)) is not None:
|
||||
return TUYA_STATUS_TO_HA.get(status)
|
||||
|
||||
if self._read_wrapper(self._pause_wrapper):
|
||||
return VacuumActivity.PAUSED
|
||||
if not (status := self.device.status.get(DPCode.STATUS)):
|
||||
return None
|
||||
return TUYA_STATUS_TO_HA.get(status)
|
||||
return None
|
||||
|
||||
async def async_start(self, **kwargs: Any) -> None:
|
||||
"""Start the device."""
|
||||
@@ -167,9 +175,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
"""Stop the device."""
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
|
||||
def pause(self, **kwargs: Any) -> None:
|
||||
async def async_pause(self, **kwargs: Any) -> None:
|
||||
"""Pause the device."""
|
||||
self._send_command([{"code": DPCode.POWER_GO, "value": False}])
|
||||
await self.async_stop(**kwargs)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Return device to dock."""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"requirements": ["aiounifi==87"],
|
||||
"requirements": ["aiounifi==88"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -104,6 +104,15 @@ def async_client_uptime_value_fn(hub: UnifiHub, client: Client) -> datetime:
|
||||
return dt_util.utc_from_timestamp(float(client.uptime))
|
||||
|
||||
|
||||
@callback
|
||||
def async_wired_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool:
|
||||
"""Check if client is wired and allowed."""
|
||||
client = hub.api.clients[obj_id]
|
||||
if not client.is_wired or client.wired_rate_mbps <= 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_wlan_client_value_fn(hub: UnifiHub, wlan: Wlan) -> int:
|
||||
"""Calculate the amount of clients connected to a wlan."""
|
||||
@@ -407,6 +416,23 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
|
||||
unique_id_fn=lambda hub, obj_id: f"tx-{obj_id}",
|
||||
value_fn=async_client_tx_value_fn,
|
||||
),
|
||||
UnifiSensorEntityDescription[Clients, Client](
|
||||
key="Wired client speed",
|
||||
translation_key="wired_client_link_speed",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
entity_registry_enabled_default=False,
|
||||
allowed_fn=async_wired_client_allowed_fn,
|
||||
api_handler_fn=lambda api: api.clients,
|
||||
device_info_fn=async_client_device_info_fn,
|
||||
is_connected_fn=async_client_is_connected_fn,
|
||||
name_fn=lambda _: "Link speed",
|
||||
object_fn=lambda api, obj_id: api.clients[obj_id],
|
||||
unique_id_fn=lambda hub, obj_id: f"wired_speed-{obj_id}",
|
||||
value_fn=lambda hub, client: client.wired_rate_mbps,
|
||||
),
|
||||
UnifiSensorEntityDescription[Ports, Port](
|
||||
key="PoE port power sensor",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"provisioning": "Provisioning",
|
||||
"upgrading": "Upgrading"
|
||||
}
|
||||
},
|
||||
"wired_client_link_speed": {
|
||||
"name": "Link speed"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
54
homeassistant/components/victron_ble/__init__.py
Normal file
54
homeassistant/components/victron_ble/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""The Victron Bluetooth Low Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from victron_ble_ha_parser import VictronBluetoothDeviceData
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
async_rediscover_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Victron BLE device from a config entry."""
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
key = entry.data[CONF_ACCESS_TOKEN]
|
||||
data = VictronBluetoothDeviceData(key)
|
||||
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=data.update,
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, [Platform.SENSOR]
|
||||
)
|
||||
|
||||
if unload_ok:
|
||||
async_rediscover_address(hass, entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
123
homeassistant/components/victron_ble/config_flow.py
Normal file
123
homeassistant/components/victron_ble/config_flow.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Config flow for Victron Bluetooth Low Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from victron_ble_ha_parser import VictronBluetoothDeviceData
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
|
||||
|
||||
from .const import DOMAIN, VICTRON_IDENTIFIER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_ACCESS_TOKEN_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCESS_TOKEN): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Victron Bluetooth Low Energy."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_device: str | None = None
|
||||
self._discovered_devices: dict[str, str] = {}
|
||||
self._discovered_devices_info: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
_LOGGER.debug("async_step_bluetooth: %s", discovery_info.address)
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
device = VictronBluetoothDeviceData()
|
||||
if not device.supported(discovery_info):
|
||||
_LOGGER.debug("device %s not supported", discovery_info.address)
|
||||
return self.async_abort(reason="not_supported")
|
||||
|
||||
self._discovered_device = discovery_info.address
|
||||
self._discovered_devices_info[discovery_info.address] = discovery_info
|
||||
self._discovered_devices[discovery_info.address] = discovery_info.name
|
||||
|
||||
self.context["title_placeholders"] = {"title": discovery_info.name}
|
||||
|
||||
return await self.async_step_access_token()
|
||||
|
||||
async def async_step_access_token(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle advertisement key input."""
|
||||
# should only be called if there are discovered devices
|
||||
assert self._discovered_device is not None
|
||||
discovery_info = self._discovered_devices_info[self._discovered_device]
|
||||
title = discovery_info.name
|
||||
|
||||
if user_input is not None:
|
||||
# see if we can create a device with the access token
|
||||
device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN])
|
||||
if device.validate_advertisement_key(
|
||||
discovery_info.manufacturer_data[VICTRON_IDENTIFIER]
|
||||
):
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_abort(reason="invalid_access_token")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="access_token",
|
||||
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
|
||||
description_placeholders={"title": title},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle select a device to set up."""
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS]
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovered_device = address
|
||||
title = self._discovered_devices_info[address].name
|
||||
return self.async_show_form(
|
||||
step_id="access_token",
|
||||
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
|
||||
description_placeholders={"title": title},
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
continue
|
||||
device = VictronBluetoothDeviceData()
|
||||
if device.supported(discovery_info):
|
||||
self._discovered_devices_info[address] = discovery_info
|
||||
self._discovered_devices[address] = discovery_info.name
|
||||
|
||||
if len(self._discovered_devices) < 1:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
_LOGGER.debug("Discovered %s devices", len(self._discovered_devices))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
|
||||
),
|
||||
)
|
||||
4
homeassistant/components/victron_ble/const.py
Normal file
4
homeassistant/components/victron_ble/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for the Victron Bluetooth Low Energy integration."""
|
||||
|
||||
DOMAIN = "victron_ble"
|
||||
VICTRON_IDENTIFIER = 0x02E1
|
||||
19
homeassistant/components/victron_ble/manifest.json
Normal file
19
homeassistant/components/victron_ble/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "victron_ble",
|
||||
"name": "Victron BLE",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": false,
|
||||
"manufacturer_data_start": [16],
|
||||
"manufacturer_id": 737
|
||||
}
|
||||
],
|
||||
"codeowners": ["@rajlaud"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/victron_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["victron-ble-ha-parser==0.4.9"]
|
||||
}
|
||||
85
homeassistant/components/victron_ble/quality_scale.yaml
Normal file
85
homeassistant/components/victron_ble/quality_scale.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional 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 provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is nothing to test, the integration just passively receives BLE advertisements.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options to configure
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: done
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use IP addresses. Bluetooth MAC addresses do not change.
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
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: |
|
||||
This integration has a fixed single device per instance, and each device needs a user-supplied encryption key to set up.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
474
homeassistant/components/victron_ble/sensor.py
Normal file
474
homeassistant/components/victron_ble/sensor.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""Sensor platform for Victron BLE."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sensor_state_data import DeviceKey
|
||||
from victron_ble_ha_parser import Keys, Units
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothEntityKey,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AC_IN_OPTIONS = [
|
||||
"ac_in_1",
|
||||
"ac_in_2",
|
||||
"not_connected",
|
||||
]
|
||||
|
||||
ALARM_OPTIONS = [
|
||||
"low_voltage",
|
||||
"high_voltage",
|
||||
"low_soc",
|
||||
"low_starter_voltage",
|
||||
"high_starter_voltage",
|
||||
"low_temperature",
|
||||
"high_temperature",
|
||||
"mid_voltage",
|
||||
"overload",
|
||||
"dc_ripple",
|
||||
"low_v_ac_out",
|
||||
"high_v_ac_out",
|
||||
"short_circuit",
|
||||
"bms_lockout",
|
||||
]
|
||||
|
||||
CHARGER_ERROR_OPTIONS = [
|
||||
"no_error",
|
||||
"temperature_battery_high",
|
||||
"voltage_high",
|
||||
"remote_temperature_auto_reset",
|
||||
"remote_temperature_not_auto_reset",
|
||||
"remote_battery",
|
||||
"high_ripple",
|
||||
"temperature_battery_low",
|
||||
"temperature_charger",
|
||||
"over_current",
|
||||
"bulk_time",
|
||||
"current_sensor",
|
||||
"internal_temperature",
|
||||
"fan",
|
||||
"overheated",
|
||||
"short_circuit",
|
||||
"converter_issue",
|
||||
"over_charge",
|
||||
"input_voltage",
|
||||
"input_current",
|
||||
"input_power",
|
||||
"input_shutdown_voltage",
|
||||
"input_shutdown_current",
|
||||
"input_shutdown_failure",
|
||||
"inverter_shutdown_pv_isolation",
|
||||
"inverter_shutdown_ground_fault",
|
||||
"inverter_overload",
|
||||
"inverter_temperature",
|
||||
"inverter_peak_current",
|
||||
"inverter_output_voltage",
|
||||
"inverter_self_test",
|
||||
"inverter_ac",
|
||||
"communication",
|
||||
"synchronisation",
|
||||
"bms",
|
||||
"network",
|
||||
"pv_input_shutdown",
|
||||
"cpu_temperature",
|
||||
"calibration_lost",
|
||||
"firmware",
|
||||
"settings",
|
||||
"tester_fail",
|
||||
"internal_dc_voltage",
|
||||
"self_test",
|
||||
"internal_supply",
|
||||
]
|
||||
|
||||
|
||||
def error_to_state(value: float | str | None) -> str | None:
|
||||
"""Convert error code to state string."""
|
||||
value_map: dict[Any, str] = {
|
||||
"internal_supply_a": "internal_supply",
|
||||
"internal_supply_b": "internal_supply",
|
||||
"internal_supply_c": "internal_supply",
|
||||
"internal_supply_d": "internal_supply",
|
||||
"inverter_shutdown_41": "inverter_shutdown_pv_isolation",
|
||||
"inverter_shutdown_42": "inverter_shutdown_pv_isolation",
|
||||
"inverter_shutdown_43": "inverter_shutdown_ground_fault",
|
||||
"internal_temperature_a": "internal_temperature",
|
||||
"internal_temperature_b": "internal_temperature",
|
||||
"inverter_output_voltage_a": "inverter_output_voltage",
|
||||
"inverter_output_voltage_b": "inverter_output_voltage",
|
||||
"internal_dc_voltage_a": "internal_dc_voltage",
|
||||
"internal_dc_voltage_b": "internal_dc_voltage",
|
||||
"remote_temperature_a": "remote_temperature_auto_reset",
|
||||
"remote_temperature_b": "remote_temperature_auto_reset",
|
||||
"remote_temperature_c": "remote_temperature_not_auto_reset",
|
||||
"remote_battery_a": "remote_battery",
|
||||
"remote_battery_b": "remote_battery",
|
||||
"remote_battery_c": "remote_battery",
|
||||
"pv_input_shutdown_80": "pv_input_shutdown",
|
||||
"pv_input_shutdown_81": "pv_input_shutdown",
|
||||
"pv_input_shutdown_82": "pv_input_shutdown",
|
||||
"pv_input_shutdown_83": "pv_input_shutdown",
|
||||
"pv_input_shutdown_84": "pv_input_shutdown",
|
||||
"pv_input_shutdown_85": "pv_input_shutdown",
|
||||
"pv_input_shutdown_86": "pv_input_shutdown",
|
||||
"pv_input_shutdown_87": "pv_input_shutdown",
|
||||
"inverter_self_test_a": "inverter_self_test",
|
||||
"inverter_self_test_b": "inverter_self_test",
|
||||
"inverter_self_test_c": "inverter_self_test",
|
||||
"network_a": "network",
|
||||
"network_b": "network",
|
||||
"network_c": "network",
|
||||
"network_d": "network",
|
||||
}
|
||||
return value_map.get(value)
|
||||
|
||||
|
||||
DEVICE_STATE_OPTIONS = [
|
||||
"off",
|
||||
"low_power",
|
||||
"fault",
|
||||
"bulk",
|
||||
"absorption",
|
||||
"float",
|
||||
"storage",
|
||||
"equalize_manual",
|
||||
"inverting",
|
||||
"power_supply",
|
||||
"starting_up",
|
||||
"repeated_absorption",
|
||||
"recondition",
|
||||
"battery_safe",
|
||||
"active",
|
||||
"external_control",
|
||||
"not_available",
|
||||
]
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class VictronBLESensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Victron BLE sensor entity."""
|
||||
|
||||
value_fn: Callable[[float | int | str | None], float | int | str | None] = (
|
||||
lambda x: x
|
||||
)
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = {
|
||||
Keys.AC_IN_POWER: VictronBLESensorEntityDescription(
|
||||
key=Keys.AC_IN_POWER,
|
||||
translation_key=Keys.AC_IN_POWER,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.AC_IN_STATE: VictronBLESensorEntityDescription(
|
||||
key=Keys.AC_IN_STATE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="ac_in_state",
|
||||
options=AC_IN_OPTIONS,
|
||||
),
|
||||
Keys.AC_OUT_POWER: VictronBLESensorEntityDescription(
|
||||
key=Keys.AC_OUT_POWER,
|
||||
translation_key=Keys.AC_OUT_POWER,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.AC_OUT_STATE: VictronBLESensorEntityDescription(
|
||||
key=Keys.AC_OUT_STATE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="device_state",
|
||||
options=DEVICE_STATE_OPTIONS,
|
||||
),
|
||||
Keys.ALARM: VictronBLESensorEntityDescription(
|
||||
key=Keys.ALARM,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="alarm",
|
||||
options=ALARM_OPTIONS,
|
||||
),
|
||||
Keys.BALANCER_STATUS: VictronBLESensorEntityDescription(
|
||||
key=Keys.BALANCER_STATUS,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="balancer_status",
|
||||
options=["balanced", "balancing", "imbalance"],
|
||||
),
|
||||
Keys.BATTERY_CURRENT: VictronBLESensorEntityDescription(
|
||||
key=Keys.BATTERY_CURRENT,
|
||||
translation_key=Keys.BATTERY_CURRENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.BATTERY_TEMPERATURE: VictronBLESensorEntityDescription(
|
||||
key=Keys.BATTERY_TEMPERATURE,
|
||||
translation_key=Keys.BATTERY_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.BATTERY_VOLTAGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.BATTERY_VOLTAGE,
|
||||
translation_key=Keys.BATTERY_VOLTAGE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.CHARGER_ERROR: VictronBLESensorEntityDescription(
|
||||
key=Keys.CHARGER_ERROR,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="charger_error",
|
||||
options=CHARGER_ERROR_OPTIONS,
|
||||
value_fn=error_to_state,
|
||||
),
|
||||
Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription(
|
||||
key=Keys.CONSUMED_AMPERE_HOURS,
|
||||
translation_key=Keys.CONSUMED_AMPERE_HOURS,
|
||||
native_unit_of_measurement=Units.ELECTRIC_CURRENT_FLOW_AMPERE_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.CURRENT: VictronBLESensorEntityDescription(
|
||||
key=Keys.CURRENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.DEVICE_STATE: VictronBLESensorEntityDescription(
|
||||
key=Keys.DEVICE_STATE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="device_state",
|
||||
options=DEVICE_STATE_OPTIONS,
|
||||
),
|
||||
Keys.ERROR_CODE: VictronBLESensorEntityDescription(
|
||||
key=Keys.ERROR_CODE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="charger_error",
|
||||
options=CHARGER_ERROR_OPTIONS,
|
||||
),
|
||||
Keys.EXTERNAL_DEVICE_LOAD: VictronBLESensorEntityDescription(
|
||||
key=Keys.EXTERNAL_DEVICE_LOAD,
|
||||
translation_key=Keys.EXTERNAL_DEVICE_LOAD,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.INPUT_VOLTAGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.INPUT_VOLTAGE,
|
||||
translation_key=Keys.INPUT_VOLTAGE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.METER_TYPE: VictronBLESensorEntityDescription(
|
||||
key=Keys.METER_TYPE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="meter_type",
|
||||
options=[
|
||||
"solar_charger",
|
||||
"wind_charger",
|
||||
"shaft_generator",
|
||||
"alternator",
|
||||
"fuel_cell",
|
||||
"water_generator",
|
||||
"dc_dc_charger",
|
||||
"ac_charger",
|
||||
"generic_source",
|
||||
"generic_load",
|
||||
"electric_drive",
|
||||
"fridge",
|
||||
"water_pump",
|
||||
"bilge_pump",
|
||||
"dc_system",
|
||||
"inverter",
|
||||
"water_heater",
|
||||
],
|
||||
),
|
||||
Keys.MIDPOINT_VOLTAGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.MIDPOINT_VOLTAGE,
|
||||
translation_key=Keys.MIDPOINT_VOLTAGE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.OFF_REASON: VictronBLESensorEntityDescription(
|
||||
key=Keys.OFF_REASON,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="off_reason",
|
||||
options=[
|
||||
"no_reason",
|
||||
"no_input_power",
|
||||
"switched_off_switch",
|
||||
"switched_off_register",
|
||||
"remote_input",
|
||||
"protection_active",
|
||||
"pay_as_you_go_out_of_credit",
|
||||
"bms",
|
||||
"engine_shutdown",
|
||||
"analysing_input_voltage",
|
||||
],
|
||||
),
|
||||
Keys.OUTPUT_VOLTAGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_VOLTAGE,
|
||||
translation_key=Keys.OUTPUT_VOLTAGE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.REMAINING_MINUTES: VictronBLESensorEntityDescription(
|
||||
key=Keys.REMAINING_MINUTES,
|
||||
translation_key=Keys.REMAINING_MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorDeviceClass.SIGNAL_STRENGTH: VictronBLESensorEntityDescription(
|
||||
key=SensorDeviceClass.SIGNAL_STRENGTH.value,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.SOLAR_POWER: VictronBLESensorEntityDescription(
|
||||
key=Keys.SOLAR_POWER,
|
||||
translation_key=Keys.SOLAR_POWER,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.STARTER_VOLTAGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.STARTER_VOLTAGE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.STATE_OF_CHARGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.STATE_OF_CHARGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.TEMPERATURE: VictronBLESensorEntityDescription(
|
||||
key=Keys.TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.VOLTAGE: VictronBLESensorEntityDescription(
|
||||
key=Keys.VOLTAGE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.WARNING: VictronBLESensorEntityDescription(
|
||||
key=Keys.WARNING,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
translation_key="alarm",
|
||||
options=ALARM_OPTIONS,
|
||||
),
|
||||
Keys.YIELD_TODAY: VictronBLESensorEntityDescription(
|
||||
key=Keys.YIELD_TODAY,
|
||||
translation_key=Keys.YIELD_TODAY,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
}
|
||||
|
||||
for i in range(1, 8):
|
||||
cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE")
|
||||
SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription(
|
||||
key=cell_key,
|
||||
translation_key="cell_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
)
|
||||
|
||||
|
||||
def _device_key_to_bluetooth_entity_key(
|
||||
device_key: DeviceKey,
|
||||
) -> PassiveBluetoothEntityKey:
|
||||
"""Convert a device key to an entity key."""
|
||||
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
|
||||
|
||||
|
||||
def sensor_update_to_bluetooth_data_update(
|
||||
sensor_update,
|
||||
) -> PassiveBluetoothDataUpdate:
|
||||
"""Convert a sensor update to a bluetooth data update."""
|
||||
return PassiveBluetoothDataUpdate(
|
||||
devices={
|
||||
device_id: sensor_device_info_to_hass_device_info(device_info)
|
||||
for device_id, device_info in sensor_update.devices.items()
|
||||
},
|
||||
entity_descriptions={
|
||||
_device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
|
||||
device_key.key
|
||||
]
|
||||
for device_key in sensor_update.entity_descriptions
|
||||
if device_key.key in SENSOR_DESCRIPTIONS
|
||||
},
|
||||
entity_data={
|
||||
_device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
|
||||
for device_key, sensor_values in sensor_update.entity_values.items()
|
||||
if device_key.key in SENSOR_DESCRIPTIONS
|
||||
},
|
||||
entity_names={},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Victron BLE sensor."""
|
||||
coordinator = entry.runtime_data
|
||||
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
|
||||
entry.async_on_unload(
|
||||
processor.async_add_entities_listener(
|
||||
VictronBLESensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
|
||||
|
||||
class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
|
||||
"""Representation of Victron BLE sensor."""
|
||||
|
||||
entity_description: VictronBLESensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | int | str | None:
|
||||
"""Return the state of the sensor."""
|
||||
value = self.processor.entity_data.get(self.entity_key)
|
||||
|
||||
return self.entity_description.value_fn(value)
|
||||
234
homeassistant/components/victron_ble/strings.json
Normal file
234
homeassistant/components/victron_ble/strings.json
Normal file
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"common": {
|
||||
"high_voltage": "High voltage",
|
||||
"low_voltage": "Low voltage",
|
||||
"midpoint_voltage": "Midpoint voltage",
|
||||
"starter_voltage": "Starter voltage"
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_access_token": "Invalid encryption key for instant readout",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"flow_title": "{title}",
|
||||
"step": {
|
||||
"access_token": {
|
||||
"data": {
|
||||
"access_token": "The encryption key for instant readout of the Victron device."
|
||||
},
|
||||
"data_description": {
|
||||
"access_token": "The encryption key for instant readout may be found in the VictronConnect app under Settings > Product info > Instant readout details > Encryption data > Encryption Key."
|
||||
},
|
||||
"title": "{title}"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "The Bluetooth address of the Victron device."
|
||||
},
|
||||
"data_description": {
|
||||
"address": "This Bluetooth address is automatically discovered. You may view a device's Bluetooth address in the VictronConnect app under Settings > Product info > Instant readout details > Encryption data > MAC Address."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ac_in_power": {
|
||||
"name": "AC-in power"
|
||||
},
|
||||
"ac_in_state": {
|
||||
"name": "AC-in state",
|
||||
"state": {
|
||||
"ac_in_1": "AC-in 1",
|
||||
"ac_in_2": "AC-in 2",
|
||||
"not_connected": "Not connected"
|
||||
}
|
||||
},
|
||||
"ac_out_power": {
|
||||
"name": "AC-out power"
|
||||
},
|
||||
"alarm": {
|
||||
"name": "Alarm",
|
||||
"state": {
|
||||
"bms_lockout": "Battery management system lockout",
|
||||
"dc_ripple": "DC ripple",
|
||||
"high_starter_voltage": "High starter voltage",
|
||||
"high_temperature": "High temperature",
|
||||
"high_v_ac_out": "AC-out overvoltage",
|
||||
"high_voltage": "Overvoltage",
|
||||
"low_soc": "Low state of charge",
|
||||
"low_starter_voltage": "Low starter voltage",
|
||||
"low_temperature": "Low temperature",
|
||||
"low_v_ac_out": "AC-out undervoltage",
|
||||
"low_voltage": "Undervoltage",
|
||||
"mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]",
|
||||
"overload": "Overload",
|
||||
"short_circuit": "Short circuit"
|
||||
}
|
||||
},
|
||||
"balancer_status": {
|
||||
"name": "Balancer status",
|
||||
"state": {
|
||||
"balanced": "Balanced",
|
||||
"balancing": "Balancing",
|
||||
"imbalance": "Imbalance"
|
||||
}
|
||||
},
|
||||
"battery_current": {
|
||||
"name": "Battery current"
|
||||
},
|
||||
"battery_temperature": {
|
||||
"name": "Battery temperature"
|
||||
},
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
},
|
||||
"cell_voltage": {
|
||||
"name": "Cell {cell} voltage"
|
||||
},
|
||||
"charger_error": {
|
||||
"name": "Charger error",
|
||||
"state": {
|
||||
"bms": "BMS connection lost",
|
||||
"bulk_time": "Bulk time limit exceeded",
|
||||
"calibration_lost": "Factory calibration data lost",
|
||||
"communication": "Communication warning",
|
||||
"converter_issue": "Converter issue",
|
||||
"cpu_temperature": "CPU temperature too high",
|
||||
"current_sensor": "Current sensor issue",
|
||||
"fan": "Fan failure",
|
||||
"firmware": "Invalid or incompatible firmware",
|
||||
"high_ripple": "Battery high ripple voltage",
|
||||
"input_current": "Input overcurrent",
|
||||
"input_power": "Input overpower",
|
||||
"input_shutdown_current": "Input shutdown (current flow during off mode)",
|
||||
"input_shutdown_failure": "PV input failed to shutdown",
|
||||
"input_shutdown_voltage": "Input shutdown (battery overvoltage)",
|
||||
"input_voltage": "Input overvoltage",
|
||||
"internal_dc_voltage": "Internal DC voltage error",
|
||||
"internal_supply": "Internal supply voltage error",
|
||||
"internal_temperature": "Internal temperature sensor failure",
|
||||
"inverter_ac": "Inverter AC voltage on output",
|
||||
"inverter_output_voltage": "Inverter output voltage",
|
||||
"inverter_overload": "Inverter overload",
|
||||
"inverter_peak_current": "Inverter peak current",
|
||||
"inverter_self_test": "Inverter self-test failed",
|
||||
"inverter_shutdown_ground_fault": "Inverter shutdown (Ground fault)",
|
||||
"inverter_shutdown_pv_isolation": "Inverter shutdown (PV isolation)",
|
||||
"inverter_temperature": "Inverter temperature too high",
|
||||
"network": "Network misconfigured",
|
||||
"no_error": "No error",
|
||||
"over_charge": "Overcharge protection",
|
||||
"over_current": "Charger overcurrent",
|
||||
"overheated": "Terminals overheated",
|
||||
"pv_input_shutdown": "PV input shutdown",
|
||||
"remote_battery": "Remote battery voltage sense failure",
|
||||
"remote_temperature_auto_reset": "Remote temperature sensor failure (auto-reset)",
|
||||
"remote_temperature_not_auto_reset": "Remote temperature sensor failure (not auto-reset)",
|
||||
"self_test": "PV residual current sensor self-test failure",
|
||||
"settings": "Settings data lost",
|
||||
"short_circuit": "Charger short circuit",
|
||||
"synchronisation": "Synchronized charging device configuration issue",
|
||||
"temperature_battery_high": "Battery temperature too high",
|
||||
"temperature_battery_low": "Battery temperature too low",
|
||||
"temperature_charger": "Charger temperature too high",
|
||||
"tester_fail": "Tester fail",
|
||||
"voltage_high": "Battery overvoltage"
|
||||
}
|
||||
},
|
||||
"consumed_ampere_hours": {
|
||||
"name": "Consumed ampere hours"
|
||||
},
|
||||
"device_state": {
|
||||
"name": "Device state",
|
||||
"state": {
|
||||
"absorption": "Absorption",
|
||||
"active": "Active",
|
||||
"battery_safe": "Battery safe",
|
||||
"bulk": "Bulk",
|
||||
"equalize_manual": "Equalize (manual)",
|
||||
"external_control": "External control",
|
||||
"fault": "Fault",
|
||||
"float": "Float",
|
||||
"inverting": "Inverting",
|
||||
"low_power": "Low power",
|
||||
"not_available": "Not available",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"power_supply": "Power supply",
|
||||
"recondition": "Recondition",
|
||||
"repeated_absorption": "Repeated absorption",
|
||||
"starting_up": "Starting up",
|
||||
"storage": "Storage"
|
||||
}
|
||||
},
|
||||
"error_code": {
|
||||
"name": "Error code"
|
||||
},
|
||||
"external_device_load": {
|
||||
"name": "External device load"
|
||||
},
|
||||
"input_voltage": {
|
||||
"name": "Input voltage"
|
||||
},
|
||||
"meter_type": {
|
||||
"name": "Meter type",
|
||||
"state": {
|
||||
"ac_charger": "AC charger",
|
||||
"alternator": "Alternator",
|
||||
"bilge_pump": "Bilge pump",
|
||||
"dc_dc_charger": "DC-DC charger",
|
||||
"dc_system": "DC system",
|
||||
"electric_drive": "Electric drive",
|
||||
"fridge": "Fridge",
|
||||
"fuel_cell": "Fuel cell",
|
||||
"generic_load": "Generic load",
|
||||
"generic_source": "Generic source",
|
||||
"inverter": "Inverter",
|
||||
"shaft_generator": "Shaft generator",
|
||||
"solar_charger": "Solar charger",
|
||||
"water_generator": "Water generator",
|
||||
"water_heater": "Water heater",
|
||||
"water_pump": "Water pump",
|
||||
"wind_charger": "Wind charger"
|
||||
}
|
||||
},
|
||||
"midpoint_voltage": {
|
||||
"name": "[%key:component::victron_ble::common::midpoint_voltage%]"
|
||||
},
|
||||
"off_reason": {
|
||||
"name": "Off reason",
|
||||
"state": {
|
||||
"analysing_input_voltage": "Analyzing input voltage",
|
||||
"bms": "Battery management system",
|
||||
"engine_shutdown": "Engine shutdown",
|
||||
"no_input_power": "No input power",
|
||||
"no_reason": "No reason",
|
||||
"pay_as_you_go_out_of_credit": "Pay-as-you-go out of credit",
|
||||
"protection_active": "Protection active",
|
||||
"remote_input": "Remote input",
|
||||
"switched_off_register": "Switched off by register",
|
||||
"switched_off_switch": "Switched off by switch"
|
||||
}
|
||||
},
|
||||
"output_voltage": {
|
||||
"name": "Output voltage"
|
||||
},
|
||||
"remaining_minutes": {
|
||||
"name": "Remaining minutes"
|
||||
},
|
||||
"solar_power": {
|
||||
"name": "Solar power"
|
||||
},
|
||||
"starter_voltage": {
|
||||
"name": "[%key:component::victron_ble::common::starter_voltage%]"
|
||||
},
|
||||
"warning": {
|
||||
"name": "Warning"
|
||||
},
|
||||
"yield_today": {
|
||||
"name": "Yield today"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,15 @@ from wallbox import Wallbox
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from .const import UPDATE_INTERVAL
|
||||
from .coordinator import (
|
||||
InvalidAuth,
|
||||
WallboxConfigEntry,
|
||||
WallboxCoordinator,
|
||||
async_validate_input,
|
||||
from .const import (
|
||||
CHARGER_JWT_REFRESH_TOKEN,
|
||||
CHARGER_JWT_REFRESH_TTL,
|
||||
CHARGER_JWT_TOKEN,
|
||||
CHARGER_JWT_TTL,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LOCK,
|
||||
@@ -32,10 +32,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> b
|
||||
entry.data[CONF_PASSWORD],
|
||||
jwtTokenDrift=UPDATE_INTERVAL,
|
||||
)
|
||||
try:
|
||||
await async_validate_input(hass, wallbox)
|
||||
except InvalidAuth as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
|
||||
if CHARGER_JWT_TOKEN in entry.data and check_token_validity(
|
||||
jwt_token_ttl=entry.data.get(CHARGER_JWT_TTL, 0),
|
||||
jwt_token_drift=UPDATE_INTERVAL,
|
||||
):
|
||||
wallbox.jwtToken = entry.data.get(CHARGER_JWT_TOKEN)
|
||||
wallbox.jwtRefreshToken = entry.data.get(CHARGER_JWT_REFRESH_TOKEN)
|
||||
wallbox.jwtTokenTtl = entry.data.get(CHARGER_JWT_TTL)
|
||||
wallbox.jwtRefreshTokenTtl = entry.data.get(CHARGER_JWT_REFRESH_TTL)
|
||||
wallbox.headers["Authorization"] = f"Bearer {entry.data.get(CHARGER_JWT_TOKEN)}"
|
||||
|
||||
wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox)
|
||||
await wallbox_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -12,7 +12,15 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowRe
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_STATION, DOMAIN
|
||||
from .const import (
|
||||
CHARGER_JWT_REFRESH_TOKEN,
|
||||
CHARGER_JWT_REFRESH_TTL,
|
||||
CHARGER_JWT_TOKEN,
|
||||
CHARGER_JWT_TTL,
|
||||
CONF_STATION,
|
||||
DOMAIN,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
from .coordinator import InvalidAuth, async_validate_input
|
||||
|
||||
COMPONENT_DOMAIN = DOMAIN
|
||||
@@ -26,17 +34,22 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
wallbox = Wallbox(data["username"], data["password"])
|
||||
wallbox = Wallbox(data[CONF_USERNAME], data[CONF_PASSWORD], UPDATE_INTERVAL)
|
||||
|
||||
await async_validate_input(hass, wallbox)
|
||||
|
||||
data[CHARGER_JWT_TOKEN] = wallbox.jwtToken
|
||||
data[CHARGER_JWT_REFRESH_TOKEN] = wallbox.jwtRefreshToken
|
||||
data[CHARGER_JWT_TTL] = wallbox.jwtTokenTtl
|
||||
data[CHARGER_JWT_REFRESH_TTL] = wallbox.jwtRefreshTokenTtl
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
return {"title": "Wallbox Portal"}
|
||||
return {"title": "Wallbox Portal", "data": data}
|
||||
|
||||
|
||||
class WallboxConfigFlow(ConfigFlow, domain=COMPONENT_DOMAIN):
|
||||
@@ -64,8 +77,11 @@ class WallboxConfigFlow(ConfigFlow, domain=COMPONENT_DOMAIN):
|
||||
await self.async_set_unique_id(user_input["station"])
|
||||
if self.source != SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_configured()
|
||||
info = await validate_input(self.hass, user_input)
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
validation_data = await validate_input(self.hass, user_input)
|
||||
return self.async_create_entry(
|
||||
title=validation_data["title"],
|
||||
data=validation_data["data"],
|
||||
)
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input["station"] == reauth_entry.data[CONF_STATION]:
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=user_input)
|
||||
|
||||
@@ -47,6 +47,12 @@ CHARGER_CONNECTIONS = "connections"
|
||||
CHARGER_ECO_SMART_KEY = "ecosmart"
|
||||
CHARGER_ECO_SMART_STATUS_KEY = "enabled"
|
||||
CHARGER_ECO_SMART_MODE_KEY = "mode"
|
||||
CHARGER_WALLBOX_OBJECT_KEY = "wallbox"
|
||||
|
||||
CHARGER_JWT_TOKEN = "jwtToken"
|
||||
CHARGER_JWT_REFRESH_TOKEN = "jwtRefreshToken"
|
||||
CHARGER_JWT_TTL = "jwtTokenTtl"
|
||||
CHARGER_JWT_REFRESH_TTL = "jwtRefreshTokenTtl"
|
||||
|
||||
|
||||
class ChargerStatus(StrEnum):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
@@ -27,6 +27,10 @@ from .const import (
|
||||
CHARGER_ECO_SMART_STATUS_KEY,
|
||||
CHARGER_ENERGY_PRICE_KEY,
|
||||
CHARGER_FEATURES_KEY,
|
||||
CHARGER_JWT_REFRESH_TOKEN,
|
||||
CHARGER_JWT_REFRESH_TTL,
|
||||
CHARGER_JWT_TOKEN,
|
||||
CHARGER_JWT_TTL,
|
||||
CHARGER_LOCKED_UNLOCKED_KEY,
|
||||
CHARGER_MAX_CHARGING_CURRENT_KEY,
|
||||
CHARGER_MAX_CHARGING_CURRENT_POST_KEY,
|
||||
@@ -86,27 +90,25 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P](
|
||||
) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]:
|
||||
"""Authenticate with decorator using Wallbox API."""
|
||||
|
||||
def require_authentication(
|
||||
async def require_authentication(
|
||||
self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> Any:
|
||||
"""Authenticate using Wallbox API."""
|
||||
try:
|
||||
self.authenticate()
|
||||
return func(self, *args, **kwargs)
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from wallbox_connection_error
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
await self.async_authenticate()
|
||||
return await func(self, *args, **kwargs)
|
||||
|
||||
return require_authentication
|
||||
|
||||
|
||||
def check_token_validity(jwt_token_ttl: int, jwt_token_drift: int) -> bool:
|
||||
"""Check if the jwtToken is still valid in order to reuse if possible."""
|
||||
return round((jwt_token_ttl / 1000) - jwt_token_drift, 0) > datetime.timestamp(
|
||||
datetime.now()
|
||||
)
|
||||
|
||||
|
||||
def _validate(wallbox: Wallbox) -> None:
|
||||
"""Authenticate using Wallbox API."""
|
||||
"""Authenticate using Wallbox API to check if the used credentials are valid."""
|
||||
try:
|
||||
wallbox.authenticate()
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
@@ -142,11 +144,38 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
|
||||
def authenticate(self) -> None:
|
||||
"""Authenticate using Wallbox API."""
|
||||
self._wallbox.authenticate()
|
||||
def _authenticate(self) -> dict[str, str]:
|
||||
"""Authenticate using Wallbox API. First check token validity."""
|
||||
data = dict(self.config_entry.data)
|
||||
if not check_token_validity(
|
||||
jwt_token_ttl=data.get(CHARGER_JWT_TTL, 0),
|
||||
jwt_token_drift=UPDATE_INTERVAL,
|
||||
):
|
||||
try:
|
||||
self._wallbox.authenticate()
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if (
|
||||
wallbox_connection_error.response.status_code
|
||||
== HTTPStatus.FORBIDDEN
|
||||
):
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from wallbox_connection_error
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
else:
|
||||
data[CHARGER_JWT_TOKEN] = self._wallbox.jwtToken
|
||||
data[CHARGER_JWT_REFRESH_TOKEN] = self._wallbox.jwtRefreshToken
|
||||
data[CHARGER_JWT_TTL] = self._wallbox.jwtTokenTtl
|
||||
data[CHARGER_JWT_REFRESH_TTL] = self._wallbox.jwtRefreshTokenTtl
|
||||
return data
|
||||
|
||||
async def async_authenticate(self) -> None:
|
||||
"""Authenticate using Wallbox API."""
|
||||
data = await self.hass.async_add_executor_job(self._authenticate)
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
|
||||
|
||||
@_require_authentication
|
||||
def _get_data(self) -> dict[str, Any]:
|
||||
"""Get new sensor data for Wallbox component."""
|
||||
try:
|
||||
@@ -208,6 +237,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations."""
|
||||
|
||||
@@ -217,7 +247,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
return await self.hass.async_add_executor_job(self._get_data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_charging_current(
|
||||
self, charging_current: float
|
||||
) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
@@ -246,6 +275,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_charging_current(self, charging_current: float) -> None:
|
||||
"""Set maximum charging current for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
@@ -253,7 +283,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_icp_current(self, icp_current: float) -> dict[str, Any]:
|
||||
"""Set maximum icp current for Wallbox."""
|
||||
try:
|
||||
@@ -276,6 +305,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_icp_current(self, icp_current: float) -> None:
|
||||
"""Set maximum icp current for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
@@ -283,7 +313,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]:
|
||||
"""Set energy cost for Wallbox."""
|
||||
try:
|
||||
@@ -300,6 +329,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_energy_cost(self, energy_cost: float) -> None:
|
||||
"""Set energy cost for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
@@ -307,7 +337,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Set wallbox to locked or unlocked."""
|
||||
try:
|
||||
@@ -335,12 +364,12 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_lock_unlock(self, lock: bool) -> None:
|
||||
"""Set wallbox to locked or unlocked."""
|
||||
data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock)
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
@_require_authentication
|
||||
def _pause_charger(self, pause: bool) -> None:
|
||||
"""Set wallbox to pause or resume."""
|
||||
try:
|
||||
@@ -357,12 +386,12 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_pause_charger(self, pause: bool) -> None:
|
||||
"""Set wallbox to pause or resume."""
|
||||
await self.hass.async_add_executor_job(self._pause_charger, pause)
|
||||
await self.async_request_refresh()
|
||||
|
||||
@_require_authentication
|
||||
def _set_eco_smart(self, option: str) -> None:
|
||||
"""Set wallbox solar charging mode."""
|
||||
try:
|
||||
@@ -381,6 +410,7 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@_require_authentication
|
||||
async def async_set_eco_smart(self, option: str) -> None:
|
||||
"""Set wallbox solar charging mode."""
|
||||
|
||||
|
||||
12
homeassistant/generated/bluetooth.py
generated
12
homeassistant/generated/bluetooth.py
generated
@@ -706,6 +706,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "shelly",
|
||||
"local_name": "Shelly*",
|
||||
},
|
||||
{
|
||||
"domain": "shelly",
|
||||
"manufacturer_id": 2985,
|
||||
},
|
||||
{
|
||||
"domain": "snooz",
|
||||
"local_name": "Snooz*",
|
||||
@@ -849,6 +853,14 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"manufacturer_id": 34714,
|
||||
"service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb",
|
||||
},
|
||||
{
|
||||
"connectable": False,
|
||||
"domain": "victron_ble",
|
||||
"manufacturer_data_start": [
|
||||
16,
|
||||
],
|
||||
"manufacturer_id": 737,
|
||||
},
|
||||
{
|
||||
"connectable": False,
|
||||
"domain": "xiaomi_ble",
|
||||
|
||||
2
homeassistant/generated/config_flows.py
generated
2
homeassistant/generated/config_flows.py
generated
@@ -264,6 +264,7 @@ FLOWS = {
|
||||
"growatt_server",
|
||||
"guardian",
|
||||
"habitica",
|
||||
"hanna",
|
||||
"harmony",
|
||||
"heos",
|
||||
"here_travel_time",
|
||||
@@ -723,6 +724,7 @@ FLOWS = {
|
||||
"version",
|
||||
"vesync",
|
||||
"vicare",
|
||||
"victron_ble",
|
||||
"victron_remote_monitoring",
|
||||
"vilfo",
|
||||
"vizio",
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"hanna": {
|
||||
"name": "Hanna",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"hardkernel": {
|
||||
"name": "Hardkernel",
|
||||
"integration_type": "hardware",
|
||||
@@ -7282,11 +7288,22 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"victron_remote_monitoring": {
|
||||
"name": "Victron Remote Monitoring",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
"victron": {
|
||||
"name": "Victron",
|
||||
"integrations": {
|
||||
"victron_ble": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Victron BLE"
|
||||
},
|
||||
"victron_remote_monitoring": {
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "Victron Remote Monitoring"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vilfo": {
|
||||
"name": "Vilfo Router",
|
||||
|
||||
14
homeassistant/generated/labs.py
generated
Normal file
14
homeassistant/generated/labs.py
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Automatically generated file.
|
||||
|
||||
To update, run python3 -m script.hassfest
|
||||
"""
|
||||
|
||||
LABS_PREVIEW_FEATURES = {
|
||||
"kitchen_sink": {
|
||||
"special_repair": {
|
||||
"feedback_url": "https://community.home-assistant.io",
|
||||
"learn_more_url": "https://www.home-assistant.io/integrations/kitchen_sink",
|
||||
"report_issue_url": "https://github.com/home-assistant/core/issues/new?template=bug_report.yml&integration_link=https://www.home-assistant.io/integrations/kitchen_sink&integration_name=Kitchen%20Sink",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -68,8 +68,8 @@ class AreasRegistryStoreData(TypedDict):
|
||||
class EventAreaRegistryUpdatedData(TypedDict):
|
||||
"""EventAreaRegistryUpdated data."""
|
||||
|
||||
action: Literal["create", "remove", "update"]
|
||||
area_id: str
|
||||
action: Literal["create", "remove", "update", "reorder"]
|
||||
area_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
@@ -420,6 +420,26 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
|
||||
self.async_schedule_save()
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_reorder(self, area_ids: list[str]) -> None:
|
||||
"""Reorder areas."""
|
||||
self.hass.verify_event_loop_thread("area_registry.async_reorder")
|
||||
|
||||
if set(area_ids) != set(self.areas.data.keys()):
|
||||
raise ValueError(
|
||||
"The area_ids list must contain all existing area IDs exactly once"
|
||||
)
|
||||
|
||||
reordered_data = {area_id: self.areas.data[area_id] for area_id in area_ids}
|
||||
self.areas.data.clear()
|
||||
self.areas.data.update(reordered_data)
|
||||
|
||||
self.async_schedule_save()
|
||||
self.hass.bus.async_fire_internal(
|
||||
EVENT_AREA_REGISTRY_UPDATED,
|
||||
EventAreaRegistryUpdatedData(action="reorder", area_id=None),
|
||||
)
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the area registry."""
|
||||
self._async_setup_cleanup()
|
||||
@@ -489,7 +509,9 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
|
||||
@callback
|
||||
def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None:
|
||||
"""Update areas that are associated with a floor that has been removed."""
|
||||
floor_id = event.data["floor_id"]
|
||||
floor_id = event.data.get("floor_id")
|
||||
if floor_id is None:
|
||||
return
|
||||
for area in self.areas.get_areas_for_floor(floor_id):
|
||||
self.async_update(area.id, floor_id=None)
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ class FloorRegistryStoreData(TypedDict):
|
||||
class EventFloorRegistryUpdatedData(TypedDict):
|
||||
"""Event data for when the floor registry is updated."""
|
||||
|
||||
action: Literal["create", "remove", "update"]
|
||||
floor_id: str
|
||||
action: Literal["create", "remove", "update", "reorder"]
|
||||
floor_id: str | None
|
||||
|
||||
|
||||
type EventFloorRegistryUpdated = Event[EventFloorRegistryUpdatedData]
|
||||
@@ -261,6 +261,28 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
|
||||
|
||||
return new
|
||||
|
||||
@callback
|
||||
def async_reorder(self, floor_ids: list[str]) -> None:
|
||||
"""Reorder floors."""
|
||||
self.hass.verify_event_loop_thread("floor_registry.async_reorder")
|
||||
|
||||
if set(floor_ids) != set(self.floors.data.keys()):
|
||||
raise ValueError(
|
||||
"The floor_ids list must contain all existing floor IDs exactly once"
|
||||
)
|
||||
|
||||
reordered_data = {
|
||||
floor_id: self.floors.data[floor_id] for floor_id in floor_ids
|
||||
}
|
||||
self.floors.data.clear()
|
||||
self.floors.data.update(reordered_data)
|
||||
|
||||
self.async_schedule_save()
|
||||
self.hass.bus.async_fire_internal(
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
EventFloorRegistryUpdatedData(action="reorder", floor_id=None),
|
||||
)
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the floor registry."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
@@ -266,6 +266,7 @@ class Manifest(TypedDict, total=False):
|
||||
loggers: list[str]
|
||||
import_executor: bool
|
||||
single_config_entry: bool
|
||||
preview_features: dict[str, dict[str, str]]
|
||||
|
||||
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
@@ -900,6 +901,11 @@ class Integration:
|
||||
"""Return Integration bluetooth entries."""
|
||||
return self.manifest.get("bluetooth")
|
||||
|
||||
@property
|
||||
def preview_features(self) -> dict[str, dict[str, str]] | None:
|
||||
"""Return Integration preview features entries."""
|
||||
return self.manifest.get("preview_features")
|
||||
|
||||
@property
|
||||
def dhcp(self) -> list[dict[str, str | bool]] | None:
|
||||
"""Return Integration dhcp entries."""
|
||||
|
||||
@@ -33,7 +33,7 @@ cryptography==46.0.2
|
||||
dbus-fast==3.0.0
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.7.0
|
||||
hass-nabucasa==1.5.1
|
||||
|
||||
26
requirements_all.txt
generated
26
requirements_all.txt
generated
@@ -80,7 +80,7 @@ PyQRCode==1.2.1
|
||||
PyRMVtransport==0.3.3
|
||||
|
||||
# homeassistant.components.sunricher_dali
|
||||
PySrDaliGateway==0.13.1
|
||||
PySrDaliGateway==0.16.2
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.73.0
|
||||
@@ -209,7 +209,7 @@ aioaseko==1.0.0
|
||||
aioasuswrt==1.5.1
|
||||
|
||||
# homeassistant.components.husqvarna_automower
|
||||
aioautomower==2.7.0
|
||||
aioautomower==2.7.1
|
||||
|
||||
# homeassistant.components.azure_devops
|
||||
aioazuredevops==2.2.2
|
||||
@@ -389,7 +389,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.17.0
|
||||
aioshelly==13.19.0
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -422,7 +422,7 @@ aiotedee==0.2.25
|
||||
aiotractive==0.6.0
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==87
|
||||
aiounifi==88
|
||||
|
||||
# homeassistant.components.usb
|
||||
aiousbwatcher==1.1.1
|
||||
@@ -1049,7 +1049,7 @@ gitterpy==0.1.7
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
@@ -1143,6 +1143,9 @@ habiticalib==0.4.6
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.7.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
|
||||
@@ -1601,7 +1604,7 @@ odp-amsterdam==6.1.2
|
||||
oemthermostat==1.1.1
|
||||
|
||||
# homeassistant.components.ohme
|
||||
ohme==1.5.2
|
||||
ohme==1.6.0
|
||||
|
||||
# homeassistant.components.ollama
|
||||
ollama==0.5.1
|
||||
@@ -1613,7 +1616,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.16
|
||||
onedrive-personal-sdk==0.0.17
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -2150,7 +2153,7 @@ pylitejet==0.6.3
|
||||
pylitterbot==2025.0.0
|
||||
|
||||
# homeassistant.components.lutron_caseta
|
||||
pylutron-caseta==0.25.0
|
||||
pylutron-caseta==0.26.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.2.18
|
||||
@@ -2269,7 +2272,7 @@ pypaperless==4.1.1
|
||||
pypca==0.0.7
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.3
|
||||
pypck==0.9.5
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -3054,7 +3057,7 @@ unifi_ap==0.0.2
|
||||
unifiled==0.11
|
||||
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
universal-silabs-flasher==0.1.0
|
||||
universal-silabs-flasher==0.1.2
|
||||
|
||||
# homeassistant.components.upb
|
||||
upb-lib==0.6.1
|
||||
@@ -3088,6 +3091,9 @@ velbus-aio==2025.11.0
|
||||
# homeassistant.components.venstar
|
||||
venstarcolortouch==0.21
|
||||
|
||||
# homeassistant.components.victron_ble
|
||||
victron-ble-ha-parser==0.4.9
|
||||
|
||||
# homeassistant.components.victron_remote_monitoring
|
||||
victron-vrm==0.1.8
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
astroid==4.0.1
|
||||
coverage==7.10.6
|
||||
freezegun==1.5.2
|
||||
go2rtc-client==0.2.1
|
||||
go2rtc-client==0.3.0
|
||||
# librt is an internal mypy dependency
|
||||
librt==0.2.1
|
||||
license-expression==30.4.3
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user