Compare commits

..

53 Commits

Author SHA1 Message Date
Erik
20bafe99ae Remove overwriting of haffmpeg in smartthings test 2026-01-16 09:38:00 +01:00
Erwin Douna
fc281b2fae Firefly III fix background task (#160935) 2026-01-14 21:24:44 +01:00
Abílio Costa
3b111287d5 Remove entity performance optimization section from copilot-instructions (#160944) 2026-01-14 19:36:52 +00:00
Marc Mueller
00f42efc7e Update PyNaCl to 1.6.2 (#160909) 2026-01-14 18:21:09 +01:00
Erik Montnemery
9b9f94414b Add shared helper to assert conditions are hidden behind labs flag (#160941) 2026-01-14 16:53:17 +00:00
Erik Montnemery
f01653633d Add shared enable_experimental_triggers_conditions test fixture (#160937) 2026-01-14 16:01:06 +00:00
Erik Montnemery
1ace3e248f Add create_target_condition test helper (#160936) 2026-01-14 16:19:41 +01:00
epenet
d9bde85b58 Mark device_class type hints as compulsory in binary_sensor platform (#160934) 2026-01-14 16:18:04 +01:00
Joost Lekkerkerker
766a50abd7 Translate Hikvision NVR channel device name (#160862) 2026-01-14 16:16:26 +01:00
Niracler
9e6073099c Add button platform to sunricher_dali (#160908) 2026-01-14 16:02:25 +01:00
Erik Montnemery
892618d2ff Add fan conditions (#160832)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-01-14 15:50:22 +01:00
epenet
79c4164e03 Mark device_class type hints as compulsory in various platforms (#160929) 2026-01-14 15:47:17 +01:00
epenet
77dd4189b1 Mark device_class type hints as compulsory in sensor platform (#160931) 2026-01-14 15:46:40 +01:00
karwosts
4dbab23ada Duration selector for timer.change (#160645) 2026-01-14 15:45:32 +01:00
Erik Montnemery
ce7f1a6f6a Adjust docstring in entity registry (#160926) 2026-01-14 15:14:46 +01:00
Erik Montnemery
6fc28298aa Update matter test snapshots (#160924) 2026-01-14 14:53:02 +01:00
Artur Pragacz
0130919128 Improve entity id generation (#160302) 2026-01-14 14:34:52 +01:00
Erik Montnemery
200627a695 Simplify light condition tests (#160910) 2026-01-14 14:15:51 +01:00
epenet
82926f8e9d Mark send_message type hints as compulsory in notify (#160850) 2026-01-14 13:01:33 +01:00
Arie Catsman
07fc81361b Bump pyenphase from 2.4.2 to 2.4.3 (#160912) 2026-01-14 12:57:05 +01:00
Martin Hjelmare
bd8aed8e63 Bump zwave-js-server-python to 0.68.0 (#160911) 2026-01-14 12:55:48 +01:00
Martin Hjelmare
2c1693d50a Fix Generate requirements task (#160916) 2026-01-14 12:54:15 +01:00
Marek Tyburec
6e60b70691 Add SmartThings media-player audio notifications (#153287) 2026-01-14 12:50:27 +01:00
Erik Montnemery
ac889feb75 Minor optimization of light conditions (#160915) 2026-01-14 11:49:56 +00:00
Erik Montnemery
a902f3bb00 Improve comments in trigger and condition test helpers (#160830) 2026-01-14 11:42:32 +00:00
Erwin Douna
fcb0c9500b Firefly III expand asyncio.gather usage (#160913) 2026-01-14 12:19:02 +01:00
Abílio Costa
f049fbdf77 Add calendar event_started/event_ended triggers (#159659) 2026-01-14 11:12:17 +00:00
dependabot[bot]
20102cd83f Bump j178/prek-action from 1.0.11 to 1.0.12 (#160902)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 10:28:11 +01:00
Erik Montnemery
6d6324dae5 Fix some reversed asserts in sensor group tests (#160905) 2026-01-14 09:43:26 +01:00
Erik Montnemery
2ee5410a6c Remove set of _attr_extra_state_attributes in sensor group (#160846)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-14 09:21:54 +01:00
Erik Montnemery
56f02a41ca Adjust sensor group behavior (#152167) 2026-01-14 08:23:34 +01:00
Erwin Douna
d43102de1b Bump pyportainer 1.0.23 (#160878) 2026-01-14 07:09:35 +01:00
Ludovic BOUÉ
2bcd02b296 Add MatterOutdoorTemperature attribute to Matter binary sensor discovery schema only if OutdoorTemperature exists (#160879) 2026-01-14 06:58:55 +01:00
Brett Adams
ad11c72488 Add retry logic to Teslemetry coordinators (#160756)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:36:43 +01:00
Manu
ddfa6f83c3 Refactor Namecheap DNS update logic to use a coordinator (#160863) 2026-01-14 01:34:27 +01:00
epenet
85baf7a41d Improve type hints in mobile_app notify (#160853)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2026-01-14 01:26:10 +01:00
epenet
bf4d5a0bab Improve type hints in telegram notify (#160855) 2026-01-14 01:26:00 +01:00
Erwin Douna
16527ba707 Melcloud small config flow refactor (#160892) 2026-01-14 01:15:36 +01:00
Brett Adams
0612ea4ee8 Bump tesla-fleet-api to 1.4.2 (#159616)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 01:14:58 +01:00
Ville Skyttä
9e842152f7 Upgrade prettier-plugin-sort to 4.2.0 (#160894) 2026-01-14 01:13:16 +01:00
Erwin Douna
63e79c3639 Firefly III add asyncio.gather pattern (#160886) 2026-01-14 01:12:44 +01:00
Erwin Douna
d0e4a7fa75 Melcloud Pythonic refactor init (#160891) 2026-01-14 00:38:41 +01:00
Glenn de Haan
815976b9a4 Add HDFury sensor platform (#160628) 2026-01-14 00:35:48 +01:00
scheric
86a5cc5edb Add keep_alive to generic_thermostat config flow (#156641)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-13 23:20:40 +00:00
Björn Ebbinghaus
3ebc08c5ec Prefer explicit DeviceClass over hint in entity_id in homekit (#152507)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-13 23:00:58 +00:00
Paul Bottein
1bcbebb00c Use config entity category for Matter door lock operating mode (#160507) 2026-01-13 23:46:54 +01:00
Jan Bouwhuis
2895225552 Improve test coverage on mobile app legacy notify service action (#160869) 2026-01-13 22:39:01 +01:00
Erwin Douna
f4f772ea31 Bump pyfirefly 0.1.11 (#160877) 2026-01-13 22:37:32 +01:00
Manu
66f60e6757 Add reconfigure flow to Namecheap integration (#160870) 2026-01-13 19:47:50 +00:00
Lukas
72d299f088 Mark pooldose as strictly typed (#160779)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-13 19:40:52 +00:00
Thomas55555
9c66561381 Make pollutants dynamic in Google Air Quality (#160747) 2026-01-13 19:28:41 +00:00
Erik Montnemery
e762f839fa Improve sensor group tests (#160854) 2026-01-13 20:16:06 +01:00
Joost Lekkerkerker
0c9d97c89f Unmark integrations with a config flow as legacy (#160861) 2026-01-13 19:59:39 +01:00
1014 changed files with 17184 additions and 1935 deletions

View File

@@ -1024,18 +1024,6 @@ class MyCoordinator(DataUpdateCoordinator[MyData]):
)
```
### Entity Performance Optimization
```python
# Use __slots__ for memory efficiency
class MySensor(SensorEntity):
__slots__ = ("_attr_native_value", "_attr_available")
@property
def should_poll(self) -> bool:
"""Disable polling when using coordinator."""
return False # ✅ Let coordinator handle updates
```
## Testing Patterns
### Testing Best Practices
@@ -1181,4 +1169,4 @@ python -m script.hassfest --integration-path homeassistant/components/my_integra
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```
```

View File

@@ -260,7 +260,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github

View File

@@ -39,7 +39,7 @@ repos:
- id: prettier
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.1.1
- prettier-plugin-sort-json@4.2.0
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:

View File

@@ -407,6 +407,7 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
homeassistant.components.pooldose.*
homeassistant.components.portainer.*
homeassistant.components.powerfox.*
homeassistant.components.powerwall.*

2
.vscode/tasks.json vendored
View File

@@ -120,7 +120,7 @@
{
"label": "Generate Requirements",
"type": "shell",
"command": "./script/gen_requirements_all.py",
"command": "${command:python.interpreterPath} -m script.gen_requirements_all",
"group": {
"kind": "build",
"isDefault": true

View File

@@ -123,6 +123,7 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"fan",
"light",
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
from typing import Any
from azure.servicebus import ServiceBusMessage
from azure.servicebus.aio import ServiceBusClient, ServiceBusSender
@@ -92,7 +93,7 @@ class ServiceBusNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
async def async_send_message(self, message, **kwargs):
async def async_send_message(self, message: str, **kwargs: Any) -> None:
"""Send a message."""
dto = {ATTR_ASB_MESSAGE: message}

View File

@@ -15,5 +15,13 @@
"get_events": {
"service": "mdi:calendar-month"
}
},
"triggers": {
"event_ended": {
"trigger": "mdi:calendar-end"
},
"event_started": {
"trigger": "mdi:calendar-start"
}
}
}

View File

@@ -45,6 +45,14 @@
"title": "Detected use of deprecated action calendar.list_events"
}
},
"selector": {
"trigger_offset_type": {
"options": {
"after": "After",
"before": "Before"
}
}
},
"services": {
"create_event": {
"description": "Adds a new calendar event.",
@@ -103,5 +111,35 @@
"name": "Get events"
}
},
"title": "Calendar"
"title": "Calendar",
"triggers": {
"event_ended": {
"description": "Triggers when a calendar event ends.",
"fields": {
"offset": {
"description": "Offset from the end of the event.",
"name": "Offset"
},
"offset_type": {
"description": "Whether to trigger before or after the end of the event, if an offset is defined.",
"name": "Offset type"
}
},
"name": "Calendar event ended"
},
"event_started": {
"description": "Triggers when a calendar event starts.",
"fields": {
"offset": {
"description": "Offset from the start of the event.",
"name": "Offset"
},
"offset_type": {
"description": "Whether to trigger before or after the start of the event, if an offset is defined.",
"name": "Offset type"
}
},
"name": "Calendar event started"
}
}
}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import datetime
@@ -10,8 +11,15 @@ from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_OFFSET,
CONF_OPTIONS,
CONF_TARGET,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
@@ -20,12 +28,13 @@ from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_time_interval,
)
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import CalendarEntity, CalendarEvent
from .const import DATA_COMPONENT
from .const import DATA_COMPONENT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,19 +42,35 @@ EVENT_START = "start"
EVENT_END = "end"
UPDATE_INTERVAL = datetime.timedelta(minutes=15)
CONF_OFFSET_TYPE = "offset_type"
OFFSET_TYPE_BEFORE = "before"
OFFSET_TYPE_AFTER = "after"
_OPTIONS_SCHEMA_DICT = {
_SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA = {
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
_CONFIG_SCHEMA = vol.Schema(
_SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
vol.Required(CONF_OPTIONS): _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA,
},
)
_EVENT_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In(
{OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER}
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
# mypy: disallow-any-generics
@@ -55,6 +80,7 @@ class QueuedCalendarEvent:
trigger_time: datetime.datetime
event: CalendarEvent
entity_id: str
@dataclass
@@ -94,7 +120,7 @@ class Timespan:
return f"[{self.start}, {self.end})"
type EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]]
type EventFetcher = Callable[[Timespan], Awaitable[list[tuple[str, CalendarEvent]]]]
type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]]
@@ -110,15 +136,24 @@ def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity:
return entity
def event_fetcher(hass: HomeAssistant, entity_id: str) -> EventFetcher:
def event_fetcher(hass: HomeAssistant, entity_ids: set[str]) -> EventFetcher:
"""Build an async_get_events wrapper to fetch events during a time span."""
async def async_get_events(timespan: Timespan) -> list[CalendarEvent]:
async def async_get_events(timespan: Timespan) -> list[tuple[str, CalendarEvent]]:
"""Return events active in the specified time span."""
entity = get_entity(hass, entity_id)
# Expand by one second to make the end time exclusive
end_time = timespan.end + datetime.timedelta(seconds=1)
return await entity.async_get_events(hass, timespan.start, end_time)
events: list[tuple[str, CalendarEvent]] = []
for entity_id in entity_ids:
entity = get_entity(hass, entity_id)
events.extend(
(entity_id, event)
for event in await entity.async_get_events(
hass, timespan.start, end_time
)
)
return events
return async_get_events
@@ -142,12 +177,11 @@ def queued_event_fetcher(
# Example: For an EVENT_END trigger the event may start during this
# time span, but need to be triggered later when the end happens.
results = []
for trigger_time, event in zip(
map(get_trigger_time, active_events), active_events, strict=False
):
for entity_id, event in active_events:
trigger_time = get_trigger_time(event)
if trigger_time not in offset_timespan:
continue
results.append(QueuedCalendarEvent(trigger_time + offset, event))
results.append(QueuedCalendarEvent(trigger_time + offset, event, entity_id))
_LOGGER.debug(
"Scan events @ %s%s found %s eligible of %s active",
@@ -240,6 +274,7 @@ class CalendarEventListener:
_LOGGER.debug("Dispatching event: %s", queued_event.event)
payload = {
**self._trigger_payload,
ATTR_ENTITY_ID: queued_event.entity_id,
"calendar_event": queued_event.event.as_dict(),
}
self._action_runner(payload, "calendar event state change")
@@ -260,8 +295,77 @@ class CalendarEventListener:
self._listen_next_calendar_event()
class EventTrigger(Trigger):
"""Calendar event trigger."""
class TargetCalendarEventListener(TargetEntityChangeTracker):
"""Helper class to listen to calendar events for target entity changes."""
def __init__(
self,
hass: HomeAssistant,
target_selection: TargetSelection,
event_type: str,
offset: datetime.timedelta,
run_action: TriggerActionRunner,
) -> None:
"""Initialize the state change tracker."""
def entity_filter(entities: set[str]) -> set[str]:
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
super().__init__(hass, target_selection, entity_filter)
self._event_type = event_type
self._offset = offset
self._run_action = run_action
self._trigger_data = {
"event": event_type,
"offset": offset,
}
self._pending_listener_task: asyncio.Task[None] | None = None
self._calendar_event_listener: CalendarEventListener | None = None
@callback
def _handle_entities_update(self, tracked_entities: set[str]) -> None:
"""Restart the listeners when the list of entities of the tracked targets is updated."""
if self._pending_listener_task:
self._pending_listener_task.cancel()
self._pending_listener_task = self._hass.async_create_task(
self._start_listening(tracked_entities)
)
async def _start_listening(self, tracked_entities: set[str]) -> None:
"""Start listening for calendar events."""
_LOGGER.debug("Tracking events for calendars: %s", tracked_entities)
if self._calendar_event_listener:
self._calendar_event_listener.async_detach()
self._calendar_event_listener = CalendarEventListener(
self._hass,
self._run_action,
self._trigger_data,
queued_event_fetcher(
event_fetcher(self._hass, tracked_entities),
self._event_type,
self._offset,
),
)
await self._calendar_event_listener.async_attach()
def _unsubscribe(self) -> None:
"""Unsubscribe from all events."""
super()._unsubscribe()
if self._pending_listener_task:
self._pending_listener_task.cancel()
self._pending_listener_task = None
if self._calendar_event_listener:
self._calendar_event_listener.async_detach()
self._calendar_event_listener = None
class SingleEntityEventTrigger(Trigger):
"""Legacy single calendar entity event trigger."""
_options: dict[str, Any]
@@ -271,7 +375,7 @@ class EventTrigger(Trigger):
) -> ConfigType:
"""Validate complete config."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _OPTIONS_SCHEMA_DICT
complete_config, _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA
)
return await super().async_validate_complete_config(hass, complete_config)
@@ -280,7 +384,7 @@ class EventTrigger(Trigger):
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _CONFIG_SCHEMA(config))
return cast(ConfigType, _SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
@@ -311,15 +415,72 @@ class EventTrigger(Trigger):
run_action,
trigger_data,
queued_event_fetcher(
event_fetcher(self._hass, entity_id), event_type, offset
event_fetcher(self._hass, {entity_id}), event_type, offset
),
)
await listener.async_attach()
return listener.async_detach
class EventTrigger(Trigger):
"""Calendar event trigger."""
_options: dict[str, Any]
_event_type: str
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _EVENT_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
offset = self._options[CONF_OFFSET]
offset_type = self._options[CONF_OFFSET_TYPE]
if offset_type == OFFSET_TYPE_BEFORE:
offset = -offset
target_selection = TargetSelection(self._target)
if not target_selection.has_any_target:
raise HomeAssistantError(f"No target defined in {self._target}")
listener = TargetCalendarEventListener(
self._hass, target_selection, self._event_type, offset, run_action
)
return listener.async_setup()
class EventStartedTrigger(EventTrigger):
"""Calendar event started trigger."""
_event_type = EVENT_START
class EventEndedTrigger(EventTrigger):
"""Calendar event ended trigger."""
_event_type = EVENT_END
TRIGGERS: dict[str, type[Trigger]] = {
"_": EventTrigger,
"_": SingleEntityEventTrigger,
"event_started": EventStartedTrigger,
"event_ended": EventEndedTrigger,
}

View File

@@ -0,0 +1,27 @@
.trigger_common: &trigger_common
target:
entity:
domain: calendar
fields:
offset:
required: true
default:
days: 0
hours: 0
minutes: 0
seconds: 0
selector:
duration:
enable_day: true
offset_type:
required: true
default: before
selector:
select:
translation_key: trigger_offset_type
options:
- before
- after
event_started: *trigger_common
event_ended: *trigger_common

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from webexpythonsdk import ApiError, WebexAPI, exceptions
@@ -51,7 +52,7 @@ class CiscoWebexNotificationService(BaseNotificationService):
self.room = room
self.client = client
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
title = ""

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
from typing import Any
import requests
import voluptuous as vol
@@ -81,7 +82,7 @@ class ClicksendNotificationService(BaseNotificationService):
self.language = config[CONF_LANGUAGE]
self.voice = config[CONF_VOICE]
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a voice call to a user."""
data = {
"messages": [

View File

@@ -119,7 +119,7 @@ class Concord232ZoneSensor(BinarySensorEntity):
self._zone_type = zone_type
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type

View File

@@ -11,13 +11,11 @@ from homeassistant import config_entries
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.entity_component import async_get_entity_suggested_object_id
from homeassistant.helpers.json import json_dumps
_LOGGER = logging.getLogger(__name__)
@@ -351,26 +349,12 @@ def websocket_get_automatic_entity_ids(
if not (entry := registry.entities.get(entity_id)):
automatic_entity_ids[entity_id] = None
continue
try:
suggested = async_get_entity_suggested_object_id(hass, entity_id)
except HomeAssistantError as err:
# This is raised if the entity has no object.
_LOGGER.debug(
"Unable to get suggested object ID for %s, entity ID: %s (%s)",
entry.entity_id,
entity_id,
err,
)
automatic_entity_ids[entity_id] = None
continue
suggested_entity_id = registry.async_generate_entity_id(
entry.domain,
suggested or f"{entry.platform}_{entry.unique_id}",
current_entity_id=entity_id,
new_entity_id = registry.async_regenerate_entity_id(
entry,
reserved_entity_ids=reserved_entity_ids,
)
automatic_entity_ids[entity_id] = suggested_entity_id
reserved_entity_ids.add(suggested_entity_id)
automatic_entity_ids[entity_id] = new_entity_id
reserved_entity_ids.add(new_entity_id)
connection.send_message(
websocket_api.result_message(msg["id"], automatic_entity_ids)

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["datadog"],
"quality_scale": "legacy",
"requirements": ["datadog==0.52.0"]
}

View File

@@ -84,7 +84,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
return self.data.status == "active"
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
return BinarySensorDeviceClass.MOVING

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.core import HomeAssistant
@@ -29,7 +30,7 @@ class DovadoSMSNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
def send_message(self, message, **kwargs):
def send_message(self, message: str, **kwargs: Any) -> None:
"""Send SMS to the specified target phone number."""
if not (target := kwargs.get(ATTR_TARGET)):
_LOGGER.error("One target is required")

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import datetime
import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -96,7 +96,7 @@ class EbusdSensor(SensorEntity):
return None
@property
def device_class(self):
def device_class(self) -> SensorDeviceClass | None:
"""Return the class of this device, from component DEVICE_CLASSES."""
return self._device_class

View File

@@ -75,6 +75,6 @@ class EgardiaBinarySensor(BinarySensorEntity):
return self._state == STATE_ON
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass | None:
"""Return the device class."""
return self._device_class

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.2"],
"requirements": ["pyenphase==2.4.3"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
import datetime
import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import ATTR_LAST_TRIP_TIME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -102,7 +105,7 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
return self._info["status"]["open"]
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
from typing import Any
import requests
import voluptuous as vol
@@ -46,7 +47,7 @@ class FacebookNotificationService(BaseNotificationService):
"""Initialize the service."""
self.page_access_token = access_token
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send some message."""
payload = {"access_token": self.page_access_token}
targets = kwargs.get(ATTR_TARGET)

View File

@@ -0,0 +1,17 @@
"""Provides conditions for fans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from . import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the fan conditions."""
return CONDITIONS

View File

@@ -0,0 +1,17 @@
.condition_common: &condition_common
target:
entity:
domain: fan
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_off": {
"condition": "mdi:fan-off"
},
"is_on": {
"condition": "mdi:fan"
}
},
"entity_component": {
"_": {
"default": "mdi:fan",

View File

@@ -1,8 +1,32 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted fans.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted fans to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Tests if one or more fans are off.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::condition_behavior_description%]",
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is off"
},
"is_on": {
"description": "Tests if one or more fans are on.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::condition_behavior_description%]",
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is on"
}
},
"device_automation": {
"action_type": {
"toggle": "[%key:common::device_automation::action_type::toggle%]",
@@ -65,6 +89,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"direction": {
"options": {
"forward": "Forward",

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
@@ -97,17 +98,30 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
end_date = now
try:
accounts = await self.firefly.get_accounts()
categories = await self.firefly.get_categories()
category_details = [
await self.firefly.get_category(
category_id=int(category.id), start=start_date, end=end_date
(
accounts,
categories,
primary_currency,
budgets,
bills,
) = await asyncio.gather(
self.firefly.get_accounts(),
self.firefly.get_categories(),
self.firefly.get_currency_primary(),
self.firefly.get_budgets(start=start_date, end=end_date),
self.firefly.get_bills(),
)
category_details = await asyncio.gather(
*(
self.firefly.get_category(
category_id=int(category.id),
start=start_date,
end=end_date,
)
for category in categories
)
for category in categories
]
primary_currency = await self.firefly.get_currency_primary()
budgets = await self.firefly.get_budgets(start=start_date, end=end_date)
bills = await self.firefly.get_bills()
)
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.10"]
"requirements": ["pyfirefly==0.1.11"]
}

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from http import HTTPStatus
import logging
from typing import Any
import voluptuous as vol
@@ -47,7 +48,7 @@ class FlockNotificationService(BaseNotificationService):
self._url = url
self._session = session
async def async_send_message(self, message, **kwargs):
async def async_send_message(self, message: str, **kwargs: Any) -> None:
"""Send the message to the user."""
payload = {"text": message}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
from typing import Any
from freesms import FreeClient
import voluptuous as vol
@@ -40,7 +41,7 @@ class FreeSMSNotificationService(BaseNotificationService):
"""Initialize the service."""
self.free_client = FreeClient(username, access_token)
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the Free Mobile user cell."""
resp = self.free_client.send_sms(message)

View File

@@ -66,6 +66,7 @@ from .const import (
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -81,7 +82,6 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Generic Thermostat"
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
CONF_KEEP_ALIVE = "keep_alive"
CONF_PRECISION = "precision"
CONF_TARGET_TEMP = "target_temp"
CONF_TEMP_STEP = "target_temp_step"

View File

@@ -21,6 +21,7 @@ from .const import (
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -59,6 +60,9 @@ OPTIONS_SCHEMA = {
vol.Optional(CONF_MIN_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1

View File

@@ -33,4 +33,5 @@ CONF_PRESETS = {
)
}
CONF_SENSOR = "target_sensor"
CONF_KEEP_ALIVE = "keep_alive"
DEFAULT_TOLERANCE = 0.3

View File

@@ -18,6 +18,7 @@
"cold_tolerance": "Cold tolerance",
"heater": "Actuator switch",
"hot_tolerance": "Hot tolerance",
"keep_alive": "Keep-alive interval",
"max_temp": "Maximum target temperature",
"min_cycle_duration": "Minimum cycle duration",
"min_temp": "Minimum target temperature",
@@ -29,6 +30,7 @@
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"target_sensor": "Temperature sensor that reflects the current temperature."
},
@@ -45,6 +47,7 @@
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
@@ -55,6 +58,7 @@
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
}

View File

@@ -57,7 +57,7 @@ class GeniusSwitch(GeniusZone, SwitchEntity):
"""Representation of a Genius Hub switch."""
@property
def device_class(self):
def device_class(self) -> SwitchDeviceClass:
"""Return the class of this device, from component DEVICE_CLASSES."""
return SwitchDeviceClass.OUTLET

View File

@@ -112,6 +112,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CO,
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
exists_fn=lambda x: "co" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.co.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -143,6 +144,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
translation_key="nitrogen_dioxide",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.no2.concentration.units,
exists_fn=lambda x: "no2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.no2.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -150,6 +152,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
translation_key="ozone",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.o3.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -157,6 +160,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement_fn=lambda x: x.pollutants.pm10.concentration.units,
exists_fn=lambda x: "pm10" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.pm10.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -164,6 +168,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement_fn=lambda x: x.pollutants.pm25.concentration.units,
exists_fn=lambda x: "pm25" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.pm25.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -171,6 +176,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
translation_key="sulphur_dioxide",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.so2.concentration.units,
exists_fn=lambda x: "so2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.so2.concentration.value,
),
)

View File

@@ -346,7 +346,6 @@ class SensorGroup(GroupEntity, SensorEntity):
self._attr_name = name
if name == DEFAULT_NAME:
self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize()
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._ignore_non_numeric = ignore_non_numeric
self.mode = all if ignore_non_numeric is False else any
@@ -374,7 +373,7 @@ class SensorGroup(GroupEntity, SensorEntity):
def async_update_group_state(self) -> None:
"""Query all members and determine the sensor group state."""
self.calculate_state_attributes(self._get_valid_entities())
states: list[str] = []
states: list[str | None] = []
valid_units = self._valid_units
valid_states: list[bool] = []
sensor_values: list[tuple[str, float, State]] = []
@@ -435,9 +434,12 @@ class SensorGroup(GroupEntity, SensorEntity):
state.attributes.get("unit_of_measurement"),
self.entity_id,
)
else:
states.append(None)
valid_states.append(False)
# Set group as unavailable if all members do not have numeric values
self._attr_available = any(numeric_state for numeric_state in valid_states)
# Set group as unavailable if all members are unavailable or missing
self._attr_available = not all(s in (STATE_UNAVAILABLE, None) for s in states)
valid_state = self.mode(
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
@@ -446,6 +448,7 @@ class SensorGroup(GroupEntity, SensorEntity):
if not valid_state or not valid_state_numeric:
self._attr_native_value = None
self._extra_state_attribute = {}
return
# Calculate values

View File

@@ -8,6 +8,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
PLATFORMS = [
Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -16,6 +16,50 @@
"default": "mdi:hdmi-port"
}
},
"sensor": {
"aud0": {
"default": "mdi:audio-input-rca"
},
"aud1": {
"default": "mdi:audio-input-rca"
},
"audout": {
"default": "mdi:television-speaker"
},
"earcrx": {
"default": "mdi:audio-video"
},
"edida0": {
"default": "mdi:format-list-text"
},
"edida1": {
"default": "mdi:format-list-text"
},
"edida2": {
"default": "mdi:format-list-text"
},
"rx0": {
"default": "mdi:video-input-hdmi"
},
"rx1": {
"default": "mdi:video-input-hdmi"
},
"sink0": {
"default": "mdi:television"
},
"sink1": {
"default": "mdi:television"
},
"sink2": {
"default": "mdi:audio-video"
},
"tx0": {
"default": "mdi:cable-data"
},
"tx1": {
"default": "mdi:cable-data"
}
},
"switch": {
"autosw": {
"default": "mdi:import"

View File

@@ -0,0 +1,121 @@
"""Sensor platform for HDFury Integration."""
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="RX0",
translation_key="rx0",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="RX1",
translation_key="rx1",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="TX0",
translation_key="tx0",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="TX1",
translation_key="tx1",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="AUD0",
translation_key="aud0",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="AUD1",
translation_key="aud1",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="AUDOUT",
translation_key="audout",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="EARCRX",
translation_key="earcrx",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="SINK0",
translation_key="sink0",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="SINK1",
translation_key="sink1",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="SINK2",
translation_key="sink2",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="EDIDA0",
translation_key="edida0",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="EDIDA1",
translation_key="edida1",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="EDIDA2",
translation_key="edida2",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HDFuryConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors using the platform schema."""
coordinator = entry.runtime_data
async_add_entities(
HDFurySensor(coordinator, description)
for description in SENSORS
if description.key in coordinator.data.info
)
class HDFurySensor(HDFuryEntity, SensorEntity):
"""Base HDFury Sensor Class."""
entity_description: SensorEntityDescription
@property
def native_value(self) -> str:
"""Set Sensor Value."""
return self.coordinator.data.info[self.entity_description.key]

View File

@@ -57,6 +57,50 @@
}
}
},
"sensor": {
"aud0": {
"name": "Audio TX0"
},
"aud1": {
"name": "Audio TX1"
},
"audout": {
"name": "Audio output"
},
"earcrx": {
"name": "eARC/ARC status"
},
"edida0": {
"name": "EDID TXA0"
},
"edida1": {
"name": "EDID TXA1"
},
"edida2": {
"name": "EDID AUDA"
},
"rx0": {
"name": "Input RX0"
},
"rx1": {
"name": "Input RX1"
},
"sink0": {
"name": "EDID TX0"
},
"sink1": {
"name": "EDID TX1"
},
"sink2": {
"name": "EDID AUD"
},
"tx0": {
"name": "Output TX0"
},
"tx1": {
"name": "Output TX1"
}
},
"switch": {
"autosw": {
"name": "Auto switch inputs"

View File

@@ -191,7 +191,11 @@ class HikvisionBinarySensor(BinarySensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
via_device=(DOMAIN, self._data.device_id),
name=f"{self._data.device_name} Channel {channel}",
translation_key="nvr_channel",
translation_placeholders={
"device_name": self._data.device_name,
"channel_number": str(channel),
},
manufacturer="Hikvision",
model="NVR Channel",
)

View File

@@ -62,7 +62,11 @@ class HikvisionCamera(Camera):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
via_device=(DOMAIN, self._data.device_id),
name=f"{self._data.device_name} Channel {channel}",
translation_key="nvr_channel",
translation_placeholders={
"device_name": self._data.device_name,
"channel_number": str(channel),
},
manufacturer="Hikvision",
model="NVR Channel",
)

View File

@@ -29,6 +29,11 @@
}
}
},
"device": {
"nvr_channel": {
"name": "{device_name} channel {channel_number}"
}
},
"issues": {
"deprecated_yaml_import_issue": {
"description": "Configuring {integration_title} using YAML is deprecated and the import failed. Please remove the `{domain}` entry from your `configuration.yaml` file and set up the integration manually.",

View File

@@ -220,31 +220,33 @@ def get_accessory( # noqa: C901
a_type = "TemperatureSensor"
elif device_class == SensorDeviceClass.HUMIDITY and unit == PERCENTAGE:
a_type = "HumiditySensor"
elif (
device_class == SensorDeviceClass.PM10
or SensorDeviceClass.PM10 in state.entity_id
):
elif device_class == SensorDeviceClass.PM10:
a_type = "PM10Sensor"
elif (
device_class == SensorDeviceClass.PM25
or SensorDeviceClass.PM25 in state.entity_id
):
elif device_class == SensorDeviceClass.PM25:
a_type = "PM25Sensor"
elif device_class == SensorDeviceClass.NITROGEN_DIOXIDE:
a_type = "NitrogenDioxideSensor"
elif device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS:
a_type = "VolatileOrganicCompoundsSensor"
elif (
device_class == SensorDeviceClass.GAS
or SensorDeviceClass.GAS in state.entity_id
):
elif device_class == SensorDeviceClass.GAS:
a_type = "AirQualitySensor"
elif device_class == SensorDeviceClass.CO:
a_type = "CarbonMonoxideSensor"
elif device_class == SensorDeviceClass.CO2 or "co2" in state.entity_id:
elif device_class == SensorDeviceClass.CO2:
a_type = "CarbonDioxideSensor"
elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX:
a_type = "LightSensor"
# Fallbacks based on entity_id
elif SensorDeviceClass.PM10 in state.entity_id:
a_type = "PM10Sensor"
elif SensorDeviceClass.PM25 in state.entity_id:
a_type = "PM25Sensor"
elif SensorDeviceClass.GAS in state.entity_id:
a_type = "AirQualitySensor"
elif "co2" in state.entity_id:
a_type = "CarbonDioxideSensor"
else:
_LOGGER.debug(
"%s: Unsupported sensor type (device_class=%s) (unit=%s)",

View File

@@ -66,7 +66,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
return bool(self._hm_get_state())
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass | None:
"""Return the class of this sensor from DEVICE_CLASSES."""
# If state is MOTION (Only RemoteMotion working)
if self._state == "MOTION":

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.notify import (
@@ -60,7 +62,7 @@ class HomematicNotificationService(BaseNotificationService):
self.hass = hass
self.data = data
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a notification to the device."""
data = {**self.data, **kwargs.get(ATTR_DATA, {})}

View File

@@ -9,6 +9,7 @@ from http import HTTPStatus
import json
import logging
import time
from typing import Any
from urllib.parse import urlparse
import uuid
@@ -451,7 +452,7 @@ class HTML5NotificationService(BaseNotificationService):
"""
await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs))
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
tag = str(uuid.uuid4())
payload = {

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from pyjoin import get_devices, send_notification
import voluptuous as vol
@@ -66,7 +67,7 @@ class JoinNotificationService(BaseNotificationService):
self._device_ids = device_ids
self._device_names = device_names
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
data = kwargs.get(ATTR_DATA) or {}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
from homeassistant.components.notify import ATTR_DATA, BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -27,7 +29,7 @@ class KebaNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
async def async_send_message(self, message="", **kwargs):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send the message."""
text = message.replace(" ", "$") # Will be translated back by the display

View File

@@ -116,7 +116,6 @@ class KnxExposeOptions:
dpt: type[DPTBase]
respond_to_read: bool
cooldown: float
periodic_send: float
default: Any | None
value_template: Template | None
@@ -131,17 +130,12 @@ def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions:
else:
dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation
ga = parse_device_group_address(config[KNX_ADDRESS])
cooldown_seconds = config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN].total_seconds()
periodic_send_seconds = config[
ExposeSchema.CONF_KNX_EXPOSE_PERIODIC_SEND
].total_seconds()
return KnxExposeOptions(
attribute=config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE),
group_address=ga,
dpt=dpt,
respond_to_read=config[CONF_RESPOND_TO_READ],
cooldown=cooldown_seconds,
periodic_send=periodic_send_seconds,
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
default=config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT),
value_template=config.get(CONF_VALUE_TEMPLATE),
)
@@ -173,7 +167,6 @@ class KnxExposeEntity:
respond_to_read=option.respond_to_read,
value_type=option.dpt,
cooldown=option.cooldown,
periodic_send=option.periodic_send,
),
)
for option in options

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from abc import ABC
from collections import OrderedDict
from datetime import timedelta
import math
from typing import ClassVar, Final
@@ -539,7 +538,6 @@ class ExposeSchema(KNXPlatformSchema):
CONF_KNX_EXPOSE_ATTRIBUTE = "attribute"
CONF_KNX_EXPOSE_BINARY = "binary"
CONF_KNX_EXPOSE_COOLDOWN = "cooldown"
CONF_KNX_EXPOSE_PERIODIC_SEND = "periodic_send"
CONF_KNX_EXPOSE_DEFAULT = "default"
CONF_TIME = "time"
CONF_DATE = "date"
@@ -556,12 +554,7 @@ class ExposeSchema(KNXPlatformSchema):
)
EXPOSE_SENSOR_SCHEMA = vol.Schema(
{
vol.Optional(
CONF_KNX_EXPOSE_COOLDOWN, default=timedelta(0)
): cv.positive_time_period,
vol.Optional(
CONF_KNX_EXPOSE_PERIODIC_SEND, default=timedelta(0)
): cv.positive_time_period,
vol.Optional(CONF_KNX_EXPOSE_COOLDOWN, default=0): cv.positive_float,
vol.Optional(CONF_RESPOND_TO_READ, default=True): cv.boolean,
vol.Required(CONF_KNX_EXPOSE_TYPE): vol.Any(
CONF_KNX_EXPOSE_BINARY, sensor_type_validator

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import aiohttp
import jsonrpc_async
@@ -93,7 +94,7 @@ class KodiNotificationService(BaseNotificationService):
self._server = jsonrpc_async.Server(self._url, **kwargs)
async def async_send_message(self, message="", **kwargs):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Kodi."""
try:
data = kwargs.get(ATTR_DATA) or {}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import socket
from typing import Any
from urllib.parse import urlencode
import voluptuous as vol
@@ -73,7 +74,7 @@ class LannouncerNotificationService(BaseNotificationService):
self._host = host
self._port = port
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Lannouncer."""
data = kwargs.get(ATTR_DATA)
if data is not None and ATTR_METHOD in data:

View File

@@ -1,127 +1,14 @@
"""Provides conditions for lights."""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final, Unpack, override
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, CONF_TARGET, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import config_validation as cv, target
from homeassistant.helpers.condition import (
Condition,
ConditionChecker,
ConditionCheckParams,
ConditionConfig,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
ATTR_BEHAVIOR: Final = "behavior"
BEHAVIOR_ANY: Final = "any"
BEHAVIOR_ALL: Final = "all"
STATE_CONDITION_VALID_STATES: Final = [STATE_ON, STATE_OFF]
STATE_CONDITION_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
}
STATE_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): STATE_CONDITION_OPTIONS_SCHEMA,
}
)
class StateConditionBase(Condition):
"""State condition."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return STATE_CONDITION_SCHEMA(config) # type: ignore[no-any-return]
def __init__(
self, hass: HomeAssistant, config: ConditionConfig, state: str
) -> None:
"""Initialize condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target
assert config.options
self._target = config.target
self._behavior = config.options[ATTR_BEHAVIOR]
self._state = state
@override
async def async_get_checker(self) -> ConditionChecker:
"""Get the condition checker."""
def check_any_match_state(states: list[str]) -> bool:
"""Test if any entity match the state."""
return any(state == self._state for state in states)
def check_all_match_state(states: list[str]) -> bool:
"""Test if all entities match the state."""
return all(state == self._state for state in states)
matcher: Callable[[list[str]], bool]
if self._behavior == BEHAVIOR_ANY:
matcher = check_any_match_state
elif self._behavior == BEHAVIOR_ALL:
matcher = check_all_match_state
def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Test state condition."""
target_selection = target.TargetSelection(self._target)
targeted_entities = target.async_extract_referenced_entity_ids(
self._hass, target_selection, expand_group=False
)
referenced_entity_ids = targeted_entities.referenced.union(
targeted_entities.indirectly_referenced
)
light_entity_ids = {
entity_id
for entity_id in referenced_entity_ids
if split_entity_id(entity_id)[0] == DOMAIN
}
light_entity_states = [
state.state
for entity_id in light_entity_ids
if (state := self._hass.states.get(entity_id))
and state.state in STATE_CONDITION_VALID_STATES
]
return matcher(light_entity_states)
return test_state
class IsOnCondition(StateConditionBase):
"""Is on condition."""
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config, STATE_ON)
class IsOffCondition(StateConditionBase):
"""Is off condition."""
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config, STATE_OFF)
CONDITIONS: dict[str, type[Condition]] = {
"is_off": IsOffCondition,
"is_on": IsOnCondition,
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
from typing import Any
import requests
import voluptuous as vol
@@ -56,7 +57,7 @@ class AutomateNotificationService(BaseNotificationService):
self._recipient = recipient
self._device = device
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
# Extract params from data dict

View File

@@ -7,7 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["london_tube_status"],
"quality_scale": "legacy",
"requirements": ["london-tube-status==0.5"],
"single_config_entry": true
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from pymailgunner import (
Client,
@@ -91,7 +92,7 @@ class MailgunNotificationService(BaseNotificationService):
return False
return True
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a mail to the recipient."""
subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)

View File

@@ -528,7 +528,10 @@ DISCOVERY_SCHEMAS = [
),
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.Thermostat.Attributes.RemoteSensing,),
required_attributes=(
clusters.Thermostat.Attributes.RemoteSensing,
clusters.Thermostat.Attributes.OutdoorTemperature,
),
allow_multi=True,
),
MatterDiscoverySchema(

View File

@@ -642,6 +642,7 @@ DISCOVERY_SCHEMAS = [
list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes,
device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get,
ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get,
entity_category=EntityCategory.CONFIG,
),
entity_class=MatterDoorLockOperatingModeSelectEntity,
required_attributes=(

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
from http import HTTPStatus
from aiohttp import ClientConnectionError, ClientResponseError
from pymelcloud import get_devices
@@ -23,21 +24,18 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
"""Establish connection with MELCloud."""
token = entry.data[CONF_TOKEN]
session = async_get_clientsession(hass)
try:
async with asyncio.timeout(10):
all_devices = await get_devices(
token,
session,
token=entry.data[CONF_TOKEN],
session=async_get_clientsession(hass),
conf_update_interval=timedelta(minutes=30),
device_set_debounce=timedelta(seconds=2),
)
except ClientResponseError as ex:
if ex.status in (401, 403):
if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
raise ConfigEntryAuthFailed from ex
if ex.status == 429:
if ex.status == HTTPStatus.TOO_MANY_REQUESTS:
raise UpdateFailed(
"MELCloud rate limit exceeded. Your account may be temporarily blocked"
) from ex
@@ -49,13 +47,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) ->
coordinators: dict[str, list[MelCloudDeviceUpdateCoordinator]] = {}
device_registry = dr.async_get(hass)
for device_type, devices in all_devices.items():
coordinators[device_type] = []
for device in devices:
coordinator = MelCloudDeviceUpdateCoordinator(hass, device, entry)
# Perform initial refresh for this device
await coordinator.async_config_entry_first_refresh()
coordinators[device_type].append(coordinator)
# Register parent device now so zone entities can reference it via via_device
# Build coordinators for this device_type
coordinators[device_type] = [
MelCloudDeviceUpdateCoordinator(hass, device, entry) for device in devices
]
# Perform initial refreshes concurrently
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators[device_type]
)
)
# Register parent devices so zone entities can reference via_device
for coordinator in coordinators[device_type]:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
**coordinator.device_info,

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
@@ -18,8 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -37,8 +34,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_client(
self,
username: str,
*,
password: str | None = None,
password: str,
token: str | None = None,
) -> ConfigFlowResult:
"""Create client."""
@@ -46,13 +42,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
async with asyncio.timeout(10):
if (acquired_token := token) is None:
acquired_token = await pymelcloud.login(
username,
password,
async_get_clientsession(self.hass),
email=username,
password=password,
session=async_get_clientsession(self.hass),
)
await pymelcloud.get_devices(
acquired_token,
async_get_clientsession(self.hass),
token=acquired_token,
session=async_get_clientsession(self.hass),
)
except ClientResponseError as err:
if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
@@ -78,8 +74,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
),
)
username = user_input[CONF_USERNAME]
return await self._create_client(username, password=user_input[CONF_PASSWORD])
return await self._create_client(
username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD]
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -118,9 +115,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(10):
acquired_token = await pymelcloud.login(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
async_get_clientsession(self.hass),
email=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
except (ClientResponseError, AttributeError) as err:
if (
@@ -134,10 +131,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
except (
TimeoutError,
ClientError,
):
except (TimeoutError, ClientError):
errors["base"] = "cannot_connect"
return acquired_token, errors
@@ -155,9 +149,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(10):
acquired_token = await pymelcloud.login(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
async_get_clientsession(self.hass),
email=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
except (ClientResponseError, AttributeError) as err:
if (

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import messagebird
from messagebird.client import ErrorException
@@ -55,7 +56,7 @@ class MessageBirdNotificationService(BaseNotificationService):
self.sender = sender
self.client = client
def send_message(self, message=None, **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a specified target."""
if not (targets := kwargs.get(ATTR_TARGET)):
_LOGGER.error("No target specified")

View File

@@ -116,7 +116,7 @@ class MfiSensor(SensorEntity):
return round(self._port.value, digits)
@property
def device_class(self):
def device_class(self) -> SensorDeviceClass | None:
"""Return the device class of the sensor."""
try:
tag = self._port.tag

View File

@@ -46,7 +46,7 @@ class MobileAppFlowHandler(ConfigFlow, domain=DOMAIN):
"device_tracker",
DOMAIN,
user_input[ATTR_DEVICE_ID],
suggested_object_id=user_input[ATTR_DEVICE_NAME],
object_id_base=user_input[ATTR_DEVICE_NAME],
)
await person.async_add_user_device_tracker(
self.hass, user_input[CONF_USER_ID], devt_entry.entity_id

View File

@@ -16,5 +16,5 @@
"iot_class": "local_push",
"loggers": ["nacl"],
"quality_scale": "internal",
"requirements": ["PyNaCl==1.6.0"]
"requirements": ["PyNaCl==1.6.2"]
}

View File

@@ -6,6 +6,7 @@ import asyncio
from functools import partial
from http import HTTPStatus
import logging
from typing import Any
import aiohttp
@@ -47,7 +48,7 @@ from .util import supports_push
_LOGGER = logging.getLogger(__name__)
def push_registrations(hass):
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
targets = {}
@@ -90,38 +91,32 @@ async def async_get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MobileAppNotificationService:
"""Get the mobile_app notification service."""
service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService(hass)
service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService()
return service
class MobileAppNotificationService(BaseNotificationService):
"""Implement the notification service for mobile_app."""
def __init__(self, hass):
"""Initialize the service."""
self._hass = hass
@property
def targets(self):
def targets(self) -> dict[str, str]:
"""Return a dictionary of registered targets."""
return push_registrations(self.hass)
async def async_send_message(self, message="", **kwargs):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the Lambda APNS gateway."""
data = {ATTR_MESSAGE: message}
# Remove default title from notifications.
if (
kwargs.get(ATTR_TITLE) is not None
and kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT
):
data[ATTR_TITLE] = kwargs.get(ATTR_TITLE)
title_arg := kwargs.get(ATTR_TITLE)
) is not None and title_arg != ATTR_TITLE_DEFAULT:
data[ATTR_TITLE] = title_arg
if not (targets := kwargs.get(ATTR_TARGET)):
targets = push_registrations(self.hass).values()
if kwargs.get(ATTR_DATA) is not None:
data[ATTR_DATA] = kwargs.get(ATTR_DATA)
if (data_arg := kwargs.get(ATTR_DATA)) is not None:
data[ATTR_DATA] = data_arg
local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL]
@@ -166,7 +161,7 @@ class MobileAppNotificationService(BaseNotificationService):
try:
async with asyncio.timeout(10):
response = await async_get_clientsession(self._hass).post(
response = await async_get_clientsession(self.hass).post(
push_url, json=target_data
)
result = await response.json()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import pymsteams
import voluptuous as vol
@@ -49,7 +50,7 @@ class MSTeamsNotificationService(BaseNotificationService):
"""Initialize the service."""
self._webhook_url = webhook_url
def send_message(self, message=None, **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the webhook."""
teams_message = pymsteams.connectorcard(self._webhook_url)

View File

@@ -1,28 +1,21 @@
"""Support for namecheap DNS services."""
from datetime import timedelta
import logging
from aiohttp import ClientError, ClientSession
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, UPDATE_URL
from .const import DOMAIN
from .coordinator import NamecheapConfigEntry, NamecheapDnsUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
INTERVAL = timedelta(minutes=5)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -36,8 +29,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
type NamecheapConfigEntry = ConfigEntry[None]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the namecheap DNS component."""
@@ -54,37 +45,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Set up Namecheap DynamicDNS from a config entry."""
host = entry.data[CONF_HOST]
domain = entry.data[CONF_DOMAIN]
password = entry.data[CONF_PASSWORD]
session = async_get_clientsession(hass)
coordinator = NamecheapDnsUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
try:
if not await update_namecheapdns(session, host, domain, password):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
)
except ClientError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
) from e
async def update_domain_interval(now):
"""Update the namecheap DNS entry."""
await update_namecheapdns(session, host, domain, password)
entry.async_on_unload(
async_track_time_interval(hass, update_domain_interval, INTERVAL)
)
# Add a dummy listener as we do not have regular entities
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
return True
@@ -92,19 +59,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def update_namecheapdns(
session: ClientSession, host: str, domain: str, password: str
):
"""Update namecheap DNS entry."""
params = {"host": host, "domain": domain, "password": password}
resp = await session.get(UPDATE_URL, params=params)
xml_string = await resp.text()
if "<ErrCount>0</ErrCount>" not in xml_string:
_LOGGER.warning("Updating namecheap domain failed: %s", domain)
return False
return True

View File

@@ -9,7 +9,7 @@ from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_NAME, CONF_PASSWORD
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
@@ -18,8 +18,8 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from . import update_namecheapdns
from .const import DOMAIN
from .helpers import update_namecheapdns
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
@@ -37,6 +37,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
}
)
class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Namecheap DynamicDNS."""
@@ -89,3 +99,41 @@ class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
deprecate_yaml_issue(self.hass, import_success=True)
return result
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure flow."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
session = async_get_clientsession(self.hass)
try:
if not await update_namecheapdns(
session,
entry.data[CONF_HOST],
entry.data[CONF_DOMAIN],
user_input[CONF_PASSWORD],
):
errors["base"] = "update_failed"
except ClientError:
_LOGGER.debug("Cannot connect", exc_info=True)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
errors=errors,
description_placeholders={CONF_NAME: entry.title},
)

View File

@@ -0,0 +1,61 @@
"""Coordinator for the Namecheap DynamicDNS integration."""
from datetime import timedelta
import logging
from aiohttp import ClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .helpers import update_namecheapdns
_LOGGER = logging.getLogger(__name__)
type NamecheapConfigEntry = ConfigEntry[NamecheapDnsUpdateCoordinator]
INTERVAL = timedelta(minutes=5)
class NamecheapDnsUpdateCoordinator(DataUpdateCoordinator[None]):
"""Namecheap DynamicDNS update coordinator."""
config_entry: NamecheapConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: NamecheapConfigEntry) -> None:
"""Initialize the Namecheap DynamicDNS update coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=INTERVAL,
)
self.session = async_get_clientsession(hass)
async def _async_update_data(self) -> None:
"""Update Namecheap DNS."""
host = self.config_entry.data[CONF_HOST]
domain = self.config_entry.data[CONF_DOMAIN]
password = self.config_entry.data[CONF_PASSWORD]
try:
if not await update_namecheapdns(self.session, host, domain, password):
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
)
except ClientError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
) from e

View File

@@ -0,0 +1,24 @@
"""Helpers for the Namecheap DynamicDNS integration."""
import logging
from aiohttp import ClientSession
from .const import UPDATE_URL
_LOGGER = logging.getLogger(__name__)
async def update_namecheapdns(
session: ClientSession, host: str, domain: str, password: str
):
"""Update namecheap DNS entry."""
params = {"host": host, "domain": domain, "password": password}
resp = await session.get(UPDATE_URL, params=params)
xml_string = await resp.text()
if "<ErrCount>0</ErrCount>" not in xml_string:
return False
return True

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,15 @@
"update_failed": "Updating DNS failed"
},
"step": {
"reconfigure": {
"data": {
"password": "[%key:component::namecheapdns::config::step::user::data::password%]"
},
"data_description": {
"password": "[%key:component::namecheapdns::config::step::user::data_description::password%]"
},
"title": "Re-configure {name}"
},
"user": {
"data": {
"domain": "[%key:common::config_flow::data::username%]",

View File

@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": ["nsapi==3.1.3"]
}

View File

@@ -2,7 +2,10 @@
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -75,7 +78,7 @@ class NessZoneBinarySensor(BinarySensorEntity):
return self._state == 1
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._type

View File

@@ -40,7 +40,7 @@ class NetgearNotifyService(BaseNotificationService):
self.modem: Modem = discovery_info["modem"]
discovery_info["entry"].async_on_unload(self.async_unregister_services)
async def async_send_message(self, message="", **kwargs):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
if not self.modem.token:

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
"quality_scale": "legacy",
"requirements": ["pyrail==0.4.1"]
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os.path
from typing import Any
from notify_events import Message
@@ -123,7 +124,7 @@ class NotifyEventsNotificationService(BaseNotificationService):
return msg
def send_message(self, message, **kwargs):
def send_message(self, message: str, **kwargs: Any) -> None:
"""Send a message."""
data = kwargs.get(ATTR_DATA) or {}
token = data.get(ATTR_TOKEN, self.token)

View File

@@ -96,7 +96,7 @@ class NX584ZoneSensor(BinarySensorEntity):
self._zone_type = zone_type
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type

View File

@@ -84,7 +84,7 @@ class OASATelematicsSensor(SensorEntity):
return self._name
@property
def device_class(self):
def device_class(self) -> SensorDeviceClass:
"""Return the class of this sensor."""
return SensorDeviceClass.TIMESTAMP

View File

@@ -9,6 +9,6 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["nacl"],
"requirements": ["PyNaCl==1.6.0"],
"requirements": ["PyNaCl==1.6.2"],
"single_config_entry": true
}

View File

@@ -2,7 +2,8 @@
from __future__ import annotations
from typing import Literal
from collections.abc import Callable, Coroutine
from typing import Any, Literal
from pooldose.type_definitions import DeviceInfoDict, ValueDict
@@ -80,7 +81,10 @@ class PooldoseEntity(CoordinatorEntity[PooldoseCoordinator]):
return platform_data.get(self.entity_description.key)
async def _async_perform_write(
self, api_call, key: str, value: bool | str | float
self,
api_call: Callable[[str, Any], Coroutine[Any, Any, bool]],
key: str,
value: bool | str | float,
) -> None:
"""Perform a write call to the API with unified error handling.

View File

@@ -11,6 +11,6 @@
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "gold",
"quality_scale": "platinum",
"requirements": ["python-pooldose==0.8.2"]
}

View File

@@ -71,4 +71,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
strict-typing: done

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.22"]
"requirements": ["pyportainer==1.0.23"]
}

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["prowl"],
"quality_scale": "legacy",
"requirements": ["prowlpy==1.1.1"]
}

View File

@@ -126,9 +126,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"media_player",
DOMAIN,
unique_id,
suggested_object_id=new_id,
config_entry=entry,
device_id=e_entry.device_id,
object_id_base=new_id,
)
_LOGGER.debug(
"PlayStation 4 identifier for entity: %s has changed",

View File

@@ -6,7 +6,10 @@ import logging
from pyqwikswitch.qwikswitch import SENSORS
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -76,6 +79,6 @@ class QSBinarySensor(QSEntity, BinarySensorEntity):
return f"qs{self.qsid}:{self.channel}"
@property
def device_class(self):
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
return self._class

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
from typing import Any
from rocketchat_API.APIExceptions.RocketExceptions import (
RocketAuthenticationException,
@@ -69,7 +70,7 @@ class RocketChatNotificationService(BaseNotificationService):
self._room = room
self._server = RocketChat(username, password, server_url=url)
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Rocket.Chat."""
data = kwargs.get(ATTR_DATA) or {}
resp = self._server.chat_post_message(message, channel=self._room, **data)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
from typing import Any
from sendgrid import SendGridAPIClient
import voluptuous as vol
@@ -61,7 +62,7 @@ class SendgridNotificationService(BaseNotificationService):
self._sg = SendGridAPIClient(self.api_key)
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send an email to a user via SendGrid."""
subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from clx.xms.api import MtBatchTextSmsResult
from clx.xms.client import Client
@@ -67,7 +68,7 @@ class SinchNotificationService(BaseNotificationService):
self.sender = config[CONF_SENDER]
self.client = Client(config[CONF_SERVICE_PLAN_ID], config[CONF_API_KEY])
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
targets = kwargs.get(ATTR_TARGET, self.default_recipients)
data = kwargs.get(ATTR_DATA) or {}

View File

@@ -0,0 +1,265 @@
"""Audio helper for SmartThings audio notifications."""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
from datetime import timedelta
import logging
import secrets
from aiohttp import hdrs, web
from homeassistant.components import ffmpeg
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.network import NoURLAvailableError, get_url
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PCM_SAMPLE_RATE = 24000
PCM_SAMPLE_WIDTH = 2
PCM_CHANNELS = 1
PCM_MIME = "audio/L16"
PCM_EXTENSION = ".pcm"
WARNING_DURATION_SECONDS = 40
FFMPEG_MAX_DURATION_SECONDS = 10 * 60
TRANSCODE_TIMEOUT_SECONDS = WARNING_DURATION_SECONDS + 10
_TRUNCATION_EPSILON = 1 / PCM_SAMPLE_RATE
ENTRY_TTL = timedelta(minutes=5)
MAX_STORED_ENTRIES = 4 # Limit the number of cached notifications.
PCM_FRAME_BYTES = PCM_SAMPLE_WIDTH * PCM_CHANNELS
DATA_AUDIO_MANAGER = "audio_manager"
class SmartThingsAudioError(HomeAssistantError):
"""Error raised when SmartThings audio preparation fails."""
@dataclass
class _AudioEntry:
"""Stored PCM audio entry."""
pcm: bytes
created: float
expires: float
class SmartThingsAudioManager(HomeAssistantView):
"""Manage PCM proxy URLs for SmartThings audio notifications."""
url = "/api/smartthings/audio/{token}"
name = "api:smartthings:audio"
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the manager."""
self.hass = hass
self._entries: dict[str, _AudioEntry] = {}
self._cleanup_handle: asyncio.TimerHandle | None = None
async def async_prepare_notification(self, source_url: str) -> str:
"""Generate an externally accessible PCM URL for SmartThings."""
pcm, duration, truncated = await self._transcode_to_pcm(source_url)
if not pcm:
raise SmartThingsAudioError("Converted audio is empty")
if truncated:
_LOGGER.warning(
"SmartThings audio notification truncated to %s seconds (output length %.1fs); longer sources may be cut off",
FFMPEG_MAX_DURATION_SECONDS,
duration,
)
elif duration > WARNING_DURATION_SECONDS:
_LOGGER.warning(
"SmartThings audio notification is %.1fs; playback over %s seconds may be cut off",
duration,
WARNING_DURATION_SECONDS,
)
token = secrets.token_urlsafe(
16
) # Shorter tokens avoid playback issues in some devices.
now = self.hass.loop.time()
entry = _AudioEntry(
pcm=pcm,
created=now,
expires=now + ENTRY_TTL.total_seconds(),
)
self._cleanup(now)
while token in self._entries:
token = secrets.token_urlsafe(16)
self._entries[token] = entry
while len(self._entries) > MAX_STORED_ENTRIES:
dropped_token = next(iter(self._entries))
self._entries.pop(dropped_token, None)
_LOGGER.debug(
"Dropped oldest SmartThings audio token %s to cap cache",
dropped_token,
)
self._schedule_cleanup()
path = f"/api/smartthings/audio/{token}{PCM_EXTENSION}"
try:
base_url = get_url(
self.hass,
allow_internal=True,
allow_external=True,
allow_cloud=True,
prefer_external=False, # Prevent NAT loopback failures; may break non-local access for devices outside the LAN.
prefer_cloud=True,
)
except NoURLAvailableError as err:
self._entries.pop(token, None)
self._schedule_cleanup()
raise SmartThingsAudioError(
"SmartThings audio notifications require an accessible Home Assistant URL"
) from err
return f"{base_url}{path}"
async def get(self, request: web.Request, token: str) -> web.StreamResponse:
"""Serve a PCM audio response."""
token = token.removesuffix(PCM_EXTENSION)
now = self.hass.loop.time()
self._cleanup(now)
self._schedule_cleanup()
entry = self._entries.get(token)
if entry is None:
raise web.HTTPNotFound
_LOGGER.debug("Serving SmartThings audio token=%s to %s", token, request.remote)
response = web.Response(body=entry.pcm, content_type=PCM_MIME)
response.headers[hdrs.CACHE_CONTROL] = "no-store"
response.headers[hdrs.ACCEPT_RANGES] = "none"
response.headers[hdrs.CONTENT_DISPOSITION] = (
f'inline; filename="{token}{PCM_EXTENSION}"'
)
return response
async def _transcode_to_pcm(self, source_url: str) -> tuple[bytes, float, bool]:
"""Use ffmpeg to convert the source media to 24kHz mono PCM."""
manager = ffmpeg.get_ffmpeg_manager(self.hass)
command = [
manager.binary,
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-i",
source_url,
"-ac",
str(PCM_CHANNELS),
"-ar",
str(PCM_SAMPLE_RATE),
"-c:a",
"pcm_s16le",
"-t",
str(FFMPEG_MAX_DURATION_SECONDS),
"-f",
"s16le",
"pipe:1",
]
try:
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as err:
raise SmartThingsAudioError(
"FFmpeg is required for SmartThings audio notifications"
) from err
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=TRANSCODE_TIMEOUT_SECONDS
)
except TimeoutError:
_LOGGER.warning(
"FFmpeg timed out after %s seconds while converting SmartThings audio from %s",
TRANSCODE_TIMEOUT_SECONDS,
source_url,
)
with contextlib.suppress(ProcessLookupError):
process.kill()
stdout, stderr = await process.communicate()
if process.returncode != 0:
message = stderr.decode().strip() or "unknown error"
_LOGGER.error(
"FFmpeg failed to convert SmartThings audio from %s: %s",
source_url,
message,
)
raise SmartThingsAudioError(
"Unable to convert audio to PCM for SmartThings"
)
if not stdout:
return b"", 0.0, False
frame_count, remainder = divmod(len(stdout), PCM_FRAME_BYTES)
if remainder:
_LOGGER.debug(
"SmartThings audio conversion produced misaligned PCM: dropping %s extra byte(s)",
remainder,
)
stdout = stdout[: len(stdout) - remainder]
frame_count = len(stdout) // PCM_FRAME_BYTES
if frame_count == 0:
return b"", 0.0, False
duration = frame_count / PCM_SAMPLE_RATE
truncated = duration >= (FFMPEG_MAX_DURATION_SECONDS - _TRUNCATION_EPSILON)
return stdout, duration, truncated
@callback
def _schedule_cleanup(self) -> None:
"""Schedule the next cleanup based on entry expiry."""
if self._cleanup_handle is not None:
self._cleanup_handle.cancel()
self._cleanup_handle = None
if not self._entries:
return
next_expiry = min(entry.expires for entry in self._entries.values())
delay = max(0.0, next_expiry - self.hass.loop.time())
self._cleanup_handle = self.hass.loop.call_later(delay, self._cleanup_callback)
@callback
def _cleanup_callback(self) -> None:
"""Run a cleanup pass."""
self._cleanup_handle = None
now = self.hass.loop.time()
self._cleanup(now)
self._schedule_cleanup()
def _cleanup(self, now: float) -> None:
"""Remove expired entries."""
expired = [
token for token, entry in self._entries.items() if entry.expires <= now
]
for token in expired:
self._entries.pop(token, None)
async def async_get_audio_manager(hass: HomeAssistant) -> SmartThingsAudioManager:
"""Return the shared SmartThings audio manager."""
domain_data = hass.data.setdefault(DOMAIN, {})
if (manager := domain_data.get(DATA_AUDIO_MANAGER)) is None:
manager = SmartThingsAudioManager(hass)
hass.http.register_view(manager)
domain_data[DATA_AUDIO_MANAGER] = manager
return manager

View File

@@ -3,7 +3,7 @@
"name": "SmartThings",
"codeowners": ["@joostlek"],
"config_flow": true,
"dependencies": ["application_credentials"],
"dependencies": ["application_credentials", "http", "ffmpeg"],
"dhcp": [
{
"hostname": "st*",

View File

@@ -6,17 +6,22 @@ from typing import Any
from pysmartthings import Attribute, Capability, Category, Command, SmartThings
from homeassistant.components import media_source
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
async_process_play_media_url,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullDevice, SmartThingsConfigEntry
from .audio import SmartThingsAudioError, async_get_audio_manager
from .const import MAIN
from .entity import SmartThingsEntity
@@ -84,6 +89,7 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
Capability.AUDIO_MUTE,
Capability.AUDIO_TRACK_DATA,
Capability.AUDIO_VOLUME,
Capability.AUDIO_NOTIFICATION,
Capability.MEDIA_INPUT_SOURCE,
Capability.MEDIA_PLAYBACK,
Capability.MEDIA_PLAYBACK_REPEAT,
@@ -128,6 +134,8 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
flags |= MediaPlayerEntityFeature.SHUFFLE_SET
if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT):
flags |= MediaPlayerEntityFeature.REPEAT_SET
if self.supports_capability(Capability.AUDIO_NOTIFICATION):
flags |= MediaPlayerEntityFeature.PLAY_MEDIA
return flags
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -233,6 +241,40 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat],
)
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play media using SmartThings audio notifications."""
if not self.supports_capability(Capability.AUDIO_NOTIFICATION):
raise HomeAssistantError("Device does not support audio notifications")
if media_type not in (MediaType.MUSIC,):
raise HomeAssistantError(
"Unsupported media type for SmartThings audio notification"
)
if media_source.is_media_source_id(media_id):
play_item = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = async_process_play_media_url(self.hass, play_item.url)
else:
media_id = async_process_play_media_url(self.hass, media_id)
audio_manager = await async_get_audio_manager(self.hass)
try:
proxy_url = await audio_manager.async_prepare_notification(media_id)
except SmartThingsAudioError as err:
raise HomeAssistantError(str(err)) from err
command = Command("playTrackAndResume")
await self.execute_device_command(
Capability.AUDIO_NOTIFICATION,
command,
argument=[proxy_url],
)
@property
def media_title(self) -> str | None:
"""Title of current playing media."""

View File

@@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
from .types import DaliCenterConfigEntry, DaliCenterData
_PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE]
_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.LIGHT, Platform.SCENE]
_LOGGER = logging.getLogger(__name__)

View File

@@ -0,0 +1,63 @@
"""Support for Sunricher DALI device identify button."""
from __future__ import annotations
import logging
from PySrDaliGateway import Device
from PySrDaliGateway.helper import is_light_device
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .entity import DaliDeviceEntity
from .types import DaliCenterConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: DaliCenterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sunricher DALI button entities from config entry."""
devices = entry.runtime_data.devices
async_add_entities(
DaliCenterIdentifyButton(device)
for device in devices
if is_light_device(device.dev_type)
)
class DaliCenterIdentifyButton(DaliDeviceEntity, ButtonEntity):
"""Representation of a Sunricher DALI device identify button."""
_attr_device_class = ButtonDeviceClass.IDENTIFY
_attr_entity_category = EntityCategory.CONFIG
_attr_name = None
def __init__(self, device: Device) -> None:
"""Initialize the device identify button."""
super().__init__(device)
self._device = device
self._attr_unique_id = f"{device.unique_id}_identify"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.dev_id)},
name=device.name,
manufacturer=MANUFACTURER,
model=device.model,
via_device=(DOMAIN, device.gw_sn),
)
async def async_press(self) -> None:
"""Handle button press to identify device."""
_LOGGER.debug("Identifying device %s", self._device.dev_id)
self._device.identify()

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
from typing import Any
import requests
import voluptuous as vol
@@ -51,7 +52,7 @@ class SynologyChatNotificationService(BaseNotificationService):
self._resource = resource
self._verify_ssl = verify_ssl
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
data = {"text": message}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import syslog
from typing import Any
import voluptuous as vol
@@ -91,7 +92,7 @@ class SyslogNotificationService(BaseNotificationService):
self._option = option
self._priority = priority
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to syslog."""
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)

View File

@@ -83,8 +83,8 @@ def _create_entry(
DOMAIN,
DOMAIN,
tag_id,
object_id_base=slugify(name) if name else tag_id,
original_name=f"{DEFAULT_NAME} {tag_id}",
suggested_object_id=slugify(name) if name else tag_id,
)
if name:
return entity_registry.async_update_entity(entry.entity_id, name=name)

Some files were not shown because too many files have changed in this diff Show More