Compare commits

..

67 Commits

Author SHA1 Message Date
Ludovic BOUÉ
b650e71660 Update mock thermostat snapshots with new attributes and state values 2025-11-18 18:05:29 +00:00
Ludovic BOUÉ
9ddf15e348 Update mock thermostat JSON with additional attributes and values 2025-11-18 18:03:46 +00:00
Ludovic BOUÉ
15082f9111 Merge branch 'dev' into setpoint_change_source 2025-11-18 16:45:05 +01:00
Ludovic BOUÉ
12f16611ff Rename mock thermostat entity IDs and friendly names in snapshots for consistency 2025-11-18 15:30:39 +00:00
Heindrich Paul
0de2a16d0f Add binary sensor support and refactor NS sensor integration (#154589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 16:29:09 +01:00
David Rapan
c8c2413a09 Fix Shelly sleeping sensor with channel name (#156708)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-18 16:28:21 +01:00
Fredrik Mårtensson
291331f878 Fix is_matching in samsungtv config flow (#156594)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-18 16:12:21 +01:00
David
a13cdbdf3d Add new Tuya dehumidifier test fixture (#156799)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-18 16:07:37 +01:00
epenet
1bf713f279 Set kw_only in Tuya TypeInformation (#156804)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-18 15:58:46 +01:00
Heindrich Paul
10c8ee417b Refactor Nederlandse Spoorwegen integration (#154616)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 15:53:49 +01:00
Jamin
b23134f4f1 Reset state on error during VOIP announcement (#156384) 2025-11-18 15:36:41 +01:00
Ludovic BOUÉ
8041be3d08 Merge branch 'dev' into setpoint_change_source 2025-11-18 14:08:38 +01:00
Ludovic BOUÉ
40b021e755 Add tests for Thermostat SetpointChangeSource, Timestamp, and Amount sensors 2025-11-18 13:02:44 +00:00
Ludovic BOUÉ
aab57eda96 Update mock thermostat product name to "Mock Thermostat" 2025-11-18 13:00:26 +00:00
Ludovic BOUÉ
f0dd37caa5 Add mock thermostat sensors and states for testing 2025-11-18 12:49:32 +00:00
Ludovic BOUÉ
662b178495 Remove unused attribute from thermostat fixture 2025-11-18 12:48:56 +00:00
Ludovic BOUÉ
cb3d30884a Add mock thermostat fixture for integration tests 2025-11-18 12:48:21 +00:00
Ludovic BOUÉ
49e6f20372 Add Setpoint Change Source timestamp and amount sensors with localization strings 2025-11-18 12:39:28 +00:00
Joost Lekkerkerker
f45a6f806b Add Cosori virtual integration (#156792) 2025-11-18 13:38:32 +01:00
Ludovic BOUÉ
d3857a00d5 Rename Matter thermostat fixture (#156795) 2025-11-18 13:25:08 +01:00
Copilot
8c9b90a9f9 Fix hvv_departures to pass config_entry explicitly to DataUpdateCoordinator (#156794)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
2025-11-18 12:32:55 +01:00
Timothy
4eedc88935 Store Mobile app pending updates when enabling back an entity (#156026)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-18 12:26:13 +01:00
Abílio Costa
343ea1b82d Return target in trigger description command (#156766)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-11-18 12:16:28 +01:00
Lukas
36e13653d2 New virtual integration Vagner Pool supported by pooldose (#156678)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 11:46:46 +01:00
dependabot[bot]
80444b2165 Bump actions/checkout from 5.0.0 to 5.0.1 (#156780)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 11:43:52 +01:00
epenet
262f06dd2b Migrate Tuya light (color_temp) to use wrapper class (#156743)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 11:43:32 +01:00
epenet
bd87119c2e Fix blocking call in cync (#156782) 2025-11-18 11:41:54 +01:00
Andre Lengwenus
0dfa037aa8 Refactor device classes for LCN (#156791) 2025-11-18 11:39:23 +01:00
Artur Pragacz
c32a471573 Register music assistant services in async setup (#155963) 2025-11-18 11:38:58 +01:00
valexi7
97b7e51171 Add fixture for Tuya Wifi Knob Thermostat wk_t94pit6zjbask9qo (#156781)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-18 08:34:21 +01:00
David Rapan
433712b407 Add Shelly binary sensor translation (#154116)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-17 22:39:14 +02:00
Luca Angemi
5d87e0f429 Make Google sheets datetime column optional (#155861)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-11-17 19:48:17 +00:00
tronikos
acb087f1e5 Mark Google Assistant SDK as gold (#148077) 2025-11-17 17:37:41 +01:00
Andre Lengwenus
10c12623bf Switch LCN integration to local polling (#152601) 2025-11-17 15:50:26 +01:00
stegm
2fe20553b3 Add new settings option to kostal plenticore (#153162) 2025-11-17 15:45:50 +01:00
Tom Matheussen
b431bb197a Sync quality scale tracking with codebase (#156440) 2025-11-17 15:41:30 +01:00
Manu
eb9d625926 Fix return type annotations and enable strict typing in Xbox integration (#156746) 2025-11-17 14:38:02 +01:00
PaulCavill
3a69534b09 Bump pyiCloud to 2.2.0 (#156485) 2025-11-17 14:32:01 +01:00
Erik Montnemery
8f2cedcb73 Run hassfest if conditions.yaml or triggers.yaml is changed (#156738) 2025-11-17 12:26:50 +01:00
epenet
3658953ff3 Migrate Tuya light (brightness) to use wrapper class (#156735) 2025-11-17 11:58:16 +01:00
dotlambda
0be5893e37 sonos requires defusedxml (#156718) 2025-11-17 08:21:55 +01:00
Allen Porter
c87e38c4cf Add Nest config flow data_description fields to fix quality scale item (#156713) 2025-11-17 08:13:15 +01:00
Allen Porter
4874610ad6 Bump google-nest-sdm to 9.0.1 (#156707) 2025-11-16 23:13:40 +01:00
Michael
9180282fc6 Add update entity to AdGUard Home (#156682)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 22:52:38 +01:00
J. Nick Koston
118f30f32e Bump dbus to 3.0.0 (#156704) 2025-11-16 22:34:31 +01:00
Bouwe Westerdijk
bd10da126f Revisit diagnostic-category assignments for Plugwise (#156279) 2025-11-16 21:29:24 +01:00
starkillerOG
b73a7928ca Enable Reolink RTSP and ONVIF port when supported (#156700) 2025-11-16 21:24:25 +01:00
Jan Bouwhuis
3e20c2ea93 Fix missing description placeholders in MQTT subentry flow (#156684) 2025-11-16 21:21:22 +01:00
andreipoenaru
60130d3d68 Add support for encoded URLs to RESTful Command (#154957)
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-16 21:04:18 +01:00
Denis Shulyaka
c45ede2e5d Bump anthropic to 0.73.0 (#156692) 2025-11-16 21:02:55 +01:00
J. Nick Koston
e167061f53 Bump dbus-fast to 2.46.4 (#156703) 2025-11-16 20:52:05 +01:00
Kamil Breguła
5560fb6c9e Refactor tests in GIOS (#155756)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-16 20:21:46 +01:00
J. Nick Koston
9808b6c961 Bump dbus-fast to 2.46.1 (#156695) 2025-11-16 19:34:29 +01:00
J. Nick Koston
e8cfde579e Bump dbus-fast to 2.46.0 (#156693) 2025-11-16 09:46:53 -06:00
J. Nick Koston
f695fb4d51 Bump dbus-fast to 2.45.1 (#156691) 2025-11-16 09:12:44 -06:00
Jan Bouwhuis
a0e0549d90 Fix missing temperature_delta device class translations (#156685) 2025-11-16 15:00:51 +01:00
epenet
ba034c6c8c Add alarm_state to Tuya siren alarm (#151221) 2025-11-16 12:26:33 +01:00
Åke Strandberg
008bb85c59 Mock arguments in senz tests (#156677) 2025-11-16 12:25:28 +01:00
Michael
cf1c1294d3 Bump adguardhome to 0.8.1 (#156679) 2025-11-16 12:16:17 +01:00
Åke Strandberg
11d5d314cc Fix type hints in miele tests (#156657) 2025-11-16 12:12:45 +01:00
Joost Lekkerkerker
6f0de3071a Add fixture for dual washing machine to SmartThings (#156646) 2025-11-16 11:43:25 +01:00
mettolen
87d2597292 Add diagnostics to Saunum integration (#156623) 2025-11-16 11:40:49 +01:00
Manu
437bc04fe8 Remove Live-TV support from Xbox integration (#156669) 2025-11-16 11:30:04 +01:00
Åke Strandberg
67a0d6a187 Mock arguments to ClientResponseError() in miele tests (#156676) 2025-11-16 10:03:32 +01:00
Lukas
abb52bca81 Add more sensors to Pooldose (#156002)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 23:02:27 +01:00
J. Nick Koston
d2d6889278 Bump thermopro-ble to 1.1.2 (#156652) 2025-11-15 14:10:59 -06:00
Ludovic BOUÉ
75d02661eb Add Setpoint Change Source sensor and localization strings 2025-11-14 17:19:28 +00:00
202 changed files with 9248 additions and 2294 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.8.0"]
"requirements": ["adguardhome==0.8.1"]
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Virtual integration: Cosori."""

View File

@@ -0,0 +1,6 @@
{
"domain": "cosori",
"name": "Cosori",
"integration_type": "virtual",
"supported_by": "vesync"
}

View File

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

View File

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

View File

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

View File

@@ -56,6 +56,9 @@
"init": {
"data": {
"language_code": "Language code"
},
"data_description": {
"language_code": "Language for the Google Assistant SDK requests and responses."
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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] != "":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ SELECT_TYPES = (
PlugwiseSelectEntityDescription(
key=SELECT_SCHEDULE,
translation_key=SELECT_SCHEDULE,
entity_category=EntityCategory.CONFIG,
options_key="available_schedules",
),
PlugwiseSelectEntityDescription(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Virtual integration: VÁGNER POOL."""

View File

@@ -0,0 +1,6 @@
{
"domain": "vagner_pool",
"name": "V\u00c1GNER POOL",
"integration_type": "virtual",
"supported_by": "pooldose"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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