mirror of
https://github.com/home-assistant/core.git
synced 2026-01-23 16:12:42 +01:00
Compare commits
24 Commits
edenhaus-r
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b386bbc8f | ||
|
|
8983d06d05 | ||
|
|
12e9241f71 | ||
|
|
1e1445f393 | ||
|
|
1b08b578a8 | ||
|
|
e469e50f76 | ||
|
|
9046ae1602 | ||
|
|
f1bf2625e6 | ||
|
|
1451af72ff | ||
|
|
26311e9480 | ||
|
|
c208b06c6a | ||
|
|
be373a76a7 | ||
|
|
5721c6c168 | ||
|
|
0843cd761f | ||
|
|
ff43003ce3 | ||
|
|
8e0f905aca | ||
|
|
2b730069d7 | ||
|
|
4d87627091 | ||
|
|
d9eff759dc | ||
|
|
9c3ffda4d2 | ||
|
|
fa30ed1dd8 | ||
|
|
947ed121dc | ||
|
|
9448f52d4a | ||
|
|
54be76f0ab |
12
.github/workflows/builder.yml
vendored
12
.github/workflows/builder.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -519,7 +519,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -30,7 +30,7 @@ RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv==0.9.17
|
||||
&& pip3 install uv==0.9.26
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ from .const import (
|
||||
BASE_PLATFORMS,
|
||||
FORMAT_DATETIME,
|
||||
KEY_DATA_LOGGING as DATA_LOGGING,
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
REQUIRED_NEXT_PYTHON_VER,
|
||||
SIGNAL_BOOTSTRAP_INTEGRATIONS,
|
||||
)
|
||||
from .core_config import async_process_ha_core_config
|
||||
@@ -514,6 +516,38 @@ async def async_from_config_dict(
|
||||
|
||||
stop = monotonic()
|
||||
_LOGGER.info("Home Assistant initialized in %.2fs", stop - start)
|
||||
|
||||
if (
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE
|
||||
and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER
|
||||
):
|
||||
current_python_version = ".".join(str(x) for x in sys.version_info[:3])
|
||||
required_python_version = ".".join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Support for the running Python version %s is deprecated and "
|
||||
"will be removed in Home Assistant %s; "
|
||||
"Please upgrade Python to %s"
|
||||
),
|
||||
current_python_version,
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
required_python_version,
|
||||
)
|
||||
issue_registry.async_create_issue(
|
||||
hass,
|
||||
core.DOMAIN,
|
||||
f"python_version_{required_python_version}",
|
||||
is_fixable=False,
|
||||
severity=issue_registry.IssueSeverity.WARNING,
|
||||
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
translation_key="python_version",
|
||||
translation_placeholders={
|
||||
"current_python_version": current_python_version,
|
||||
"required_python_version": required_python_version,
|
||||
"breaks_in_ha_version": REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
},
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ from advantage_air import ApiError, advantage_air
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import ADVANTAGE_AIR_RETRY
|
||||
from .const import ADVANTAGE_AIR_RETRY, DOMAIN
|
||||
from .models import AdvantageAirData
|
||||
from .services import async_setup_services
|
||||
|
||||
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData]
|
||||
|
||||
@@ -32,6 +35,14 @@ PLATFORMS = [
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUEST_REFRESH_DELAY = 0.5
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AdvantageAirDataConfigEntry
|
||||
|
||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -14,7 +12,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
@@ -24,7 +21,6 @@ from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -53,13 +49,6 @@ async def async_setup_entry(
|
||||
entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key))
|
||||
async_add_entities(entities)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
{vol.Required("minutes"): cv.positive_int},
|
||||
"set_time_to",
|
||||
)
|
||||
|
||||
|
||||
class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
|
||||
"""Representation of Advantage Air timer control."""
|
||||
|
||||
27
homeassistant/components/advantage_air/services.py
Normal file
27
homeassistant/components/advantage_air/services.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Services for Advantage Air integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Home Assistant services."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
entity_domain=SENSOR_DOMAIN,
|
||||
schema={vol.Required("minutes"): cv.positive_int},
|
||||
func="set_time_to",
|
||||
)
|
||||
@@ -51,7 +51,7 @@ DEFAULT_NAME_HP = "HomePod"
|
||||
BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
|
||||
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
|
||||
63
homeassistant/components/apple_tv/binary_sensor.py
Normal file
63
homeassistant/components/apple_tv/binary_sensor.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Binary sensor support for Apple TV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.const import KeyboardFocusState
|
||||
from pyatv.interface import AppleTV, KeyboardListener
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AppleTvConfigEntry
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AppleTvConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Load Apple TV binary sensor based on a config entry."""
|
||||
# apple_tv config entries always have a unique id
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
manager = config_entry.runtime_data
|
||||
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
|
||||
|
||||
|
||||
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
|
||||
"""Binary sensor for Text input focused."""
|
||||
|
||||
_attr_translation_key = "keyboard_focused"
|
||||
_attr_available = True
|
||||
|
||||
@callback
|
||||
def async_device_connected(self, atv: AppleTV) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
self._attr_available = True
|
||||
# Listen to keyboard updates
|
||||
atv.keyboard.listener = self
|
||||
# Set initial state based on current focus state
|
||||
self._update_state(atv.keyboard.text_focus_state == KeyboardFocusState.Focused)
|
||||
|
||||
@callback
|
||||
def async_device_disconnected(self) -> None:
|
||||
"""Handle when connection was lost to device."""
|
||||
self._attr_available = False
|
||||
self._update_state(False)
|
||||
|
||||
def focusstate_update(
|
||||
self, old_state: KeyboardFocusState, new_state: KeyboardFocusState
|
||||
) -> None:
|
||||
"""Update keyboard state when it changes.
|
||||
|
||||
This is a callback function from pyatv.interface.KeyboardListener.
|
||||
"""
|
||||
self._update_state(new_state == KeyboardFocusState.Focused)
|
||||
|
||||
def _update_state(self, new_state: bool) -> None:
|
||||
"""Update and report."""
|
||||
self._attr_is_on = new_state
|
||||
self.async_write_ha_state()
|
||||
@@ -18,7 +18,6 @@ class AppleTVEntity(Entity):
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
atv: AppleTVInterface | None = None
|
||||
|
||||
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
|
||||
|
||||
12
homeassistant/components/apple_tv/icons.json
Normal file
12
homeassistant/components/apple_tv/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"keyboard_focused": {
|
||||
"default": "mdi:keyboard",
|
||||
"state": {
|
||||
"off": "mdi:keyboard-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ class AppleTvMediaPlayer(
|
||||
"""Representation of an Apple TV media player."""
|
||||
|
||||
_attr_supported_features = SUPPORT_APPLE_TV
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
|
||||
"""Initialize the Apple TV media player."""
|
||||
|
||||
@@ -51,6 +51,8 @@ async def async_setup_entry(
|
||||
class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
||||
"""Device that sends commands to an Apple TV."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is on."""
|
||||
|
||||
@@ -62,6 +62,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"keyboard_focused": {
|
||||
"name": "Keyboard focus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -125,6 +125,7 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
|
||||
_EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"light",
|
||||
"siren",
|
||||
|
||||
@@ -17,10 +17,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
from .websocket import BeoWebsocket
|
||||
|
||||
|
||||
@@ -41,6 +43,14 @@ PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
@@ -38,7 +38,6 @@ from mozart_api.models import (
|
||||
VolumeState,
|
||||
)
|
||||
from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -56,17 +55,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MODEL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import BeoConfigEntry
|
||||
@@ -74,7 +66,6 @@ from .const import (
|
||||
BEO_REPEAT_FROM_HA,
|
||||
BEO_REPEAT_TO_HA,
|
||||
BEO_STATES,
|
||||
BEOLINK_JOIN_SOURCES,
|
||||
BEOLINK_JOIN_SOURCES_TO_UPPER,
|
||||
CONF_BEOLINK_JID,
|
||||
CONNECTION_STATUS,
|
||||
@@ -129,61 +120,6 @@ async def async_setup_entry(
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
# Register actions.
|
||||
platform = async_get_current_platform()
|
||||
|
||||
jid_regex = vol.Match(
|
||||
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
name="beolink_join",
|
||||
schema={
|
||||
vol.Optional("beolink_jid"): jid_regex,
|
||||
vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES),
|
||||
},
|
||||
func="async_beolink_join",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
name="beolink_expand",
|
||||
schema={
|
||||
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
|
||||
vol.Exclusive(
|
||||
"beolink_jids",
|
||||
"devices",
|
||||
"Define either specific Beolink JIDs or all discovered",
|
||||
): vol.All(
|
||||
cv.ensure_list,
|
||||
[jid_regex],
|
||||
),
|
||||
},
|
||||
func="async_beolink_expand",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
name="beolink_unexpand",
|
||||
schema={
|
||||
vol.Required("beolink_jids"): vol.All(
|
||||
cv.ensure_list,
|
||||
[jid_regex],
|
||||
),
|
||||
},
|
||||
func="async_beolink_unexpand",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
name="beolink_leave",
|
||||
schema=None,
|
||||
func="async_beolink_leave",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
name="beolink_allstandby",
|
||||
schema=None,
|
||||
func="async_beolink_allstandby",
|
||||
)
|
||||
|
||||
|
||||
class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
"""Representation of a media player."""
|
||||
|
||||
83
homeassistant/components/bang_olufsen/services.py
Normal file
83
homeassistant/components/bang_olufsen/services.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Services for Bang & Olufsen integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import BEOLINK_JOIN_SOURCES, DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Home Assistant services."""
|
||||
|
||||
jid_regex = vol.Match(
|
||||
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"beolink_join",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Optional("beolink_jid"): jid_regex,
|
||||
vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES),
|
||||
},
|
||||
func="async_beolink_join",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"beolink_expand",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
|
||||
vol.Exclusive(
|
||||
"beolink_jids",
|
||||
"devices",
|
||||
"Define either specific Beolink JIDs or all discovered",
|
||||
): vol.All(
|
||||
cv.ensure_list,
|
||||
[jid_regex],
|
||||
),
|
||||
},
|
||||
func="async_beolink_expand",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"beolink_unexpand",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required("beolink_jids"): vol.All(
|
||||
cv.ensure_list,
|
||||
[jid_regex],
|
||||
),
|
||||
},
|
||||
func="async_beolink_unexpand",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"beolink_leave",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_beolink_leave",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"beolink_allstandby",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_beolink_allstandby",
|
||||
)
|
||||
17
homeassistant/components/device_tracker/condition.py
Normal file
17
homeassistant/components/device_tracker/condition.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Provides conditions for device trackers."""
|
||||
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_home": make_entity_state_condition(DOMAIN, STATE_HOME),
|
||||
"is_not_home": make_entity_state_condition(DOMAIN, STATE_NOT_HOME),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for device trackers."""
|
||||
return CONDITIONS
|
||||
17
homeassistant/components/device_tracker/conditions.yaml
Normal file
17
homeassistant/components/device_tracker/conditions.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: device_tracker
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_home: *condition_common
|
||||
is_not_home: *condition_common
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_home": {
|
||||
"condition": "mdi:account"
|
||||
},
|
||||
"is_not_home": {
|
||||
"condition": "mdi:account-arrow-right"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:account",
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted device trackers.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_home": {
|
||||
"description": "Tests if one or more device trackers are home.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::device_tracker::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Device tracker is home"
|
||||
},
|
||||
"is_not_home": {
|
||||
"description": "Tests if one or more device trackers are not home.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::device_tracker::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Device tracker is not home"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_home": "{entity_name} is home",
|
||||
@@ -49,6 +73,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"benzene": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"nitrogen_monoxide": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"non_methane_hydrocarbons": {
|
||||
"default": "mdi:molecule"
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
key="no",
|
||||
translation_key="nitrogen_monoxide",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.NITROGEN_MONOXIDE,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.no.concentration.units,
|
||||
value_fn=lambda x: x.pollutants.no.concentration.value,
|
||||
exists_fn=lambda x: "no" in {p.code for p in x.pollutants},
|
||||
|
||||
@@ -205,9 +205,6 @@
|
||||
"so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||
}
|
||||
},
|
||||
"nitrogen_monoxide": {
|
||||
"name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
|
||||
},
|
||||
"non_methane_hydrocarbons": {
|
||||
"name": "Non-methane hydrocarbons"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
from pyhik.constants import SENSOR_MAP
|
||||
from pyhik.hikvision import HikCamera
|
||||
@@ -88,7 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
|
||||
def fetch_and_inject_nvr_events() -> None:
|
||||
"""Fetch and inject NVR events in a single executor job."""
|
||||
nvr_events = camera.get_event_triggers(nvr_notification_methods)
|
||||
try:
|
||||
nvr_events = camera.get_event_triggers(nvr_notification_methods)
|
||||
except (requests.exceptions.RequestException, ParseError) as err:
|
||||
_LOGGER.warning("Unable to fetch event triggers from %s: %s", host, err)
|
||||
return
|
||||
|
||||
_LOGGER.debug("NVR events fetched with extended methods: %s", nvr_events)
|
||||
if nvr_events:
|
||||
# Map raw event type names to friendly names using SENSOR_MAP
|
||||
@@ -101,6 +107,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
mapped_events[friendly_name] = list(channels)
|
||||
_LOGGER.debug("Mapped NVR events: %s", mapped_events)
|
||||
camera.inject_events(mapped_events)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No event triggers returned from %s. "
|
||||
"Ensure events are configured on the device",
|
||||
host,
|
||||
)
|
||||
|
||||
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -36,6 +35,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import HikvisionConfigEntry
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .entity import HikvisionEntity
|
||||
|
||||
CONF_IGNORED = "ignored"
|
||||
|
||||
@@ -150,7 +150,12 @@ async def async_setup_entry(
|
||||
|
||||
sensors = camera.current_event_states
|
||||
if sensors is None or not sensors:
|
||||
_LOGGER.warning("Hikvision device has no sensors available")
|
||||
_LOGGER.warning(
|
||||
"Hikvision %s %s has no sensors available. "
|
||||
"Ensure event detection is enabled and configured on the device",
|
||||
data.device_type,
|
||||
data.device_name,
|
||||
)
|
||||
return
|
||||
|
||||
async_add_entities(
|
||||
@@ -164,10 +169,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class HikvisionBinarySensor(BinarySensorEntity):
|
||||
class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity):
|
||||
"""Representation of a Hikvision binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
@@ -177,38 +181,14 @@ class HikvisionBinarySensor(BinarySensorEntity):
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
self._data = entry.runtime_data
|
||||
self._camera = self._data.camera
|
||||
super().__init__(entry, channel)
|
||||
self._sensor_type = sensor_type
|
||||
self._channel = channel
|
||||
|
||||
# Build unique ID
|
||||
# Build unique ID (includes sensor_type for uniqueness per sensor)
|
||||
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
|
||||
|
||||
# Device info for device registry
|
||||
if self._data.device_type == "NVR":
|
||||
# NVR channels get their own device linked to the NVR via via_device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
|
||||
via_device=(DOMAIN, self._data.device_id),
|
||||
translation_key="nvr_channel",
|
||||
translation_placeholders={
|
||||
"device_name": self._data.device_name,
|
||||
"channel_number": str(channel),
|
||||
},
|
||||
manufacturer="Hikvision",
|
||||
model="NVR Channel",
|
||||
)
|
||||
self._attr_name = sensor_type
|
||||
else:
|
||||
# Single camera device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
self._attr_name = sensor_type
|
||||
# Set entity name
|
||||
self._attr_name = sensor_type
|
||||
|
||||
# Set device class
|
||||
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)
|
||||
|
||||
@@ -5,11 +5,10 @@ from __future__ import annotations
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HikvisionConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import HikvisionEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -35,10 +34,9 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HikvisionCamera(Camera):
|
||||
class HikvisionCamera(HikvisionEntity, Camera):
|
||||
"""Representation of a Hikvision camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
@@ -48,37 +46,11 @@ class HikvisionCamera(Camera):
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__()
|
||||
self._data = entry.runtime_data
|
||||
self._channel = channel
|
||||
self._camera = self._data.camera
|
||||
super().__init__(entry, channel)
|
||||
|
||||
# Build unique ID (unique per platform per integration)
|
||||
self._attr_unique_id = f"{self._data.device_id}_{channel}"
|
||||
|
||||
# Device info for device registry
|
||||
if self._data.device_type == "NVR":
|
||||
# NVR channels get their own device linked to the NVR via via_device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
|
||||
via_device=(DOMAIN, self._data.device_id),
|
||||
translation_key="nvr_channel",
|
||||
translation_placeholders={
|
||||
"device_name": self._data.device_name,
|
||||
"channel_number": str(channel),
|
||||
},
|
||||
manufacturer="Hikvision",
|
||||
model="NVR Channel",
|
||||
)
|
||||
else:
|
||||
# Single camera device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
|
||||
49
homeassistant/components/hikvision/entity.py
Normal file
49
homeassistant/components/hikvision/entity.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Base entity for Hikvision integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import HikvisionConfigEntry, HikvisionData
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class HikvisionEntity(Entity):
|
||||
"""Base class for Hikvision entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: HikvisionConfigEntry,
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__()
|
||||
self._data: HikvisionData = entry.runtime_data
|
||||
self._camera = self._data.camera
|
||||
self._channel = channel
|
||||
|
||||
# Device info for device registry
|
||||
if self._data.device_type == "NVR":
|
||||
# NVR channels get their own device linked to the NVR via via_device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
|
||||
via_device=(DOMAIN, self._data.device_id),
|
||||
translation_key="nvr_channel",
|
||||
translation_placeholders={
|
||||
"device_name": self._data.device_name,
|
||||
"channel_number": str(channel),
|
||||
},
|
||||
manufacturer="Hikvision",
|
||||
model="NVR Channel",
|
||||
)
|
||||
else:
|
||||
# Single camera device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
@@ -157,6 +157,10 @@
|
||||
"description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant.",
|
||||
"title": "The {domain} integration does not support YAML configuration under its own key"
|
||||
},
|
||||
"python_version": {
|
||||
"description": "Support for running Home Assistant in the currently used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking.",
|
||||
"title": "Support for Python {current_python_version} is being removed"
|
||||
},
|
||||
"storage_corruption": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
|
||||
@@ -108,7 +108,6 @@ _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"]
|
||||
|
||||
HTTP_SCHEMA: Final = vol.All(
|
||||
cv.deprecated(CONF_BASE_URL),
|
||||
cv.deprecated(CONF_SERVER_HOST), # Deprecated in HA Core 2025.12
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_SERVER_HOST): vol.All(
|
||||
@@ -209,20 +208,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if conf is None:
|
||||
conf = cast(ConfData, HTTP_SCHEMA({}))
|
||||
|
||||
if CONF_SERVER_HOST in conf:
|
||||
if is_hassio(hass):
|
||||
issue_id = "server_host_deprecated_hassio"
|
||||
severity = ir.IssueSeverity.ERROR
|
||||
else:
|
||||
issue_id = "server_host_deprecated"
|
||||
severity = ir.IssueSeverity.WARNING
|
||||
if CONF_SERVER_HOST in conf and is_hassio(hass):
|
||||
issue_id = "server_host_deprecated_hassio"
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
severity=severity,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key=issue_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"issues": {
|
||||
"server_host_deprecated": {
|
||||
"description": "The `server_host` configuration option in the HTTP integration is deprecated and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
|
||||
"title": "The `server_host` HTTP configuration option is deprecated"
|
||||
},
|
||||
"server_host_deprecated_hassio": {
|
||||
"description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
|
||||
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.6.4",
|
||||
"insteon-frontend-home-assistant==0.6.0"
|
||||
"insteon-frontend-home-assistant==0.6.1"
|
||||
],
|
||||
"single_config_entry": true,
|
||||
"usb": [
|
||||
|
||||
@@ -73,7 +73,7 @@ SENSOR_TYPES: list[IOmeterEntityDescription] = [
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.status.device.core.battery_level,
|
||||
value_fn=lambda data: int(round(data.status.device.core.battery_level)),
|
||||
),
|
||||
IOmeterEntityDescription(
|
||||
key="pin_status",
|
||||
|
||||
@@ -51,6 +51,7 @@ ATTR_ALBUM = "album"
|
||||
ATTR_URL = "url"
|
||||
ATTR_USE_PRE_ANNOUNCE = "use_pre_announce"
|
||||
ATTR_ANNOUNCE_VOLUME = "announce_volume"
|
||||
ATTR_PRE_ANNOUNCE_URL = "pre_announce_url"
|
||||
ATTR_SOURCE_PLAYER = "source_player"
|
||||
ATTR_AUTO_PLAY = "auto_play"
|
||||
ATTR_QUEUE_ID = "queue_id"
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["music_assistant"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["music-assistant-client==1.3.2"],
|
||||
"requirements": ["music-assistant-client==1.3.3"],
|
||||
"zeroconf": ["_mass._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
await self._async_handle_play_announcement(
|
||||
media_id,
|
||||
use_pre_announce=kwargs[ATTR_MEDIA_EXTRA].get("use_pre_announce"),
|
||||
pre_announce_url=kwargs[ATTR_MEDIA_EXTRA].get("pre_announce_url"),
|
||||
announce_volume=kwargs[ATTR_MEDIA_EXTRA].get("announce_volume"),
|
||||
)
|
||||
return
|
||||
@@ -464,11 +465,16 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
self,
|
||||
url: str,
|
||||
use_pre_announce: bool | None = None,
|
||||
pre_announce_url: str | None = None,
|
||||
announce_volume: int | None = None,
|
||||
) -> None:
|
||||
"""Send the play_announcement command to the media player."""
|
||||
await self.mass.players.play_announcement(
|
||||
self.player_id, url, use_pre_announce, announce_volume
|
||||
self.player_id,
|
||||
url,
|
||||
pre_announce=use_pre_announce,
|
||||
pre_announce_url=pre_announce_url,
|
||||
volume_level=announce_volume,
|
||||
)
|
||||
|
||||
@catch_musicassistant_error
|
||||
|
||||
@@ -42,6 +42,7 @@ from .const import (
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_PRE_ANNOUNCE_URL,
|
||||
ATTR_RADIO,
|
||||
ATTR_RADIO_MODE,
|
||||
ATTR_SEARCH,
|
||||
@@ -150,6 +151,7 @@ def register_actions(hass: HomeAssistant) -> None:
|
||||
schema={
|
||||
vol.Required(ATTR_URL): cv.string,
|
||||
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
|
||||
vol.Optional(ATTR_PRE_ANNOUNCE_URL): cv.string,
|
||||
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
|
||||
},
|
||||
func="_async_handle_play_announcement",
|
||||
|
||||
@@ -68,6 +68,10 @@ play_announcement:
|
||||
example: "true"
|
||||
selector:
|
||||
boolean:
|
||||
pre_announce_url:
|
||||
example: "http://someremotesite.com/chime.mp3"
|
||||
selector:
|
||||
text:
|
||||
announce_volume:
|
||||
example: 75
|
||||
selector:
|
||||
|
||||
@@ -169,6 +169,10 @@
|
||||
"description": "Use a forced volume level for the announcement. Omit to use player default.",
|
||||
"name": "Announce volume"
|
||||
},
|
||||
"pre_announce_url": {
|
||||
"description": "URL to the pre-announcement sound.",
|
||||
"name": "Pre-announce URL"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL to the notification sound.",
|
||||
"name": "URL"
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.4"]
|
||||
"requirements": ["opower==0.16.5"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"data_description": {
|
||||
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
|
||||
},
|
||||
"description": "The NPSSO token for **{name}** has expired. To obtain a new one, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token.",
|
||||
"description": "The NPSSO token for **{name}** has expired. To obtain a new one, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token.\n\nNote: After obtaining the NPSSO token, do not log out of your PlayStation account, as this will invalidate the token.",
|
||||
"title": "Re-authenticate {name} with PlayStation Network"
|
||||
},
|
||||
"reconfigure": {
|
||||
@@ -44,7 +44,7 @@
|
||||
"data_description": {
|
||||
"npsso": "The NPSSO token is generated upon successful login of your PlayStation Network account and is used to authenticate your requests within Home Assistant."
|
||||
},
|
||||
"description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token."
|
||||
"description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token.\n\nNote: Do not log out of your PlayStation account after obtaining the NPSSO token."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"homekit": {
|
||||
"models": ["Rachio"]
|
||||
},
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["rachiopy"],
|
||||
"requirements": ["RachioPy==1.1.0"],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/rainforest_eagle",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioeagle", "eagle100"],
|
||||
"requirements": ["aioeagle==1.1.0", "eagle100==0.1.1"]
|
||||
|
||||
@@ -355,16 +355,3 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
await self._async_handle_restored_state()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle update of the data."""
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if self.handle_rendered_result(CONF_STATE):
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
@@ -183,8 +184,32 @@ class AbstractTemplateBinarySensor(
|
||||
|
||||
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
|
||||
self._template: template.Template = config[CONF_STATE]
|
||||
self._delay_on = None
|
||||
self._delay_off = None
|
||||
self._delay_cancel: CALLBACK_TYPE | None = None
|
||||
|
||||
self.setup_state_template(
|
||||
CONF_STATE,
|
||||
"_attr_is_on",
|
||||
on_update=self._update_state,
|
||||
)
|
||||
self._delay_on = None
|
||||
try:
|
||||
self._delay_on = cv.positive_time_period(config.get(CONF_DELAY_ON))
|
||||
except vol.Invalid:
|
||||
self.setup_template(CONF_DELAY_ON, "_delay_on", cv.positive_time_period)
|
||||
|
||||
self._delay_off = None
|
||||
try:
|
||||
self._delay_off = cv.positive_time_period(config.get(CONF_DELAY_OFF))
|
||||
except vol.Invalid:
|
||||
self.setup_template(CONF_DELAY_OFF, "_delay_off", cv.positive_time_period)
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def _update_state(self, result: Any) -> None:
|
||||
"""Update the state."""
|
||||
|
||||
|
||||
class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
|
||||
"""A virtual binary sensor that triggers from another sensor."""
|
||||
@@ -200,17 +225,15 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
|
||||
"""Initialize the Template binary sensor."""
|
||||
TemplateEntity.__init__(self, hass, config, unique_id)
|
||||
AbstractTemplateBinarySensor.__init__(self, config)
|
||||
self._delay_on = None
|
||||
self._delay_on_template = config.get(CONF_DELAY_ON)
|
||||
self._delay_off = None
|
||||
self._delay_off_template = config.get(CONF_DELAY_OFF)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore state."""
|
||||
if (
|
||||
(
|
||||
self._delay_on_template is not None
|
||||
or self._delay_off_template is not None
|
||||
CONF_DELAY_ON in self._templates
|
||||
or CONF_DELAY_OFF in self._templates
|
||||
or self._delay_on is not None
|
||||
or self._delay_off is not None
|
||||
)
|
||||
and (last_state := await self.async_get_last_state()) is not None
|
||||
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
@@ -218,29 +241,6 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def _async_setup_templates(self) -> None:
|
||||
"""Set up templates."""
|
||||
self.add_template_attribute("_state", self._template, None, self._update_state)
|
||||
|
||||
if self._delay_on_template is not None:
|
||||
try:
|
||||
self._delay_on = cv.positive_time_period(self._delay_on_template)
|
||||
except vol.Invalid:
|
||||
self.add_template_attribute(
|
||||
"_delay_on", self._delay_on_template, cv.positive_time_period
|
||||
)
|
||||
|
||||
if self._delay_off_template is not None:
|
||||
try:
|
||||
self._delay_off = cv.positive_time_period(self._delay_off_template)
|
||||
except vol.Invalid:
|
||||
self.add_template_attribute(
|
||||
"_delay_off", self._delay_off_template, cv.positive_time_period
|
||||
)
|
||||
|
||||
super()._async_setup_templates()
|
||||
|
||||
@callback
|
||||
def _update_state(self, result):
|
||||
super()._update_state(result)
|
||||
@@ -291,15 +291,11 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
TriggerEntity.__init__(self, hass, coordinator, config)
|
||||
AbstractTemplateBinarySensor.__init__(self, config)
|
||||
|
||||
for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF):
|
||||
if isinstance(config.get(key), template.Template):
|
||||
self._to_render_simple.append(key)
|
||||
self._parse_result.add(key)
|
||||
|
||||
self._last_delay_from: bool | None = None
|
||||
self._last_delay_to: bool | None = None
|
||||
self._auto_off_cancel: CALLBACK_TYPE | None = None
|
||||
self._auto_off_time: datetime | None = None
|
||||
self.setup_template(CONF_AUTO_OFF, "_auto_off_time", cv.positive_time_period)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
@@ -329,26 +325,7 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
self._set_auto_off(auto_off_time)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle update of the data."""
|
||||
self._process_data()
|
||||
|
||||
raw = self._rendered.get(CONF_STATE)
|
||||
state: bool | None = None
|
||||
if raw is not None:
|
||||
state = template.result_as_boolean(raw)
|
||||
|
||||
key = CONF_DELAY_ON if state else CONF_DELAY_OFF
|
||||
delay = self._rendered.get(key) or self._config.get(key)
|
||||
|
||||
if (
|
||||
self._delay_cancel
|
||||
and delay
|
||||
and self._attr_is_on == self._last_delay_from
|
||||
and state == self._last_delay_to
|
||||
):
|
||||
return
|
||||
|
||||
def _cancel_delays(self):
|
||||
if self._delay_cancel:
|
||||
self._delay_cancel()
|
||||
self._delay_cancel = None
|
||||
@@ -358,10 +335,27 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
self._auto_off_cancel = None
|
||||
self._auto_off_time = None
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
@callback
|
||||
def _update_state(self, result):
|
||||
state: bool | None = None
|
||||
if result is not None:
|
||||
state = template.result_as_boolean(result)
|
||||
|
||||
if state:
|
||||
delay = self._rendered.get(CONF_DELAY_ON) or self._delay_on
|
||||
else:
|
||||
delay = self._rendered.get(CONF_DELAY_OFF) or self._delay_off
|
||||
|
||||
if (
|
||||
self._delay_cancel
|
||||
and delay
|
||||
and self._attr_is_on == self._last_delay_from
|
||||
and state == self._last_delay_to
|
||||
):
|
||||
return
|
||||
|
||||
self._cancel_delays()
|
||||
|
||||
# state without delay.
|
||||
if self._attr_is_on == state or delay is None:
|
||||
self._set_state(state)
|
||||
@@ -371,6 +365,7 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
try:
|
||||
delay = cv.positive_time_period(delay)
|
||||
except vol.Invalid as err:
|
||||
key = CONF_DELAY_ON if state else CONF_DELAY_OFF
|
||||
logging.getLogger(__name__).warning(
|
||||
"Error rendering %s template: %s", key, err
|
||||
)
|
||||
@@ -412,6 +407,14 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
|
||||
auto_off_time = dt_util.utcnow() + auto_off_delay
|
||||
self._set_auto_off(auto_off_time)
|
||||
|
||||
def _render_availability_template(self, variables):
|
||||
available = super()._render_availability_template(variables)
|
||||
if not available:
|
||||
# Cancel any delay_on, delay_off, or auto_off when
|
||||
# the entity goes unavailable
|
||||
self._cancel_delays()
|
||||
return available
|
||||
|
||||
def _set_auto_off(self, auto_off_time: datetime) -> None:
|
||||
@callback
|
||||
def _auto_off(_):
|
||||
|
||||
@@ -500,7 +500,6 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -96,6 +96,30 @@ class AbstractTemplateEntity(Entity):
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
|
||||
@abstractmethod
|
||||
def setup_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages any property or attribute of the entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
option
|
||||
The configuration key provided by ConfigFlow or the yaml option
|
||||
attribute
|
||||
The name of the attribute to link to. This attribute must exist
|
||||
unless a custom on_update method is supplied.
|
||||
validator:
|
||||
Optional function that validates the rendered result.
|
||||
on_update:
|
||||
Called to store the template result rather than storing it
|
||||
the supplied attribute. Passed the result of the validator.
|
||||
"""
|
||||
|
||||
def add_template(
|
||||
self,
|
||||
option: str,
|
||||
@@ -109,7 +133,11 @@ class AbstractTemplateEntity(Entity):
|
||||
if (template := self._config.get(option)) and isinstance(template, Template):
|
||||
if add_if_static or (not template.is_static):
|
||||
self._templates[option] = EntityTemplate(
|
||||
attribute, template, validator, on_update, none_on_template_error
|
||||
attribute,
|
||||
template,
|
||||
validator,
|
||||
on_update,
|
||||
none_on_template_error,
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ class TriggerEventEntity(TriggerEntity, AbstractTemplateEvent, RestoreEntity):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
for key, updater in (
|
||||
|
||||
@@ -552,7 +552,6 @@ class TriggerFanEntity(TriggerEntity, AbstractTemplateFan):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -180,3 +180,4 @@ class TriggerImageEntity(TriggerEntity, AbstractTemplateImage):
|
||||
"""Process new data."""
|
||||
super()._process_data()
|
||||
self._handle_state(self._rendered.get(CONF_URL))
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1123,7 +1123,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -377,7 +377,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -236,7 +236,6 @@ class TriggerNumberEntity(TriggerEntity, AbstractTemplateNumber):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -209,7 +209,6 @@ class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -323,3 +323,4 @@ class TriggerSensorEntity(TriggerEntity, AbstractTemplateSensor):
|
||||
|
||||
rendered = self._rendered.get(CONF_STATE)
|
||||
self._handle_state(rendered)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -281,7 +281,6 @@ class TriggerSwitchEntity(TriggerEntity, AbstractTemplateSwitch):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -303,6 +303,30 @@ class TemplateEntity(AbstractTemplateEntity):
|
||||
|
||||
self.add_template(option, attribute, on_update=_update_state)
|
||||
|
||||
def setup_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
):
|
||||
"""Set up a template that manages any property or attribute of the entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
option
|
||||
The configuration key provided by ConfigFlow or the yaml option
|
||||
attribute
|
||||
The name of the attribute to link to. This attribute must exist
|
||||
unless a custom on_update method is supplied.
|
||||
validator:
|
||||
Optional function that validates the rendered result.
|
||||
on_update:
|
||||
Called to store the template result rather than storing it
|
||||
the supplied attribute. Passed the result of the validator.
|
||||
"""
|
||||
self.add_template(option, attribute, validator, on_update, True)
|
||||
|
||||
def add_template_attribute(
|
||||
self,
|
||||
attribute: str,
|
||||
|
||||
@@ -59,10 +59,33 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
if self._config.get(option):
|
||||
self._to_render_simple.append(CONF_STATE)
|
||||
self._parse_result.add(CONF_STATE)
|
||||
self.add_template(option, attribute, validator, on_update)
|
||||
if self.add_template(option, attribute, validator, on_update):
|
||||
self._to_render_simple.append(option)
|
||||
self._parse_result.add(option)
|
||||
|
||||
def setup_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages any property or attribute of the entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
option
|
||||
The configuration key provided by ConfigFlow or the yaml option
|
||||
attribute
|
||||
The name of the attribute to link to. This attribute must exist
|
||||
unless a custom on_update method is supplied.
|
||||
validator:
|
||||
Optional function that validates the rendered result.
|
||||
on_update:
|
||||
Called to store the template result rather than storing it
|
||||
the supplied attribute. Passed the result of the validator.
|
||||
"""
|
||||
self.setup_state_template(option, attribute, validator, on_update)
|
||||
|
||||
@property
|
||||
def referenced_blueprint(self) -> str | None:
|
||||
@@ -103,21 +126,35 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
self._render_attributes(rendered, variables)
|
||||
self._rendered = rendered
|
||||
|
||||
def handle_rendered_result(self, key: str) -> bool:
|
||||
def _handle_rendered_results(self) -> bool:
|
||||
"""Get a rendered result and return the value."""
|
||||
if (rendered := self._rendered.get(key)) is not None:
|
||||
if (entity_template := self._templates.get(key)) is not None:
|
||||
# Handle any templates.
|
||||
for option, entity_template in self._templates.items():
|
||||
value = _SENTINEL
|
||||
if (rendered := self._rendered.get(option)) is not None:
|
||||
value = rendered
|
||||
if entity_template.validator:
|
||||
value = entity_template.validator(rendered)
|
||||
|
||||
if entity_template.on_update:
|
||||
entity_template.on_update(value)
|
||||
else:
|
||||
setattr(self, entity_template.attribute, value)
|
||||
if entity_template.validator:
|
||||
value = entity_template.validator(rendered)
|
||||
|
||||
# Capture templates that did not render a result due to an exception and
|
||||
# ensure the state object updates. _SENTINEL is used to differentiate
|
||||
# templates that render None.
|
||||
if value is _SENTINEL:
|
||||
return True
|
||||
|
||||
if entity_template.on_update:
|
||||
entity_template.on_update(value)
|
||||
else:
|
||||
setattr(self, entity_template.attribute, value)
|
||||
return True
|
||||
|
||||
if len(self._rendered) > 0:
|
||||
# In some cases, the entity may be state optimistic or
|
||||
# attribute optimistic, in these scenarios the state needs
|
||||
# to update.
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@callback
|
||||
@@ -136,13 +173,35 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
else:
|
||||
self._rendered_entity_variables = coordinator_variables
|
||||
variables = self._template_variables(self._rendered_entity_variables)
|
||||
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
if self._render_availability_template(variables):
|
||||
self._render_templates(variables)
|
||||
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
write_state = False
|
||||
# While transitioning platforms to the new framework, this
|
||||
# if-statement is necessary for backward compatibility with existing
|
||||
# trigger based platforms.
|
||||
if self._templates:
|
||||
# Handle any results that were rendered.
|
||||
write_state = self._handle_rendered_results()
|
||||
|
||||
# Check availability after rendering the results because the state
|
||||
# template could render the entity unavailable
|
||||
if not self.available:
|
||||
write_state = True
|
||||
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
"""Handle updated data from the coordinator.
|
||||
|
||||
While transitioning platforms to the new framework, this
|
||||
function is necessary for backward compatibility with existing
|
||||
trigger based platforms.
|
||||
"""
|
||||
self._process_data()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -438,7 +438,6 @@ class TriggerUpdateEntity(TriggerEntity, AbstractTemplateUpdate):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -489,7 +489,6 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -747,7 +747,6 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
|
||||
self._process_data()
|
||||
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
write_ha_state = False
|
||||
|
||||
@@ -107,6 +107,11 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
|
||||
translation_key="feeding",
|
||||
on_value="feeding",
|
||||
),
|
||||
TuyaBinarySensorEntityDescription(
|
||||
key=DPCode.CHARGE_STATE,
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
),
|
||||
DeviceCategory.DGNBJ: (
|
||||
TuyaBinarySensorEntityDescription(
|
||||
|
||||
@@ -242,6 +242,13 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.CWWSQ: (
|
||||
TuyaLightEntityDescription(
|
||||
key=DPCode.LIGHT,
|
||||
translation_key="light",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.DC: (
|
||||
TuyaLightEntityDescription(
|
||||
key=DPCode.SWITCH_LED,
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Self
|
||||
|
||||
from tuya_sharing import CustomerDevice
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
|
||||
from .type_information import (
|
||||
BitmapTypeInformation,
|
||||
BooleanTypeInformation,
|
||||
@@ -17,12 +20,15 @@ from .type_information import (
|
||||
TypeInformation,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceWrapper[T]:
|
||||
"""Base device wrapper."""
|
||||
|
||||
native_unit: str | None = None
|
||||
suggested_unit: str | None = None
|
||||
state_class: SensorStateClass | None = None
|
||||
|
||||
max_value: float
|
||||
min_value: float
|
||||
@@ -30,6 +36,13 @@ class DeviceWrapper[T]:
|
||||
|
||||
options: list[str]
|
||||
|
||||
def initialize(self, device: CustomerDevice) -> None:
|
||||
"""Initialize the wrapper with device data.
|
||||
|
||||
Called when the entity is added to Home Assistant.
|
||||
Override in subclasses to perform initialization logic.
|
||||
"""
|
||||
|
||||
def skip_update(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
@@ -210,6 +223,59 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation])
|
||||
)
|
||||
|
||||
|
||||
class DPCodeDeltaIntegerWrapper(DPCodeIntegerWrapper):
|
||||
"""Wrapper for integer values with delta report accumulation.
|
||||
|
||||
This wrapper handles sensors that report incremental (delta) values
|
||||
instead of cumulative totals. It accumulates the delta values locally
|
||||
to provide a running total.
|
||||
"""
|
||||
|
||||
_accumulated_value: float = 0
|
||||
_last_dp_timestamp: int | None = None
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
|
||||
"""Init DPCodeDeltaIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
# Delta reports use TOTAL_INCREASING state class
|
||||
self.state_class = SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
def skip_update(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
updated_status_properties: list[str] | None,
|
||||
dp_timestamps: dict[str, int] | None,
|
||||
) -> bool:
|
||||
"""Override skip_update to process delta updates.
|
||||
|
||||
Processes delta accumulation before determining if update should be skipped.
|
||||
"""
|
||||
if (
|
||||
super().skip_update(device, updated_status_properties, dp_timestamps)
|
||||
or dp_timestamps is None
|
||||
or (current_timestamp := dp_timestamps.get(self.dpcode)) is None
|
||||
or current_timestamp == self._last_dp_timestamp
|
||||
or (raw_value := super().read_device_status(device)) is None
|
||||
):
|
||||
return True
|
||||
|
||||
delta = float(raw_value)
|
||||
self._accumulated_value += delta
|
||||
_LOGGER.debug(
|
||||
"Delta update for %s: +%s, total: %s",
|
||||
self.dpcode,
|
||||
delta,
|
||||
self._accumulated_value,
|
||||
)
|
||||
|
||||
self._last_dp_timestamp = current_timestamp
|
||||
return False
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read device status, returning accumulated value for delta reports."""
|
||||
return self._accumulated_value
|
||||
|
||||
|
||||
class DPCodeRawWrapper(DPCodeTypeInformationWrapper[RawTypeInformation]):
|
||||
"""Wrapper to extract information from a RAW/binary value."""
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from .const import (
|
||||
from .entity import TuyaEntity
|
||||
from .models import (
|
||||
DeviceWrapper,
|
||||
DPCodeDeltaIntegerWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
@@ -48,7 +49,7 @@ from .models import (
|
||||
DPCodeWrapper,
|
||||
)
|
||||
from .raw_data_models import ElectricityData
|
||||
from .type_information import EnumTypeInformation
|
||||
from .type_information import EnumTypeInformation, IntegerTypeInformation
|
||||
|
||||
|
||||
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
|
||||
@@ -350,6 +351,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
*BATTERY_SENSORS,
|
||||
),
|
||||
DeviceCategory.CWWSQ: (
|
||||
*BATTERY_SENSORS,
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.FEED_REPORT,
|
||||
translation_key="last_amount",
|
||||
@@ -1739,11 +1741,13 @@ def _get_dpcode_wrapper(
|
||||
return wrapper
|
||||
return None
|
||||
|
||||
for cls in (DPCodeIntegerWrapper, DPCodeEnumWrapper):
|
||||
if wrapper := cls.find_dpcode(device, dpcode):
|
||||
return wrapper
|
||||
# Check for integer type first, using delta wrapper only for sum report_type
|
||||
if type_information := IntegerTypeInformation.find_dpcode(device, dpcode):
|
||||
if type_information.report_type == "sum":
|
||||
return DPCodeDeltaIntegerWrapper(type_information.dpcode, type_information)
|
||||
return DPCodeIntegerWrapper(type_information.dpcode, type_information)
|
||||
|
||||
return None
|
||||
return DPCodeEnumWrapper.find_dpcode(device, dpcode)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -1798,6 +1802,8 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
|
||||
if description.suggested_unit_of_measurement is None:
|
||||
self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit
|
||||
if description.state_class is None:
|
||||
self._attr_state_class = dpcode_wrapper.state_class
|
||||
|
||||
self._validate_device_class_unit()
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ class TypeInformation[T]:
|
||||
return raw_value
|
||||
|
||||
@classmethod
|
||||
def _from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
def _from_json(
|
||||
cls, dpcode: str, type_data: str, *, report_type: str | None
|
||||
) -> Self | None:
|
||||
"""Load JSON string and return a TypeInformation object."""
|
||||
return cls(dpcode=dpcode, type_data=type_data)
|
||||
|
||||
@@ -80,13 +82,18 @@ class TypeInformation[T]:
|
||||
)
|
||||
|
||||
for dpcode in dpcodes:
|
||||
report_type = (
|
||||
sr.report_type if (sr := device.status_range.get(dpcode)) else None
|
||||
)
|
||||
for device_specs in lookup_tuple:
|
||||
if (
|
||||
(current_definition := device_specs.get(dpcode))
|
||||
and parse_dptype(current_definition.type) is cls._DPTYPE
|
||||
and (
|
||||
type_information := cls._from_json(
|
||||
dpcode=dpcode, type_data=current_definition.values
|
||||
dpcode=dpcode,
|
||||
type_data=current_definition.values,
|
||||
report_type=report_type,
|
||||
)
|
||||
)
|
||||
):
|
||||
@@ -104,7 +111,9 @@ class BitmapTypeInformation(TypeInformation[int]):
|
||||
label: list[str]
|
||||
|
||||
@classmethod
|
||||
def _from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
def _from_json(
|
||||
cls, dpcode: str, type_data: str, *, report_type: str | None
|
||||
) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
|
||||
return None
|
||||
@@ -177,7 +186,9 @@ class EnumTypeInformation(TypeInformation[str]):
|
||||
return raw_value
|
||||
|
||||
@classmethod
|
||||
def _from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
def _from_json(
|
||||
cls, dpcode: str, type_data: str, *, report_type: str | None
|
||||
) -> Self | None:
|
||||
"""Load JSON string and return an EnumTypeInformation object."""
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
@@ -199,6 +210,7 @@ class IntegerTypeInformation(TypeInformation[float]):
|
||||
scale: int
|
||||
step: int
|
||||
unit: str | None = None
|
||||
report_type: str | None
|
||||
|
||||
def scale_value(self, value: int) -> float:
|
||||
"""Scale a value."""
|
||||
@@ -234,7 +246,9 @@ class IntegerTypeInformation(TypeInformation[float]):
|
||||
return raw_value / (10**self.scale)
|
||||
|
||||
@classmethod
|
||||
def _from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
def _from_json(
|
||||
cls, dpcode: str, type_data: str, *, report_type: str | None
|
||||
) -> Self | None:
|
||||
"""Load JSON string and return an IntegerTypeInformation object."""
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
|
||||
return None
|
||||
@@ -247,6 +261,7 @@ class IntegerTypeInformation(TypeInformation[float]):
|
||||
scale=int(parsed["scale"]),
|
||||
step=int(parsed["step"]),
|
||||
unit=parsed.get("unit"),
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/xbox",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["python-xbox==0.1.2"],
|
||||
"requirements": ["python-xbox==0.1.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Microsoft Corporation",
|
||||
|
||||
@@ -21,6 +21,9 @@ PATCH_VERSION: Final = "0.dev0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
# Truthy date string triggers showing related deprecation warning messages.
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = ""
|
||||
|
||||
# Format for platform files
|
||||
PLATFORM_FORMAT: Final = "{platform}.{domain}"
|
||||
|
||||
@@ -5452,7 +5452,7 @@
|
||||
"name": "Rainforest Automation",
|
||||
"integrations": {
|
||||
"rainforest_eagle": {
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "Rainforest Eagle"
|
||||
|
||||
@@ -47,14 +47,6 @@ SERVER_SOFTWARE = (
|
||||
f"aiohttp/{aiohttp.__version__} Python/{sys.version_info[0]}.{sys.version_info[1]}"
|
||||
)
|
||||
|
||||
ENABLE_CLEANUP_CLOSED = (3, 13, 0) <= sys.version_info < (
|
||||
3,
|
||||
13,
|
||||
1,
|
||||
) or sys.version_info < (3, 12, 7)
|
||||
# Cleanup closed is no longer needed after https://github.com/python/cpython/pull/118960
|
||||
# which first appeared in Python 3.12.7 and 3.13.1
|
||||
|
||||
WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session"
|
||||
|
||||
#
|
||||
@@ -380,7 +372,6 @@ def _async_get_connector(
|
||||
|
||||
connector = HomeAssistantTCPConnector(
|
||||
family=family,
|
||||
enable_cleanup_closed=ENABLE_CLEANUP_CLOSED,
|
||||
ssl=ssl_context,
|
||||
limit=MAXIMUM_CONNECTIONS,
|
||||
limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST,
|
||||
|
||||
@@ -69,7 +69,7 @@ standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.17
|
||||
uv==0.9.26
|
||||
voluptuous-openapi==0.2.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"typing-extensions>=4.15.0,<5.0",
|
||||
"ulid-transform==1.5.2",
|
||||
"urllib3>=2.0",
|
||||
"uv==0.9.17",
|
||||
"uv==0.9.26",
|
||||
"voluptuous==0.15.2",
|
||||
"voluptuous-serialize==2.7.0",
|
||||
"voluptuous-openapi==0.2.0",
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -53,7 +53,7 @@ standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.17
|
||||
uv==0.9.26
|
||||
voluptuous-openapi==0.2.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
|
||||
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -1296,7 +1296,7 @@ influxdb==5.3.1
|
||||
inkbird-ble==1.1.1
|
||||
|
||||
# homeassistant.components.insteon
|
||||
insteon-frontend-home-assistant==0.6.0
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.2.1
|
||||
@@ -1526,7 +1526,7 @@ mozart-api==5.3.1.108.0
|
||||
mullvad-api==1.0.0
|
||||
|
||||
# homeassistant.components.music_assistant
|
||||
music-assistant-client==1.3.2
|
||||
music-assistant-client==1.3.3
|
||||
|
||||
# homeassistant.components.tts
|
||||
mutagen==1.47.0
|
||||
@@ -1683,7 +1683,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.4
|
||||
opower==0.16.5
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -2607,7 +2607,7 @@ python-telegram-bot[socks]==22.1
|
||||
python-vlc==3.0.18122
|
||||
|
||||
# homeassistant.components.xbox
|
||||
python-xbox==0.1.2
|
||||
python-xbox==0.1.3
|
||||
|
||||
# homeassistant.components.egardia
|
||||
pythonegardia==1.0.52
|
||||
|
||||
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -1142,7 +1142,7 @@ influxdb==5.3.1
|
||||
inkbird-ble==1.1.1
|
||||
|
||||
# homeassistant.components.insteon
|
||||
insteon-frontend-home-assistant==0.6.0
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.2.1
|
||||
@@ -1333,7 +1333,7 @@ mozart-api==5.3.1.108.0
|
||||
mullvad-api==1.0.0
|
||||
|
||||
# homeassistant.components.music_assistant
|
||||
music-assistant-client==1.3.2
|
||||
music-assistant-client==1.3.3
|
||||
|
||||
# homeassistant.components.tts
|
||||
mutagen==1.47.0
|
||||
@@ -1457,7 +1457,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.4
|
||||
opower==0.16.5
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -2194,7 +2194,7 @@ python-technove==2.0.0
|
||||
python-telegram-bot[socks]==22.1
|
||||
|
||||
# homeassistant.components.xbox
|
||||
python-xbox==0.1.2
|
||||
python-xbox==0.1.3
|
||||
|
||||
# homeassistant.components.uptime_kuma
|
||||
pythonkuma==0.3.2
|
||||
|
||||
2
script/hassfest/docker/Dockerfile
generated
2
script/hassfest/docker/Dockerfile
generated
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
|
||||
COPY . /usr/src/homeassistant
|
||||
|
||||
# Uv is only needed during build
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.17,source=/uv,target=/bin/uv \
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
|
||||
# Uv creates a lock file in /tmp
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
# Required for PyTurboJPEG
|
||||
|
||||
@@ -41,7 +41,7 @@ PACKAGE_CHECK_VERSION_RANGE = {
|
||||
"pymodbus": "Custom",
|
||||
"pytz": "CalVer",
|
||||
"requests": "SemVer",
|
||||
"typing_extensions": "SemVer",
|
||||
"typing-extensions": "SemVer",
|
||||
"urllib3": "SemVer",
|
||||
"yarl": "SemVer",
|
||||
"zeroconf": "SemVer",
|
||||
|
||||
@@ -5,9 +5,11 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant.components.advantage_air.const import DOMAIN
|
||||
from homeassistant.components.advantage_air.sensor import (
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE,
|
||||
)
|
||||
from homeassistant.components.advantage_air.services import (
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
156
tests/components/device_tracker/test_condition.py
Normal file
156
tests/components/device_tracker/test_condition.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Test device tracker conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_device_trackers(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple device tracker entities associated with different targets."""
|
||||
return (await target_entities(hass, "device_tracker"))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"device_tracker.is_home",
|
||||
"device_tracker.is_not_home",
|
||||
],
|
||||
)
|
||||
async def test_device_tracker_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the device tracker conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("device_tracker"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_any(
|
||||
condition="device_tracker.is_home",
|
||||
target_states=[STATE_HOME],
|
||||
other_states=[STATE_NOT_HOME],
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="device_tracker.is_not_home",
|
||||
target_states=[STATE_NOT_HOME],
|
||||
other_states=[STATE_HOME],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_tracker_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_device_trackers: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the device tracker state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_device_trackers) - {entity_id}
|
||||
|
||||
# Set all device trackers, including the tested one, to the initial state
|
||||
for eid in target_device_trackers:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other device trackers also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("device_tracker"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_all(
|
||||
condition="device_tracker.is_home",
|
||||
target_states=[STATE_HOME],
|
||||
other_states=[STATE_NOT_HOME],
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="device_tracker.is_not_home",
|
||||
target_states=[STATE_NOT_HOME],
|
||||
other_states=[STATE_HOME],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_tracker_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_device_trackers: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the device tracker state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_device_trackers) - {entity_id}
|
||||
|
||||
# Set all device trackers, including the tested one, to the initial state
|
||||
for eid in target_device_trackers:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
@@ -376,14 +376,14 @@
|
||||
'object_id_base': 'Nitrogen monoxide',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_device_class': <SensorDeviceClass.NITROGEN_MONOXIDE: 'nitrogen_monoxide'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Nitrogen monoxide',
|
||||
'platform': 'google_air_quality',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'nitrogen_monoxide',
|
||||
'translation_key': None,
|
||||
'unique_id': 'no_10.1_20.1',
|
||||
'unit_of_measurement': 'ppb',
|
||||
})
|
||||
@@ -392,6 +392,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Google Air Quality',
|
||||
'device_class': 'nitrogen_monoxide',
|
||||
'friendly_name': 'Home Nitrogen monoxide',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'ppb',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Test Hikvision integration setup and unload."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -102,3 +104,69 @@ async def test_setup_entry_nvr_fetches_events(
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_called_once()
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_event_fetch_request_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR event fetch fails with request error."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.side_effect = (
|
||||
requests.exceptions.RequestException("Connection error")
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
assert f"Unable to fetch event triggers from {TEST_HOST}" in caplog.text
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_event_fetch_parse_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR event fetch fails with parse error."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.side_effect = ParseError("Invalid XML")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
assert f"Unable to fetch event triggers from {TEST_HOST}" in caplog.text
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_no_events_returned(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR returns no events."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.return_value = None
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
|
||||
|
||||
async def test_setup_entry_nvr_empty_events_returned(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hik_nvr: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup continues when NVR returns empty events."""
|
||||
mock_hik_nvr.return_value.get_event_triggers.return_value = {}
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
|
||||
mock_hik_nvr.return_value.inject_events.assert_not_called()
|
||||
|
||||
@@ -683,26 +683,18 @@ async def test_ssl_issue_urls_configured(
|
||||
"hassio",
|
||||
"http_config",
|
||||
"expected_serverhost",
|
||||
"expected_warning_count",
|
||||
"expected_issues",
|
||||
),
|
||||
[
|
||||
(False, {}, ["0.0.0.0", "::"], 0, set()),
|
||||
(
|
||||
False,
|
||||
{"server_host": "0.0.0.0"},
|
||||
["0.0.0.0"],
|
||||
1,
|
||||
{("http", "server_host_deprecated")},
|
||||
),
|
||||
(True, {}, ["0.0.0.0", "::"], 0, set()),
|
||||
(False, {}, ["0.0.0.0", "::"], set()),
|
||||
(False, {"server_host": "0.0.0.0"}, ["0.0.0.0"], set()),
|
||||
(True, {}, ["0.0.0.0", "::"], set()),
|
||||
(
|
||||
True,
|
||||
{"server_host": "0.0.0.0"},
|
||||
[
|
||||
"0.0.0.0",
|
||||
],
|
||||
1,
|
||||
{("http", "server_host_deprecated_hassio")},
|
||||
),
|
||||
],
|
||||
@@ -713,7 +705,6 @@ async def test_server_host(
|
||||
issue_registry: ir.IssueRegistry,
|
||||
http_config: dict,
|
||||
expected_serverhost: list,
|
||||
expected_warning_count: int,
|
||||
expected_issues: set[tuple[str, str]],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
@@ -743,11 +734,4 @@ async def test_server_host(
|
||||
reuse_port=None,
|
||||
)
|
||||
|
||||
assert (
|
||||
caplog.text.count(
|
||||
"The 'server_host' option is deprecated, please remove it from your configuration"
|
||||
)
|
||||
== expected_warning_count
|
||||
)
|
||||
|
||||
assert set(issue_registry.issues) == expected_issues
|
||||
|
||||
@@ -37,6 +37,7 @@ from homeassistant.components.music_assistant.const import (
|
||||
ATTR_AUTO_PLAY,
|
||||
ATTR_MEDIA_ID,
|
||||
ATTR_MEDIA_TYPE,
|
||||
ATTR_PRE_ANNOUNCE_URL,
|
||||
ATTR_RADIO_MODE,
|
||||
ATTR_SOURCE_PLAYER,
|
||||
ATTR_URL,
|
||||
@@ -529,6 +530,7 @@ async def test_media_player_play_announcement_action(
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_URL: "http://blah.com/announcement.mp3",
|
||||
ATTR_USE_PRE_ANNOUNCE: True,
|
||||
ATTR_PRE_ANNOUNCE_URL: "http://blah.com/chime.mp3",
|
||||
ATTR_ANNOUNCE_VOLUME: 50,
|
||||
},
|
||||
blocking=True,
|
||||
@@ -538,8 +540,9 @@ async def test_media_player_play_announcement_action(
|
||||
"players/cmd/play_announcement",
|
||||
player_id=mass_player_id,
|
||||
url="http://blah.com/announcement.mp3",
|
||||
use_pre_announce=True,
|
||||
pre_announce=True,
|
||||
volume_level=50,
|
||||
pre_announce_url="http://blah.com/chime.mp3",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class MockDeviceListener(DeviceListener):
|
||||
hass: HomeAssistant,
|
||||
device: CustomerDevice,
|
||||
updated_status_properties: dict[str, Any] | None = None,
|
||||
dp_timestamps: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
"""Mock update device method."""
|
||||
property_list: list[str] = []
|
||||
@@ -40,7 +41,7 @@ class MockDeviceListener(DeviceListener):
|
||||
)
|
||||
device.status[key] = value
|
||||
property_list.append(key)
|
||||
self.update_device(device, property_list)
|
||||
self.update_device(device, property_list, dp_timestamps)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
|
||||
@@ -148,6 +148,56 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[binary_sensor.cleverio_pf100_charging-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.cleverio_pf100_charging',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Charging',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Charging',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'tuya.iomszlsve0yyzkfwqswwccharge_state',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[binary_sensor.cleverio_pf100_charging-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery_charging',
|
||||
'friendly_name': 'Cleverio PF100 Charging',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.cleverio_pf100_charging',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -528,6 +528,64 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.cat_feeder_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'light.cat_feeder_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Light',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Light',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light',
|
||||
'unique_id': 'tuya.igkrtodqg14xvfxlqswwclight',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.cat_feeder_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'color_mode': <ColorMode.ONOFF: 'onoff'>,
|
||||
'friendly_name': 'Cat Feeder Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.cat_feeder_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.ceiling_fan_light_v2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -766,6 +824,64 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.cleverio_pf100_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'light.cleverio_pf100_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Light',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Light',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light',
|
||||
'unique_id': 'tuya.iomszlsve0yyzkfwqswwclight',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.cleverio_pf100_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'color_mode': <ColorMode.ONOFF: 'onoff'>,
|
||||
'friendly_name': 'Cleverio PF100 Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.cleverio_pf100_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.colorful_pir_night_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -4606,6 +4606,60 @@
|
||||
'state': '0.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_battery-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.cleverio_pf100_battery',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Battery',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Battery',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'battery',
|
||||
'unique_id': 'tuya.iomszlsve0yyzkfwqswwcbattery_percentage',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_battery-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery',
|
||||
'friendly_name': 'Cleverio PF100 Battery',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cleverio_pf100_battery',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '90.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_last_amount-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -8861,7 +8915,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '0.084',
|
||||
'state': '0',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.ha_socket_delta_test_voltage-entry]
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -81,3 +82,106 @@ async def test_selective_state_update(
|
||||
expected_state=expected_state,
|
||||
last_reported=last_reported,
|
||||
)
|
||||
|
||||
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR])
|
||||
@pytest.mark.parametrize("mock_device_code", ["cz_guitoc9iylae4axs"])
|
||||
async def test_delta_report_sensor(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device: CustomerDevice,
|
||||
mock_listener: MockDeviceListener,
|
||||
) -> None:
|
||||
"""Test delta report sensor behavior."""
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
|
||||
entity_id = "sensor.ha_socket_delta_test_total_energy"
|
||||
timestamp = 1000
|
||||
|
||||
# Delta sensors start from zero and accumulate values
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "0"
|
||||
assert state.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
# Send delta update
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"add_ele": 200},
|
||||
{"add_ele": timestamp},
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.2)
|
||||
|
||||
# Send delta update (multiple dpcode)
|
||||
timestamp += 100
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"add_ele": 300, "switch_1": True},
|
||||
{"add_ele": timestamp, "switch_1": timestamp},
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.5)
|
||||
|
||||
# Send delta update (timestamp not incremented)
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"add_ele": 500},
|
||||
{"add_ele": timestamp}, # same timestamp
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.5) # unchanged
|
||||
|
||||
# Send delta update (unrelated dpcode)
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"switch_1": False},
|
||||
{"switch_1": timestamp + 100},
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.5) # unchanged
|
||||
|
||||
# Send delta update
|
||||
timestamp += 100
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"add_ele": 100},
|
||||
{"add_ele": timestamp},
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.6)
|
||||
|
||||
# Send delta update (None value)
|
||||
timestamp += 100
|
||||
mock_device.status["add_ele"] = None
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"add_ele": None},
|
||||
{"add_ele": timestamp},
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.6) # unchanged
|
||||
|
||||
# Send delta update (no timestamp - skipped)
|
||||
mock_device.status["add_ele"] = 200
|
||||
await mock_listener.async_send_device_update(
|
||||
hass,
|
||||
mock_device,
|
||||
{"add_ele": 200},
|
||||
None,
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert float(state.state) == pytest.approx(0.6) # unchanged
|
||||
|
||||
Reference in New Issue
Block a user