mirror of
https://github.com/home-assistant/core.git
synced 2025-11-21 08:47:15 +00:00
Compare commits
67 Commits
blueprint-
...
setpoint_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b650e71660 | ||
|
|
9ddf15e348 | ||
|
|
15082f9111 | ||
|
|
12f16611ff | ||
|
|
0de2a16d0f | ||
|
|
c8c2413a09 | ||
|
|
291331f878 | ||
|
|
a13cdbdf3d | ||
|
|
1bf713f279 | ||
|
|
10c8ee417b | ||
|
|
b23134f4f1 | ||
|
|
8041be3d08 | ||
|
|
40b021e755 | ||
|
|
aab57eda96 | ||
|
|
f0dd37caa5 | ||
|
|
662b178495 | ||
|
|
cb3d30884a | ||
|
|
49e6f20372 | ||
|
|
f45a6f806b | ||
|
|
d3857a00d5 | ||
|
|
8c9b90a9f9 | ||
|
|
4eedc88935 | ||
|
|
343ea1b82d | ||
|
|
36e13653d2 | ||
|
|
80444b2165 | ||
|
|
262f06dd2b | ||
|
|
bd87119c2e | ||
|
|
0dfa037aa8 | ||
|
|
c32a471573 | ||
|
|
97b7e51171 | ||
|
|
433712b407 | ||
|
|
5d87e0f429 | ||
|
|
acb087f1e5 | ||
|
|
10c12623bf | ||
|
|
2fe20553b3 | ||
|
|
b431bb197a | ||
|
|
eb9d625926 | ||
|
|
3a69534b09 | ||
|
|
8f2cedcb73 | ||
|
|
3658953ff3 | ||
|
|
0be5893e37 | ||
|
|
c87e38c4cf | ||
|
|
4874610ad6 | ||
|
|
9180282fc6 | ||
|
|
118f30f32e | ||
|
|
bd10da126f | ||
|
|
b73a7928ca | ||
|
|
3e20c2ea93 | ||
|
|
60130d3d68 | ||
|
|
c45ede2e5d | ||
|
|
e167061f53 | ||
|
|
5560fb6c9e | ||
|
|
9808b6c961 | ||
|
|
e8cfde579e | ||
|
|
f695fb4d51 | ||
|
|
a0e0549d90 | ||
|
|
ba034c6c8c | ||
|
|
008bb85c59 | ||
|
|
cf1c1294d3 | ||
|
|
11d5d314cc | ||
|
|
6f0de3071a | ||
|
|
87d2597292 | ||
|
|
437bc04fe8 | ||
|
|
67a0d6a187 | ||
|
|
abb52bca81 | ||
|
|
d2d6889278 | ||
|
|
75d02661eb |
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
- arch: i386
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
@@ -463,7 +463,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
|
||||
@@ -87,7 +87,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||
|
||||
@@ -579,6 +579,7 @@ homeassistant.components.wiz.*
|
||||
homeassistant.components.wled.*
|
||||
homeassistant.components.workday.*
|
||||
homeassistant.components.worldclock.*
|
||||
homeassistant.components.xbox.*
|
||||
homeassistant.components.xiaomi_ble.*
|
||||
homeassistant.components.yale_smart_alarm.*
|
||||
homeassistant.components.yalexs_ble.*
|
||||
|
||||
@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
|
||||
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
|
||||
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adguardhome"],
|
||||
"requirements": ["adguardhome==0.8.0"]
|
||||
"requirements": ["adguardhome==0.8.1"]
|
||||
}
|
||||
|
||||
71
homeassistant/components/adguard/update.py
Normal file
71
homeassistant/components/adguard/update.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""AdGuard Home Update platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from adguardhome import AdGuardHomeError
|
||||
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdGuardConfigEntry, AdGuardData
|
||||
from .const import DOMAIN
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=300)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AdGuardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AdGuard Home update entity based on a config entry."""
|
||||
data = entry.runtime_data
|
||||
|
||||
if (await data.client.update.update_available()).disabled:
|
||||
return
|
||||
|
||||
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
|
||||
|
||||
|
||||
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
|
||||
"""Defines an AdGuard Home update."""
|
||||
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: AdGuardData,
|
||||
entry: AdGuardConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize AdGuard Home update."""
|
||||
super().__init__(data, entry)
|
||||
|
||||
self._attr_unique_id = "_".join(
|
||||
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
value = await self.adguard.update.update_available()
|
||||
self._attr_installed_version = self.data.version
|
||||
self._attr_latest_version = value.new_version
|
||||
self._attr_release_summary = value.announcement
|
||||
self._attr_release_url = value.announcement_url
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install latest update."""
|
||||
try:
|
||||
await self.adguard.update.begin_update()
|
||||
except AdGuardHomeError as err:
|
||||
raise HomeAssistantError(f"Failed to install update: {err}") from err
|
||||
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
|
||||
@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.69.0"]
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"""The blueprint integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_NAME, CONF_SELECTOR
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.selector import selector as create_selector
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import websocket_api
|
||||
@@ -35,61 +29,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the blueprint integration."""
|
||||
websocket_api.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_find_relevant_blueprints(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Find all blueprints relevant to a specific device."""
|
||||
results = {}
|
||||
entities = [
|
||||
entry
|
||||
for entry in er.async_entries_for_device(er.async_get(hass), device_id)
|
||||
if not entry.entity_category
|
||||
]
|
||||
|
||||
async def all_blueprints_generator(hass: HomeAssistant):
|
||||
"""Yield all blueprints from all domains."""
|
||||
blueprint_domains: dict[str, DomainBlueprints] = hass.data[DOMAIN]
|
||||
for blueprint_domain in blueprint_domains.values():
|
||||
blueprints = await blueprint_domain.async_get_blueprints()
|
||||
for blueprint in blueprints.values():
|
||||
yield blueprint
|
||||
|
||||
async for blueprint in all_blueprints_generator(hass):
|
||||
blueprint_input_matches: dict[str, list[str]] = {}
|
||||
|
||||
for info in blueprint.inputs.values():
|
||||
if (
|
||||
not info
|
||||
or not (selector_conf := info.get(CONF_SELECTOR))
|
||||
or "entity" not in selector_conf
|
||||
):
|
||||
continue
|
||||
|
||||
selector = create_selector(selector_conf)
|
||||
|
||||
matched = []
|
||||
|
||||
for entity in entities:
|
||||
try:
|
||||
entity.entity_id, selector(entity.entity_id)
|
||||
except vol.Invalid:
|
||||
continue
|
||||
|
||||
matched.append(entity.entity_id)
|
||||
|
||||
if matched:
|
||||
blueprint_input_matches[info[CONF_NAME]] = matched
|
||||
|
||||
if not blueprint_input_matches:
|
||||
continue
|
||||
|
||||
results.setdefault(blueprint.domain, []).append(
|
||||
{
|
||||
"blueprint": blueprint,
|
||||
"matched_input": blueprint_input_matches,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==2.45.0",
|
||||
"dbus-fast==3.0.0",
|
||||
"habluetooth==5.7.0"
|
||||
]
|
||||
}
|
||||
|
||||
1
homeassistant/components/cosori/__init__.py
Normal file
1
homeassistant/components/cosori/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Cosori."""
|
||||
6
homeassistant/components/cosori/manifest.json
Normal file
6
homeassistant/components/cosori/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "cosori",
|
||||
"name": "Cosori",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "vesync"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
@@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
|
||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||
)
|
||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||
ssl_context = get_default_context()
|
||||
|
||||
try:
|
||||
cync = await Cync.create(cync_auth)
|
||||
cync = await Cync.create(
|
||||
auth=cync_auth,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||
except CyncError as ex:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["gassist-text==0.0.14"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: No polling.
|
||||
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: No entities.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: No entities to update.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration acts as a service and does not represent physical devices.
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud service integration that cannot be discovered locally.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: No entities to update.
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: No devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: No devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: The underlying library uses gRPC, not aiohttp/httpx, for communication.
|
||||
strict-typing: done
|
||||
@@ -56,6 +56,9 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"language_code": "Language code"
|
||||
},
|
||||
"data_description": {
|
||||
"language_code": "Language for the Google Assistant SDK requests and responses."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ from .const import DOMAIN
|
||||
if TYPE_CHECKING:
|
||||
from . import GoogleSheetsConfigEntry
|
||||
|
||||
ADD_CREATED_COLUMN = "add_created_column"
|
||||
DATA = "data"
|
||||
DATA_CONFIG_ENTRY = "config_entry"
|
||||
ROWS = "rows"
|
||||
@@ -43,6 +44,7 @@ SHEET_SERVICE_SCHEMA = vol.All(
|
||||
{
|
||||
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||
vol.Optional(WORKSHEET): cv.string,
|
||||
vol.Optional(ADD_CREATED_COLUMN, default=True): cv.boolean,
|
||||
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
|
||||
},
|
||||
)
|
||||
@@ -69,10 +71,11 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
|
||||
|
||||
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
|
||||
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
|
||||
add_created_column = call.data[ADD_CREATED_COLUMN]
|
||||
now = str(datetime.now())
|
||||
rows = []
|
||||
for d in call.data[DATA]:
|
||||
row_data = {"created": now} | d
|
||||
row_data = ({"created": now} | d) if add_created_column else d
|
||||
row = [row_data.get(column, "") for column in columns]
|
||||
for key, value in row_data.items():
|
||||
if key not in columns:
|
||||
|
||||
@@ -9,6 +9,11 @@ append_sheet:
|
||||
example: "Sheet1"
|
||||
selector:
|
||||
text:
|
||||
add_created_column:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
data:
|
||||
required: true
|
||||
example: '{"hello": world, "cool": True, "count": 5}'
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
"append_sheet": {
|
||||
"description": "Appends data to a worksheet in Google Sheets.",
|
||||
"fields": {
|
||||
"add_created_column": {
|
||||
"description": "Add a \"created\" column with the current date-time to the appended data.",
|
||||
"name": "Add created column"
|
||||
},
|
||||
"config_entry": {
|
||||
"description": "The sheet to add data to.",
|
||||
"name": "Sheet"
|
||||
|
||||
@@ -112,6 +112,7 @@ async def async_setup_entry(
|
||||
update_method=async_update_data,
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=timedelta(hours=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
|
||||
@@ -12,6 +12,7 @@ from pyicloud.exceptions import (
|
||||
PyiCloudFailedLoginException,
|
||||
PyiCloudNoDevicesException,
|
||||
PyiCloudServiceNotActivatedException,
|
||||
PyiCloudServiceUnavailable,
|
||||
)
|
||||
from pyicloud.services.findmyiphone import AppleDevice
|
||||
|
||||
@@ -130,15 +131,21 @@ class IcloudAccount:
|
||||
except (
|
||||
PyiCloudServiceNotActivatedException,
|
||||
PyiCloudNoDevicesException,
|
||||
PyiCloudServiceUnavailable,
|
||||
) as err:
|
||||
_LOGGER.error("No iCloud device found")
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
|
||||
if user_info is None:
|
||||
raise ConfigEntryNotReady("No user info found in iCloud devices response")
|
||||
|
||||
self._owner_fullname = (
|
||||
f"{user_info.get('firstName')} {user_info.get('lastName')}"
|
||||
)
|
||||
|
||||
self._family_members_fullname = {}
|
||||
if user_info.get("membersInfo") is not None:
|
||||
for prs_id, member in user_info["membersInfo"].items():
|
||||
for prs_id, member in user_info.get("membersInfo").items():
|
||||
self._family_members_fullname[prs_id] = (
|
||||
f"{member['firstName']} {member['lastName']}"
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/icloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.1.0"]
|
||||
"requirements": ["pyicloud==2.2.0"]
|
||||
}
|
||||
|
||||
@@ -237,14 +237,23 @@ class SettingDataUpdateCoordinator(
|
||||
"""Implementation of PlenticoreUpdateCoordinator for settings data."""
|
||||
|
||||
async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]:
|
||||
client = self._plenticore.client
|
||||
|
||||
if not self._fetch or client is None:
|
||||
if (client := self._plenticore.client) is None:
|
||||
return {}
|
||||
|
||||
_LOGGER.debug("Fetching %s for %s", self.name, self._fetch)
|
||||
fetch = defaultdict(set)
|
||||
|
||||
return await client.get_setting_values(self._fetch)
|
||||
for module_id, data_ids in self._fetch.items():
|
||||
fetch[module_id].update(data_ids)
|
||||
|
||||
for module_id, data_id in self.async_contexts():
|
||||
fetch[module_id].add(data_id)
|
||||
|
||||
if not fetch:
|
||||
return {}
|
||||
|
||||
_LOGGER.debug("Fetching %s for %s", self.name, fetch)
|
||||
|
||||
return await client.get_setting_values(fetch)
|
||||
|
||||
|
||||
class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
|
||||
@@ -34,6 +34,29 @@ async def async_get_config_entry_diagnostics(
|
||||
},
|
||||
}
|
||||
|
||||
# Add important information how the inverter is configured
|
||||
string_count_setting = await plenticore.client.get_setting_values(
|
||||
"devices:local", "Properties:StringCnt"
|
||||
)
|
||||
try:
|
||||
string_count = int(
|
||||
string_count_setting["devices:local"]["Properties:StringCnt"]
|
||||
)
|
||||
except ValueError:
|
||||
string_count = 0
|
||||
|
||||
configuration_settings = await plenticore.client.get_setting_values(
|
||||
"devices:local",
|
||||
(
|
||||
"Properties:StringCnt",
|
||||
*(f"Properties:String{idx}Features" for idx in range(string_count)),
|
||||
),
|
||||
)
|
||||
|
||||
data["configuration"] = {
|
||||
**configuration_settings,
|
||||
}
|
||||
|
||||
device_info = {**plenticore.device_info}
|
||||
device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number
|
||||
data["device"] = device_info
|
||||
|
||||
@@ -5,12 +5,13 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -66,7 +67,7 @@ async def async_setup_entry(
|
||||
"""Add kostal plenticore Switch."""
|
||||
plenticore = entry.runtime_data
|
||||
|
||||
entities = []
|
||||
entities: list[Entity] = []
|
||||
|
||||
available_settings_data = await plenticore.client.get_settings()
|
||||
settings_data_update_coordinator = SettingDataUpdateCoordinator(
|
||||
@@ -103,6 +104,57 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
# add shadow management switches for strings which support it
|
||||
string_count_setting = await plenticore.client.get_setting_values(
|
||||
"devices:local", "Properties:StringCnt"
|
||||
)
|
||||
try:
|
||||
string_count = int(
|
||||
string_count_setting["devices:local"]["Properties:StringCnt"]
|
||||
)
|
||||
except ValueError:
|
||||
string_count = 0
|
||||
|
||||
dc_strings = tuple(range(string_count))
|
||||
dc_string_feature_ids = tuple(
|
||||
PlenticoreShadowMgmtSwitch.DC_STRING_FEATURE_DATA_ID % dc_string
|
||||
for dc_string in dc_strings
|
||||
)
|
||||
|
||||
dc_string_features = await plenticore.client.get_setting_values(
|
||||
PlenticoreShadowMgmtSwitch.MODULE_ID,
|
||||
dc_string_feature_ids,
|
||||
)
|
||||
|
||||
for dc_string, dc_string_feature_id in zip(
|
||||
dc_strings, dc_string_feature_ids, strict=True
|
||||
):
|
||||
try:
|
||||
dc_string_feature = int(
|
||||
dc_string_features[PlenticoreShadowMgmtSwitch.MODULE_ID][
|
||||
dc_string_feature_id
|
||||
]
|
||||
)
|
||||
except ValueError:
|
||||
dc_string_feature = 0
|
||||
|
||||
if dc_string_feature == PlenticoreShadowMgmtSwitch.SHADOW_MANAGEMENT_SUPPORT:
|
||||
entities.append(
|
||||
PlenticoreShadowMgmtSwitch(
|
||||
settings_data_update_coordinator,
|
||||
dc_string,
|
||||
entry.entry_id,
|
||||
entry.title,
|
||||
plenticore.device_info,
|
||||
)
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Skipping shadow management for DC string %d, not supported (Feature: %d)",
|
||||
dc_string + 1,
|
||||
dc_string_feature,
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -136,7 +188,6 @@ class PlenticoreDataSwitch(
|
||||
self.off_value = description.off_value
|
||||
self.off_label = description.off_label
|
||||
self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}"
|
||||
|
||||
self._attr_device_info = device_info
|
||||
|
||||
@property
|
||||
@@ -189,3 +240,98 @@ class PlenticoreDataSwitch(
|
||||
f"{self.platform_name} {self._name} {self.off_label}"
|
||||
)
|
||||
return bool(self.coordinator.data[self.module_id][self.data_id] == self._is_on)
|
||||
|
||||
|
||||
class PlenticoreShadowMgmtSwitch(
|
||||
CoordinatorEntity[SettingDataUpdateCoordinator], SwitchEntity
|
||||
):
|
||||
"""Representation of a Plenticore Switch for shadow management.
|
||||
|
||||
The shadow management switch can be controlled for each DC string separately. The DC string is
|
||||
coded as bit in a single settings value, bit 0 for DC string 1, bit 1 for DC string 2, etc.
|
||||
|
||||
Not all DC strings are available for shadown management, for example if one of them is used
|
||||
for a battery.
|
||||
"""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: SwitchEntityDescription
|
||||
|
||||
MODULE_ID: Final = "devices:local"
|
||||
|
||||
SHADOW_DATA_ID: Final = "Generator:ShadowMgmt:Enable"
|
||||
"""Settings id for the bit coded shadow management."""
|
||||
|
||||
DC_STRING_FEATURE_DATA_ID: Final = "Properties:String%dFeatures"
|
||||
"""Settings id pattern for the DC string features."""
|
||||
|
||||
SHADOW_MANAGEMENT_SUPPORT: Final = 1
|
||||
"""Feature value for shadow management support in the DC string features."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SettingDataUpdateCoordinator,
|
||||
dc_string: int,
|
||||
entry_id: str,
|
||||
platform_name: str,
|
||||
device_info: DeviceInfo,
|
||||
) -> None:
|
||||
"""Create a new Switch Entity for Plenticore shadow management."""
|
||||
super().__init__(coordinator, context=(self.MODULE_ID, self.SHADOW_DATA_ID))
|
||||
|
||||
self._mask: Final = 1 << dc_string
|
||||
|
||||
self.entity_description = SwitchEntityDescription(
|
||||
key=f"ShadowMgmt{dc_string}",
|
||||
name=f"Shadow Management DC string {dc_string + 1}",
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
|
||||
self.platform_name = platform_name
|
||||
self._attr_name = f"{platform_name} {self.entity_description.name}"
|
||||
self._attr_unique_id = (
|
||||
f"{entry_id}_{self.MODULE_ID}_{self.SHADOW_DATA_ID}_{dc_string}"
|
||||
)
|
||||
self._attr_device_info = device_info
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self.MODULE_ID in self.coordinator.data
|
||||
and self.SHADOW_DATA_ID in self.coordinator.data[self.MODULE_ID]
|
||||
)
|
||||
|
||||
def _get_shadow_mgmt_value(self) -> int:
|
||||
"""Return the current shadow management value for all strings as integer."""
|
||||
try:
|
||||
return int(self.coordinator.data[self.MODULE_ID][self.SHADOW_DATA_ID])
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn shadow management on."""
|
||||
shadow_mgmt_value = self._get_shadow_mgmt_value()
|
||||
shadow_mgmt_value |= self._mask
|
||||
|
||||
if await self.coordinator.async_write_data(
|
||||
self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)}
|
||||
):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn shadow management off."""
|
||||
shadow_mgmt_value = self._get_shadow_mgmt_value()
|
||||
shadow_mgmt_value &= ~self._mask
|
||||
|
||||
if await self.coordinator.async_write_data(
|
||||
self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)}
|
||||
):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if shadow management is on."""
|
||||
return (self._get_shadow_mgmt_value() & self._mask) != 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for LCN binary sensors."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
|
||||
import pypck
|
||||
@@ -19,6 +20,7 @@ from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
@@ -69,21 +71,11 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity):
|
||||
config[CONF_DOMAIN_DATA][CONF_SOURCE]
|
||||
]
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(
|
||||
self.bin_sensor_port
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(
|
||||
self.bin_sensor_port
|
||||
)
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_binary_sensors(
|
||||
SCAN_INTERVAL.seconds
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set sensor value when LCN input object (command) is received."""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Support for LCN climate control."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -36,6 +38,7 @@ from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
@@ -110,20 +113,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.variable)
|
||||
await self.device_connection.activate_status_request_handler(self.setpoint)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.variable)
|
||||
await self.device_connection.cancel_status_request_handler(self.setpoint)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
@@ -192,6 +181,17 @@ class LcnClimate(LcnEntity, ClimateEntity):
|
||||
self._target_temperature = temperature
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await asyncio.gather(
|
||||
self.device_connection.request_status_variable(
|
||||
self.variable, SCAN_INTERVAL.seconds
|
||||
),
|
||||
self.device_connection.request_status_variable(
|
||||
self.setpoint, SCAN_INTERVAL.seconds
|
||||
),
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set temperature value when LCN input object is received."""
|
||||
if not isinstance(input_obj, pypck.inputs.ModStatusVar):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Support for LCN covers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
@@ -27,6 +29,7 @@ from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
@@ -73,7 +76,7 @@ async def async_setup_entry(
|
||||
class LcnOutputsCover(LcnEntity, CoverEntity):
|
||||
"""Representation of a LCN cover connected to output ports."""
|
||||
|
||||
_attr_is_closed = False
|
||||
_attr_is_closed = True
|
||||
_attr_is_closing = False
|
||||
_attr_is_opening = False
|
||||
_attr_assumed_state = True
|
||||
@@ -93,28 +96,6 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
|
||||
else:
|
||||
self.reverse_time = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(
|
||||
pypck.lcn_defs.OutputPort["OUTPUTUP"]
|
||||
)
|
||||
await self.device_connection.activate_status_request_handler(
|
||||
pypck.lcn_defs.OutputPort["OUTPUTDOWN"]
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(
|
||||
pypck.lcn_defs.OutputPort["OUTPUTUP"]
|
||||
)
|
||||
await self.device_connection.cancel_status_request_handler(
|
||||
pypck.lcn_defs.OutputPort["OUTPUTDOWN"]
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
state = pypck.lcn_defs.MotorStateModifier.DOWN
|
||||
@@ -147,6 +128,18 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
|
||||
self._attr_is_opening = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
if not self.device_connection.is_group:
|
||||
await asyncio.gather(
|
||||
self.device_connection.request_status_output(
|
||||
pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds
|
||||
),
|
||||
self.device_connection.request_status_output(
|
||||
pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds
|
||||
),
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set cover states when LCN input object (command) is received."""
|
||||
if (
|
||||
@@ -175,7 +168,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
|
||||
class LcnRelayCover(LcnEntity, CoverEntity):
|
||||
"""Representation of a LCN cover connected to relays."""
|
||||
|
||||
_attr_is_closed = False
|
||||
_attr_is_closed = True
|
||||
_attr_is_closing = False
|
||||
_attr_is_opening = False
|
||||
_attr_assumed_state = True
|
||||
@@ -206,20 +199,6 @@ class LcnRelayCover(LcnEntity, CoverEntity):
|
||||
self._is_closing = False
|
||||
self._is_opening = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(
|
||||
self.motor, self.positioning_mode
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.motor)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
if not await self.device_connection.control_motor_relays(
|
||||
@@ -274,6 +253,17 @@ class LcnRelayCover(LcnEntity, CoverEntity):
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
coros = [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(
|
||||
self.motor, self.positioning_mode, SCAN_INTERVAL.seconds
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*coros)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set cover states when LCN input object (command) is received."""
|
||||
if isinstance(input_obj, pypck.inputs.ModStatusRelays):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from pypck.device import DeviceConnection
|
||||
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -10,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from .const import CONF_DOMAIN_DATA, DOMAIN
|
||||
from .helpers import (
|
||||
AddressType,
|
||||
DeviceConnectionType,
|
||||
InputType,
|
||||
LcnConfigEntry,
|
||||
generate_unique_id,
|
||||
@@ -22,9 +23,8 @@ from .helpers import (
|
||||
class LcnEntity(Entity):
|
||||
"""Parent class for all entities associated with the LCN component."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
device_connection: DeviceConnectionType
|
||||
device_connection: DeviceConnection
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -57,15 +57,24 @@ class LcnEntity(Entity):
|
||||
).lower(),
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Groups may not poll for a status."""
|
||||
return not self.device_connection.is_group
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
self.device_connection = get_device_connection(
|
||||
self.hass, self.config[CONF_ADDRESS], self.config_entry
|
||||
)
|
||||
if not self.device_connection.is_group:
|
||||
self._unregister_for_inputs = self.device_connection.register_for_inputs(
|
||||
self.input_received
|
||||
)
|
||||
if self.device_connection.is_group:
|
||||
return
|
||||
|
||||
self._unregister_for_inputs = self.device_connection.register_for_inputs(
|
||||
self.input_received
|
||||
)
|
||||
|
||||
self.schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import cast
|
||||
|
||||
import pypck
|
||||
from pypck.connection import PchkConnectionManager
|
||||
from pypck.device import DeviceConnection
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -48,7 +49,7 @@ class LcnRuntimeData:
|
||||
connection: PchkConnectionManager
|
||||
"""Connection to PCHK host."""
|
||||
|
||||
device_connections: dict[str, DeviceConnectionType]
|
||||
device_connections: dict[str, DeviceConnection]
|
||||
"""Logical addresses of devices connected to the host."""
|
||||
|
||||
add_entities_callbacks: dict[str, Callable[[Iterable[ConfigType]], None]]
|
||||
@@ -59,7 +60,6 @@ class LcnRuntimeData:
|
||||
type LcnConfigEntry = ConfigEntry[LcnRuntimeData]
|
||||
|
||||
type AddressType = tuple[int, int, bool]
|
||||
type DeviceConnectionType = pypck.module.ModuleConnection | pypck.module.GroupConnection
|
||||
|
||||
type InputType = type[pypck.inputs.Input]
|
||||
|
||||
@@ -82,11 +82,11 @@ DOMAIN_LOOKUP = {
|
||||
|
||||
def get_device_connection(
|
||||
hass: HomeAssistant, address: AddressType, config_entry: LcnConfigEntry
|
||||
) -> DeviceConnectionType:
|
||||
) -> DeviceConnection:
|
||||
"""Return a lcn device_connection."""
|
||||
host_connection = config_entry.runtime_data.connection
|
||||
addr = pypck.lcn_addr.LcnAddr(*address)
|
||||
return host_connection.get_address_conn(addr)
|
||||
return host_connection.get_device_connection(addr)
|
||||
|
||||
|
||||
def get_resource(domain_name: str, domain_data: ConfigType) -> str:
|
||||
@@ -246,18 +246,24 @@ def register_lcn_address_devices(
|
||||
|
||||
|
||||
async def async_update_device_config(
|
||||
device_connection: DeviceConnectionType, device_config: ConfigType
|
||||
device_connection: DeviceConnection, device_config: ConfigType
|
||||
) -> None:
|
||||
"""Fill missing values in device_config with infos from LCN bus."""
|
||||
# fetch serial info if device is module
|
||||
if not (is_group := device_config[CONF_ADDRESS][2]): # is module
|
||||
await device_connection.serial_known
|
||||
await device_connection.serials_known()
|
||||
if device_config[CONF_HARDWARE_SERIAL] == -1:
|
||||
device_config[CONF_HARDWARE_SERIAL] = device_connection.hardware_serial
|
||||
device_config[CONF_HARDWARE_SERIAL] = (
|
||||
device_connection.serials.hardware_serial
|
||||
)
|
||||
if device_config[CONF_SOFTWARE_SERIAL] == -1:
|
||||
device_config[CONF_SOFTWARE_SERIAL] = device_connection.software_serial
|
||||
device_config[CONF_SOFTWARE_SERIAL] = (
|
||||
device_connection.serials.software_serial
|
||||
)
|
||||
if device_config[CONF_HARDWARE_TYPE] == -1:
|
||||
device_config[CONF_HARDWARE_TYPE] = device_connection.hardware_type.value
|
||||
device_config[CONF_HARDWARE_TYPE] = (
|
||||
device_connection.serials.hardware_type.value
|
||||
)
|
||||
|
||||
# fetch name if device is module
|
||||
if device_config[CONF_NAME] != "":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for LCN lights."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
@@ -33,6 +34,7 @@ from .helpers import InputType, LcnConfigEntry
|
||||
BRIGHTNESS_SCALE = (1, 100)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
@@ -100,18 +102,6 @@ class LcnOutputLight(LcnEntity, LightEntity):
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
self._attr_supported_color_modes = {self._attr_color_mode}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.output)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.output)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
@@ -157,6 +147,12 @@ class LcnOutputLight(LcnEntity, LightEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_output(
|
||||
self.output, SCAN_INTERVAL.seconds
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set light state when LCN input object (command) is received."""
|
||||
if (
|
||||
@@ -184,18 +180,6 @@ class LcnRelayLight(LcnEntity, LightEntity):
|
||||
|
||||
self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.output)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.output)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
|
||||
@@ -214,6 +198,10 @@ class LcnRelayLight(LcnEntity, LightEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set light state when LCN input object (command) is received."""
|
||||
if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"iot_class": "local_push",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pypck==0.8.12", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.3", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for LCN sensors."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
|
||||
@@ -40,6 +41,8 @@ from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
DEVICE_CLASS_MAPPING = {
|
||||
pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE,
|
||||
@@ -128,17 +131,11 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
|
||||
)
|
||||
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.variable)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.variable)
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_variable(
|
||||
self.variable, SCAN_INTERVAL.seconds
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set sensor value when LCN input object (command) is received."""
|
||||
@@ -170,17 +167,11 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity):
|
||||
config[CONF_DOMAIN_DATA][CONF_SOURCE]
|
||||
]
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.source)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.source)
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_led_and_logic_ops(
|
||||
SCAN_INTERVAL.seconds
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set sensor value when LCN input object (command) is received."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from enum import StrEnum, auto
|
||||
|
||||
import pypck
|
||||
from pypck.device import DeviceConnection
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -48,7 +49,7 @@ from .const import (
|
||||
VAR_UNITS,
|
||||
VARIABLES,
|
||||
)
|
||||
from .helpers import DeviceConnectionType, LcnConfigEntry, is_states_string
|
||||
from .helpers import LcnConfigEntry, is_states_string
|
||||
|
||||
|
||||
class LcnServiceCall:
|
||||
@@ -65,7 +66,7 @@ class LcnServiceCall:
|
||||
"""Initialize service call."""
|
||||
self.hass = hass
|
||||
|
||||
def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType:
|
||||
def get_device_connection(self, service: ServiceCall) -> DeviceConnection:
|
||||
"""Get address connection object."""
|
||||
entries: list[LcnConfigEntry] = self.hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
@@ -380,9 +381,6 @@ class LockKeys(LcnServiceCall):
|
||||
else:
|
||||
await device_connection.lock_keys(table_id, states)
|
||||
|
||||
handler = device_connection.status_requests_handler
|
||||
await handler.request_status_locked_keys_timeout()
|
||||
|
||||
|
||||
class DynText(LcnServiceCall):
|
||||
"""Send dynamic text to LCN-GTxD displays."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for LCN switches."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
@@ -17,6 +18,7 @@ from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
def add_lcn_switch_entities(
|
||||
@@ -77,18 +79,6 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity):
|
||||
|
||||
self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.output)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.output)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
if not await self.device_connection.dim_output(self.output.value, 100, 0):
|
||||
@@ -103,6 +93,12 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_output(
|
||||
self.output, SCAN_INTERVAL.seconds
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set switch state when LCN input object (command) is received."""
|
||||
if (
|
||||
@@ -126,18 +122,6 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity):
|
||||
|
||||
self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.output)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.output)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
|
||||
@@ -156,6 +140,10 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set switch state when LCN input object (command) is received."""
|
||||
if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
|
||||
@@ -179,22 +167,6 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity):
|
||||
]
|
||||
self.reg_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint_variable)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(
|
||||
self.setpoint_variable
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(
|
||||
self.setpoint_variable
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
if not await self.device_connection.lock_regulator(self.reg_id, True):
|
||||
@@ -209,6 +181,12 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_variable(
|
||||
self.setpoint_variable, SCAN_INTERVAL.seconds
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set switch state when LCN input object (command) is received."""
|
||||
if (
|
||||
@@ -234,18 +212,6 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity):
|
||||
self.table_id = ord(self.key.name[0]) - 65
|
||||
self.key_id = int(self.key.name[1]) - 1
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.activate_status_request_handler(self.key)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.key)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
states = [pypck.lcn_defs.KeyLockStateModifier.NOCHANGE] * 8
|
||||
@@ -268,6 +234,10 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self.device_connection.request_status_locked_keys(SCAN_INTERVAL.seconds)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set switch state when LCN input object (command) is received."""
|
||||
if (
|
||||
|
||||
@@ -7,6 +7,7 @@ from functools import wraps
|
||||
from typing import Any, Final
|
||||
|
||||
import lcn_frontend as lcn_panel
|
||||
from pypck.device import DeviceConnection
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import panel_custom, websocket_api
|
||||
@@ -37,7 +38,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import (
|
||||
DeviceConnectionType,
|
||||
LcnConfigEntry,
|
||||
async_update_device_config,
|
||||
generate_unique_id,
|
||||
@@ -182,7 +182,7 @@ async def websocket_scan_devices(
|
||||
host_connection = config_entry.runtime_data.connection
|
||||
await host_connection.scan_modules()
|
||||
|
||||
for device_connection in host_connection.address_conns.values():
|
||||
for device_connection in host_connection.device_connections.values():
|
||||
if not device_connection.is_group:
|
||||
await async_create_or_update_device_in_config_entry(
|
||||
hass, device_connection, config_entry
|
||||
@@ -421,7 +421,7 @@ async def websocket_delete_entity(
|
||||
|
||||
async def async_create_or_update_device_in_config_entry(
|
||||
hass: HomeAssistant,
|
||||
device_connection: DeviceConnectionType,
|
||||
device_connection: DeviceConnection,
|
||||
config_entry: LcnConfigEntry,
|
||||
) -> None:
|
||||
"""Create or update device in config_entry according to given device_connection."""
|
||||
|
||||
@@ -183,6 +183,13 @@ 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
|
||||
|
||||
@@ -1488,4 +1495,47 @@ 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,6 +223,9 @@
|
||||
"pump_setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"setpoint_change_source_timestamp": {
|
||||
"name": "Last change"
|
||||
},
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
@@ -518,6 +521,20 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -41,9 +41,11 @@ from .const import (
|
||||
DATA_CONFIG_ENTRIES,
|
||||
DATA_DELETED_IDS,
|
||||
DATA_DEVICES,
|
||||
DATA_PENDING_UPDATES,
|
||||
DATA_PUSH_CHANNEL,
|
||||
DATA_STORE,
|
||||
DOMAIN,
|
||||
SENSOR_TYPES,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
@@ -75,6 +77,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
DATA_DEVICES: {},
|
||||
DATA_PUSH_CHANNEL: {},
|
||||
DATA_STORE: store,
|
||||
DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES},
|
||||
}
|
||||
|
||||
hass.http.register_view(RegistrationsView())
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON
|
||||
from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -75,8 +75,9 @@ class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity):
|
||||
|
||||
async def async_restore_last_state(self, last_state: State) -> None:
|
||||
"""Restore previous state."""
|
||||
await super().async_restore_last_state(last_state)
|
||||
self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON
|
||||
if self._config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN):
|
||||
await super().async_restore_last_state(last_state)
|
||||
self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON
|
||||
self._async_update_attr_from_config()
|
||||
|
||||
@callback
|
||||
|
||||
@@ -20,6 +20,7 @@ DATA_DEVICES = "devices"
|
||||
DATA_STORE = "store"
|
||||
DATA_NOTIFY = "notify"
|
||||
DATA_PUSH_CHANNEL = "push_channel"
|
||||
DATA_PENDING_UPDATES = "pending_updates"
|
||||
|
||||
ATTR_APP_DATA = "app_data"
|
||||
ATTR_APP_ID = "app_id"
|
||||
@@ -94,3 +95,5 @@ SCHEMA_APP_DATA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR)
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE
|
||||
from homeassistant.const import (
|
||||
ATTR_ICON,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import State, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -18,10 +24,15 @@ from .const import (
|
||||
ATTR_SENSOR_ICON,
|
||||
ATTR_SENSOR_STATE,
|
||||
ATTR_SENSOR_STATE_CLASS,
|
||||
ATTR_SENSOR_TYPE,
|
||||
DATA_PENDING_UPDATES,
|
||||
DOMAIN,
|
||||
SIGNAL_SENSOR_UPDATE,
|
||||
)
|
||||
from .helpers import device_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MobileAppEntity(RestoreEntity):
|
||||
"""Representation of a mobile app entity."""
|
||||
@@ -56,11 +67,14 @@ class MobileAppEntity(RestoreEntity):
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}",
|
||||
f"{SIGNAL_SENSOR_UPDATE}-{self._config[ATTR_SENSOR_TYPE]}-{self._attr_unique_id}",
|
||||
self._handle_update,
|
||||
)
|
||||
)
|
||||
|
||||
# Apply any pending updates
|
||||
self._handle_update()
|
||||
|
||||
if (state := await self.async_get_last_state()) is None:
|
||||
return
|
||||
|
||||
@@ -69,13 +83,16 @@ class MobileAppEntity(RestoreEntity):
|
||||
async def async_restore_last_state(self, last_state: State) -> None:
|
||||
"""Restore previous state."""
|
||||
config = self._config
|
||||
config[ATTR_SENSOR_STATE] = last_state.state
|
||||
config[ATTR_SENSOR_ATTRIBUTES] = {
|
||||
**last_state.attributes,
|
||||
**self._config[ATTR_SENSOR_ATTRIBUTES],
|
||||
}
|
||||
if ATTR_ICON in last_state.attributes:
|
||||
config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON]
|
||||
|
||||
# Only restore state if we don't have one already, since it can be set by a pending update
|
||||
if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN):
|
||||
config[ATTR_SENSOR_STATE] = last_state.state
|
||||
config[ATTR_SENSOR_ATTRIBUTES] = {
|
||||
**last_state.attributes,
|
||||
**self._config[ATTR_SENSOR_ATTRIBUTES],
|
||||
}
|
||||
if ATTR_ICON in last_state.attributes:
|
||||
config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON]
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
@@ -83,8 +100,21 @@ class MobileAppEntity(RestoreEntity):
|
||||
return device_info(self._registration)
|
||||
|
||||
@callback
|
||||
def _handle_update(self, data: dict[str, Any]) -> None:
|
||||
def _handle_update(self) -> None:
|
||||
"""Handle async event updates."""
|
||||
self._config.update(data)
|
||||
self._apply_pending_update()
|
||||
self._async_update_attr_from_config()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _apply_pending_update(self) -> None:
|
||||
"""Restore any pending update for this entity."""
|
||||
entity_type = self._config[ATTR_SENSOR_TYPE]
|
||||
pending_updates = self.hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type]
|
||||
if update := pending_updates.pop(self._attr_unique_id, None):
|
||||
_LOGGER.debug(
|
||||
"Applying pending update for %s: %s",
|
||||
self._attr_unique_id,
|
||||
update,
|
||||
)
|
||||
# Apply the pending update
|
||||
self._config.update(update)
|
||||
|
||||
@@ -86,24 +86,26 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor):
|
||||
|
||||
async def async_restore_last_state(self, last_state: State) -> None:
|
||||
"""Restore previous state."""
|
||||
await super().async_restore_last_state(last_state)
|
||||
config = self._config
|
||||
if not (last_sensor_data := await self.async_get_last_sensor_data()):
|
||||
# Workaround to handle migration to RestoreSensor, can be removed
|
||||
# in HA Core 2023.4
|
||||
config[ATTR_SENSOR_STATE] = None
|
||||
webhook_id = self._entry.data[CONF_WEBHOOK_ID]
|
||||
if TYPE_CHECKING:
|
||||
assert self.unique_id is not None
|
||||
sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id)
|
||||
if (
|
||||
self.device_class == SensorDeviceClass.TEMPERATURE
|
||||
and sensor_unique_id == "battery_temperature"
|
||||
):
|
||||
config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS
|
||||
else:
|
||||
config[ATTR_SENSOR_STATE] = last_sensor_data.native_value
|
||||
config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement
|
||||
if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN):
|
||||
await super().async_restore_last_state(last_state)
|
||||
|
||||
if not (last_sensor_data := await self.async_get_last_sensor_data()):
|
||||
# Workaround to handle migration to RestoreSensor, can be removed
|
||||
# in HA Core 2023.4
|
||||
config[ATTR_SENSOR_STATE] = None
|
||||
webhook_id = self._entry.data[CONF_WEBHOOK_ID]
|
||||
if TYPE_CHECKING:
|
||||
assert self.unique_id is not None
|
||||
sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id)
|
||||
if (
|
||||
self.device_class == SensorDeviceClass.TEMPERATURE
|
||||
and sensor_unique_id == "battery_temperature"
|
||||
):
|
||||
config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS
|
||||
else:
|
||||
config[ATTR_SENSOR_STATE] = last_sensor_data.native_value
|
||||
config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement
|
||||
|
||||
self._async_update_attr_from_config()
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ from .const import (
|
||||
ATTR_SENSOR_STATE,
|
||||
ATTR_SENSOR_STATE_CLASS,
|
||||
ATTR_SENSOR_TYPE,
|
||||
ATTR_SENSOR_TYPE_BINARY_SENSOR,
|
||||
ATTR_SENSOR_TYPE_SENSOR,
|
||||
ATTR_SENSOR_UNIQUE_ID,
|
||||
ATTR_SENSOR_UOM,
|
||||
@@ -98,12 +97,14 @@ from .const import (
|
||||
DATA_CONFIG_ENTRIES,
|
||||
DATA_DELETED_IDS,
|
||||
DATA_DEVICES,
|
||||
DATA_PENDING_UPDATES,
|
||||
DOMAIN,
|
||||
ERR_ENCRYPTION_ALREADY_ENABLED,
|
||||
ERR_ENCRYPTION_REQUIRED,
|
||||
ERR_INVALID_FORMAT,
|
||||
ERR_SENSOR_NOT_REGISTERED,
|
||||
SCHEMA_APP_DATA,
|
||||
SENSOR_TYPES,
|
||||
SIGNAL_LOCATION_UPDATE,
|
||||
SIGNAL_SENSOR_UPDATE,
|
||||
)
|
||||
@@ -125,8 +126,6 @@ WEBHOOK_COMMANDS: Registry[
|
||||
str, Callable[[HomeAssistant, ConfigEntry, Any], Coroutine[Any, Any, Response]]
|
||||
] = Registry()
|
||||
|
||||
SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR)
|
||||
|
||||
WEBHOOK_PAYLOAD_SCHEMA = vol.Any(
|
||||
vol.Schema(
|
||||
{
|
||||
@@ -601,14 +600,16 @@ async def webhook_register_sensor(
|
||||
if changes:
|
||||
entity_registry.async_update_entity(existing_sensor, **changes)
|
||||
|
||||
async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data)
|
||||
_async_update_sensor_entity(
|
||||
hass, entity_type=entity_type, unique_store_key=unique_store_key, data=data
|
||||
)
|
||||
else:
|
||||
data[CONF_UNIQUE_ID] = unique_store_key
|
||||
data[CONF_NAME] = (
|
||||
f"{config_entry.data[ATTR_DEVICE_NAME]} {data[ATTR_SENSOR_NAME]}"
|
||||
)
|
||||
|
||||
register_signal = f"{DOMAIN}_{data[ATTR_SENSOR_TYPE]}_register"
|
||||
register_signal = f"{DOMAIN}_{entity_type}_register"
|
||||
async_dispatcher_send(hass, register_signal, data)
|
||||
|
||||
return webhook_response(
|
||||
@@ -685,10 +686,12 @@ async def webhook_update_sensor_states(
|
||||
continue
|
||||
|
||||
sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID]
|
||||
async_dispatcher_send(
|
||||
|
||||
_async_update_sensor_entity(
|
||||
hass,
|
||||
f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}",
|
||||
sensor,
|
||||
entity_type=entity_type,
|
||||
unique_store_key=unique_store_key,
|
||||
data=sensor,
|
||||
)
|
||||
|
||||
resp[unique_id] = {"success": True}
|
||||
@@ -697,11 +700,26 @@ async def webhook_update_sensor_states(
|
||||
entry = entity_registry.async_get(entity_id)
|
||||
|
||||
if entry and entry.disabled_by:
|
||||
# Inform the app that the entity is disabled
|
||||
resp[unique_id]["is_disabled"] = True
|
||||
|
||||
return webhook_response(resp, registration=config_entry.data)
|
||||
|
||||
|
||||
def _async_update_sensor_entity(
|
||||
hass: HomeAssistant, entity_type: str, unique_store_key: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update a sensor entity with new data."""
|
||||
# Replace existing pending update with the latest sensor data.
|
||||
hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type][unique_store_key] = data
|
||||
|
||||
# The signal might not be handled if the entity was just enabled, but the data is stored
|
||||
# in pending updates and will be applied on entity initialization.
|
||||
async_dispatcher_send(
|
||||
hass, f"{SIGNAL_SENSOR_UPDATE}-{entity_type}-{unique_store_key}"
|
||||
)
|
||||
|
||||
|
||||
@WEBHOOK_COMMANDS.register("get_zones")
|
||||
async def webhook_get_zones(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, data: Any
|
||||
|
||||
@@ -4237,7 +4237,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
return self.async_show_form(
|
||||
step_id="entity",
|
||||
data_schema=data_schema,
|
||||
description_placeholders={
|
||||
description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS
|
||||
| {
|
||||
"mqtt_device": device_name,
|
||||
"entity_name_label": entity_name_label,
|
||||
"platform_label": platform_label,
|
||||
|
||||
@@ -1312,6 +1312,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -27,7 +27,8 @@ from homeassistant.helpers.issue_registry import (
|
||||
)
|
||||
|
||||
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
|
||||
from .services import get_music_assistant_client, register_actions
|
||||
from .helpers import get_music_assistant_client
|
||||
from .services import register_actions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_assistant_models.event import MassEvent
|
||||
|
||||
@@ -4,11 +4,18 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import functools
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from music_assistant_models.errors import MusicAssistantError
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
|
||||
|
||||
def catch_musicassistant_error[**_P, _R](
|
||||
@@ -26,3 +33,16 @@ def catch_musicassistant_error[**_P, _R](
|
||||
raise HomeAssistantError(error_msg) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@callback
|
||||
def get_music_assistant_client(
|
||||
hass: HomeAssistant, config_entry_id: str
|
||||
) -> MusicAssistantClient:
|
||||
"""Get the Music Assistant client for the given config entry."""
|
||||
entry: MusicAssistantConfigEntry | None
|
||||
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
return entry.runtime_data.mass
|
||||
|
||||
@@ -22,11 +22,9 @@ from music_assistant_models.errors import MediaNotFoundError
|
||||
from music_assistant_models.event import MassEvent
|
||||
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
|
||||
from music_assistant_models.player_queue import PlayerQueue
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_ENQUEUE,
|
||||
ATTR_MEDIA_EXTRA,
|
||||
BrowseMedia,
|
||||
MediaPlayerDeviceClass,
|
||||
@@ -41,38 +39,26 @@ from homeassistant.components.media_player import (
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.const import ATTR_NAME, STATE_OFF, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utc_from_timestamp
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
from .const import (
|
||||
ATTR_ACTIVE,
|
||||
ATTR_ACTIVE_QUEUE,
|
||||
ATTR_ALBUM,
|
||||
ATTR_ANNOUNCE_VOLUME,
|
||||
ATTR_ARTIST,
|
||||
ATTR_AUTO_PLAY,
|
||||
ATTR_CURRENT_INDEX,
|
||||
ATTR_CURRENT_ITEM,
|
||||
ATTR_ELAPSED_TIME,
|
||||
ATTR_ITEMS,
|
||||
ATTR_MASS_PLAYER_TYPE,
|
||||
ATTR_MEDIA_ID,
|
||||
ATTR_MEDIA_TYPE,
|
||||
ATTR_NEXT_ITEM,
|
||||
ATTR_QUEUE_ID,
|
||||
ATTR_RADIO_MODE,
|
||||
ATTR_REPEAT_MODE,
|
||||
ATTR_SHUFFLE_ENABLED,
|
||||
ATTR_SOURCE_PLAYER,
|
||||
ATTR_URL,
|
||||
ATTR_USE_PRE_ANNOUNCE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .entity import MusicAssistantEntity
|
||||
@@ -122,11 +108,6 @@ REPEAT_MODE_MAPPING_TO_HA = {
|
||||
# UNKNOWN is intentionally not mapped - will return None
|
||||
}
|
||||
|
||||
SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
|
||||
SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
|
||||
SERVICE_TRANSFER_QUEUE = "transfer_queue"
|
||||
SERVICE_GET_QUEUE = "get_queue"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -143,44 +124,6 @@ async def async_setup_entry(
|
||||
# register callback to add players when they are discovered
|
||||
entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player)
|
||||
|
||||
# add platform service for play_media with advanced options
|
||||
platform = async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_PLAY_MEDIA_ADVANCED,
|
||||
{
|
||||
vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
|
||||
vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
|
||||
vol.Optional(ATTR_ARTIST): cv.string,
|
||||
vol.Optional(ATTR_ALBUM): cv.string,
|
||||
vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
|
||||
},
|
||||
"_async_handle_play_media",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_PLAY_ANNOUNCEMENT,
|
||||
{
|
||||
vol.Required(ATTR_URL): cv.string,
|
||||
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
|
||||
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
|
||||
},
|
||||
"_async_handle_play_announcement",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_TRANSFER_QUEUE,
|
||||
{
|
||||
vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
|
||||
vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
|
||||
},
|
||||
"_async_handle_transfer_queue",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_GET_QUEUE,
|
||||
schema=None,
|
||||
func="_async_handle_get_queue",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
"""Representation of MediaPlayerEntity from Music Assistant Player."""
|
||||
|
||||
@@ -4,10 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from music_assistant_models.enums import MediaType
|
||||
from music_assistant_models.enums import MediaType, QueueOption
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_ENQUEUE,
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
)
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -17,31 +20,41 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import (
|
||||
ATTR_ALBUM,
|
||||
ATTR_ALBUM_ARTISTS_ONLY,
|
||||
ATTR_ALBUM_TYPE,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ANNOUNCE_VOLUME,
|
||||
ATTR_ARTIST,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_AUTO_PLAY,
|
||||
ATTR_FAVORITE,
|
||||
ATTR_ITEMS,
|
||||
ATTR_LIBRARY_ONLY,
|
||||
ATTR_LIMIT,
|
||||
ATTR_MEDIA_ID,
|
||||
ATTR_MEDIA_TYPE,
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_RADIO,
|
||||
ATTR_RADIO_MODE,
|
||||
ATTR_SEARCH,
|
||||
ATTR_SEARCH_ALBUM,
|
||||
ATTR_SEARCH_ARTIST,
|
||||
ATTR_SEARCH_NAME,
|
||||
ATTR_SOURCE_PLAYER,
|
||||
ATTR_TRACKS,
|
||||
ATTR_URL,
|
||||
ATTR_USE_PRE_ANNOUNCE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import get_music_assistant_client
|
||||
from .schemas import (
|
||||
LIBRARY_RESULTS_SCHEMA,
|
||||
SEARCH_RESULT_SCHEMA,
|
||||
@@ -49,7 +62,6 @@ from .schemas import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_models.media_items import (
|
||||
Album,
|
||||
Artist,
|
||||
@@ -60,28 +72,18 @@ if TYPE_CHECKING:
|
||||
Track,
|
||||
)
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
|
||||
SERVICE_SEARCH = "search"
|
||||
SERVICE_GET_LIBRARY = "get_library"
|
||||
SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
|
||||
SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
|
||||
SERVICE_TRANSFER_QUEUE = "transfer_queue"
|
||||
SERVICE_GET_QUEUE = "get_queue"
|
||||
|
||||
DEFAULT_OFFSET = 0
|
||||
DEFAULT_LIMIT = 25
|
||||
DEFAULT_SORT_ORDER = "name"
|
||||
|
||||
|
||||
@callback
|
||||
def get_music_assistant_client(
|
||||
hass: HomeAssistant, config_entry_id: str
|
||||
) -> MusicAssistantClient:
|
||||
"""Get the Music Assistant client for the given config entry."""
|
||||
entry: MusicAssistantConfigEntry | None
|
||||
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
return entry.runtime_data.mass
|
||||
|
||||
|
||||
@callback
|
||||
def register_actions(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
@@ -124,6 +126,55 @@ def register_actions(hass: HomeAssistant) -> None:
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Platform entity services
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_MEDIA_ADVANCED,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
|
||||
vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
|
||||
vol.Optional(ATTR_ARTIST): cv.string,
|
||||
vol.Optional(ATTR_ALBUM): cv.string,
|
||||
vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
|
||||
},
|
||||
func="_async_handle_play_media",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_ANNOUNCEMENT,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_URL): cv.string,
|
||||
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
|
||||
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
|
||||
},
|
||||
func="_async_handle_play_announcement",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_TRANSFER_QUEUE,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
|
||||
vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
|
||||
},
|
||||
func="_async_handle_transfer_queue",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_GET_QUEUE,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="_async_handle_get_queue",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
async def handle_search(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle queue_command action."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool:
|
||||
|
||||
120
homeassistant/components/nederlandse_spoorwegen/binary_sensor.py
Normal file
120
homeassistant/components/nederlandse_spoorwegen/binary_sensor.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Support for Nederlandse Spoorwegen public transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from ns_api import Trip
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, ROUTE_MODEL
|
||||
from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0 # since we use coordinator pattern
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NSBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Nederlandse Spoorwegen sensor entity."""
|
||||
|
||||
value_fn: Callable[[Trip], bool]
|
||||
|
||||
|
||||
def get_delay(planned: datetime | None, actual: datetime | None) -> bool:
|
||||
"""Return True if delay is present, False otherwise."""
|
||||
return bool(planned and actual and planned != actual)
|
||||
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS = [
|
||||
NSBinarySensorEntityDescription(
|
||||
key="is_departure_delayed",
|
||||
translation_key="is_departure_delayed",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda trip: get_delay(
|
||||
trip.departure_time_planned, trip.departure_time_actual
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSBinarySensorEntityDescription(
|
||||
key="is_arrival_delayed",
|
||||
translation_key="is_arrival_delayed",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda trip: get_delay(
|
||||
trip.arrival_time_planned, trip.arrival_time_actual
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSBinarySensorEntityDescription(
|
||||
key="is_going",
|
||||
translation_key="is_going",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda trip: trip.going,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: NSConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the departure sensor from a config entry."""
|
||||
|
||||
coordinators = config_entry.runtime_data
|
||||
|
||||
for subentry_id, coordinator in coordinators.items():
|
||||
async_add_entities(
|
||||
(
|
||||
NSBinarySensor(coordinator, subentry_id, description)
|
||||
for description in BINARY_SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class NSBinarySensor(CoordinatorEntity[NSDataUpdateCoordinator], BinarySensorEntity):
|
||||
"""Generic NS binary sensor based on entity description."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = "Data provided by NS"
|
||||
entity_description: NSBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NSDataUpdateCoordinator,
|
||||
subentry_id: str,
|
||||
description: NSBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._subentry_id = subentry_id
|
||||
self._attr_unique_id = f"{subentry_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry_id)},
|
||||
name=coordinator.name,
|
||||
manufacturer=INTEGRATION_TITLE,
|
||||
model=ROUTE_MODEL,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
if not (trip := self.coordinator.data.first_trip):
|
||||
return None
|
||||
return self.entity_description.value_fn(trip)
|
||||
15
homeassistant/components/nederlandse_spoorwegen/icons.json
Normal file
15
homeassistant/components/nederlandse_spoorwegen/icons.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_arrival_delayed": {
|
||||
"default": "mdi:bell-alert-outline"
|
||||
},
|
||||
"is_departure_delayed": {
|
||||
"default": "mdi:bell-alert-outline"
|
||||
},
|
||||
"is_going": {
|
||||
"default": "mdi:bell-cancel-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ns_api import Trip
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -38,6 +39,33 @@ from .const import (
|
||||
)
|
||||
from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
|
||||
|
||||
|
||||
def _get_departure_time(trip: Trip | None) -> datetime | None:
|
||||
"""Get next departure time from trip data."""
|
||||
return trip.departure_time_actual or trip.departure_time_planned if trip else None
|
||||
|
||||
|
||||
def _get_time_str(time: datetime | None) -> str | None:
|
||||
"""Get time as string."""
|
||||
return time.strftime("%H:%M") if time else None
|
||||
|
||||
|
||||
def _get_route(trip: Trip | None) -> list[str]:
|
||||
"""Get the route as a list of station names from trip data."""
|
||||
if not trip or not (trip_parts := trip.trip_parts):
|
||||
return []
|
||||
route = []
|
||||
if departure := trip.departure:
|
||||
route.append(departure)
|
||||
route.extend(part.destination for part in trip_parts)
|
||||
return route
|
||||
|
||||
|
||||
def _get_delay(planned: datetime | None, actual: datetime | None) -> bool:
|
||||
"""Return True if delay is present, False otherwise."""
|
||||
return bool(planned and actual and planned != actual)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ROUTE_SCHEMA = vol.Schema(
|
||||
@@ -127,7 +155,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
"""Implementation of a NS Departure Sensor."""
|
||||
"""Implementation of a NS Departure Sensor (legacy)."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_attribution = "Data provided by NS"
|
||||
@@ -163,94 +191,40 @@ class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity
|
||||
return None
|
||||
|
||||
first_trip = route_data.first_trip
|
||||
if first_trip.departure_time_actual:
|
||||
return first_trip.departure_time_actual
|
||||
return first_trip.departure_time_planned
|
||||
return _get_departure_time(first_trip)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
route_data = self.coordinator.data
|
||||
if not route_data:
|
||||
return None
|
||||
|
||||
first_trip = route_data.first_trip
|
||||
next_trip = route_data.next_trip
|
||||
first_trip = self.coordinator.data.first_trip
|
||||
next_trip = self.coordinator.data.next_trip
|
||||
|
||||
if not first_trip:
|
||||
return None
|
||||
|
||||
route = []
|
||||
if first_trip.trip_parts:
|
||||
route = [first_trip.departure]
|
||||
route.extend(k.destination for k in first_trip.trip_parts)
|
||||
status = first_trip.status
|
||||
|
||||
# Static attributes
|
||||
attributes = {
|
||||
return {
|
||||
"going": first_trip.going,
|
||||
"departure_time_planned": None,
|
||||
"departure_time_actual": None,
|
||||
"departure_delay": False,
|
||||
"departure_time_planned": _get_time_str(first_trip.departure_time_planned),
|
||||
"departure_time_actual": _get_time_str(first_trip.departure_time_actual),
|
||||
"departure_delay": _get_delay(
|
||||
first_trip.departure_time_planned,
|
||||
first_trip.departure_time_actual,
|
||||
),
|
||||
"departure_platform_planned": first_trip.departure_platform_planned,
|
||||
"departure_platform_actual": first_trip.departure_platform_actual,
|
||||
"arrival_time_planned": None,
|
||||
"arrival_time_actual": None,
|
||||
"arrival_delay": False,
|
||||
"arrival_time_planned": _get_time_str(first_trip.arrival_time_planned),
|
||||
"arrival_time_actual": _get_time_str(first_trip.arrival_time_actual),
|
||||
"arrival_delay": _get_delay(
|
||||
first_trip.arrival_time_planned,
|
||||
first_trip.arrival_time_actual,
|
||||
),
|
||||
"arrival_platform_planned": first_trip.arrival_platform_planned,
|
||||
"arrival_platform_actual": first_trip.arrival_platform_actual,
|
||||
"next": None,
|
||||
"status": first_trip.status.lower() if first_trip.status else None,
|
||||
"next": _get_time_str(_get_departure_time(next_trip)),
|
||||
"status": status.lower() if status else None,
|
||||
"transfers": first_trip.nr_transfers,
|
||||
"route": route,
|
||||
"route": _get_route(first_trip),
|
||||
"remarks": None,
|
||||
}
|
||||
|
||||
# Planned departure attributes
|
||||
if first_trip.departure_time_planned is not None:
|
||||
attributes["departure_time_planned"] = (
|
||||
first_trip.departure_time_planned.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Actual departure attributes
|
||||
if first_trip.departure_time_actual is not None:
|
||||
attributes["departure_time_actual"] = (
|
||||
first_trip.departure_time_actual.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Delay departure attributes
|
||||
if (
|
||||
attributes["departure_time_planned"]
|
||||
and attributes["departure_time_actual"]
|
||||
and attributes["departure_time_planned"]
|
||||
!= attributes["departure_time_actual"]
|
||||
):
|
||||
attributes["departure_delay"] = True
|
||||
|
||||
# Planned arrival attributes
|
||||
if first_trip.arrival_time_planned is not None:
|
||||
attributes["arrival_time_planned"] = (
|
||||
first_trip.arrival_time_planned.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Actual arrival attributes
|
||||
if first_trip.arrival_time_actual is not None:
|
||||
attributes["arrival_time_actual"] = first_trip.arrival_time_actual.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
|
||||
# Delay arrival attributes
|
||||
if (
|
||||
attributes["arrival_time_planned"]
|
||||
and attributes["arrival_time_actual"]
|
||||
and attributes["arrival_time_planned"] != attributes["arrival_time_actual"]
|
||||
):
|
||||
attributes["arrival_delay"] = True
|
||||
|
||||
# Next trip attributes
|
||||
if next_trip:
|
||||
if next_trip.departure_time_actual is not None:
|
||||
attributes["next"] = next_trip.departure_time_actual.strftime("%H:%M")
|
||||
elif next_trip.departure_time_planned is not None:
|
||||
attributes["next"] = next_trip.departure_time_planned.strftime("%H:%M")
|
||||
|
||||
return attributes
|
||||
|
||||
@@ -64,6 +64,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_arrival_delayed": {
|
||||
"name": "Arrival delayed"
|
||||
},
|
||||
"is_departure_delayed": {
|
||||
"name": "Departure delayed"
|
||||
},
|
||||
"is_going": {
|
||||
"name": "Going"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the NS API. Please check your internet connection and the status of the NS API, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.",
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==7.1.4"]
|
||||
"requirements": ["google-nest-sdm==9.0.1"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
rules:
|
||||
# Bronze
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Some fields are missing a data_description
|
||||
config-flow: done
|
||||
brands: done
|
||||
dependency-transparency: done
|
||||
common-modules:
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"data": {
|
||||
"cloud_project_id": "Google Cloud Project ID"
|
||||
},
|
||||
"data_description": {
|
||||
"cloud_project_id": "The Google Cloud Project ID which can be obtained from the Cloud Console"
|
||||
},
|
||||
"description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).",
|
||||
"title": "Nest: Enter Cloud Project ID"
|
||||
},
|
||||
@@ -45,6 +48,9 @@
|
||||
"data": {
|
||||
"project_id": "Device Access Project ID"
|
||||
},
|
||||
"data_description": {
|
||||
"project_id": "The Device Access Project ID which can be obtained from the Device Access Console"
|
||||
},
|
||||
"description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).",
|
||||
"title": "Nest: Create a Device Access Project"
|
||||
},
|
||||
@@ -64,6 +70,9 @@
|
||||
"data": {
|
||||
"subscription_name": "Pub/Sub subscription name"
|
||||
},
|
||||
"data_description": {
|
||||
"subscription_name": "The Pub/Sub subscription name to receive Nest device updates"
|
||||
},
|
||||
"description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).",
|
||||
"title": "Configure Cloud Pub/Sub subscription"
|
||||
},
|
||||
@@ -71,6 +80,9 @@
|
||||
"data": {
|
||||
"topic_name": "Pub/Sub topic name"
|
||||
},
|
||||
"data_description": {
|
||||
"topic_name": "The Pub/Sub topic name configured in the Device Access Console"
|
||||
},
|
||||
"description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).",
|
||||
"title": "Configure Cloud Pub/Sub topic"
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ SELECT_TYPES = (
|
||||
PlugwiseSelectEntityDescription(
|
||||
key=SELECT_SCHEDULE,
|
||||
translation_key=SELECT_SCHEDULE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options_key="available_schedules",
|
||||
),
|
||||
PlugwiseSelectEntityDescription(
|
||||
|
||||
@@ -48,7 +48,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="setpoint_high",
|
||||
@@ -56,7 +55,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="setpoint_low",
|
||||
@@ -64,13 +62,11 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
@@ -94,6 +90,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
translation_key="outdoor_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
@@ -352,8 +349,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
key="illuminance",
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="modulation_level",
|
||||
@@ -365,8 +362,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="valve_position",
|
||||
translation_key="valve_position",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
|
||||
@@ -2,5 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
|
||||
|
||||
DOMAIN = "pooldose"
|
||||
MANUFACTURER = "SEKO"
|
||||
|
||||
# Mapping of device units to Home Assistant units
|
||||
UNIT_MAPPING: dict[str, str] = {
|
||||
# Temperature units
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
# Volume flow rate units
|
||||
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"default": "mdi:pool"
|
||||
},
|
||||
"cl_type_dosing": {
|
||||
"default": "mdi:flask"
|
||||
},
|
||||
"flow_rate": {
|
||||
"default": "mdi:pipe-valve"
|
||||
},
|
||||
"ofa_orp_time": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
@@ -22,6 +31,9 @@
|
||||
"orp_type_dosing": {
|
||||
"default": "mdi:flask"
|
||||
},
|
||||
"peristaltic_cl_dosing": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
"peristaltic_orp_dosing": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -10,36 +11,61 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PooldoseConfigEntry
|
||||
from .const import UNIT_MAPPING
|
||||
from .entity import PooldoseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PooldoseSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes PoolDose sensor entity."""
|
||||
|
||||
use_dynamic_unit: bool = False
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
|
||||
PooldoseSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
# Unit dynamically determined via API
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp",
|
||||
translation_key="orp",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="cl",
|
||||
translation_key="cl",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="flow_rate",
|
||||
translation_key="flow_rate",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_type_dosing",
|
||||
translation_key="ph_type_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["alcalyne", "acid"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="peristaltic_ph_dosing",
|
||||
translation_key="peristaltic_ph_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -47,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["proportional", "on_off", "timed"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ofa_ph_time",
|
||||
translation_key="ofa_ph_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -55,7 +81,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_type_dosing",
|
||||
translation_key="orp_type_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -63,7 +89,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["low", "high"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="peristaltic_orp_dosing",
|
||||
translation_key="peristaltic_orp_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -71,7 +97,23 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "proportional", "on_off", "timed"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="cl_type_dosing",
|
||||
translation_key="cl_type_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["low", "high"],
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="peristaltic_cl_dosing",
|
||||
translation_key="peristaltic_cl_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "proportional", "on_off", "timed"],
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ofa_orp_time",
|
||||
translation_key="ofa_orp_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
@@ -79,7 +121,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_calibration_type",
|
||||
translation_key="ph_calibration_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -87,7 +129,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "reference", "1_point", "2_points"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_calibration_offset",
|
||||
translation_key="ph_calibration_offset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -96,7 +138,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_calibration_slope",
|
||||
translation_key="ph_calibration_slope",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -105,7 +147,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_calibration_type",
|
||||
translation_key="orp_calibration_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -113,7 +155,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "reference", "1_point"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_calibration_offset",
|
||||
translation_key="orp_calibration_offset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -122,7 +164,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_calibration_slope",
|
||||
translation_key="orp_calibration_slope",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -163,6 +205,8 @@ async def async_setup_entry(
|
||||
class PooldoseSensor(PooldoseEntity, SensorEntity):
|
||||
"""Sensor entity for the Seko PoolDose Python API."""
|
||||
|
||||
entity_description: PooldoseSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | int | str | None:
|
||||
"""Return the current value of the sensor."""
|
||||
@@ -175,9 +219,12 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement."""
|
||||
if (
|
||||
self.entity_description.key == "temperature"
|
||||
self.entity_description.use_dynamic_unit
|
||||
and (data := self.get_data()) is not None
|
||||
and (device_unit := data.get("unit"))
|
||||
):
|
||||
return data["unit"] # °C or °F
|
||||
# Map device unit to Home Assistant unit, return None if unknown
|
||||
return UNIT_MAPPING.get(device_unit)
|
||||
|
||||
# Fall back to static unit from entity description
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"name": "Chlorine"
|
||||
},
|
||||
"cl_type_dosing": {
|
||||
"name": "Chlorine dosing type",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]"
|
||||
}
|
||||
},
|
||||
"flow_rate": {
|
||||
"name": "Flow rate"
|
||||
},
|
||||
"ofa_orp_time": {
|
||||
"name": "ORP overfeed alert time"
|
||||
},
|
||||
@@ -64,6 +77,15 @@
|
||||
"low": "[%key:common::state::low%]"
|
||||
}
|
||||
},
|
||||
"peristaltic_cl_dosing": {
|
||||
"name": "Chlorine peristaltic dosing",
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]",
|
||||
"proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]",
|
||||
"timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]"
|
||||
}
|
||||
},
|
||||
"peristaltic_orp_dosing": {
|
||||
"name": "ORP peristaltic dosing",
|
||||
"state": {
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -222,17 +222,13 @@ class ReolinkHost:
|
||||
enable_onvif = None
|
||||
enable_rtmp = None
|
||||
|
||||
if not self._api.rtsp_enabled and not self._api.baichuan_only:
|
||||
if not self._api.rtsp_enabled and self._api.supported(None, "RTSP"):
|
||||
_LOGGER.debug(
|
||||
"RTSP is disabled on %s, trying to enable it", self._api.nvr_name
|
||||
)
|
||||
enable_rtsp = True
|
||||
|
||||
if (
|
||||
not self._api.onvif_enabled
|
||||
and onvif_supported
|
||||
and not self._api.baichuan_only
|
||||
):
|
||||
if not self._api.onvif_enabled and onvif_supported:
|
||||
_LOGGER.debug(
|
||||
"ONVIF is disabled on %s, trying to enable it", self._api.nvr_name
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
import aiohttp
|
||||
from aiohttp import hdrs
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_AUTHENTICATION,
|
||||
@@ -51,6 +52,7 @@ SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"]
|
||||
|
||||
CONF_CONTENT_TYPE = "content_type"
|
||||
CONF_INSECURE_CIPHER = "insecure_cipher"
|
||||
CONF_SKIP_URL_ENCODING = "skip_url_encoding"
|
||||
|
||||
COMMAND_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -69,6 +71,7 @@ COMMAND_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_CONTENT_TYPE): cv.string,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
|
||||
vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean,
|
||||
vol.Optional(CONF_SKIP_URL_ENCODING, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -113,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
method = command_config[CONF_METHOD]
|
||||
|
||||
template_url = command_config[CONF_URL]
|
||||
skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING]
|
||||
|
||||
auth = None
|
||||
digest_middleware = None
|
||||
@@ -179,7 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
request_kwargs["middlewares"] = (digest_middleware,)
|
||||
|
||||
async with getattr(websession, method)(
|
||||
request_url,
|
||||
URL(request_url, encoded=skip_url_encoding),
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
if response.status < HTTPStatus.BAD_REQUEST:
|
||||
|
||||
@@ -441,7 +441,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def is_matching(self, other_flow: Self) -> bool:
|
||||
"""Return True if other_flow is matching this flow."""
|
||||
return other_flow._host == self._host # noqa: SLF001
|
||||
return getattr(other_flow, "_host", None) == self._host
|
||||
|
||||
@callback
|
||||
def _abort_if_manufacturer_is_not_samsung(self) -> None:
|
||||
|
||||
@@ -7,23 +7,23 @@ rules:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
brands: done
|
||||
common-modules: todo
|
||||
config-flow-test-coverage: todo
|
||||
config-flow: todo
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: todo
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide any service actions.
|
||||
docs-high-level-description: todo
|
||||
docs-installation-instructions: todo
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup: todo
|
||||
entity-unique-id: todo
|
||||
has-entity-name: todo
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: todo
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
|
||||
49
homeassistant/components/saunum/diagnostics.py
Normal file
49
homeassistant/components/saunum/diagnostics.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Diagnostics support for Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import LeilSaunaConfigEntry
|
||||
|
||||
REDACT_CONFIG = {CONF_HOST}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: LeilSaunaConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Build diagnostics data
|
||||
diagnostics_data: dict[str, Any] = {
|
||||
"config": async_redact_data(entry.data, REDACT_CONFIG),
|
||||
"client_info": {"connected": coordinator.client.is_connected},
|
||||
"coordinator_info": {
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"update_interval": str(coordinator.update_interval),
|
||||
"last_exception": str(coordinator.last_exception)
|
||||
if coordinator.last_exception
|
||||
else None,
|
||||
},
|
||||
}
|
||||
|
||||
# Add coordinator data if available
|
||||
if coordinator.data:
|
||||
data_dict = asdict(coordinator.data)
|
||||
diagnostics_data["coordinator_data"] = data_dict
|
||||
|
||||
# Add alarm summary
|
||||
alarm_fields = [
|
||||
key
|
||||
for key, value in data_dict.items()
|
||||
if key.startswith("alarm_") and value is True
|
||||
]
|
||||
diagnostics_data["active_alarms"] = alarm_fields
|
||||
|
||||
return diagnostics_data
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold tier
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network.
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, cast
|
||||
|
||||
from aioshelly.block_device import Block
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -16,10 +17,11 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import STATE_ON, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD, MODEL_FRANKEVER_WATER_VALVE, ROLE_GENERIC
|
||||
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
RestEntityDescription,
|
||||
@@ -37,6 +39,10 @@ from .utils import (
|
||||
async_remove_orphaned_entities,
|
||||
get_blu_trv_device_info,
|
||||
get_device_entry_gen,
|
||||
get_entity_translation_attributes,
|
||||
get_rpc_channel_name,
|
||||
get_rpc_custom_name,
|
||||
get_rpc_key,
|
||||
is_block_momentary_input,
|
||||
is_rpc_momentary_input,
|
||||
is_view_for_platform,
|
||||
@@ -67,6 +73,44 @@ class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: RpcBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
key: str,
|
||||
attribute: str,
|
||||
description: RpcBinarySensorDescription,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, key, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
if not description.role and description.key == "input":
|
||||
_, component, component_id = get_rpc_key(key)
|
||||
if not get_rpc_custom_name(coordinator.device, key) and (
|
||||
component.lower() == "input" and component_id.isnumeric()
|
||||
):
|
||||
self._attr_translation_placeholders = {"input_number": component_id}
|
||||
self._attr_translation_key = "input_with_number"
|
||||
else:
|
||||
return
|
||||
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
if not description.role and description.key != "input":
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
get_rpc_channel_name(coordinator.device, key),
|
||||
description.translation_key,
|
||||
description.device_class,
|
||||
self._default_to_device_class_name(),
|
||||
)
|
||||
)
|
||||
|
||||
if translation_placeholders:
|
||||
self._attr_translation_placeholders = translation_placeholders
|
||||
if translation_key:
|
||||
self._attr_translation_key = translation_key
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if RPC sensor state is on."""
|
||||
@@ -107,85 +151,84 @@ class RpcBluTrvBinarySensor(RpcBinarySensor):
|
||||
SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
|
||||
("device", "overtemp"): BlockBinarySensorDescription(
|
||||
key="device|overtemp",
|
||||
name="Overheating",
|
||||
translation_key="overheating",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
("device", "overpower"): BlockBinarySensorDescription(
|
||||
key="device|overpower",
|
||||
name="Overpowering",
|
||||
translation_key="overpowering",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
("light", "overpower"): BlockBinarySensorDescription(
|
||||
key="light|overpower",
|
||||
name="Overpowering",
|
||||
translation_key="overpowering",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
("relay", "overpower"): BlockBinarySensorDescription(
|
||||
key="relay|overpower",
|
||||
name="Overpowering",
|
||||
translation_key="overpowering",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
("sensor", "dwIsOpened"): BlockBinarySensorDescription(
|
||||
key="sensor|dwIsOpened",
|
||||
name="Door",
|
||||
translation_key="door",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
available=lambda block: cast(int, block.dwIsOpened) != -1,
|
||||
),
|
||||
("sensor", "flood"): BlockBinarySensorDescription(
|
||||
key="sensor|flood", name="Flood", device_class=BinarySensorDeviceClass.MOISTURE
|
||||
key="sensor|flood",
|
||||
translation_key="flood",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
("sensor", "gas"): BlockBinarySensorDescription(
|
||||
key="sensor|gas",
|
||||
name="Gas",
|
||||
device_class=BinarySensorDeviceClass.GAS,
|
||||
translation_key="gas",
|
||||
value=lambda value: value in ["mild", "heavy"],
|
||||
),
|
||||
("sensor", "smoke"): BlockBinarySensorDescription(
|
||||
key="sensor|smoke", name="Smoke", device_class=BinarySensorDeviceClass.SMOKE
|
||||
key="sensor|smoke", device_class=BinarySensorDeviceClass.SMOKE
|
||||
),
|
||||
("sensor", "vibration"): BlockBinarySensorDescription(
|
||||
key="sensor|vibration",
|
||||
name="Vibration",
|
||||
device_class=BinarySensorDeviceClass.VIBRATION,
|
||||
),
|
||||
("input", "input"): BlockBinarySensorDescription(
|
||||
key="input|input",
|
||||
name="Input",
|
||||
translation_key="input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
removal_condition=is_block_momentary_input,
|
||||
),
|
||||
("relay", "input"): BlockBinarySensorDescription(
|
||||
key="relay|input",
|
||||
name="Input",
|
||||
translation_key="input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
removal_condition=is_block_momentary_input,
|
||||
),
|
||||
("device", "input"): BlockBinarySensorDescription(
|
||||
key="device|input",
|
||||
name="Input",
|
||||
translation_key="input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
removal_condition=is_block_momentary_input,
|
||||
),
|
||||
("sensor", "extInput"): BlockBinarySensorDescription(
|
||||
key="sensor|extInput",
|
||||
name="External input",
|
||||
translation_key="external_input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
("sensor", "motion"): BlockBinarySensorDescription(
|
||||
key="sensor|motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION
|
||||
key="sensor|motion", device_class=BinarySensorDeviceClass.MOTION
|
||||
),
|
||||
}
|
||||
|
||||
REST_SENSORS: Final = {
|
||||
"cloud": RestBinarySensorDescription(
|
||||
key="cloud",
|
||||
name="Cloud",
|
||||
translation_key="cloud",
|
||||
value=lambda status, _: status["cloud"]["connected"],
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -197,13 +240,14 @@ RPC_SENSORS: Final = {
|
||||
"input": RpcBinarySensorDescription(
|
||||
key="input",
|
||||
sub_key="state",
|
||||
translation_key="input",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
removal_condition=is_rpc_momentary_input,
|
||||
),
|
||||
"cloud": RpcBinarySensorDescription(
|
||||
key="cloud",
|
||||
sub_key="connected",
|
||||
name="Cloud",
|
||||
translation_key="cloud",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -211,7 +255,7 @@ RPC_SENSORS: Final = {
|
||||
"external_power": RpcBinarySensorDescription(
|
||||
key="devicepower",
|
||||
sub_key="external",
|
||||
name="External power",
|
||||
translation_key="external_power",
|
||||
value=lambda status, _: status["present"],
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -219,7 +263,7 @@ RPC_SENSORS: Final = {
|
||||
"overtemp": RpcBinarySensorDescription(
|
||||
key="switch",
|
||||
sub_key="errors",
|
||||
name="Overheating",
|
||||
translation_key="overheating",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value=lambda status, _: False if status is None else "overtemp" in status,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -228,7 +272,7 @@ RPC_SENSORS: Final = {
|
||||
"overpower": RpcBinarySensorDescription(
|
||||
key="switch",
|
||||
sub_key="errors",
|
||||
name="Overpowering",
|
||||
translation_key="overpowering",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value=lambda status, _: False if status is None else "overpower" in status,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -237,7 +281,7 @@ RPC_SENSORS: Final = {
|
||||
"overvoltage": RpcBinarySensorDescription(
|
||||
key="switch",
|
||||
sub_key="errors",
|
||||
name="Overvoltage",
|
||||
translation_key="overvoltage",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value=lambda status, _: False if status is None else "overvoltage" in status,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -246,7 +290,7 @@ RPC_SENSORS: Final = {
|
||||
"overcurrent": RpcBinarySensorDescription(
|
||||
key="switch",
|
||||
sub_key="errors",
|
||||
name="Overcurrent",
|
||||
translation_key="overcurrent",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value=lambda status, _: False if status is None else "overcurrent" in status,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -255,13 +299,12 @@ RPC_SENSORS: Final = {
|
||||
"smoke": RpcBinarySensorDescription(
|
||||
key="smoke",
|
||||
sub_key="alarm",
|
||||
name="Smoke",
|
||||
device_class=BinarySensorDeviceClass.SMOKE,
|
||||
),
|
||||
"restart": RpcBinarySensorDescription(
|
||||
key="sys",
|
||||
sub_key="restart_required",
|
||||
name="Restart required",
|
||||
translation_key="restart_required",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -269,7 +312,7 @@ RPC_SENSORS: Final = {
|
||||
"boolean_generic": RpcBinarySensorDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
||||
removal_condition=lambda config, _, key: not is_view_for_platform(
|
||||
config, key, BINARY_SENSOR_PLATFORM
|
||||
),
|
||||
role=ROLE_GENERIC,
|
||||
@@ -285,7 +328,7 @@ RPC_SENSORS: Final = {
|
||||
"calibration": RpcBinarySensorDescription(
|
||||
key="blutrv",
|
||||
sub_key="errors",
|
||||
name="Calibration",
|
||||
translation_key="calibration",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value=lambda status, _: False if status is None else "not_calibrated" in status,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -294,13 +337,13 @@ RPC_SENSORS: Final = {
|
||||
"flood": RpcBinarySensorDescription(
|
||||
key="flood",
|
||||
sub_key="alarm",
|
||||
name="Flood",
|
||||
translation_key="flood",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
"mute": RpcBinarySensorDescription(
|
||||
key="flood",
|
||||
sub_key="mute",
|
||||
name="Mute",
|
||||
translation_key="mute",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"flood_cable_unplugged": RpcBinarySensorDescription(
|
||||
@@ -309,7 +352,7 @@ RPC_SENSORS: Final = {
|
||||
value=lambda status, _: False
|
||||
if status is None
|
||||
else "cable_unplugged" in status,
|
||||
name="Cable unplugged",
|
||||
translation_key="cable_unplugged",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
supported=lambda status: status.get("alarm") is not None,
|
||||
@@ -318,14 +361,12 @@ RPC_SENSORS: Final = {
|
||||
key="presence",
|
||||
sub_key="num_objects",
|
||||
value=lambda status, _: bool(status),
|
||||
name="Occupancy",
|
||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||
entity_class=RpcPresenceBinarySensor,
|
||||
),
|
||||
"presencezone_state": RpcBinarySensorDescription(
|
||||
key="presencezone",
|
||||
sub_key="value",
|
||||
name="Occupancy",
|
||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||
entity_class=RpcPresenceBinarySensor,
|
||||
),
|
||||
@@ -413,6 +454,19 @@ class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: BlockBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
block: Block,
|
||||
attribute: str,
|
||||
description: BlockBinarySensorDescription,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, block, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor state is on."""
|
||||
@@ -424,6 +478,18 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: RestBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
attribute: str,
|
||||
description: RestBinarySensorDescription,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if REST sensor state is on."""
|
||||
@@ -437,6 +503,20 @@ class BlockSleepingBinarySensor(
|
||||
|
||||
entity_description: BlockBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
block: Block | None,
|
||||
attribute: str,
|
||||
description: BlockBinarySensorDescription,
|
||||
entry: RegistryEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sleeping sensor."""
|
||||
super().__init__(coordinator, block, attribute, description, entry)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -461,6 +541,35 @@ class RpcSleepingBinarySensor(
|
||||
|
||||
entity_description: RpcBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
key: str,
|
||||
attribute: str,
|
||||
description: RpcBinarySensorDescription,
|
||||
entry: RegistryEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sleeping sensor."""
|
||||
super().__init__(coordinator, key, attribute, description, entry)
|
||||
|
||||
if coordinator.device.initialized:
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
get_rpc_channel_name(coordinator.device, key),
|
||||
description.translation_key,
|
||||
description.device_class,
|
||||
self._default_to_device_class_name(),
|
||||
)
|
||||
)
|
||||
|
||||
if translation_placeholders:
|
||||
self._attr_translation_placeholders = translation_placeholders
|
||||
if translation_key:
|
||||
self._attr_translation_key = translation_key
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -1919,8 +1919,23 @@ class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, RestoreSensor):
|
||||
super().__init__(coordinator, key, attribute, description, entry)
|
||||
self.restored_data: SensorExtraStoredData | None = None
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
if coordinator.device.initialized:
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
get_rpc_channel_name(coordinator.device, key),
|
||||
description.translation_key,
|
||||
description.device_class,
|
||||
self._default_to_device_class_name(),
|
||||
)
|
||||
)
|
||||
|
||||
if translation_placeholders:
|
||||
self._attr_translation_placeholders = translation_placeholders
|
||||
if translation_key:
|
||||
self._attr_translation_key = translation_key
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
|
||||
@@ -129,6 +129,80 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"cable_unplugged": {
|
||||
"name": "Cable unplugged"
|
||||
},
|
||||
"cable_unplugged_with_channel_name": {
|
||||
"name": "{channel_name} cable unplugged"
|
||||
},
|
||||
"calibration": {
|
||||
"name": "Calibration"
|
||||
},
|
||||
"cloud": {
|
||||
"name": "Cloud"
|
||||
},
|
||||
"door": {
|
||||
"name": "Door"
|
||||
},
|
||||
"external_input": {
|
||||
"name": "External input"
|
||||
},
|
||||
"external_power": {
|
||||
"name": "External power"
|
||||
},
|
||||
"flood": {
|
||||
"name": "Flood"
|
||||
},
|
||||
"flood_with_channel_name": {
|
||||
"name": "{channel_name} flood"
|
||||
},
|
||||
"input": {
|
||||
"name": "Input"
|
||||
},
|
||||
"input_with_number": {
|
||||
"name": "Input {input_number}"
|
||||
},
|
||||
"mute": {
|
||||
"name": "Mute"
|
||||
},
|
||||
"mute_with_channel_name": {
|
||||
"name": "{channel_name} mute"
|
||||
},
|
||||
"occupancy_with_channel_name": {
|
||||
"name": "{channel_name} occupancy"
|
||||
},
|
||||
"overcurrent": {
|
||||
"name": "Overcurrent"
|
||||
},
|
||||
"overcurrent_with_channel_name": {
|
||||
"name": "{channel_name} overcurrent"
|
||||
},
|
||||
"overheating": {
|
||||
"name": "Overheating"
|
||||
},
|
||||
"overheating_with_channel_name": {
|
||||
"name": "{channel_name} overheating"
|
||||
},
|
||||
"overpowering": {
|
||||
"name": "Overpowering"
|
||||
},
|
||||
"overpowering_with_channel_name": {
|
||||
"name": "{channel_name} overpowering"
|
||||
},
|
||||
"overvoltage": {
|
||||
"name": "Overvoltage"
|
||||
},
|
||||
"overvoltage_with_channel_name": {
|
||||
"name": "{channel_name} overvoltage"
|
||||
},
|
||||
"restart_required": {
|
||||
"name": "Restart required"
|
||||
},
|
||||
"smoke_with_channel_name": {
|
||||
"name": "{channel_name} smoke"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"calibrate": {
|
||||
"name": "Calibrate"
|
||||
|
||||
@@ -391,7 +391,13 @@ def get_shelly_model_name(
|
||||
return cast(str, MODEL_NAMES.get(model))
|
||||
|
||||
|
||||
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
|
||||
def get_rpc_key(value: str) -> tuple[bool, str, str]:
|
||||
"""Get split device key."""
|
||||
parts = value.split(":")
|
||||
return len(parts) > 1, parts[0], parts[-1]
|
||||
|
||||
|
||||
def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
|
||||
"""Get component name from device config."""
|
||||
if (
|
||||
key in device.config
|
||||
@@ -403,6 +409,11 @@ def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
|
||||
"""Get component name from device config."""
|
||||
return get_rpc_custom_name(device, key)
|
||||
|
||||
|
||||
def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
|
||||
"""Get name based on device and channel name."""
|
||||
if BLU_TRV_IDENTIFIER in key:
|
||||
@@ -414,11 +425,11 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
|
||||
component = key.split(":")[0]
|
||||
component_id = key.split(":")[-1]
|
||||
|
||||
if component_name := get_rpc_component_name(device, key):
|
||||
if custom_name := get_rpc_custom_name(device, key):
|
||||
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
|
||||
return component_name
|
||||
return custom_name
|
||||
|
||||
return component_name if instances == 1 else None
|
||||
return custom_name if instances == 1 else None
|
||||
|
||||
if component in (*VIRTUAL_COMPONENTS, "input"):
|
||||
return f"{component.title()} {component_id}"
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["soco", "sonos_websocket"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["soco==0.30.12", "sonos-websocket==0.1.3"],
|
||||
"requirements": [
|
||||
"defusedxml==0.7.1",
|
||||
"soco==0.30.12",
|
||||
"sonos-websocket==0.1.3"
|
||||
],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -1111,6 +1111,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/thermopro",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["thermopro-ble==0.13.1"]
|
||||
"requirements": ["thermopro-ble==1.1.2"]
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ class _CustomDPCodeWrapper(DPCodeWrapper):
|
||||
_valid_values: set[bool | float | int | str]
|
||||
|
||||
def __init__(
|
||||
self, dpcode: str, valid_values: set[bool | float | int | str]
|
||||
self, dpcode: DPCode, valid_values: set[bool | float | int | str]
|
||||
) -> None:
|
||||
"""Init CustomDPCodeBooleanWrapper."""
|
||||
super().__init__(dpcode)
|
||||
@@ -390,7 +390,7 @@ def _get_dpcode_wrapper(
|
||||
description: TuyaBinarySensorEntityDescription,
|
||||
) -> DPCodeWrapper | None:
|
||||
"""Get DPCode wrapper for an entity description."""
|
||||
dpcode = description.dpcode or description.key
|
||||
dpcode = description.dpcode or DPCode(description.key)
|
||||
if description.bitmap_key is not None:
|
||||
return DPCodeBitmapBitWrapper.find_dpcode(
|
||||
device, dpcode, bitmap_key=description.bitmap_key
|
||||
|
||||
@@ -607,6 +607,7 @@ class DPCode(StrEnum):
|
||||
ALARM_DELAY_TIME = "alarm_delay_time"
|
||||
ALARM_MESSAGE = "alarm_message"
|
||||
ALARM_MSG = "alarm_msg"
|
||||
ALARM_STATE = "alarm_state"
|
||||
ALARM_SWITCH = "alarm_switch" # Alarm switch
|
||||
ALARM_TIME = "alarm_time" # Alarm time
|
||||
ALARM_VOLUME = "alarm_volume" # Alarm volume
|
||||
|
||||
@@ -32,12 +32,110 @@ from .entity import TuyaEntity
|
||||
from .models import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
IntegerTypeData,
|
||||
find_dpcode,
|
||||
)
|
||||
from .util import get_dpcode, get_dptype, remap_value
|
||||
|
||||
|
||||
class _BrightnessWrapper(DPCodeIntegerWrapper):
|
||||
"""Wrapper for brightness DP code.
|
||||
|
||||
Handles brightness value conversion between device scale and Home Assistant's
|
||||
0-255 scale. Supports optional dynamic brightness_min and brightness_max
|
||||
wrappers that allow the device to specify runtime brightness range limits.
|
||||
"""
|
||||
|
||||
brightness_min: DPCodeIntegerWrapper | None = None
|
||||
brightness_max: DPCodeIntegerWrapper | None = None
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> Any | None:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if (brightness := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
|
||||
# Remap value to our scale
|
||||
brightness = self.type_information.remap_value_to(brightness)
|
||||
|
||||
# If there is a min/max value, the brightness is actually limited.
|
||||
# Meaning it is actually not on a 0-255 scale.
|
||||
if (
|
||||
self.brightness_max is not None
|
||||
and self.brightness_min is not None
|
||||
and (brightness_max := device.status.get(self.brightness_max.dpcode))
|
||||
is not None
|
||||
and (brightness_min := device.status.get(self.brightness_min.dpcode))
|
||||
is not None
|
||||
):
|
||||
# Remap values onto our scale
|
||||
brightness_max = self.brightness_max.type_information.remap_value_to(
|
||||
brightness_max
|
||||
)
|
||||
brightness_min = self.brightness_min.type_information.remap_value_to(
|
||||
brightness_min
|
||||
)
|
||||
|
||||
# Remap the brightness value from their min-max to our 0-255 scale
|
||||
brightness = remap_value(
|
||||
brightness, from_min=brightness_min, from_max=brightness_max
|
||||
)
|
||||
|
||||
return round(brightness)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value (0..255) back to a raw device value."""
|
||||
# If there is a min/max value, the brightness is actually limited.
|
||||
# Meaning it is actually not on a 0-255 scale.
|
||||
if (
|
||||
self.brightness_max is not None
|
||||
and self.brightness_min is not None
|
||||
and (brightness_max := device.status.get(self.brightness_max.dpcode))
|
||||
is not None
|
||||
and (brightness_min := device.status.get(self.brightness_min.dpcode))
|
||||
is not None
|
||||
):
|
||||
# Remap values onto our scale
|
||||
brightness_max = self.brightness_max.type_information.remap_value_to(
|
||||
brightness_max
|
||||
)
|
||||
brightness_min = self.brightness_min.type_information.remap_value_to(
|
||||
brightness_min
|
||||
)
|
||||
|
||||
# Remap the brightness value from our 0-255 scale to their min-max
|
||||
value = remap_value(value, to_min=brightness_min, to_max=brightness_max)
|
||||
return round(self.type_information.remap_value_from(value))
|
||||
|
||||
|
||||
class _ColorTempWrapper(DPCodeIntegerWrapper):
|
||||
"""Wrapper for color temperature DP code."""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> Any | None:
|
||||
"""Return the color temperature value in Kelvin."""
|
||||
if (temperature := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
self.type_information.remap_value_to(
|
||||
temperature,
|
||||
MIN_MIREDS,
|
||||
MAX_MIREDS,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value (Kelvin) back to a raw device value."""
|
||||
return round(
|
||||
self.type_information.remap_value_from(
|
||||
color_util.color_temperature_kelvin_to_mired(value),
|
||||
MIN_MIREDS,
|
||||
MAX_MIREDS,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorTypeData:
|
||||
"""Color Type Data."""
|
||||
@@ -48,15 +146,27 @@ class ColorTypeData:
|
||||
|
||||
|
||||
DEFAULT_COLOR_TYPE_DATA = ColorTypeData(
|
||||
h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1),
|
||||
s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1),
|
||||
v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1),
|
||||
h_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
),
|
||||
s_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
),
|
||||
v_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
),
|
||||
)
|
||||
|
||||
DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData(
|
||||
h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1),
|
||||
s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1),
|
||||
v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1),
|
||||
h_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
),
|
||||
s_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
),
|
||||
v_type=IntegerTypeData(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
),
|
||||
)
|
||||
|
||||
MAX_MIREDS = 500 # 2000 K
|
||||
@@ -417,6 +527,24 @@ class ColorData:
|
||||
return round(self.type_data.v_type.remap_value_to(self.v_value, 0, 255))
|
||||
|
||||
|
||||
def _get_brightness_wrapper(
|
||||
device: CustomerDevice, description: TuyaLightEntityDescription
|
||||
) -> _BrightnessWrapper | None:
|
||||
if (
|
||||
brightness_wrapper := _BrightnessWrapper.find_dpcode(
|
||||
device, description.brightness, prefer_function=True
|
||||
)
|
||||
) is None:
|
||||
return None
|
||||
brightness_wrapper.brightness_max = DPCodeIntegerWrapper.find_dpcode(
|
||||
device, description.brightness_max, prefer_function=True
|
||||
)
|
||||
brightness_wrapper.brightness_min = DPCodeIntegerWrapper.find_dpcode(
|
||||
device, description.brightness_min, prefer_function=True
|
||||
)
|
||||
return brightness_wrapper
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -437,9 +565,13 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
brightness_wrapper=_get_brightness_wrapper(device, description),
|
||||
color_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.color_mode, prefer_function=True
|
||||
),
|
||||
color_temp_wrapper=_ColorTempWrapper.find_dpcode(
|
||||
device, description.color_temp, prefer_function=True
|
||||
),
|
||||
switch_wrapper=switch_wrapper,
|
||||
)
|
||||
for description in descriptions
|
||||
@@ -464,12 +596,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
|
||||
entity_description: TuyaLightEntityDescription
|
||||
|
||||
_brightness_max: IntegerTypeData | None = None
|
||||
_brightness_min: IntegerTypeData | None = None
|
||||
_brightness: IntegerTypeData | None = None
|
||||
_color_data_dpcode: DPCode | None = None
|
||||
_color_data_type: ColorTypeData | None = None
|
||||
_color_temp: IntegerTypeData | None = None
|
||||
_white_color_mode = ColorMode.COLOR_TEMP
|
||||
_fixed_color_mode: ColorMode | None = None
|
||||
_attr_min_color_temp_kelvin = 2000 # 500 Mireds
|
||||
@@ -481,32 +609,24 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
device_manager: Manager,
|
||||
description: TuyaLightEntityDescription,
|
||||
*,
|
||||
brightness_wrapper: _BrightnessWrapper | None,
|
||||
color_mode_wrapper: DPCodeEnumWrapper | None,
|
||||
color_temp_wrapper: _ColorTempWrapper | None,
|
||||
switch_wrapper: DPCodeBooleanWrapper,
|
||||
) -> None:
|
||||
"""Init TuyaHaLight."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._brightness_wrapper = brightness_wrapper
|
||||
self._color_mode_wrapper = color_mode_wrapper
|
||||
self._color_temp_wrapper = color_temp_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
color_modes: set[ColorMode] = {ColorMode.ONOFF}
|
||||
|
||||
if int_type := find_dpcode(
|
||||
self.device,
|
||||
description.brightness,
|
||||
dptype=DPType.INTEGER,
|
||||
prefer_function=True,
|
||||
):
|
||||
self._brightness = int_type
|
||||
if brightness_wrapper:
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
self._brightness_max = find_dpcode(
|
||||
self.device, description.brightness_max, dptype=DPType.INTEGER
|
||||
)
|
||||
self._brightness_min = find_dpcode(
|
||||
self.device, description.brightness_min, dptype=DPType.INTEGER
|
||||
)
|
||||
|
||||
if (dpcode := get_dpcode(self.device, description.color_data)) and (
|
||||
get_dptype(self.device, dpcode, prefer_function=True) == DPType.JSON
|
||||
@@ -521,26 +641,27 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
# Fetch color data type information
|
||||
if function_data := json_loads_object(values):
|
||||
self._color_data_type = ColorTypeData(
|
||||
h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])),
|
||||
s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])),
|
||||
v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])),
|
||||
h_type=IntegerTypeData(
|
||||
dpcode=dpcode, **cast(dict, function_data["h"])
|
||||
),
|
||||
s_type=IntegerTypeData(
|
||||
dpcode=dpcode, **cast(dict, function_data["s"])
|
||||
),
|
||||
v_type=IntegerTypeData(
|
||||
dpcode=dpcode, **cast(dict, function_data["v"])
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If no type is found, use a default one
|
||||
self._color_data_type = self.entity_description.default_color_type
|
||||
if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or (
|
||||
self._brightness and self._brightness.max > 255
|
||||
self._brightness_wrapper
|
||||
and self._brightness_wrapper.type_information.max > 255
|
||||
):
|
||||
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
|
||||
|
||||
# Check if the light has color temperature
|
||||
if int_type := find_dpcode(
|
||||
self.device,
|
||||
description.color_temp,
|
||||
dptype=DPType.INTEGER,
|
||||
prefer_function=True,
|
||||
):
|
||||
self._color_temp = int_type
|
||||
if color_temp_wrapper:
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
# If light has color but does not have color_temp, check if it has
|
||||
# work_mode "white"
|
||||
@@ -577,21 +698,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
),
|
||||
]
|
||||
|
||||
if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
if self._color_temp_wrapper and ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
commands += [
|
||||
{
|
||||
"code": self._color_temp.dpcode,
|
||||
"value": round(
|
||||
self._color_temp.remap_value_from(
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
),
|
||||
MIN_MIREDS,
|
||||
MAX_MIREDS,
|
||||
reverse=True,
|
||||
)
|
||||
),
|
||||
},
|
||||
self._color_temp_wrapper.get_update_command(
|
||||
self.device, kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
]
|
||||
|
||||
if self._color_data_type and (
|
||||
@@ -641,46 +752,16 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
},
|
||||
]
|
||||
|
||||
elif self._brightness and (ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs):
|
||||
elif self._brightness_wrapper and (
|
||||
ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs
|
||||
):
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
else:
|
||||
brightness = kwargs[ATTR_WHITE]
|
||||
|
||||
# If there is a min/max value, the brightness is actually limited.
|
||||
# Meaning it is actually not on a 0-255 scale.
|
||||
if (
|
||||
self._brightness_max is not None
|
||||
and self._brightness_min is not None
|
||||
and (
|
||||
brightness_max := self.device.status.get(
|
||||
self._brightness_max.dpcode
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and (
|
||||
brightness_min := self.device.status.get(
|
||||
self._brightness_min.dpcode
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
# Remap values onto our scale
|
||||
brightness_max = self._brightness_max.remap_value_to(brightness_max)
|
||||
brightness_min = self._brightness_min.remap_value_to(brightness_min)
|
||||
|
||||
# Remap the brightness value from their min-max to our 0-255 scale
|
||||
brightness = remap_value(
|
||||
brightness,
|
||||
to_min=brightness_min,
|
||||
to_max=brightness_max,
|
||||
)
|
||||
|
||||
commands += [
|
||||
{
|
||||
"code": self._brightness.dpcode,
|
||||
"value": round(self._brightness.remap_value_from(brightness)),
|
||||
},
|
||||
self._brightness_wrapper.get_update_command(self.device, brightness),
|
||||
]
|
||||
|
||||
self._send_command(commands)
|
||||
@@ -691,59 +772,17 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of the light."""
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
# If the light is currently in color mode, extract the brightness from the color data
|
||||
if self.color_mode == ColorMode.HS and (color_data := self._get_color_data()):
|
||||
return color_data.brightness
|
||||
|
||||
if not self._brightness:
|
||||
return None
|
||||
|
||||
brightness = self.device.status.get(self._brightness.dpcode)
|
||||
if brightness is None:
|
||||
return None
|
||||
|
||||
# Remap value to our scale
|
||||
brightness = self._brightness.remap_value_to(brightness)
|
||||
|
||||
# If there is a min/max value, the brightness is actually limited.
|
||||
# Meaning it is actually not on a 0-255 scale.
|
||||
if (
|
||||
self._brightness_max is not None
|
||||
and self._brightness_min is not None
|
||||
and (brightness_max := self.device.status.get(self._brightness_max.dpcode))
|
||||
is not None
|
||||
and (brightness_min := self.device.status.get(self._brightness_min.dpcode))
|
||||
is not None
|
||||
):
|
||||
# Remap values onto our scale
|
||||
brightness_max = self._brightness_max.remap_value_to(brightness_max)
|
||||
brightness_min = self._brightness_min.remap_value_to(brightness_min)
|
||||
|
||||
# Remap the brightness value from their min-max to our 0-255 scale
|
||||
brightness = remap_value(
|
||||
brightness,
|
||||
from_min=brightness_min,
|
||||
from_max=brightness_max,
|
||||
)
|
||||
|
||||
return round(brightness)
|
||||
return self._read_wrapper(self._brightness_wrapper)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temperature value in Kelvin."""
|
||||
if not self._color_temp:
|
||||
return None
|
||||
|
||||
temperature = self.device.status.get(self._color_temp.dpcode)
|
||||
if temperature is None:
|
||||
return None
|
||||
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
self._color_temp.remap_value_to(
|
||||
temperature, MIN_MIREDS, MAX_MIREDS, reverse=True
|
||||
)
|
||||
)
|
||||
return self._read_wrapper(self._color_temp_wrapper)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
|
||||
@@ -15,7 +15,7 @@ from .const import DPCode, DPType
|
||||
from .util import parse_dptype, remap_value
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class TypeInformation:
|
||||
"""Type information.
|
||||
|
||||
@@ -23,14 +23,15 @@ class TypeInformation:
|
||||
"""
|
||||
|
||||
dpcode: DPCode
|
||||
type_data: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a TypeInformation object."""
|
||||
return cls(dpcode)
|
||||
return cls(dpcode=dpcode, type_data=type_data)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class IntegerTypeData(TypeInformation):
|
||||
"""Integer Type Data."""
|
||||
|
||||
@@ -84,13 +85,14 @@ class IntegerTypeData(TypeInformation):
|
||||
return remap_value(value, from_min, from_max, self.min, self.max, reverse)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a IntegerTypeData object."""
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))):
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
dpcode,
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
min=int(parsed["min"]),
|
||||
max=int(parsed["max"]),
|
||||
scale=int(parsed["scale"]),
|
||||
@@ -99,32 +101,40 @@ class IntegerTypeData(TypeInformation):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class BitmapTypeInformation(TypeInformation):
|
||||
"""Bitmap type information."""
|
||||
|
||||
label: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := json_loads_object(data)):
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
return cls(dpcode, **cast(dict[str, list[str]], parsed))
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
**cast(dict[str, list[str]], parsed),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class EnumTypeData(TypeInformation):
|
||||
"""Enum Type Data."""
|
||||
|
||||
range: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a EnumTypeData object."""
|
||||
if not (parsed := json_loads_object(data)):
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
return cls(dpcode, **cast(dict[str, list[str]], parsed))
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
**cast(dict[str, list[str]], parsed),
|
||||
)
|
||||
|
||||
|
||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
@@ -147,7 +157,7 @@ class DPCodeWrapper(ABC):
|
||||
native_unit: str | None = None
|
||||
suggested_unit: str | None = None
|
||||
|
||||
def __init__(self, dpcode: str) -> None:
|
||||
def __init__(self, dpcode: DPCode) -> None:
|
||||
"""Init DPCodeWrapper."""
|
||||
self.dpcode = dpcode
|
||||
|
||||
@@ -190,7 +200,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
DPTYPE: DPType
|
||||
type_information: T
|
||||
|
||||
def __init__(self, dpcode: str, type_information: T) -> None:
|
||||
def __init__(self, dpcode: DPCode, type_information: T) -> None:
|
||||
"""Init DPCodeWrapper."""
|
||||
super().__init__(dpcode)
|
||||
self.type_information = type_information
|
||||
@@ -297,7 +307,7 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
|
||||
DPTYPE = DPType.INTEGER
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
|
||||
def __init__(self, dpcode: DPCode, type_information: IntegerTypeData) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.native_unit = type_information.unit
|
||||
@@ -327,7 +337,7 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
class DPCodeBitmapBitWrapper(DPCodeWrapper):
|
||||
"""Simple wrapper for a specific bit in bitmap values."""
|
||||
|
||||
def __init__(self, dpcode: str, mask: int) -> None:
|
||||
def __init__(self, dpcode: DPCode, mask: int) -> None:
|
||||
"""Init DPCodeBitmapWrapper."""
|
||||
super().__init__(dpcode)
|
||||
self._mask = mask
|
||||
@@ -428,7 +438,7 @@ def find_dpcode(
|
||||
and parse_dptype(current_definition.type) is dptype
|
||||
and (
|
||||
type_information := type_information_cls.from_json(
|
||||
dpcode, current_definition.values
|
||||
dpcode=dpcode, type_data=current_definition.values
|
||||
)
|
||||
)
|
||||
):
|
||||
|
||||
@@ -207,6 +207,11 @@ SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = {
|
||||
),
|
||||
),
|
||||
DeviceCategory.SGBJ: (
|
||||
SelectEntityDescription(
|
||||
key=DPCode.ALARM_STATE,
|
||||
translation_key="siren_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
SelectEntityDescription(
|
||||
key=DPCode.ALARM_VOLUME,
|
||||
translation_key="volume",
|
||||
|
||||
@@ -494,6 +494,15 @@
|
||||
"power_on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"siren_mode": {
|
||||
"name": "Siren mode",
|
||||
"state": {
|
||||
"alarm_light": "Light",
|
||||
"alarm_sound": "Sound",
|
||||
"alarm_sound_light": "Sound & light",
|
||||
"normal": "[%key:common::state::normal%]"
|
||||
}
|
||||
},
|
||||
"target_humidity": {
|
||||
"name": "Target humidity"
|
||||
},
|
||||
|
||||
1
homeassistant/components/vagner_pool/__init__.py
Normal file
1
homeassistant/components/vagner_pool/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: VÁGNER POOL."""
|
||||
6
homeassistant/components/vagner_pool/manifest.json
Normal file
6
homeassistant/components/vagner_pool/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "vagner_pool",
|
||||
"name": "V\u00c1GNER POOL",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "pooldose"
|
||||
}
|
||||
@@ -492,6 +492,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error while playing announcement")
|
||||
self._announcement = None
|
||||
raise
|
||||
finally:
|
||||
self._run_pipeline_task = None
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import NamedTuple
|
||||
|
||||
from pythonxbox.api.client import XboxLiveClient
|
||||
from pythonxbox.api.provider.catalog.const import HOME_APP_IDS, SYSTEM_PFN_ID_MAP
|
||||
from pythonxbox.api.provider.catalog.const import HOME_APP_IDS
|
||||
from pythonxbox.api.provider.catalog.models import (
|
||||
AlternateIdType,
|
||||
CatalogResponse,
|
||||
@@ -42,7 +42,6 @@ TYPE_MAP = {
|
||||
async def build_item_response(
|
||||
client: XboxLiveClient,
|
||||
device_id: str,
|
||||
tv_configured: bool,
|
||||
media_content_type: str,
|
||||
media_content_id: str,
|
||||
) -> BrowseMedia | None:
|
||||
@@ -83,29 +82,6 @@ async def build_item_response(
|
||||
)
|
||||
)
|
||||
|
||||
# Add TV if configured
|
||||
if tv_configured:
|
||||
tv_catalog: CatalogResponse = (
|
||||
await client.catalog.get_product_from_alternate_id(
|
||||
SYSTEM_PFN_ID_MAP["Microsoft.Xbox.LiveTV_8wekyb3d8bbwe"][id_type],
|
||||
id_type,
|
||||
)
|
||||
)
|
||||
tv_thumb = _find_media_image(
|
||||
tv_catalog.products[0].localized_properties[0].images
|
||||
)
|
||||
children.append(
|
||||
BrowseMedia(
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id="TV",
|
||||
media_content_type=MediaType.APP,
|
||||
title="Live TV",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=None if tv_thumb is None else tv_thumb.uri,
|
||||
)
|
||||
)
|
||||
|
||||
content_types = sorted(
|
||||
{app.content_type for app in apps.result if app.content_type in TYPE_MAP}
|
||||
)
|
||||
@@ -154,7 +130,7 @@ async def build_item_response(
|
||||
)
|
||||
|
||||
|
||||
def item_payload(item: InstalledPackage, images: dict[str, list[Image]]):
|
||||
def item_payload(item: InstalledPackage, images: dict[str, list[Image]]) -> BrowseMedia:
|
||||
"""Create response payload for a single media item."""
|
||||
thumbnail = None
|
||||
image = _find_media_image(images.get(item.one_store_product_id, [])) # type: ignore[arg-type]
|
||||
|
||||
@@ -176,7 +176,6 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity):
|
||||
return await build_item_response(
|
||||
self.client,
|
||||
self._console.id,
|
||||
self.data.status.is_tv_configured,
|
||||
media_content_type or "",
|
||||
media_content_id or "",
|
||||
) # type: ignore[return-value]
|
||||
@@ -187,10 +186,8 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity):
|
||||
"""Launch an app on the Xbox."""
|
||||
if media_id == "Home":
|
||||
await self.client.smartglass.go_home(self._console.id)
|
||||
elif media_id == "TV":
|
||||
await self.client.smartglass.show_tv_guide(self._console.id)
|
||||
else:
|
||||
await self.client.smartglass.launch_app(self._console.id, media_id)
|
||||
|
||||
await self.client.smartglass.launch_app(self._console.id, media_id)
|
||||
|
||||
|
||||
def _find_media_image(images: list[Image]) -> Image | None:
|
||||
|
||||
@@ -335,7 +335,7 @@ class XboxStorageDeviceSensorEntity(
|
||||
)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
def data(self) -> StorageDevice | None:
|
||||
"""Storage device data."""
|
||||
consoles = self.coordinator.data.result
|
||||
console = next((c for c in consoles if c.id == self._console.id), None)
|
||||
|
||||
@@ -1136,6 +1136,11 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"cosori": {
|
||||
"name": "Cosori",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "vesync"
|
||||
},
|
||||
"cozytouch": {
|
||||
"name": "Atlantic Cozytouch",
|
||||
"integration_type": "virtual",
|
||||
@@ -3418,7 +3423,7 @@
|
||||
"name": "LCN",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"ld2410_ble": {
|
||||
"name": "LD2410 BLE",
|
||||
@@ -7191,6 +7196,11 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"vagner_pool": {
|
||||
"name": "V\u00c1GNER POOL",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "pooldose"
|
||||
},
|
||||
"vallox": {
|
||||
"name": "Vallox",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -805,6 +805,8 @@ async def async_get_all_descriptions(
|
||||
continue
|
||||
|
||||
description = {"fields": yaml_description.get("fields", {})}
|
||||
if (target := yaml_description.get("target")) is not None:
|
||||
description["target"] = target
|
||||
|
||||
new_descriptions_cache[missing_trigger] = description
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.2
|
||||
dbus-fast==2.45.0
|
||||
dbus-fast==3.0.0
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.2.1
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -5549,6 +5549,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.xbox.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.xiaomi_ble.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user