Merge branch 'dev' into bangolufsen

This commit is contained in:
Markus Jacobsen 2023-12-08 12:14:17 +01:00 committed by GitHub
commit 5d1071cff5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
466 changed files with 11378 additions and 3623 deletions

View file

@ -909,6 +909,9 @@ omit =
homeassistant/components/opple/light.py homeassistant/components/opple/light.py
homeassistant/components/oru/* homeassistant/components/oru/*
homeassistant/components/orvibo/switch.py homeassistant/components/orvibo/switch.py
homeassistant/components/osoenergy/__init__.py
homeassistant/components/osoenergy/const.py
homeassistant/components/osoenergy/water_heater.py
homeassistant/components/osramlightify/light.py homeassistant/components/osramlightify/light.py
homeassistant/components/otp/sensor.py homeassistant/components/otp/sensor.py
homeassistant/components/overkiz/__init__.py homeassistant/components/overkiz/__init__.py

View file

@ -29,7 +29,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -59,7 +59,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -124,7 +124,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View file

@ -225,7 +225,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -269,7 +269,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -309,7 +309,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -348,7 +348,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -443,7 +443,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -511,7 +511,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -543,7 +543,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -576,7 +576,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -620,7 +620,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -702,7 +702,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -854,7 +854,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -978,7 +978,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true

View file

@ -29,11 +29,11 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2.22.8 uses: github/codeql-action/init@v2.22.9
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2.22.8 uses: github/codeql-action/analyze@v2.22.9
with: with:
category: "/language:python" category: "/language:python"

View file

@ -11,16 +11,16 @@ jobs:
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# The 90 day stale policy for PRs # The 60 day stale policy for PRs
# Used for: # Used for:
# - PRs # - PRs
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 90 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@v8.0.0 uses: actions/stale@v9.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90 days-before-stale: 60
days-before-close: 7 days-before-close: 7
days-before-issue-stale: -1 days-before-issue-stale: -1
days-before-issue-close: -1 days-before-issue-close: -1
@ -33,7 +33,11 @@ jobs:
pull request has been automatically marked as stale because of that pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days. and will be closed if no further activity occurs within 7 days.
Thank you for your contributions. If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# Generate a token for the GitHub App, we use this method to avoid # Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit. # hitting API limits for our GitHub actions + have a higher rate limit.
@ -53,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@v8.0.0 uses: actions/stale@v9.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@ -83,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@v8.0.0 uses: actions/stale@v9.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View file

@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View file

@ -153,6 +153,7 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.* homeassistant.components.hardware.*
homeassistant.components.here_travel_time.* homeassistant.components.here_travel_time.*
homeassistant.components.history.* homeassistant.components.history.*
homeassistant.components.holiday.*
homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_alerts.*
@ -317,6 +318,7 @@ homeassistant.components.statistics.*
homeassistant.components.steamist.* homeassistant.components.steamist.*
homeassistant.components.stookalert.* homeassistant.components.stookalert.*
homeassistant.components.stream.* homeassistant.components.stream.*
homeassistant.components.streamlabswater.*
homeassistant.components.sun.* homeassistant.components.sun.*
homeassistant.components.surepetcare.* homeassistant.components.surepetcare.*
homeassistant.components.switch.* homeassistant.components.switch.*

View file

@ -524,6 +524,8 @@ build.json @home-assistant/supervisor
/tests/components/hive/ @Rendili @KJonline /tests/components/hive/ @Rendili @KJonline
/homeassistant/components/hlk_sw16/ @jameshilliard /homeassistant/components/hlk_sw16/ @jameshilliard
/tests/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard
/homeassistant/components/holiday/ @jrieger
/tests/components/holiday/ @jrieger
/homeassistant/components/home_connect/ @DavidMStraub /homeassistant/components/home_connect/ @DavidMStraub
/tests/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub
/homeassistant/components/home_plus_control/ @chemaaa /homeassistant/components/home_plus_control/ @chemaaa
@ -930,6 +932,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/oralb/ @bdraco @Lash-L /homeassistant/components/oralb/ @bdraco @Lash-L
/tests/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L
/homeassistant/components/oru/ @bvlaicu /homeassistant/components/oru/ @bvlaicu
/homeassistant/components/osoenergy/ @osohotwateriot
/tests/components/osoenergy/ @osohotwateriot
/homeassistant/components/otbr/ @home-assistant/core /homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund /homeassistant/components/ourgroceries/ @OnFreund

View file

@ -6,7 +6,7 @@ FROM ${BUILD_FROM}
# Synchronize with homeassistant/core.py:async_stop # Synchronize with homeassistant/core.py:async_stop
ENV \ ENV \
S6_SERVICES_GRACETIME=220000 S6_SERVICES_GRACETIME=240000
ARG QEMU_CPU ARG QEMU_CPU

View file

@ -27,6 +27,7 @@ from .const import (
from .exceptions import HomeAssistantError from .exceptions import HomeAssistantError
from .helpers import ( from .helpers import (
area_registry, area_registry,
config_validation as cv,
device_registry, device_registry,
entity, entity,
entity_registry, entity_registry,
@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up.""" """Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant] # Filter out the repeating and common config section [homeassistant]
domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} domains = {
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN
}
# Add config entry domains # Add config entry domains
if not hass.config.recovery_mode: if not hass.config.recovery_mode:

View file

@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field
from enum import StrEnum from enum import StrEnum
import logging import logging
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Empty, Queue
from threading import Thread from threading import Thread
import time import time
from typing import TYPE_CHECKING, Any, Final, cast from typing import TYPE_CHECKING, Any, Final, cast
@ -1010,8 +1010,8 @@ class PipelineRun:
self.tts_engine = engine self.tts_engine = engine
self.tts_options = tts_options self.tts_options = tts_options
async def text_to_speech(self, tts_input: str) -> str: async def text_to_speech(self, tts_input: str) -> None:
"""Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" """Run text-to-speech portion of pipeline."""
self.process_event( self.process_event(
PipelineEvent( PipelineEvent(
PipelineEventType.TTS_START, PipelineEventType.TTS_START,
@ -1058,8 +1058,6 @@ class PipelineRun:
PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
) )
return tts_media.url
def _capture_chunk(self, audio_bytes: bytes | None) -> None: def _capture_chunk(self, audio_bytes: bytes | None) -> None:
"""Forward audio chunk to various capturing mechanisms.""" """Forward audio chunk to various capturing mechanisms."""
if self.debug_recording_queue is not None: if self.debug_recording_queue is not None:
@ -1246,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc(
# Chunk of 16-bit mono audio at 16Khz # Chunk of 16-bit mono audio at 16Khz
if wav_writer is not None: if wav_writer is not None:
wav_writer.writeframes(message) wav_writer.writeframes(message)
except Empty:
pass # occurs when pipeline has unexpected error
except Exception: # pylint: disable=broad-exception-caught except Exception: # pylint: disable=broad-exception-caught
_LOGGER.exception("Unexpected error in debug recording thread") _LOGGER.exception("Unexpected error in debug recording thread")
finally: finally:

View file

@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__)
_AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") _AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge")
_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] _FuncType = Callable[
[_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]]
]
_ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]]
@ -81,7 +83,7 @@ def handle_errors_and_zip(
if isinstance(data, dict): if isinstance(data, dict):
return dict(zip(keys, list(data.values()))) return dict(zip(keys, list(data.values())))
if not isinstance(data, list): if not isinstance(data, (list, tuple)):
raise UpdateFailed("Received invalid data type") raise UpdateFailed("Received invalid data type")
return dict(zip(keys, data)) return dict(zip(keys, data))

View file

@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
power_watts = self.client.measure(3, True) power_watts = self.client.measure(3, True)
temperature_c = self.client.measure(21) temperature_c = self.client.measure(21)
energy_wh = self.client.cumulated_energy(5) energy_wh = self.client.cumulated_energy(5)
[alarm, *_] = self.client.alarms()
except AuroraTimeoutError: except AuroraTimeoutError:
self.available = False self.available = False
_LOGGER.debug("No response from inverter (could be dark)") _LOGGER.debug("No response from inverter (could be dark)")
@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
data["instantaneouspower"] = round(power_watts, 1) data["instantaneouspower"] = round(power_watts, 1)
data["temp"] = round(temperature_c, 1) data["temp"] = round(temperature_c, 1)
data["totalenergy"] = round(energy_wh / 1000, 2) data["totalenergy"] = round(energy_wh / 1000, 2)
data["alarm"] = alarm
self.available = True self.available = True
finally: finally:

View file

@ -5,6 +5,8 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from aurorapy.mapping import Mapping as AuroraMapping
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@ -36,8 +38,16 @@ from .const import (
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ALARM_STATES = list(AuroraMapping.ALARM_STATES.values())
SENSOR_TYPES = [ SENSOR_TYPES = [
SensorEntityDescription(
key="alarm",
device_class=SensorDeviceClass.ENUM,
options=ALARM_STATES,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="alarm",
),
SensorEntityDescription( SensorEntityDescription(
key="instantaneouspower", key="instantaneouspower",
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,

View file

@ -21,11 +21,14 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"alarm": {
"name": "Alarm status"
},
"power_output": { "power_output": {
"name": "Power Output" "name": "Power output"
}, },
"total_energy": { "total_energy": {
"name": "Total Energy" "name": "Total energy"
} }
} }
} }

View file

@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error
from homeassistant.components import blueprint from homeassistant.components import blueprint
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
from homeassistant.config import config_without_domain from homeassistant.config import config_per_platform, config_without_domain
from homeassistant.const import ( from homeassistant.const import (
CONF_ALIAS, CONF_ALIAS,
CONF_CONDITION, CONF_CONDITION,
@ -21,7 +21,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_per_platform, config_validation as cv, script from homeassistant.helpers import config_validation as cv, script
from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.condition import async_validate_conditions_config
from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.trigger import async_validate_trigger_config
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType

View file

@ -7,7 +7,7 @@
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
}, },
"data_description": { "data_description": {
"host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58."
} }
} }
}, },

View file

@ -215,7 +215,7 @@ class DomainBlueprints:
def _load_blueprint(self, blueprint_path) -> Blueprint: def _load_blueprint(self, blueprint_path) -> Blueprint:
"""Load a blueprint.""" """Load a blueprint."""
try: try:
blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path)
except FileNotFoundError as err: except FileNotFoundError as err:
raise FailedToLoad( raise FailedToLoad(
self.domain, self.domain,
@ -225,7 +225,6 @@ class DomainBlueprints:
except HomeAssistantError as err: except HomeAssistantError as err:
raise FailedToLoad(self.domain, blueprint_path, err) from err raise FailedToLoad(self.domain, blueprint_path, err) from err
assert isinstance(blueprint_data, dict)
return Blueprint( return Blueprint(
blueprint_data, expected_domain=self.domain, path=blueprint_path blueprint_data, expected_domain=self.domain, path=blueprint_path
) )

View file

@ -21,6 +21,12 @@ from bluetooth_adapters import (
adapter_unique_name, adapter_unique_name,
get_adapters, get_adapters,
) )
from habluetooth import (
BluetoothScanningMode,
HaBluetoothConnector,
HaScanner,
ScannerStartError,
)
from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
from homeassistant.components import usb from homeassistant.components import usb
@ -59,7 +65,11 @@ from .api import (
async_set_fallback_availability_interval, async_set_fallback_availability_interval,
async_track_unavailable, async_track_unavailable,
) )
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice from .base_scanner import (
BaseHaScanner,
BluetoothScannerDevice,
HomeAssistantRemoteScanner,
)
from .const import ( from .const import (
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
CONF_ADAPTER, CONF_ADAPTER,
@ -71,15 +81,9 @@ from .const import (
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
SOURCE_LOCAL, SOURCE_LOCAL,
) )
from .manager import BluetoothManager from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import ( from .models import BluetoothCallback, BluetoothChange
BluetoothCallback,
BluetoothChange,
BluetoothScanningMode,
HaBluetoothConnector,
)
from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError
from .storage import BluetoothStorage from .storage import BluetoothStorage
if TYPE_CHECKING: if TYPE_CHECKING:
@ -103,7 +107,7 @@ __all__ = [
"async_scanner_count", "async_scanner_count",
"async_scanner_devices_by_address", "async_scanner_devices_by_address",
"BaseHaScanner", "BaseHaScanner",
"BaseHaRemoteScanner", "HomeAssistantRemoteScanner",
"BluetoothCallbackMatcher", "BluetoothCallbackMatcher",
"BluetoothChange", "BluetoothChange",
"BluetoothServiceInfo", "BluetoothServiceInfo",
@ -139,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await bluetooth_storage.async_setup() await bluetooth_storage.async_setup()
slot_manager = BleakSlotManager() slot_manager = BleakSlotManager()
await slot_manager.async_setup() await slot_manager.async_setup()
manager = BluetoothManager( manager = HomeAssistantBluetoothManager(
hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager
) )
await manager.async_setup() await manager.async_setup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop()
)
hass.data[DATA_MANAGER] = models.MANAGER = manager hass.data[DATA_MANAGER] = models.MANAGER = manager
adapters = await manager.async_get_bluetooth_adapters() adapters = await manager.async_get_bluetooth_adapters()
@ -280,8 +286,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
passive = entry.options.get(CONF_PASSIVE) passive = entry.options.get(CONF_PASSIVE)
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
new_info_callback = async_get_advertisement_callback(hass) new_info_callback = async_get_advertisement_callback(hass)
manager: BluetoothManager = hass.data[DATA_MANAGER] manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER]
scanner = HaScanner(hass, mode, adapter, address, new_info_callback) scanner = HaScanner(mode, adapter, address, new_info_callback)
try: try:
scanner.async_setup() scanner.async_setup()
except RuntimeError as err: except RuntimeError as err:

View file

@ -9,10 +9,10 @@ import logging
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from bleak import BleakError from bleak import BleakError
from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.util.dt import monotonic_time_coarse
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator

View file

@ -9,10 +9,10 @@ import logging
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from bleak import BleakError from bleak import BleakError
from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.util.dt import monotonic_time_coarse
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
from .passive_update_processor import PassiveBluetoothProcessorCoordinator from .passive_update_processor import PassiveBluetoothProcessorCoordinator

View file

@ -1,82 +0,0 @@
"""The bluetooth integration advertisement tracker."""
from __future__ import annotations
from typing import Any
from homeassistant.core import callback
from .models import BluetoothServiceInfoBleak
ADVERTISING_TIMES_NEEDED = 16
# Each scanner may buffer incoming packets so
# we need to give a bit of leeway before we
# mark a device unavailable
TRACKER_BUFFERING_WOBBLE_SECONDS = 5
class AdvertisementTracker:
"""Tracker to determine the interval that a device is advertising."""
__slots__ = ("intervals", "fallback_intervals", "sources", "_timings")
def __init__(self) -> None:
"""Initialize the tracker."""
self.intervals: dict[str, float] = {}
self.fallback_intervals: dict[str, float] = {}
self.sources: dict[str, str] = {}
self._timings: dict[str, list[float]] = {}
@callback
def async_diagnostics(self) -> dict[str, dict[str, Any]]:
"""Return diagnostics."""
return {
"intervals": self.intervals,
"fallback_intervals": self.fallback_intervals,
"sources": self.sources,
"timings": self._timings,
}
@callback
def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Collect timings for the tracker.
For performance reasons, it is the responsibility of the
caller to check if the device already has an interval set or
the source has changed before calling this function.
"""
address = service_info.address
self.sources[address] = service_info.source
timings = self._timings.setdefault(address, [])
timings.append(service_info.time)
if len(timings) != ADVERTISING_TIMES_NEEDED:
return
max_time_between_advertisements = timings[1] - timings[0]
for i in range(2, len(timings)):
time_between_advertisements = timings[i] - timings[i - 1]
if time_between_advertisements > max_time_between_advertisements:
max_time_between_advertisements = time_between_advertisements
# We now know the maximum time between advertisements
self.intervals[address] = max_time_between_advertisements
del self._timings[address]
@callback
def async_remove_address(self, address: str) -> None:
"""Remove the tracker."""
self.intervals.pop(address, None)
self.sources.pop(address, None)
self._timings.pop(address, None)
@callback
def async_remove_fallback_interval(self, address: str) -> None:
"""Remove fallback interval."""
self.fallback_intervals.pop(address, None)
@callback
def async_remove_source(self, source: str) -> None:
"""Remove the tracker."""
for address, tracked_source in list(self.sources.items()):
if tracked_source == source:
self.async_remove_address(address)

View file

@ -9,29 +9,25 @@ from asyncio import Future
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from habluetooth import BluetoothScanningMode
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .base_scanner import BaseHaScanner, BluetoothScannerDevice
from .const import DATA_MANAGER from .const import DATA_MANAGER
from .manager import BluetoothManager from .manager import HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher from .match import BluetoothCallbackMatcher
from .models import ( from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback
BluetoothCallback,
BluetoothChange,
BluetoothScanningMode,
ProcessAdvertisementCallback,
)
from .wrappers import HaBleakScannerWrapper from .wrappers import HaBleakScannerWrapper
if TYPE_CHECKING: if TYPE_CHECKING:
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
def _get_manager(hass: HomeAssistant) -> BluetoothManager: def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
"""Get the bluetooth manager.""" """Get the bluetooth manager."""
return cast(BluetoothManager, hass.data[DATA_MANAGER]) return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER])
@hass_callback @hass_callback

View file

@ -1,19 +1,14 @@
"""Base classes for HA Bluetooth scanners for bluetooth.""" """Base classes for HA Bluetooth scanners for bluetooth."""
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from collections.abc import Callable
from collections.abc import Callable, Generator
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
import datetime from typing import Any
from datetime import timedelta
import logging
from typing import Any, Final
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE from bluetooth_adapters import DiscoveredDeviceAdvertisementData
from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
@ -23,20 +18,8 @@ from homeassistant.core import (
HomeAssistant, HomeAssistant,
callback as hass_callback, callback as hass_callback,
) )
from homeassistant.helpers.event import async_track_time_interval
import homeassistant.util.dt as dt_util
from homeassistant.util.dt import monotonic_time_coarse
from . import models from . import models
from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from .models import HaBluetoothConnector
MONOTONIC_TIME: Final = monotonic_time_coarse
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True) @dataclass(slots=True)
@ -48,150 +31,17 @@ class BluetoothScannerDevice:
advertisement: AdvertisementData advertisement: AdvertisementData
class BaseHaScanner(ABC): class HomeAssistantRemoteScanner(BaseHaRemoteScanner):
"""Base class for Ha Scanners.""" """Home Assistant remote BLE scanner.
This is the only object that should know about
the hass object.
"""
__slots__ = ( __slots__ = (
"hass", "hass",
"adapter",
"connectable",
"source",
"connector",
"_connecting",
"name",
"scanning",
"_last_detection",
"_start_time",
"_cancel_watchdog",
)
def __init__(
self,
hass: HomeAssistant,
source: str,
adapter: str,
connector: HaBluetoothConnector | None = None,
) -> None:
"""Initialize the scanner."""
self.hass = hass
self.connectable = False
self.source = source
self.connector = connector
self._connecting = 0
self.adapter = adapter
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning = True
self._last_detection = 0.0
self._start_time = 0.0
self._cancel_watchdog: CALLBACK_TYPE | None = None
@hass_callback
def _async_stop_scanner_watchdog(self) -> None:
"""Stop the scanner watchdog."""
if self._cancel_watchdog:
self._cancel_watchdog()
self._cancel_watchdog = None
@hass_callback
def _async_setup_scanner_watchdog(self) -> None:
"""If something has restarted or updated, we need to restart the scanner."""
self._start_time = self._last_detection = MONOTONIC_TIME()
if not self._cancel_watchdog:
self._cancel_watchdog = async_track_time_interval(
self.hass,
self._async_scanner_watchdog,
SCANNER_WATCHDOG_INTERVAL,
name=f"{self.name} Bluetooth scanner watchdog",
)
@hass_callback
def _async_watchdog_triggered(self) -> bool:
"""Check if the watchdog has been triggered."""
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
_LOGGER.debug(
"%s: Scanner watchdog time_since_last_detection: %s",
self.name,
time_since_last_detection,
)
return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT
@hass_callback
def _async_scanner_watchdog(self, now: datetime.datetime) -> None:
"""Check if the scanner is running.
Override this method if you need to do something else when the watchdog
is triggered.
"""
if self._async_watchdog_triggered():
_LOGGER.info(
(
"%s: Bluetooth scanner has gone quiet for %ss, check logs on the"
" scanner device for more information"
),
self.name,
SCANNER_WATCHDOG_TIMEOUT,
)
self.scanning = False
return
self.scanning = not self._connecting
@contextmanager
def connecting(self) -> Generator[None, None, None]:
"""Context manager to track connecting state."""
self._connecting += 1
self.scanning = not self._connecting
try:
yield
finally:
self._connecting -= 1
self.scanning = not self._connecting
@property
@abstractmethod
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
@property
@abstractmethod
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
device_adv_datas = self.discovered_devices_and_advertisement_data.values()
return {
"name": self.name,
"start_time": self._start_time,
"source": self.source,
"scanning": self.scanning,
"type": self.__class__.__name__,
"last_detection": self._last_detection,
"monotonic_time": MONOTONIC_TIME(),
"discovered_devices_and_advertisement_data": [
{
"name": device.name,
"address": device.address,
"rssi": advertisement_data.rssi,
"advertisement_data": advertisement_data,
"details": device.details,
}
for device, advertisement_data in device_adv_datas
],
}
class BaseHaRemoteScanner(BaseHaScanner):
"""Base class for a Home Assistant remote BLE scanner."""
__slots__ = (
"_new_info_callback",
"_discovered_device_advertisement_datas",
"_discovered_device_timestamps",
"_details",
"_expire_seconds",
"_storage", "_storage",
"_cancel_stop",
) )
def __init__( def __init__(
@ -204,50 +54,36 @@ class BaseHaRemoteScanner(BaseHaScanner):
connectable: bool, connectable: bool,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
super().__init__(hass, scanner_id, name, connector) self.hass = hass
self._new_info_callback = new_info_callback
self._discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData]
] = {}
self._discovered_device_timestamps: dict[str, float] = {}
self.connectable = connectable
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
# Scanners only care about connectable devices. The manager
# will handle taking care of availability for non-connectable devices
self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
assert models.MANAGER is not None assert models.MANAGER is not None
self._storage = models.MANAGER.storage self._storage = models.MANAGER.storage
self._cancel_stop: CALLBACK_TYPE | None = None
super().__init__(scanner_id, name, new_info_callback, connector, connectable)
@hass_callback @hass_callback
def async_setup(self) -> CALLBACK_TYPE: def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner.""" """Set up the scanner."""
super().async_setup()
if history := self._storage.async_get_advertisement_history(self.source): if history := self._storage.async_get_advertisement_history(self.source):
self._discovered_device_advertisement_datas = ( self._discovered_device_advertisement_datas = (
history.discovered_device_advertisement_datas history.discovered_device_advertisement_datas
) )
self._discovered_device_timestamps = history.discovered_device_timestamps self._discovered_device_timestamps = history.discovered_device_timestamps
# Expire anything that is too old # Expire anything that is too old
self._async_expire_devices(dt_util.utcnow()) self._async_expire_devices()
cancel_track = async_track_time_interval( self._cancel_stop = self.hass.bus.async_listen(
self.hass,
self._async_expire_devices,
timedelta(seconds=30),
name=f"{self.name} Bluetooth scanner device expire",
)
cancel_stop = self.hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP, self._async_save_history EVENT_HOMEASSISTANT_STOP, self._async_save_history
) )
self._async_setup_scanner_watchdog() return self._unsetup
@hass_callback @hass_callback
def _cancel() -> None: def _unsetup(self) -> None:
self._async_save_history() super()._unsetup()
self._async_stop_scanner_watchdog() self._async_save_history()
cancel_track() if self._cancel_stop:
cancel_stop() self._cancel_stop()
self._cancel_stop = None
return _cancel
@hass_callback @hass_callback
def _async_save_history(self, event: Event | None = None) -> None: def _async_save_history(self, event: Event | None = None) -> None:
@ -262,146 +98,10 @@ class BaseHaRemoteScanner(BaseHaScanner):
), ),
) )
@hass_callback
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
"""Expire old devices."""
now = MONOTONIC_TIME()
expired = [
address
for address, timestamp in self._discovered_device_timestamps.items()
if now - timestamp > self._expire_seconds
]
for address in expired:
del self._discovered_device_advertisement_datas[address]
del self._discovered_device_timestamps[address]
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
device_adv_datas = self._discovered_device_advertisement_datas.values()
return [
device_advertisement_data[0]
for device_advertisement_data in device_adv_datas
]
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self._discovered_device_advertisement_datas
@hass_callback
def _async_on_advertisement(
self,
address: str,
rssi: int,
local_name: str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: int | None,
details: dict[Any, Any],
advertisement_monotonic_time: float,
) -> None:
"""Call the registered callback."""
self.scanning = not self._connecting
self._last_detection = advertisement_monotonic_time
try:
prev_discovery = self._discovered_device_advertisement_datas[address]
except KeyError:
# We expect this is the rare case and since py3.11+ has
# near zero cost try on success, and we can avoid .get()
# which is slower than [] we use the try/except pattern.
device = BLEDevice(
address=address,
name=local_name,
details=self._details | details,
rssi=rssi, # deprecated, will be removed in newer bleak
)
else:
# Merge the new data with the old data
# to function the same as BlueZ which
# merges the dicts on PropertiesChanged
prev_device = prev_discovery[0]
prev_advertisement = prev_discovery[1]
prev_service_uuids = prev_advertisement.service_uuids
prev_service_data = prev_advertisement.service_data
prev_manufacturer_data = prev_advertisement.manufacturer_data
prev_name = prev_device.name
if prev_name and (not local_name or len(prev_name) > len(local_name)):
local_name = prev_name
if service_uuids and service_uuids != prev_service_uuids:
service_uuids = list({*service_uuids, *prev_service_uuids})
elif not service_uuids:
service_uuids = prev_service_uuids
if service_data and service_data != prev_service_data:
service_data = prev_service_data | service_data
elif not service_data:
service_data = prev_service_data
if manufacturer_data and manufacturer_data != prev_manufacturer_data:
manufacturer_data = prev_manufacturer_data | manufacturer_data
elif not manufacturer_data:
manufacturer_data = prev_manufacturer_data
#
# Bleak updates the BLEDevice via create_or_update_device.
# We need to do the same to ensure integrations that already
# have the BLEDevice object get the updated details when they
# change.
#
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
#
device = prev_device
device.name = local_name
device.details = self._details | details
# pylint: disable-next=protected-access
device._rssi = rssi # deprecated, will be removed in newer bleak
advertisement_data = AdvertisementData(
local_name=None if local_name == "" else local_name,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
tx_power=NO_RSSI_VALUE if tx_power is None else tx_power,
rssi=rssi,
platform_data=(),
)
self._discovered_device_advertisement_datas[address] = (
device,
advertisement_data,
)
self._discovered_device_timestamps[address] = advertisement_monotonic_time
self._new_info_callback(
BluetoothServiceInfoBleak(
name=local_name or address,
address=address,
rssi=rssi,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=self.connectable,
time=advertisement_monotonic_time,
)
)
async def async_diagnostics(self) -> dict[str, Any]: async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner.""" """Return diagnostic information about the scanner."""
now = MONOTONIC_TIME() diag = await super().async_diagnostics()
return await super().async_diagnostics() | { diag["storage"] = self._storage.async_get_advertisement_history_as_dict(
"storage": self._storage.async_get_advertisement_history_as_dict( self.source
self.source )
), return diag
"connectable": self.connectable,
"discovered_device_timestamps": self._discovered_device_timestamps,
"time_since_last_device_detection": {
address: now - timestamp
for address, timestamp in self._discovered_device_timestamps.items()
},
}

View file

@ -1,9 +1,15 @@
"""Constants for the Bluetooth integration.""" """Constants for the Bluetooth integration."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import Final from typing import Final
from habluetooth import ( # noqa: F401
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
DOMAIN = "bluetooth" DOMAIN = "bluetooth"
CONF_ADAPTER = "adapter" CONF_ADAPTER = "adapter"
@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
START_TIMEOUT = 15 START_TIMEOUT = 15
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker cannot determine the interval.
#
# We have to set this quite high as we don't know
# when devices fall out of the ESPHome device (and other non-local scanners)'s
# stack like we do with BlueZ so its safer to assume its available
# since if it does go out of range and it is in range
# of another device the timeout is much shorter and it will
# switch over to using that adapter anyways.
#
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker can determine the interval for
# connectable devices.
#
# BlueZ uses 180 seconds by default but we give it a bit more time
# to account for the esp32's bluetooth stack being a bit slower
# than BlueZ's.
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195
# We must recover before we hit the 180s mark
# where the device is removed from the stack
# or the devices will go unavailable. Since
# we only check every 30s, we need this number
# to be
# 180s Time when device is removed from stack
# - 30s check interval
# - 30s scanner restart time * 2
#
SCANNER_WATCHDOG_TIMEOUT: Final = 90
# How often to check if the scanner has reached
# the SCANNER_WATCHDOG_TIMEOUT without seeing anything
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30)
# When the linux kernel is configured with # When the linux kernel is configured with
# CONFIG_FW_LOADER_USER_HELPER_FALLBACK it # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from datetime import datetime, timedelta
import itertools import itertools
import logging import logging
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
@ -16,6 +15,8 @@ from bluetooth_adapters import (
AdapterDetails, AdapterDetails,
BluetoothAdapters, BluetoothAdapters,
) )
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_LOGGING_CHANGED
@ -26,13 +27,7 @@ from homeassistant.core import (
callback as hass_callback, callback as hass_callback,
) )
from homeassistant.helpers import discovery_flow from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.dt import monotonic_time_coarse
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .base_scanner import BaseHaScanner, BluetoothScannerDevice
from .const import ( from .const import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
@ -103,16 +98,12 @@ class BluetoothManager:
"""Manage Bluetooth.""" """Manage Bluetooth."""
__slots__ = ( __slots__ = (
"hass",
"_integration_matcher",
"_cancel_unavailable_tracking", "_cancel_unavailable_tracking",
"_cancel_logging_listener",
"_advertisement_tracker", "_advertisement_tracker",
"_fallback_intervals", "_fallback_intervals",
"_intervals", "_intervals",
"_unavailable_callbacks", "_unavailable_callbacks",
"_connectable_unavailable_callbacks", "_connectable_unavailable_callbacks",
"_callback_index",
"_bleak_callbacks", "_bleak_callbacks",
"_all_history", "_all_history",
"_connectable_history", "_connectable_history",
@ -125,21 +116,17 @@ class BluetoothManager:
"slot_manager", "slot_manager",
"_debug", "_debug",
"shutdown", "shutdown",
"_loop",
) )
def __init__( def __init__(
self, self,
hass: HomeAssistant,
integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters, bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage, storage: BluetoothStorage,
slot_manager: BleakSlotManager, slot_manager: BleakSlotManager,
) -> None: ) -> None:
"""Init bluetooth manager.""" """Init bluetooth manager."""
self.hass = hass self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None
self._integration_matcher = integration_matcher
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
self._cancel_logging_listener: CALLBACK_TYPE | None = None
self._advertisement_tracker = AdvertisementTracker() self._advertisement_tracker = AdvertisementTracker()
self._fallback_intervals = self._advertisement_tracker.fallback_intervals self._fallback_intervals = self._advertisement_tracker.fallback_intervals
@ -152,7 +139,6 @@ class BluetoothManager:
str, list[Callable[[BluetoothServiceInfoBleak], None]] str, list[Callable[[BluetoothServiceInfoBleak], None]]
] = {} ] = {}
self._callback_index = BluetoothCallbackMatcherIndex()
self._bleak_callbacks: list[ self._bleak_callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]] tuple[AdvertisementDataCallback, dict[str, set[str]]]
] = [] ] = []
@ -167,6 +153,7 @@ class BluetoothManager:
self.slot_manager = slot_manager self.slot_manager = slot_manager
self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
self.shutdown = False self.shutdown = False
self._loop: asyncio.AbstractEventLoop | None = None
@property @property
def supports_passive_scan(self) -> bool: def supports_passive_scan(self) -> bool:
@ -209,7 +196,6 @@ class BluetoothManager:
return adapter return adapter
return None return None
@hass_callback
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
"""Return the scanner for a source.""" """Return the scanner for a source."""
return self._sources.get(source) return self._sources.get(source)
@ -232,45 +218,22 @@ class BluetoothManager:
self._adapters = self._bluetooth_adapters.adapters self._adapters = self._bluetooth_adapters.adapters
return self._find_adapter_by_address(address) return self._find_adapter_by_address(address)
@hass_callback
def _async_logging_changed(self, event: Event) -> None:
"""Handle logging change."""
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up the bluetooth manager.""" """Set up the bluetooth manager."""
self._loop = asyncio.get_running_loop()
await self._bluetooth_adapters.refresh() await self._bluetooth_adapters.refresh()
install_multiple_bleak_catcher() install_multiple_bleak_catcher()
self._all_history, self._connectable_history = async_load_history_from_system(
self._bluetooth_adapters, self.storage
)
self._cancel_logging_listener = self.hass.bus.async_listen(
EVENT_LOGGING_CHANGED, self._async_logging_changed
)
self.async_setup_unavailable_tracking() self.async_setup_unavailable_tracking()
seen: set[str] = set()
for address, service_info in itertools.chain(
self._connectable_history.items(), self._all_history.items()
):
if address in seen:
continue
seen.add(address)
self._async_trigger_matching_discovery(service_info)
@hass_callback def async_stop(self) -> None:
def async_stop(self, event: Event) -> None:
"""Stop the Bluetooth integration at shutdown.""" """Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager") _LOGGER.debug("Stopping bluetooth manager")
self.shutdown = True self.shutdown = True
if self._cancel_unavailable_tracking: if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking() self._cancel_unavailable_tracking.cancel()
self._cancel_unavailable_tracking = None self._cancel_unavailable_tracking = None
if self._cancel_logging_listener:
self._cancel_logging_listener()
self._cancel_logging_listener = None
uninstall_multiple_bleak_catcher() uninstall_multiple_bleak_catcher()
@hass_callback
def async_scanner_devices_by_address( def async_scanner_devices_by_address(
self, address: str, connectable: bool self, address: str, connectable: bool
) -> list[BluetoothScannerDevice]: ) -> list[BluetoothScannerDevice]:
@ -291,7 +254,6 @@ class BluetoothManager:
) )
] ]
@hass_callback
def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]:
"""Return all of discovered addresses. """Return all of discovered addresses.
@ -307,24 +269,25 @@ class BluetoothManager:
for scanner in self._non_connectable_scanners for scanner in self._non_connectable_scanners
) )
@hass_callback
def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
"""Return all of combined best path to discovered from all the scanners.""" """Return all of combined best path to discovered from all the scanners."""
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return [history.device for history in histories.values()] return [history.device for history in histories.values()]
@hass_callback
def async_setup_unavailable_tracking(self) -> None: def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking.""" """Set up the unavailable tracking."""
self._cancel_unavailable_tracking = async_track_time_interval( self._schedule_unavailable_tracking()
self.hass,
self._async_check_unavailable, def _schedule_unavailable_tracking(self) -> None:
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), """Schedule the unavailable tracking."""
name="Bluetooth manager unavailable tracking", if TYPE_CHECKING:
assert self._loop is not None
loop = self._loop
self._cancel_unavailable_tracking = loop.call_at(
loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable
) )
@hass_callback def _async_check_unavailable(self) -> None:
def _async_check_unavailable(self, now: datetime) -> None:
"""Watch for unavailable devices and cleanup state history.""" """Watch for unavailable devices and cleanup state history."""
monotonic_now = MONOTONIC_TIME() monotonic_now = MONOTONIC_TIME()
connectable_history = self._connectable_history connectable_history = self._connectable_history
@ -366,8 +329,7 @@ class BluetoothManager:
# available for both connectable and non-connectable # available for both connectable and non-connectable
tracker.async_remove_fallback_interval(address) tracker.async_remove_fallback_interval(address)
tracker.async_remove_address(address) tracker.async_remove_address(address)
self._integration_matcher.async_clear_address(address) self._address_disappeared(address)
self._async_dismiss_discoveries(address)
service_info = history.pop(address) service_info = history.pop(address)
@ -380,13 +342,13 @@ class BluetoothManager:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback") _LOGGER.exception("Error in unavailable callback")
def _async_dismiss_discoveries(self, address: str) -> None: self._schedule_unavailable_tracking()
"""Dismiss all discoveries for the given address."""
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( def _address_disappeared(self, address: str) -> None:
BluetoothServiceInfoBleak, """Call when an address disappears from the stack.
lambda service_info: bool(service_info.address == address),
): This method is intended to be overridden by subclasses.
self.hass.config_entries.flow.async_abort(flow["flow_id"]) """
def _prefer_previous_adv_from_different_source( def _prefer_previous_adv_from_different_source(
self, self,
@ -439,7 +401,6 @@ class BluetoothManager:
return False return False
return True return True
@hass_callback
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Handle a new advertisement from any scanner. """Handle a new advertisement from any scanner.
@ -570,16 +531,6 @@ class BluetoothManager:
time=service_info.time, time=service_info.time,
) )
matched_domains = self._integration_matcher.match_domains(service_info)
if self._debug:
_LOGGER.debug(
"%s: %s %s match: %s",
self._async_describe_source(service_info),
address,
service_info.advertisement,
matched_domains,
)
if (connectable or old_connectable_service_info) and ( if (connectable or old_connectable_service_info) and (
bleak_callbacks := self._bleak_callbacks bleak_callbacks := self._bleak_callbacks
): ):
@ -589,22 +540,14 @@ class BluetoothManager:
for callback_filters in bleak_callbacks: for callback_filters in bleak_callbacks:
_dispatch_bleak_callback(*callback_filters, device, advertisement_data) _dispatch_bleak_callback(*callback_filters, device, advertisement_data)
for match in self._callback_index.match_callbacks(service_info): self._discover_service_info(service_info)
callback = match[CALLBACK]
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
for domain in matched_domains: def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
discovery_flow.async_create_flow( """Discover a new service info.
self.hass,
domain, This method is intended to be overridden by subclasses.
{"source": config_entries.SOURCE_BLUETOOTH}, """
service_info,
)
@hass_callback
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
"""Describe a source.""" """Describe a source."""
if scanner := self._sources.get(service_info.source): if scanner := self._sources.get(service_info.source):
@ -615,7 +558,6 @@ class BluetoothManager:
description += " [connectable]" description += " [connectable]"
return description return description
@hass_callback
def async_track_unavailable( def async_track_unavailable(
self, self,
callback: Callable[[BluetoothServiceInfoBleak], None], callback: Callable[[BluetoothServiceInfoBleak], None],
@ -629,7 +571,6 @@ class BluetoothManager:
unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks = self._unavailable_callbacks
unavailable_callbacks.setdefault(address, []).append(callback) unavailable_callbacks.setdefault(address, []).append(callback)
@hass_callback
def _async_remove_callback() -> None: def _async_remove_callback() -> None:
unavailable_callbacks[address].remove(callback) unavailable_callbacks[address].remove(callback)
if not unavailable_callbacks[address]: if not unavailable_callbacks[address]:
@ -637,50 +578,6 @@ class BluetoothManager:
return _async_remove_callback return _async_remove_callback
@hass_callback
def async_register_callback(
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
if not matcher:
callback_matcher[CONNECTABLE] = True
else:
# We could write out every item in the typed dict here
# but that would be a bit inefficient and verbose.
callback_matcher.update(matcher)
callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True)
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
@hass_callback
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if address := callback_matcher.get(ADDRESS):
if service_info := history.get(address):
service_infos = [service_info]
else:
service_infos = history.values()
for service_info in service_infos:
if ble_device_matches(callback_matcher, service_info):
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
return _async_remove_callback
@hass_callback
def async_ble_device_from_address( def async_ble_device_from_address(
self, address: str, connectable: bool self, address: str, connectable: bool
) -> BLEDevice | None: ) -> BLEDevice | None:
@ -690,13 +587,11 @@ class BluetoothManager:
return history.device return history.device
return None return None
@hass_callback
def async_address_present(self, address: str, connectable: bool) -> bool: def async_address_present(self, address: str, connectable: bool) -> bool:
"""Return if the address is present.""" """Return if the address is present."""
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return address in histories return address in histories
@hass_callback
def async_discovered_service_info( def async_discovered_service_info(
self, connectable: bool self, connectable: bool
) -> Iterable[BluetoothServiceInfoBleak]: ) -> Iterable[BluetoothServiceInfoBleak]:
@ -704,7 +599,6 @@ class BluetoothManager:
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return histories.values() return histories.values()
@hass_callback
def async_last_service_info( def async_last_service_info(
self, address: str, connectable: bool self, address: str, connectable: bool
) -> BluetoothServiceInfoBleak | None: ) -> BluetoothServiceInfoBleak | None:
@ -712,28 +606,6 @@ class BluetoothManager:
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return histories.get(address) return histories.get(address)
def _async_trigger_matching_discovery(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Trigger discovery for matching domains."""
for domain in self._integration_matcher.match_domains(service_info):
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)
@hass_callback
def async_rediscover_address(self, address: str) -> None:
"""Trigger discovery of devices which have already been seen."""
self._integration_matcher.async_clear_address(address)
if service_info := self._connectable_history.get(address):
self._async_trigger_matching_discovery(service_info)
return
if service_info := self._all_history.get(address):
self._async_trigger_matching_discovery(service_info)
def async_register_scanner( def async_register_scanner(
self, self,
scanner: BaseHaScanner, scanner: BaseHaScanner,
@ -761,7 +633,6 @@ class BluetoothManager:
self.slot_manager.register_adapter(scanner.adapter, connection_slots) self.slot_manager.register_adapter(scanner.adapter, connection_slots)
return _unregister_scanner return _unregister_scanner
@hass_callback
def async_register_bleak_callback( def async_register_bleak_callback(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
@ -769,7 +640,6 @@ class BluetoothManager:
callback_entry = (callback, filters) callback_entry = (callback, filters)
self._bleak_callbacks.append(callback_entry) self._bleak_callbacks.append(callback_entry)
@hass_callback
def _remove_callback() -> None: def _remove_callback() -> None:
self._bleak_callbacks.remove(callback_entry) self._bleak_callbacks.remove(callback_entry)
@ -783,29 +653,180 @@ class BluetoothManager:
return _remove_callback return _remove_callback
@hass_callback
def async_release_connection_slot(self, device: BLEDevice) -> None: def async_release_connection_slot(self, device: BLEDevice) -> None:
"""Release a connection slot.""" """Release a connection slot."""
self.slot_manager.release_slot(device) self.slot_manager.release_slot(device)
@hass_callback
def async_allocate_connection_slot(self, device: BLEDevice) -> bool: def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
"""Allocate a connection slot.""" """Allocate a connection slot."""
return self.slot_manager.allocate_slot(device) return self.slot_manager.allocate_slot(device)
@hass_callback
def async_get_learned_advertising_interval(self, address: str) -> float | None: def async_get_learned_advertising_interval(self, address: str) -> float | None:
"""Get the learned advertising interval for a MAC address.""" """Get the learned advertising interval for a MAC address."""
return self._intervals.get(address) return self._intervals.get(address)
@hass_callback
def async_get_fallback_availability_interval(self, address: str) -> float | None: def async_get_fallback_availability_interval(self, address: str) -> float | None:
"""Get the fallback availability timeout for a MAC address.""" """Get the fallback availability timeout for a MAC address."""
return self._fallback_intervals.get(address) return self._fallback_intervals.get(address)
@hass_callback
def async_set_fallback_availability_interval( def async_set_fallback_availability_interval(
self, address: str, interval: float self, address: str, interval: float
) -> None: ) -> None:
"""Override the fallback availability timeout for a MAC address.""" """Override the fallback availability timeout for a MAC address."""
self._fallback_intervals[address] = interval self._fallback_intervals[address] = interval
class HomeAssistantBluetoothManager(BluetoothManager):
"""Manage Bluetooth for Home Assistant."""
__slots__ = (
"hass",
"_integration_matcher",
"_callback_index",
"_cancel_logging_listener",
)
def __init__(
self,
hass: HomeAssistant,
integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage,
slot_manager: BleakSlotManager,
) -> None:
"""Init bluetooth manager."""
self.hass = hass
self._integration_matcher = integration_matcher
self._callback_index = BluetoothCallbackMatcherIndex()
self._cancel_logging_listener: CALLBACK_TYPE | None = None
super().__init__(bluetooth_adapters, storage, slot_manager)
@hass_callback
def _async_logging_changed(self, event: Event) -> None:
"""Handle logging change."""
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
def _async_trigger_matching_discovery(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Trigger discovery for matching domains."""
for domain in self._integration_matcher.match_domains(service_info):
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)
@hass_callback
def async_rediscover_address(self, address: str) -> None:
"""Trigger discovery of devices which have already been seen."""
self._integration_matcher.async_clear_address(address)
if service_info := self._connectable_history.get(address):
self._async_trigger_matching_discovery(service_info)
return
if service_info := self._all_history.get(address):
self._async_trigger_matching_discovery(service_info)
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
matched_domains = self._integration_matcher.match_domains(service_info)
if self._debug:
_LOGGER.debug(
"%s: %s %s match: %s",
self._async_describe_source(service_info),
service_info.address,
service_info.advertisement,
matched_domains,
)
for match in self._callback_index.match_callbacks(service_info):
callback = match[CALLBACK]
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
for domain in matched_domains:
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)
def _address_disappeared(self, address: str) -> None:
"""Dismiss all discoveries for the given address."""
self._integration_matcher.async_clear_address(address)
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type(
BluetoothServiceInfoBleak,
lambda service_info: bool(service_info.address == address),
):
self.hass.config_entries.flow.async_abort(flow["flow_id"])
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
await super().async_setup()
self._all_history, self._connectable_history = async_load_history_from_system(
self._bluetooth_adapters, self.storage
)
self._cancel_logging_listener = self.hass.bus.async_listen(
EVENT_LOGGING_CHANGED, self._async_logging_changed
)
seen: set[str] = set()
for address, service_info in itertools.chain(
self._connectable_history.items(), self._all_history.items()
):
if address in seen:
continue
seen.add(address)
self._async_trigger_matching_discovery(service_info)
def async_register_callback(
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
if not matcher:
callback_matcher[CONNECTABLE] = True
else:
# We could write out every item in the typed dict here
# but that would be a bit inefficient and verbose.
callback_matcher.update(matcher)
callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True)
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if address := callback_matcher.get(ADDRESS):
if service_info := history.get(address):
service_infos = [service_info]
else:
service_infos = history.values()
for service_info in service_infos:
if ble_device_matches(callback_matcher, service_info):
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
return _async_remove_callback
@hass_callback
def async_stop(self) -> None:
"""Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager")
super().async_stop()
if self._cancel_logging_listener:
self._cancel_logging_listener()
self._cancel_logging_listener = None

View file

@ -18,7 +18,8 @@
"bleak-retry-connector==3.3.0", "bleak-retry-connector==3.3.0",
"bluetooth-adapters==0.16.1", "bluetooth-adapters==0.16.1",
"bluetooth-auto-recovery==1.2.3", "bluetooth-auto-recovery==1.2.3",
"bluetooth-data-tools==1.16.0", "bluetooth-data-tools==1.17.0",
"dbus-fast==2.14.0" "dbus-fast==2.20.0",
"habluetooth==0.10.0"
] ]
} }

View file

@ -2,15 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
from bleak import BaseBleakClient from bluetooth_data_tools import monotonic_time_coarse
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.util.dt import monotonic_time_coarse
if TYPE_CHECKING: if TYPE_CHECKING:
from .manager import BluetoothManager from .manager import BluetoothManager
@ -20,22 +17,6 @@ MANAGER: BluetoothManager | None = None
MONOTONIC_TIME: Final = monotonic_time_coarse MONOTONIC_TIME: Final = monotonic_time_coarse
@dataclass(slots=True)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""
client: type[BaseBleakClient]
source: str
can_connect: Callable[[], bool]
class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""
PASSIVE = "passive"
ACTIVE = "active"
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]

View file

@ -7,6 +7,8 @@ from functools import cache
import logging import logging
from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
from habluetooth import BluetoothScanningMode
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.const import (
ATTR_CONNECTIONS, ATTR_CONNECTIONS,
@ -33,11 +35,7 @@ if TYPE_CHECKING:
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .models import ( from .models import BluetoothChange, BluetoothServiceInfoBleak
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_KEY = "bluetooth.passive_update_processor"
STORAGE_VERSION = 1 STORAGE_VERSION = 1

View file

@ -1,386 +0,0 @@
"""The bluetooth integration."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime
import logging
import platform
from typing import Any
import bleak
from bleak import BleakError
from bleak.assigned_numbers import AdvertisementDataType
from bleak.backends.bluezdbus.advertisement_monitor import OrPattern
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from bleak_retry_connector import restore_discoveries
from bluetooth_adapters import DEFAULT_ADDRESS
from dbus_fast import InvalidMessageError
from homeassistant.core import HomeAssistant, callback as hass_callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.package import is_docker_env
from .base_scanner import MONOTONIC_TIME, BaseHaScanner
from .const import (
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
SOURCE_LOCAL,
START_TIMEOUT,
)
from .models import BluetoothScanningMode, BluetoothServiceInfoBleak
from .util import async_reset_adapter
OriginalBleakScanner = bleak.BleakScanner
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
_LOGGER = logging.getLogger(__name__)
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
"org.bluez.Error.InProgress",
"org.bluez.Error.NotReady",
"not found",
]
# When the adapter is still initializing, the scanner will raise an exception
# with org.freedesktop.DBus.Error.UnknownObject
WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"]
ADAPTER_INIT_TIME = 1.5
START_ATTEMPTS = 3
SCANNING_MODE_TO_BLEAK = {
BluetoothScanningMode.ACTIVE: "active",
BluetoothScanningMode.PASSIVE: "passive",
}
# The minimum number of seconds to know
# the adapter has not had advertisements
# and we already tried to restart the scanner
# without success when the first time the watch
# dog hit the failure path.
SCANNER_WATCHDOG_MULTIPLE = (
SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
class ScannerStartError(HomeAssistantError):
"""Error to indicate that the scanner failed to start."""
def create_bleak_scanner(
detection_callback: AdvertisementDataCallback,
scanning_mode: BluetoothScanningMode,
adapter: str | None,
) -> bleak.BleakScanner:
"""Create a Bleak scanner."""
scanner_kwargs: dict[str, Any] = {
"detection_callback": detection_callback,
"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode],
}
system = platform.system()
if system == "Linux":
# Only Linux supports multiple adapters
if adapter:
scanner_kwargs["adapter"] = adapter
if scanning_mode == BluetoothScanningMode.PASSIVE:
scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS
elif system == "Darwin":
# We want mac address on macOS
scanner_kwargs["cb"] = {"use_bdaddr": True}
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
try:
return OriginalBleakScanner(**scanner_kwargs)
except (FileNotFoundError, BleakError) as ex:
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
class HaScanner(BaseHaScanner):
"""Operate and automatically recover a BleakScanner.
Multiple BleakScanner can be used at the same time
if there are multiple adapters. This is only useful
if the adapters are not located physically next to each other.
Example use cases are usbip, a long extension cable, usb to bluetooth
over ethernet, usb over ethernet, etc.
"""
scanner: bleak.BleakScanner
def __init__(
self,
hass: HomeAssistant,
mode: BluetoothScanningMode,
adapter: str,
address: str,
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
) -> None:
"""Init bluetooth discovery."""
self.mac_address = address
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(hass, source, adapter)
self.connectable = True
self.mode = mode
self._start_stop_lock = asyncio.Lock()
self._new_info_callback = new_info_callback
self.scanning = False
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return self.scanner.discovered_devices
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self.scanner.discovered_devices_and_advertisement_data
@hass_callback
def async_setup(self) -> None:
"""Set up the scanner."""
self.scanner = create_bleak_scanner(
self._async_detection_callback, self.mode, self.adapter
)
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
base_diag = await super().async_diagnostics()
return base_diag | {
"adapter": self.adapter,
}
@hass_callback
def _async_detection_callback(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""Call the callback when an advertisement is received.
Currently this is used to feed the callbacks into the
central manager.
"""
callback_time = MONOTONIC_TIME()
if (
advertisement_data.local_name
or advertisement_data.manufacturer_data
or advertisement_data.service_data
or advertisement_data.service_uuids
):
# Don't count empty advertisements
# as the adapter is in a failure
# state if all the data is empty.
self._last_detection = callback_time
self._new_info_callback(
BluetoothServiceInfoBleak(
name=advertisement_data.local_name or device.name or device.address,
address=device.address,
rssi=advertisement_data.rssi,
manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids,
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=True,
time=callback_time,
)
)
async def async_start(self) -> None:
"""Start bluetooth scanner."""
async with self._start_stop_lock:
await self._async_start()
async def _async_start(self) -> None:
"""Start bluetooth scanner under the lock."""
for attempt in range(START_ATTEMPTS):
_LOGGER.debug(
"%s: Starting bluetooth discovery attempt: (%s/%s)",
self.name,
attempt + 1,
START_ATTEMPTS,
)
try:
async with asyncio.timeout(START_TIMEOUT):
await self.scanner.start() # type: ignore[no-untyped-call]
except InvalidMessageError as ex:
_LOGGER.debug(
"%s: Invalid DBus message received: %s",
self.name,
ex,
exc_info=True,
)
raise ScannerStartError(
f"{self.name}: Invalid DBus message received: {ex}; "
"try restarting `dbus`"
) from ex
except BrokenPipeError as ex:
_LOGGER.debug(
"%s: DBus connection broken: %s", self.name, ex, exc_info=True
)
if is_docker_env():
raise ScannerStartError(
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth`, `dbus`, and finally the docker container"
) from ex
raise ScannerStartError(
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth` and `dbus`"
) from ex
except FileNotFoundError as ex:
_LOGGER.debug(
"%s: FileNotFoundError while starting bluetooth: %s",
self.name,
ex,
exc_info=True,
)
if is_docker_env():
raise ScannerStartError(
f"{self.name}: DBus service not found; docker config may "
"be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
) from ex
raise ScannerStartError(
f"{self.name}: DBus service not found; make sure the DBus socket "
f"is available to Home Assistant: {ex}"
) from ex
except asyncio.TimeoutError as ex:
if attempt == 0:
await self._async_reset_adapter()
continue
raise ScannerStartError(
f"{self.name}: Timed out starting Bluetooth after"
f" {START_TIMEOUT} seconds"
) from ex
except BleakError as ex:
error_str = str(ex)
if attempt == 0:
if any(
needs_reset_error in error_str
for needs_reset_error in NEED_RESET_ERRORS
):
await self._async_reset_adapter()
continue
if attempt != START_ATTEMPTS - 1:
# If we are not out of retry attempts, and the
# adapter is still initializing, wait a bit and try again.
if any(
wait_error in error_str
for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS
):
_LOGGER.debug(
"%s: Waiting for adapter to initialize; attempt (%s/%s)",
self.name,
attempt + 1,
START_ATTEMPTS,
)
await asyncio.sleep(ADAPTER_INIT_TIME)
continue
_LOGGER.debug(
"%s: BleakError while starting bluetooth; attempt: (%s/%s): %s",
self.name,
attempt + 1,
START_ATTEMPTS,
ex,
exc_info=True,
)
raise ScannerStartError(
f"{self.name}: Failed to start Bluetooth: {ex}"
) from ex
# Everything is fine, break out of the loop
break
self.scanning = True
self._async_setup_scanner_watchdog()
await restore_discoveries(self.scanner, self.adapter)
@hass_callback
def _async_scanner_watchdog(self, now: datetime) -> None:
"""Check if the scanner is running."""
if not self._async_watchdog_triggered():
return
if self._start_stop_lock.locked():
_LOGGER.debug(
"%s: Scanner is already restarting, deferring restart",
self.name,
)
return
_LOGGER.info(
"%s: Bluetooth scanner has gone quiet for %ss, restarting",
self.name,
SCANNER_WATCHDOG_TIMEOUT,
)
# Immediately mark the scanner as not scanning
# since the restart task will have to wait for the lock
self.scanning = False
self.hass.async_create_task(self._async_restart_scanner())
async def _async_restart_scanner(self) -> None:
"""Restart the scanner."""
async with self._start_stop_lock:
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
# Stop the scanner but not the watchdog
# since we want to try again later if it's still quiet
await self._async_stop_scanner()
# If there have not been any valid advertisements,
# or the watchdog has hit the failure path multiple times,
# do the reset.
if (
self._start_time == self._last_detection
or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE
):
await self._async_reset_adapter()
try:
await self._async_start()
except ScannerStartError as ex:
_LOGGER.exception(
"%s: Failed to restart Bluetooth scanner: %s",
self.name,
ex,
)
async def _async_reset_adapter(self) -> None:
"""Reset the adapter."""
# There is currently nothing the user can do to fix this
# so we log at debug level. If we later come up with a repair
# strategy, we will change this to raise a repair issue as well.
_LOGGER.debug("%s: adapter stopped responding; executing reset", self.name)
result = await async_reset_adapter(self.adapter, self.mac_address)
_LOGGER.debug("%s: adapter reset result: %s", self.name, result)
async def async_stop(self) -> None:
"""Stop bluetooth scanner."""
async with self._start_stop_lock:
self._async_stop_scanner_watchdog()
await self._async_stop_scanner()
async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock."""
self.scanning = False
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try:
await self.scanner.stop() # type: ignore[no-untyped-call]
except BleakError as ex:
# This is not fatal, and they may want to reload
# the config entry to restart the scanner if they
# change the bluetooth dongle.
_LOGGER.error("%s: Error stopping scanner: %s", self.name, ex)

View file

@ -4,6 +4,8 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import logging import logging
from habluetooth import BluetoothScanningMode
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from .api import ( from .api import (
@ -13,7 +15,7 @@ from .api import (
async_track_unavailable, async_track_unavailable,
) )
from .match import BluetoothCallbackMatcher from .match import BluetoothCallbackMatcher
from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .models import BluetoothChange, BluetoothServiceInfoBleak
class BasePassiveBluetoothCoordinator(ABC): class BasePassiveBluetoothCoordinator(ABC):

View file

@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
from bluetooth_adapters import BluetoothAdapters from bluetooth_adapters import BluetoothAdapters
from bluetooth_auto_recovery import recover_adapter from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.util.dt import monotonic_time_coarse
from .models import BluetoothServiceInfoBleak from .models import BluetoothServiceInfoBleak
from .storage import BluetoothStorage from .storage import BluetoothStorage
@ -69,11 +68,3 @@ def async_load_history_from_system(
connectable_loaded_history[address] = service_info connectable_loaded_history[address] = service_info
return all_loaded_history, connectable_loaded_history return all_loaded_history, connectable_loaded_history
async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None:
"""Reset the adapter."""
if adapter and adapter.startswith("hci"):
adapter_id = int(adapter[3:])
return await recover_adapter(adapter_id, mac_address)
return False

View file

@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient):
self.__disconnected_callback self.__disconnected_callback
), ),
timeout=self.__timeout, timeout=self.__timeout,
hass=manager.hass,
) )
if debug_logging: if debug_logging:
# Only lookup the description if we are going to log it # Only lookup the description if we are going to log it

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["bimmer_connected"], "loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected[china]==0.14.5"] "requirements": ["bimmer-connected[china]==0.14.6"]
} }

View file

@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value, percentage_to_ranged_value,
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from homeassistant.util.scaling import int_states_in_range
from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE
from .entity import BondEntity from .entity import BondEntity

View file

@ -73,7 +73,7 @@
} }
}, },
"get_events": { "get_events": {
"name": "Get event", "name": "Get events",
"description": "Get events on a calendar within a time range.", "description": "Get events on a calendar within a time range.",
"fields": { "fields": {
"start_date_time": { "start_date_time": {

View file

@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value, percentage_to_ranged_value,
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from homeassistant.util.scaling import int_states_in_range
from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge

View file

@ -7,5 +7,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"]
} }

View file

@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = {
LightColorMode.XY: ColorMode.XY, LightColorMode.XY: ColorMode.XY,
} }
TS0601_EFFECTS = [ XMAS_LIGHT_EFFECTS = [
"carnival", "carnival",
"collide", "collide",
"fading", "fading",
@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity):
if device.effect is not None: if device.effect is not None:
self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_supported_features |= LightEntityFeature.EFFECT
self._attr_effect_list = [EFFECT_COLORLOOP] self._attr_effect_list = [EFFECT_COLORLOOP]
if device.model_id == "TS0601": if device.model_id in ("HG06467", "TS0601"):
self._attr_effect_list += TS0601_EFFECTS self._attr_effect_list = XMAS_LIGHT_EFFECTS
@property @property
def color_mode(self) -> str | None: def color_mode(self) -> str | None:

View file

@ -14,7 +14,11 @@ import voluptuous as vol
from homeassistant import util from homeassistant import util
from homeassistant.backports.functools import cached_property from homeassistant.backports.functools import cached_property
from homeassistant.components import zone from homeassistant.components import zone
from homeassistant.config import async_log_schema_error, load_yaml_config_file from homeassistant.config import (
async_log_schema_error,
config_per_platform,
load_yaml_config_file,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_GPS_ACCURACY, ATTR_GPS_ACCURACY,
@ -33,7 +37,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import (
config_per_platform,
config_validation as cv, config_validation as cv,
discovery, discovery,
entity_registry as er, entity_registry as er,
@ -284,7 +287,7 @@ class DeviceTrackerPlatform:
) -> None: ) -> None:
"""Set up a legacy platform.""" """Set up a legacy platform."""
assert self.type == PLATFORM_TYPE_LEGACY assert self.type == PLATFORM_TYPE_LEGACY
full_name = f"{DOMAIN}.{self.name}" full_name = f"{self.name}.{DOMAIN}"
LOGGER.info("Setting up %s", full_name) LOGGER.info("Setting up %s", full_name)
with async_start_setup(hass, [full_name]): with async_start_setup(hass, [full_name]):
try: try:
@ -1033,6 +1036,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None:
out.write(dump(device_config)) out.write(dump(device_config))
def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None:
"""Remove device from YAML configuration file."""
path = hass.config.path(YAML_DEVICES)
devices = load_yaml_config_file(path)
devices.pop(device_id)
dumped = dump(devices)
with open(path, "r+", encoding="utf8") as out:
out.seek(0)
out.truncate()
out.write(dumped)
def get_gravatar_for_email(email: str) -> str: def get_gravatar_for_email(email: str) -> str:
"""Return an 80px Gravatar for the given email address. """Return an 80px Gravatar for the given email address.

View file

@ -183,6 +183,7 @@ async def async_setup_entry(
for description in sensors for description in sensors
for value_key in {description.key, *description.alternative_keys} for value_key in {description.key, *description.alternative_keys}
if description.value_fn(coordinator.data, value_key, description.scale) if description.value_fn(coordinator.data, value_key, description.scale)
is not None
) )
async_add_entities(entities) async_add_entities(entities)

View file

@ -116,7 +116,7 @@ class DSMRConnection:
try: try:
transport, protocol = await asyncio.create_task(reader_factory()) transport, protocol = await asyncio.create_task(reader_factory())
except (serial.serialutil.SerialException, OSError): except (serial.SerialException, OSError):
LOGGER.exception("Error connecting to DSMR") LOGGER.exception("Error connecting to DSMR")
return False return False

View file

@ -12,8 +12,6 @@ LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
CONF_DSMR_VERSION = "dsmr_version" CONF_DSMR_VERSION = "dsmr_version"
CONF_PROTOCOL = "protocol" CONF_PROTOCOL = "protocol"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
CONF_PRECISION = "precision"
CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_TIME_BETWEEN_UPDATE = "time_between_update"
CONF_SERIAL_ID = "serial_id" CONF_SERIAL_ID = "serial_id"

View file

@ -46,9 +46,7 @@ from homeassistant.util import Throttle
from .const import ( from .const import (
CONF_DSMR_VERSION, CONF_DSMR_VERSION,
CONF_PRECISION,
CONF_PROTOCOL, CONF_PROTOCOL,
CONF_RECONNECT_INTERVAL,
CONF_SERIAL_ID, CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS, CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE, CONF_TIME_BETWEEN_UPDATE,
@ -80,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="timestamp",
obis_reference=obis_references.P1_MESSAGE_TIMESTAMP,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
DSMRSensorEntityDescription( DSMRSensorEntityDescription(
key="current_electricity_usage", key="current_electricity_usage",
translation_key="current_electricity_usage", translation_key="current_electricity_usage",
@ -647,11 +652,9 @@ async def async_setup_entry(
update_entities_telegram(None) update_entities_telegram(None)
# throttle reconnect attempts # throttle reconnect attempts
await asyncio.sleep( await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
)
except (serial.serialutil.SerialException, OSError): except (serial.SerialException, OSError):
# Log any error while establishing connection and drop to retry # Log any error while establishing connection and drop to retry
# connection wait # connection wait
LOGGER.exception("Error connecting to DSMR") LOGGER.exception("Error connecting to DSMR")
@ -663,9 +666,7 @@ async def async_setup_entry(
update_entities_telegram(None) update_entities_telegram(None)
# throttle reconnect attempts # throttle reconnect attempts
await asyncio.sleep( await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
)
except CancelledError: except CancelledError:
# Reflect disconnect state in devices state by setting an # Reflect disconnect state in devices state by setting an
# None telegram resulting in `unavailable` states # None telegram resulting in `unavailable` states
@ -795,9 +796,7 @@ class DSMREntity(SensorEntity):
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
with suppress(TypeError): with suppress(TypeError):
value = round( value = round(float(value), DEFAULT_PRECISION)
float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION)
)
# Make sure we do not return a zero value for an energy sensor # Make sure we do not return a zero value for an energy sensor
if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: if not value and self.state_class == SensorStateClass.TOTAL_INCREASING:

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy", "documentation": "https://www.home-assistant.io/integrations/easyenergy",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["easyenergy==1.0.0"] "requirements": ["easyenergy==2.1.0"]
} }

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/energyzero", "documentation": "https://www.home-assistant.io/integrations/energyzero",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["energyzero==1.0.0"] "requirements": ["energyzero==2.1.0"]
} }

View file

@ -8,7 +8,7 @@ from dataclasses import dataclass, field
from functools import partial from functools import partial
import logging import logging
import sys import sys
from typing import Any, TypeVar, cast from typing import Any, Concatenate, ParamSpec, TypeVar
import uuid import uuid
if sys.version_info < (3, 12): if sys.version_info < (3, 12):
@ -60,7 +60,9 @@ CCCD_INDICATE_BYTES = b"\x02\x00"
DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) _ESPHomeClient = TypeVar("_ESPHomeClient", bound="ESPHomeClient")
_R = TypeVar("_R")
_P = ParamSpec("_P")
def mac_to_int(address: str) -> int: def mac_to_int(address: str) -> int:
@ -68,12 +70,14 @@ def mac_to_int(address: str) -> int:
return int(address.replace(":", ""), 16) return int(address.replace(":", ""), 16)
def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: def api_error_as_bleak_error(
func: Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]
) -> Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]:
"""Define a wrapper throw esphome api errors as BleakErrors.""" """Define a wrapper throw esphome api errors as BleakErrors."""
async def _async_wrap_bluetooth_operation( async def _async_wrap_bluetooth_operation(
self: ESPHomeClient, *args: Any, **kwargs: Any self: _ESPHomeClient, *args: _P.args, **kwargs: _P.kwargs
) -> Any: ) -> _R:
# pylint: disable=protected-access # pylint: disable=protected-access
try: try:
return await func(self, *args, **kwargs) return await func(self, *args, **kwargs)
@ -107,7 +111,7 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType:
except APIConnectionError as err: except APIConnectionError as err:
raise BleakError(str(err)) from err raise BleakError(str(err)) from err
return cast(_WrapFuncType, _async_wrap_bluetooth_operation) return _async_wrap_bluetooth_operation
@dataclass(slots=True) @dataclass(slots=True)

View file

@ -7,11 +7,14 @@ from bluetooth_data_tools import (
parse_advertisement_data_tuple, parse_advertisement_data_tuple,
) )
from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
HomeAssistantRemoteScanner,
)
from homeassistant.core import callback from homeassistant.core import callback
class ESPHomeScanner(BaseHaRemoteScanner): class ESPHomeScanner(HomeAssistantRemoteScanner):
"""Scanner for esphome.""" """Scanner for esphome."""
__slots__ = () __slots__ = ()

View file

@ -105,6 +105,10 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction)
) )
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
await self._client.fan_command(key=self._key, preset_mode=preset_mode)
@property @property
@esphome_state_property @esphome_state_property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
@ -144,6 +148,17 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
"""Return the current fan direction.""" """Return the current fan direction."""
return _FAN_DIRECTIONS.from_esphome(self._state.direction) return _FAN_DIRECTIONS.from_esphome(self._state.direction)
@property
@esphome_state_property
def preset_mode(self) -> str | None:
"""Return the current fan preset mode."""
return self._state.preset_mode
@property
def preset_modes(self) -> list[str] | None:
"""Return the supported fan preset modes."""
return self._static_info.supported_preset_modes
@callback @callback
def _on_static_info_update(self, static_info: EntityInfo) -> None: def _on_static_info_update(self, static_info: EntityInfo) -> None:
"""Set attrs from static info.""" """Set attrs from static info."""
@ -156,4 +171,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
flags |= FanEntityFeature.SET_SPEED flags |= FanEntityFeature.SET_SPEED
if static_info.supports_direction: if static_info.supports_direction:
flags |= FanEntityFeature.DIRECTION flags |= FanEntityFeature.DIRECTION
if static_info.supported_preset_modes:
flags |= FanEntityFeature.PRESET_MODE
self._attr_supported_features = flags self._attr_supported_features = flags

View file

@ -15,8 +15,8 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aioesphomeapi", "noiseprotocol"], "loggers": ["aioesphomeapi", "noiseprotocol"],
"requirements": [ "requirements": [
"aioesphomeapi==19.2.1", "aioesphomeapi==19.3.0",
"bluetooth-data-tools==1.16.0", "bluetooth-data-tools==1.17.0",
"esphome-dashboard-api==1.2.3" "esphome-dashboard-api==1.2.3"
], ],
"zeroconf": ["_esphomelib._tcp.local."] "zeroconf": ["_esphomelib._tcp.local."]

View file

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/evohome", "documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"], "loggers": ["evohomeasync", "evohomeasync2"],
"requirements": ["evohome-async==0.4.9"] "requirements": ["evohome-async==0.4.15"]
} }

View file

@ -65,12 +65,12 @@ class FirmataBoard:
except RuntimeError as err: except RuntimeError as err:
_LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err)
return False return False
except serial.serialutil.SerialTimeoutException as err: except serial.SerialTimeoutException as err:
_LOGGER.error( _LOGGER.error(
"Timeout writing to serial port for PyMata board %s: %s", self.name, err "Timeout writing to serial port for PyMata board %s: %s", self.name, err
) )
return False return False
except serial.serialutil.SerialException as err: except serial.SerialException as err:
_LOGGER.error( _LOGGER.error(
"Error connecting to serial port for PyMata board %s: %s", "Error connecting to serial port for PyMata board %s: %s",
self.name, self.name,

View file

@ -41,12 +41,12 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
except RuntimeError as err: except RuntimeError as err:
_LOGGER.error("Error connecting to PyMata board %s: %s", name, err) _LOGGER.error("Error connecting to PyMata board %s: %s", name, err)
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except serial.serialutil.SerialTimeoutException as err: except serial.SerialTimeoutException as err:
_LOGGER.error( _LOGGER.error(
"Timeout writing to serial port for PyMata board %s: %s", name, err "Timeout writing to serial port for PyMata board %s: %s", name, err
) )
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except serial.serialutil.SerialException as err: except serial.SerialException as err:
_LOGGER.error( _LOGGER.error(
"Error connecting to serial port for PyMata board %s: %s", name, err "Error connecting to serial port for PyMata board %s: %s", name, err
) )

View file

@ -38,11 +38,9 @@ async def async_setup_entry(
FritzboxLight( FritzboxLight(
coordinator, coordinator,
ain, ain,
device.get_colors(),
device.get_color_temps(),
) )
for ain in coordinator.new_devices for ain in coordinator.new_devices
if (device := coordinator.data.devices[ain]).has_lightbulb if (coordinator.data.devices[ain]).has_lightbulb
) )
entry.async_on_unload(coordinator.async_add_listener(_add_entities)) entry.async_on_unload(coordinator.async_add_listener(_add_entities))
@ -57,27 +55,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
self, self,
coordinator: FritzboxDataUpdateCoordinator, coordinator: FritzboxDataUpdateCoordinator,
ain: str, ain: str,
supported_colors: dict,
supported_color_temps: list[int],
) -> None: ) -> None:
"""Initialize the FritzboxLight entity.""" """Initialize the FritzboxLight entity."""
super().__init__(coordinator, ain, None) super().__init__(coordinator, ain, None)
if supported_color_temps:
# only available for color bulbs
self._attr_max_color_temp_kelvin = int(max(supported_color_temps))
self._attr_min_color_temp_kelvin = int(min(supported_color_temps))
# Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each.
# Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup
self._supported_hs: dict[int, list[int]] = {} self._supported_hs: dict[int, list[int]] = {}
for values in supported_colors.values():
hue = int(values[0][0])
self._supported_hs[hue] = [
int(values[0][1]),
int(values[1][1]),
int(values[2][1]),
]
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -173,3 +154,28 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
"""Turn the light off.""" """Turn the light off."""
await self.hass.async_add_executor_job(self.data.set_state_off) await self.hass.async_add_executor_job(self.data.set_state_off)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
async def async_added_to_hass(self) -> None:
"""Get light attributes from device after entity is added to hass."""
await super().async_added_to_hass()
supported_colors = await self.hass.async_add_executor_job(
self.coordinator.data.devices[self.ain].get_colors
)
supported_color_temps = await self.hass.async_add_executor_job(
self.coordinator.data.devices[self.ain].get_color_temps
)
if supported_color_temps:
# only available for color bulbs
self._attr_max_color_temp_kelvin = int(max(supported_color_temps))
self._attr_min_color_temp_kelvin = int(min(supported_color_temps))
# Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each.
# Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup
for values in supported_colors.values():
hue = int(values[0][0])
self._supported_hs[hue] = [
int(values[0][1]),
int(values[1][1]),
int(values[2][1]),
]

View file

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20231130.0"] "requirements": ["home-assistant-frontend==20231206.0"]
} }

View file

@ -0,0 +1 @@
"""Fujitsu anywAIR virtual integration for Home Assistant."""

View file

@ -0,0 +1,6 @@
{
"domain": "fujitsu_anywair",
"name": "Fujitsu anywAIR",
"integration_type": "virtual",
"supported_by": "advantage_air"
}

View file

@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity):
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return the current state of the on/off zone. """Return the current state of the on/off zone.
The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). The zone is considered 'on' if the mode is either 'override' or 'timer'.
""" """
return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] return (
self._zone.data["mode"] in ["override", "timer"]
and self._zone.data["setpoint"]
)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Send the zone to Timer mode. """Send the zone to Timer mode.

View file

@ -38,10 +38,8 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .bridge import DeviceDataUpdateCoordinator from .bridge import DeviceDataUpdateCoordinator
from .const import ( from .const import (
@ -52,6 +50,7 @@ from .const import (
FAN_MEDIUM_LOW, FAN_MEDIUM_LOW,
TARGET_TEMPERATURE_STEP, TARGET_TEMPERATURE_STEP,
) )
from .entity import GreeEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -105,7 +104,7 @@ async def async_setup_entry(
) )
class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): class GreeClimateEntity(GreeEntity, ClimateEntity):
"""Representation of a Gree HVAC device.""" """Representation of a Gree HVAC device."""
_attr_precision = PRECISION_WHOLE _attr_precision = PRECISION_WHOLE
@ -120,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE
_attr_preset_modes = PRESET_MODES _attr_preset_modes = PRESET_MODES
_attr_fan_modes = [*FAN_MODES_REVERSE] _attr_fan_modes = [*FAN_MODES_REVERSE]
_attr_swing_modes = SWING_MODES _attr_swing_modes = SWING_MODES
_attr_name = None
def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None:
"""Initialize the Gree device.""" """Initialize the Gree device."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_name = coordinator.device.device_info.name self._attr_unique_id = coordinator.device.device_info.mac
mac = coordinator.device.device_info.mac
self._attr_unique_id = mac
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, mac)},
manufacturer="Gree",
name=self._attr_name,
)
units = self.coordinator.device.temperature_units units = self.coordinator.device.temperature_units
if units == TemperatureUnits.C: if units == TemperatureUnits.C:
self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_temperature_unit = UnitOfTemperature.CELSIUS

View file

@ -9,13 +9,15 @@ from .const import DOMAIN
class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
"""Generic Gree entity (base class).""" """Generic Gree entity (base class)."""
def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: _attr_has_entity_name = True
def __init__(
self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None
) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._desc = desc
name = coordinator.device.device_info.name name = coordinator.device.device_info.name
mac = coordinator.device.device_info.mac mac = coordinator.device.device_info.mac
self._attr_name = f"{name} {desc}"
self._attr_unique_id = f"{mac}_{desc}" self._attr_unique_id = f"{mac}_{desc}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, mac)}, connections={(CONNECTION_NETWORK_MAC, mac)},

View file

@ -9,5 +9,24 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
} }
},
"entity": {
"switch": {
"light": {
"name": "Panel light"
},
"quiet": {
"name": "Quiet"
},
"fresh_air": {
"name": "Fresh air"
},
"xfan": {
"name": "XFan"
},
"health_mode": {
"name": "Health mode"
}
}
} }
} }

View file

@ -33,10 +33,6 @@ class GreeRequiredKeysMixin:
class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin):
"""Describes Gree switch entity.""" """Describes Gree switch entity."""
# GreeSwitch does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
def _set_light(device: Device, value: bool) -> None: def _set_light(device: Device, value: bool) -> None:
"""Typed helper to set device light property.""" """Typed helper to set device light property."""
@ -66,33 +62,33 @@ def _set_anion(device: Device, value: bool) -> None:
GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = (
GreeSwitchEntityDescription( GreeSwitchEntityDescription(
icon="mdi:lightbulb", icon="mdi:lightbulb",
name="Panel Light", key="Panel Light",
key="light", translation_key="light",
get_value_fn=lambda d: d.light, get_value_fn=lambda d: d.light,
set_value_fn=_set_light, set_value_fn=_set_light,
), ),
GreeSwitchEntityDescription( GreeSwitchEntityDescription(
name="Quiet", key="Quiet",
key="quiet", translation_key="quiet",
get_value_fn=lambda d: d.quiet, get_value_fn=lambda d: d.quiet,
set_value_fn=_set_quiet, set_value_fn=_set_quiet,
), ),
GreeSwitchEntityDescription( GreeSwitchEntityDescription(
name="Fresh Air", key="Fresh Air",
key="fresh_air", translation_key="fresh_air",
get_value_fn=lambda d: d.fresh_air, get_value_fn=lambda d: d.fresh_air,
set_value_fn=_set_fresh_air, set_value_fn=_set_fresh_air,
), ),
GreeSwitchEntityDescription( GreeSwitchEntityDescription(
name="XFan", key="XFan",
key="xfan", translation_key="xfan",
get_value_fn=lambda d: d.xfan, get_value_fn=lambda d: d.xfan,
set_value_fn=_set_xfan, set_value_fn=_set_xfan,
), ),
GreeSwitchEntityDescription( GreeSwitchEntityDescription(
icon="mdi:pine-tree", icon="mdi:pine-tree",
name="Health mode", key="Health mode",
key="anion", translation_key="health_mode",
get_value_fn=lambda d: d.anion, get_value_fn=lambda d: d.anion,
set_value_fn=_set_anion, set_value_fn=_set_anion,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -134,7 +130,7 @@ class GreeSwitch(GreeEntity, SwitchEntity):
"""Initialize the Gree device.""" """Initialize the Gree device."""
self.entity_description = description self.entity_description = description
super().__init__(coordinator, description.name) super().__init__(coordinator, description.key)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View file

@ -23,15 +23,6 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up harmony activity switches.""" """Set up harmony activity switches."""
async_create_issue(
hass,
DOMAIN,
"deprecated_switches",
breaks_in_ha_version="2024.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_switches",
)
data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA]
activities = data.activities activities = data.activities
@ -65,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Start this activity.""" """Start this activity."""
async_create_issue(
self.hass,
DOMAIN,
"deprecated_switches",
breaks_in_ha_version="2024.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_switches",
)
await self._data.async_start_activity(self._activity_name) await self._data.async_start_activity(self._activity_name)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop this activity.""" """Stop this activity."""
async_create_issue(
self.hass,
DOMAIN,
"deprecated_switches",
breaks_in_ha_version="2024.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_switches",
)
await self._data.async_power_off() await self._data.async_power_off()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:

View file

@ -67,18 +67,20 @@ class HassIOIngress(HomeAssistantView):
self._websession = websession self._websession = websession
@lru_cache @lru_cache
def _create_url(self, token: str, path: str) -> str: def _create_url(self, token: str, path: str) -> URL:
"""Create URL to service.""" """Create URL to service."""
base_path = f"/ingress/{token}/" base_path = f"/ingress/{token}/"
url = f"http://{self._host}{base_path}{quote(path)}" url = f"http://{self._host}{base_path}{quote(path)}"
try: try:
if not URL(url).path.startswith(base_path): target_url = URL(url)
raise HTTPBadRequest()
except ValueError as err: except ValueError as err:
raise HTTPBadRequest() from err raise HTTPBadRequest() from err
return url if not target_url.path.startswith(base_path):
raise HTTPBadRequest()
return target_url
async def _handle( async def _handle(
self, request: web.Request, token: str, path: str self, request: web.Request, token: str, path: str
@ -128,7 +130,7 @@ class HassIOIngress(HomeAssistantView):
# Support GET query # Support GET query
if request.query_string: if request.query_string:
url = f"{url}?{request.query_string}" url = url.with_query(request.query_string)
# Start proxy # Start proxy
async with self._websession.ws_connect( async with self._websession.ws_connect(

View file

@ -0,0 +1,20 @@
"""The Holiday integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.CALENDAR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Holiday from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,134 @@
"""Holiday Calendar."""
from __future__ import annotations
from datetime import datetime
from holidays import HolidayBase, country_holidays
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_PROVINCE, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Holiday Calendar config entry."""
country: str = config_entry.data[CONF_COUNTRY]
province: str | None = config_entry.data.get(CONF_PROVINCE)
language = hass.config.language
obj_holidays = country_holidays(
country,
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=language,
)
if language == "en":
for lang in obj_holidays.supported_languages:
if lang.startswith("en"):
obj_holidays = country_holidays(
country,
subdiv=province,
years={dt_util.now().year, dt_util.now().year + 1},
language=lang,
)
language = lang
break
async_add_entities(
[
HolidayCalendarEntity(
config_entry.title,
country,
province,
language,
obj_holidays,
config_entry.entry_id,
)
],
True,
)
class HolidayCalendarEntity(CalendarEntity):
"""Representation of a Holiday Calendar element."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
name: str,
country: str,
province: str | None,
language: str,
obj_holidays: HolidayBase,
unique_id: str,
) -> None:
"""Initialize HolidayCalendarEntity."""
self._country = country
self._province = province
self._location = name
self._language = language
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
entry_type=DeviceEntryType.SERVICE,
name=name,
)
self._obj_holidays = obj_holidays
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
next_holiday = None
for holiday_date, holiday_name in sorted(
self._obj_holidays.items(), key=lambda x: x[0]
):
if holiday_date >= dt_util.now().date():
next_holiday = (holiday_date, holiday_name)
break
if next_holiday is None:
return None
return CalendarEvent(
summary=next_holiday[1],
start=next_holiday[0],
end=next_holiday[0],
location=self._location,
)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
obj_holidays = country_holidays(
self._country,
subdiv=self._province,
years=list({start_date.year, end_date.year}),
language=self._language,
)
event_list: list[CalendarEvent] = []
for holiday_date, holiday_name in obj_holidays.items():
if start_date.date() <= holiday_date <= end_date.date():
event = CalendarEvent(
summary=holiday_name,
start=holiday_date,
end=holiday_date,
location=self._location,
)
event_list.append(event)
return event_list

View file

@ -0,0 +1,101 @@
"""Config flow for Holiday integration."""
from __future__ import annotations
from typing import Any
from babel import Locale
from holidays import list_supported_countries
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_COUNTRY
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_PROVINCE, DOMAIN
SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False)
class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Holiday."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is not None:
self.data = user_input
selected_country = user_input[CONF_COUNTRY]
if SUPPORTED_COUNTRIES[selected_country]:
return await self.async_step_province()
self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]})
locale = Locale(self.hass.config.language)
title = locale.territories[selected_country]
return self.async_create_entry(title=title, data=user_input)
user_schema = vol.Schema(
{
vol.Optional(
CONF_COUNTRY, default=self.hass.config.country
): CountrySelector(
CountrySelectorConfig(
countries=list(SUPPORTED_COUNTRIES),
)
),
}
)
return self.async_show_form(step_id="user", data_schema=user_schema)
async def async_step_province(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the province step."""
if user_input is not None:
combined_input: dict[str, Any] = {**self.data, **user_input}
country = combined_input[CONF_COUNTRY]
province = combined_input.get(CONF_PROVINCE)
self._async_abort_entries_match(
{
CONF_COUNTRY: country,
CONF_PROVINCE: province,
}
)
locale = Locale(self.hass.config.language)
province_str = f", {province}" if province else ""
name = f"{locale.territories[country]}{province_str}"
return self.async_create_entry(title=name, data=combined_input)
province_schema = vol.Schema(
{
vol.Optional(CONF_PROVINCE): SelectSelector(
SelectSelectorConfig(
options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
)
return self.async_show_form(step_id="province", data_schema=province_schema)

View file

@ -0,0 +1,6 @@
"""Constants for the Holiday integration."""
from typing import Final
DOMAIN: Final = "holiday"
CONF_PROVINCE: Final = "province"

View file

@ -0,0 +1,9 @@
{
"domain": "holiday",
"name": "Holiday",
"codeowners": ["@jrieger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.37", "babel==2.13.1"]
}

View file

@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "Already configured. Only a single configuration for country/province combination possible."
},
"step": {
"user": {
"data": {
"country": "Country"
}
},
"province": {
"data": {
"province": "Province"
}
}
}
}
}

View file

@ -38,6 +38,7 @@ DEFAULT_EXPOSED_DOMAINS = {
"scene", "scene",
"script", "script",
"switch", "switch",
"todo",
"vacuum", "vacuum",
"water_heater", "water_heater",
} }

View file

@ -30,11 +30,7 @@ from homeassistant.core import (
callback, callback,
) )
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import config_validation as cv, entity_platform
config_per_platform,
config_validation as cv,
entity_platform,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform
from homeassistant.helpers.service import ( from homeassistant.helpers.service import (
async_extract_entity_ids, async_extract_entity_ids,
@ -208,7 +204,7 @@ async def async_setup_platform(
await platform.async_reset() await platform.async_reset()
# Extract only the config for the Home Assistant platform, ignore the rest. # Extract only the config for the Home Assistant platform, ignore the rest.
for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN):
if p_type != HA_DOMAIN: if p_type != HA_DOMAIN:
continue continue

View file

@ -1,8 +1,8 @@
"""Offer Home Assistant core automation rules.""" """Offer Home Assistant core automation rules."""
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.const import CONF_EVENT, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -30,24 +30,17 @@ async def async_attach_trigger(
job = HassJob(action, f"homeassistant trigger {trigger_info}") job = HassJob(action, f"homeassistant trigger {trigger_info}")
if event == EVENT_SHUTDOWN: if event == EVENT_SHUTDOWN:
return hass.async_add_shutdown_job(
@callback job,
def hass_shutdown(event): {
"""Execute when Home Assistant is shutting down.""" "trigger": {
hass.async_run_hass_job( **trigger_data,
job, "platform": "homeassistant",
{ "event": event,
"trigger": { "description": "Home Assistant stopping",
**trigger_data, }
"platform": "homeassistant", },
"event": event, )
"description": "Home Assistant stopping",
}
},
event.context,
)
return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown)
# Automation are enabled while hass is starting up, fire right away # Automation are enabled while hass is starting up, fire right away
# Check state because a config reload shouldn't trigger it. # Check state because a config reload shouldn't trigger it.

View file

@ -1,12 +1,61 @@
"""The Homewizard integration.""" """The Homewizard integration."""
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, PLATFORMS from .const import DOMAIN, LOGGER, PLATFORMS
from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator
async def _async_migrate_entries(
hass: HomeAssistant, config_entry: ConfigEntry
) -> None:
"""Migrate old entry.
The HWE-SKT had no total_power_*_kwh in 2023.11, in 2023.12 it does.
But simultaneously, the total_power_*_t1_kwh was removed for HWE-SKT.
This migration migrates the old unique_id to the new one, if possible.
Migration can be removed after 2024.6
"""
entity_registry = er.async_get(hass)
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
replacements = {
"total_power_import_t1_kwh": "total_power_import_kwh",
"total_power_export_t1_kwh": "total_power_export_kwh",
}
for old_id, new_id in replacements.items():
if entry.unique_id.endswith(old_id):
new_unique_id = entry.unique_id.replace(old_id, new_id)
if existing_entity_id := entity_registry.async_get_entity_id(
entry.domain, entry.platform, new_unique_id
):
LOGGER.debug(
"Cannot migrate to unique_id '%s', already exists for '%s'",
new_unique_id,
existing_entity_id,
)
return None
LOGGER.debug(
"Migrating entity '%s' unique_id from '%s' to '%s'",
entry.entity_id,
entry.unique_id,
new_unique_id,
)
return {
"new_unique_id": new_unique_id,
}
return None
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Homewizard from a config entry.""" """Set up Homewizard from a config entry."""
coordinator = Coordinator(hass) coordinator = Coordinator(hass)
@ -21,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise raise
await _async_migrate_entries(hass, entry)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
# Abort reauth config flow if active # Abort reauth config flow if active

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging
from homewizard_energy.models import Data, Device, State, System from homewizard_energy.models import Data, Device, State, System
@ -11,6 +12,8 @@ from homeassistant.const import Platform
DOMAIN = "homewizard" DOMAIN = "homewizard"
PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
LOGGER = logging.getLogger(__package__)
# Platform config. # Platform config.
CONF_API_ENABLED = "api_enabled" CONF_API_ENABLED = "api_enabled"
CONF_DATA = "data" CONF_DATA = "data"

View file

@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .const import DOMAIN from .const import DOMAIN
from .coordinator import HWEnergyDeviceUpdateCoordinator from .coordinator import HWEnergyDeviceUpdateCoordinator
@ -45,7 +46,9 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity):
@homewizard_exception_handler @homewizard_exception_handler
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Set a new value.""" """Set a new value."""
await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) await self.coordinator.api.state_set(
brightness=value_to_brightness((0, 100), value)
)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@property @property
@ -61,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity):
or (brightness := self.coordinator.data.state.brightness) is None or (brightness := self.coordinator.data.state.brightness) is None
): ):
return None return None
return round(brightness * (100 / 255)) return brightness_to_value((0, 100), brightness)

View file

@ -436,7 +436,6 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Initialize sensors.""" """Initialize sensors."""
coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
HomeWizardSensorEntity(coordinator, description) HomeWizardSensorEntity(coordinator, description)
for description in SENSORS for description in SENSORS

View file

@ -135,6 +135,7 @@ PLATFORMS = [
Platform.DEVICE_TRACKER, Platform.DEVICE_TRACKER,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
Platform.SELECT,
] ]

View file

@ -0,0 +1,132 @@
"""Support for Huawei LTE selects."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from functools import partial
import logging
from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SelectEntity,
SelectEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED
from . import HuaweiLteBaseEntityWithDevice
from .const import DOMAIN, KEY_NET_NET_MODE
_LOGGER = logging.getLogger(__name__)
@dataclass
class HuaweiSelectEntityMixin:
"""Mixin for Huawei LTE select entities, to ensure required fields are set."""
setter_fn: Callable[[str], None]
@dataclass
class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin):
"""Class describing Huawei LTE select entities."""
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up from config entry."""
router = hass.data[DOMAIN].routers[config_entry.entry_id]
selects: list[Entity] = []
desc = HuaweiSelectEntityDescription(
key=KEY_NET_NET_MODE,
entity_category=EntityCategory.CONFIG,
icon="mdi:transmission-tower",
name="Preferred network mode",
translation_key="preferred_network_mode",
options=[
NetworkModeEnum.MODE_AUTO.value,
NetworkModeEnum.MODE_4G_3G_AUTO.value,
NetworkModeEnum.MODE_4G_2G_AUTO.value,
NetworkModeEnum.MODE_4G_ONLY.value,
NetworkModeEnum.MODE_3G_2G_AUTO.value,
NetworkModeEnum.MODE_3G_ONLY.value,
NetworkModeEnum.MODE_2G_ONLY.value,
],
setter_fn=partial(
router.client.net.set_net_mode,
LTEBandEnum.ALL,
NetworkBandEnum.ALL,
),
)
selects.append(
HuaweiLteSelectEntity(
router,
entity_description=desc,
key=desc.key,
item="NetworkMode",
)
)
async_add_entities(selects, True)
@dataclass
class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity):
"""Huawei LTE select entity."""
entity_description: HuaweiSelectEntityDescription
key: str
item: str
_raw_state: str | None = field(default=None, init=False)
def __post_init__(self) -> None:
"""Initialize remaining attributes."""
name = None
if self.entity_description.name != UNDEFINED:
name = self.entity_description.name
self._attr_name = name or self.item
def select_option(self, option: str) -> None:
"""Change the selected option."""
self.entity_description.setter_fn(option)
@property
def current_option(self) -> str | None:
"""Return current option."""
return self._raw_state
@property
def _device_unique_id(self) -> str:
return f"{self.key}.{self.item}"
async def async_added_to_hass(self) -> None:
"""Subscribe to needed data on add."""
await super().async_added_to_hass()
self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}")
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from needed data on remove."""
await super().async_will_remove_from_hass()
self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}")
async def async_update(self) -> None:
"""Update state."""
try:
value = self.router.data[self.key][self.item]
except KeyError:
_LOGGER.debug("%s[%s] not in data", self.key, self.item)
self._available = False
return
self._available = True
self._raw_state = str(value)

View file

@ -286,6 +286,20 @@
"name": "SMS messages (SIM)" "name": "SMS messages (SIM)"
} }
}, },
"select": {
"preferred_network_mode": {
"name": "Preferred network mode",
"state": {
"00": "4G/3G/2G auto",
"0302": "4G/3G auto",
"0301": "4G/2G auto",
"03": "4G only",
"0201": "3G/2G auto",
"02": "3G only",
"01": "2G only"
}
}
},
"switch": { "switch": {
"mobile_data": { "mobile_data": {
"name": "Mobile data" "name": "Mobile data"

View file

@ -16,7 +16,7 @@
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of your Hue bridge." "host": "[%key:component::hue::config::step::init::data_description::host%]"
} }
}, },
"link": { "link": {

View file

@ -30,6 +30,8 @@ ATTR_DIRECTION = "direction"
ATTR_TYPE = "type" ATTR_TYPE = "type"
ATTR_DELAY = "delay" ATTR_DELAY = "delay"
ATTR_NEXT = "next" ATTR_NEXT = "next"
ATTR_CANCELLED = "cancelled"
ATTR_EXTRA = "extra"
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin")
@ -142,6 +144,8 @@ class HVVDepartureSensor(SensorEntity):
departure = data["departures"][0] departure = data["departures"][0]
line = departure["line"] line = departure["line"]
delay = departure.get("delay", 0) delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
self._attr_available = True self._attr_available = True
self._attr_native_value = ( self._attr_native_value = (
departure_time departure_time
@ -157,6 +161,8 @@ class HVVDepartureSensor(SensorEntity):
ATTR_TYPE: line["type"]["shortInfo"], ATTR_TYPE: line["type"]["shortInfo"],
ATTR_ID: line["id"], ATTR_ID: line["id"],
ATTR_DELAY: delay, ATTR_DELAY: delay,
ATTR_CANCELLED: cancelled,
ATTR_EXTRA: extra,
} }
) )
@ -164,6 +170,8 @@ class HVVDepartureSensor(SensorEntity):
for departure in data["departures"]: for departure in data["departures"]:
line = departure["line"] line = departure["line"]
delay = departure.get("delay", 0) delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
departures.append( departures.append(
{ {
ATTR_DEPARTURE: departure_time ATTR_DEPARTURE: departure_time
@ -175,6 +183,8 @@ class HVVDepartureSensor(SensorEntity):
ATTR_TYPE: line["type"]["shortInfo"], ATTR_TYPE: line["type"]["shortInfo"],
ATTR_ID: line["id"], ATTR_ID: line["id"],
ATTR_DELAY: delay, ATTR_DELAY: delay,
ATTR_CANCELLED: cancelled,
ATTR_EXTRA: extra,
} }
) )
self._attr_extra_state_attributes[ATTR_NEXT] = departures self._attr_extra_state_attributes[ATTR_NEXT] = departures

View file

@ -52,9 +52,9 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> iOSNotificationService | None: ) -> iOSNotificationService | None:
"""Get the iOS notification service.""" """Get the iOS notification service."""
if "notify.ios" not in hass.config.components: if "ios.notify" not in hass.config.components:
# Need this to enable requirements checking in the app. # Need this to enable requirements checking in the app.
hass.config.components.add("notify.ios") hass.config.components.add("ios.notify")
if not ios.devices_with_push(hass): if not ios.devices_with_push(hass):
return None return None

View file

@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value, percentage_to_ranged_value,
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from homeassistant.util.scaling import int_states_in_range
from .const import _LOGGER, DOMAIN from .const import _LOGGER, DOMAIN
from .entity import ISYNodeEntity, ISYProgramEntity from .entity import ISYNodeEntity, ISYProgramEntity

View file

@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value, percentage_to_ranged_value,
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from homeassistant.util.scaling import int_states_in_range
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
from .knx_entity import KnxEntity from .knx_entity import KnxEntity

View file

@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# validate and retrieve the model and device number for a unique id # validate and retrieve the model and device number for a unique id
data = await self.hass.async_add_executor_job(heat_meter.read) data = await self.hass.async_add_executor_job(heat_meter.read)
except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: except (asyncio.TimeoutError, serial.SerialException) as err:
_LOGGER.warning("Failed read data from: %s. %s", port, err) _LOGGER.warning("Failed read data from: %s. %s", port, err)
raise CannotConnect(f"Error communicating with device: {err}") from err raise CannotConnect(f"Error communicating with device: {err}") from err

View file

@ -33,5 +33,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]):
try: try:
async with asyncio.timeout(ULTRAHEAT_TIMEOUT): async with asyncio.timeout(ULTRAHEAT_TIMEOUT):
return await self.hass.async_add_executor_job(self.api.read) return await self.hass.async_add_executor_job(self.api.read)
except (FileNotFoundError, serial.serialutil.SerialException) as err: except (FileNotFoundError, serial.SerialException) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err raise UpdateFailed(f"Error communicating with API: {err}") from err

View file

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.16.0", "ld2410-ble==0.1.1"] "requirements": ["bluetooth-data-tools==1.17.0", "ld2410-ble==0.1.1"]
} }

View file

@ -32,5 +32,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble", "documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.16.0", "led-ble==1.0.1"] "requirements": ["bluetooth-data-tools==1.17.0", "led-ble==1.0.1"]
} }

View file

@ -6,6 +6,9 @@
"description": "If you leave the host empty, discovery will be used to find devices.", "description": "If you leave the host empty, discovery will be used to find devices.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your LIFX device."
} }
}, },
"pick_device": { "pick_device": {

View file

@ -24,7 +24,7 @@ from homeassistant.const import (
STATE_UNLOCKED, STATE_UNLOCKED,
STATE_UNLOCKING, STATE_UNLOCKING,
) )
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
@ -33,7 +33,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
) )
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.service import remove_entity_service_fields
from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.helpers.typing import ConfigType, StateType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -75,48 +74,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await component.async_setup(config) await component.async_setup(config)
component.async_register_entity_service( component.async_register_entity_service(
SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service"
) )
component.async_register_entity_service( component.async_register_entity_service(
SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service"
) )
component.async_register_entity_service( component.async_register_entity_service(
SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] SERVICE_OPEN,
LOCK_SERVICE_SCHEMA,
"async_handle_open_service",
[LockEntityFeature.OPEN],
) )
return True return True
@callback
def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]:
data = remove_entity_service_fields(service_call)
code: str = data.pop(ATTR_CODE, "")
if not code:
code = entity._lock_option_default_code # pylint: disable=protected-access
if entity.code_format_cmp and not entity.code_format_cmp.match(code):
raise ValueError(
f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}"
)
if code:
data[ATTR_CODE] = code
return data
async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None:
"""Lock the lock."""
await entity.async_lock(**_add_default_code(entity, service_call))
async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None:
"""Unlock the lock."""
await entity.async_unlock(**_add_default_code(entity, service_call))
async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None:
"""Open the door latch."""
await entity.async_open(**_add_default_code(entity, service_call))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
component: EntityComponent[LockEntity] = hass.data[DOMAIN] component: EntityComponent[LockEntity] = hass.data[DOMAIN]
@ -149,6 +121,21 @@ class LockEntity(Entity):
_lock_option_default_code: str = "" _lock_option_default_code: str = ""
__code_format_cmp: re.Pattern[str] | None = None __code_format_cmp: re.Pattern[str] | None = None
@final
@callback
def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]:
"""Add default lock code."""
code: str = data.pop(ATTR_CODE, "")
if not code:
code = self._lock_option_default_code
if self.code_format_cmp and not self.code_format_cmp.match(code):
raise ValueError(
f"Code '{code}' for locking {self.entity_id} doesn't match pattern {self.code_format}"
)
if code:
data[ATTR_CODE] = code
return data
@property @property
def changed_by(self) -> str | None: def changed_by(self) -> str | None:
"""Last change triggered by.""" """Last change triggered by."""
@ -193,6 +180,11 @@ class LockEntity(Entity):
"""Return true if the lock is jammed (incomplete locking).""" """Return true if the lock is jammed (incomplete locking)."""
return self._attr_is_jammed return self._attr_is_jammed
@final
async def async_handle_lock_service(self, **kwargs: Any) -> None:
"""Add default code and lock."""
await self.async_lock(**self.add_default_code(kwargs))
def lock(self, **kwargs: Any) -> None: def lock(self, **kwargs: Any) -> None:
"""Lock the lock.""" """Lock the lock."""
raise NotImplementedError() raise NotImplementedError()
@ -201,6 +193,11 @@ class LockEntity(Entity):
"""Lock the lock.""" """Lock the lock."""
await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs))
@final
async def async_handle_unlock_service(self, **kwargs: Any) -> None:
"""Add default code and unlock."""
await self.async_unlock(**self.add_default_code(kwargs))
def unlock(self, **kwargs: Any) -> None: def unlock(self, **kwargs: Any) -> None:
"""Unlock the lock.""" """Unlock the lock."""
raise NotImplementedError() raise NotImplementedError()
@ -209,6 +206,11 @@ class LockEntity(Entity):
"""Unlock the lock.""" """Unlock the lock."""
await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs))
@final
async def async_handle_open_service(self, **kwargs: Any) -> None:
"""Add default code and open."""
await self.async_open(**self.add_default_code(kwargs))
def open(self, **kwargs: Any) -> None: def open(self, **kwargs: Any) -> None:
"""Open the door latch.""" """Open the door latch."""
raise NotImplementedError() raise NotImplementedError()

View file

@ -14,7 +14,7 @@ from homeassistant.const import CONF_FILENAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, storage from homeassistant.helpers import collection, storage
from homeassistant.util.yaml import Secrets, load_yaml from homeassistant.util.yaml import Secrets, load_yaml_dict
from .const import ( from .const import (
CONF_ICON, CONF_ICON,
@ -201,7 +201,9 @@ class LovelaceYAML(LovelaceConfig):
is_updated = self._cache is not None is_updated = self._cache is not None
try: try:
config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) config = load_yaml_dict(
self.path, Secrets(Path(self.hass.config.config_dir))
)
except FileNotFoundError: except FileNotFoundError:
raise ConfigNotFound from None raise ConfigNotFound from None

View file

@ -11,6 +11,9 @@
"description": "Enter the IP address of the device.", "description": "Enter the IP address of the device.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Lutron Caseta Smart Bridge."
} }
}, },
"link": { "link": {

View file

@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
"Could not find target_temp_low and/or target_temp_high in" "Could not find target_temp_low and/or target_temp_high in"
" arguments" " arguments"
) )
# If the device supports "Auto" mode, don't pass the mode when setting the
# temperature
mode = (
None
if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL
else HVAC_MODES[device.changeableValues.heatCoolMode]
)
_LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high)
try: try:
await self._update_thermostat( await self._update_thermostat(
@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
device, device,
coolSetpoint=target_temp_high, coolSetpoint=target_temp_high,
heatSetpoint=target_temp_low, heatSetpoint=target_temp_low,
mode=HVAC_MODES[device.changeableValues.heatCoolMode], mode=mode,
) )
except LYRIC_EXCEPTIONS as exception: except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception) _LOGGER.error(exception)

View file

@ -13,13 +13,10 @@ from aiohttp.web_exceptions import HTTPNotFound
from homeassistant.components import frontend from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.config import config_per_platform
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import config_validation as cv, discovery
config_per_platform,
config_validation as cv,
discovery,
)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View file

@ -7,7 +7,7 @@ import logging
import mimetypes import mimetypes
import os import os
import re import re
from typing import NewType, TypedDict from typing import Final, NewType, Required, TypedDict
import aiofiles.os import aiofiles.os
from nio import AsyncClient, Event, MatrixRoom from nio import AsyncClient, Event, MatrixRoom
@ -49,11 +49,11 @@ _LOGGER = logging.getLogger(__name__)
SESSION_FILE = ".matrix.conf" SESSION_FILE = ".matrix.conf"
CONF_HOMESERVER = "homeserver" CONF_HOMESERVER: Final = "homeserver"
CONF_ROOMS = "rooms" CONF_ROOMS: Final = "rooms"
CONF_COMMANDS = "commands" CONF_COMMANDS: Final = "commands"
CONF_WORD = "word" CONF_WORD: Final = "word"
CONF_EXPRESSION = "expression" CONF_EXPRESSION: Final = "expression"
CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_USERNAME_REGEX = "^@[^:]*:.*"
CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*"
@ -78,10 +78,10 @@ RoomAnyID = RoomID | RoomAlias
class ConfigCommand(TypedDict, total=False): class ConfigCommand(TypedDict, total=False):
"""Corresponds to a single COMMAND_SCHEMA.""" """Corresponds to a single COMMAND_SCHEMA."""
name: str # CONF_NAME name: Required[str] # CONF_NAME
rooms: list[RoomID] | None # CONF_ROOMS rooms: list[RoomID] # CONF_ROOMS
word: WordCommand | None # CONF_WORD word: WordCommand # CONF_WORD
expression: ExpressionCommand | None # CONF_EXPRESSION expression: ExpressionCommand # CONF_EXPRESSION
COMMAND_SCHEMA = vol.All( COMMAND_SCHEMA = vol.All(
@ -223,15 +223,15 @@ class MatrixBot:
def _load_commands(self, commands: list[ConfigCommand]) -> None: def _load_commands(self, commands: list[ConfigCommand]) -> None:
for command in commands: for command in commands:
# Set the command for all listening_rooms, unless otherwise specified. # Set the command for all listening_rooms, unless otherwise specified.
command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] command.setdefault(CONF_ROOMS, list(self._listening_rooms.values()))
# COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set.
if (word_command := command.get(CONF_WORD)) is not None: if (word_command := command.get(CONF_WORD)) is not None:
for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] for room_id in command[CONF_ROOMS]:
self._word_commands.setdefault(room_id, {}) self._word_commands.setdefault(room_id, {})
self._word_commands[room_id][word_command] = command # type: ignore[index] self._word_commands[room_id][word_command] = command
else: else:
for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] for room_id in command[CONF_ROOMS]:
self._expression_commands.setdefault(room_id, []) self._expression_commands.setdefault(room_id, [])
self._expression_commands[room_id].append(command) self._expression_commands[room_id].append(command)
@ -263,7 +263,7 @@ class MatrixBot:
# After single-word commands, check all regex commands in the room. # After single-word commands, check all regex commands in the room.
for command in self._expression_commands.get(room_id, []): for command in self._expression_commands.get(room_id, []):
match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] match = command[CONF_EXPRESSION].match(message.body)
if not match: if not match:
continue continue
message_data = { message_data = {

View file

@ -97,22 +97,23 @@ class MatterAdapter:
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
self.matter_client.subscribe_events( self.matter_client.subscribe_events(
endpoint_added_callback, EventType.ENDPOINT_ADDED callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED
) )
) )
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
self.matter_client.subscribe_events( self.matter_client.subscribe_events(
endpoint_removed_callback, EventType.ENDPOINT_REMOVED callback=endpoint_removed_callback,
event_filter=EventType.ENDPOINT_REMOVED,
) )
) )
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
self.matter_client.subscribe_events( self.matter_client.subscribe_events(
node_removed_callback, EventType.NODE_REMOVED callback=node_removed_callback, event_filter=EventType.NODE_REMOVED
) )
) )
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
self.matter_client.subscribe_events( self.matter_client.subscribe_events(
node_added_callback, EventType.NODE_ADDED callback=node_added_callback, event_filter=EventType.NODE_ADDED
) )
) )

View file

@ -115,8 +115,9 @@ def async_discover_entities(
attributes_to_watch=attributes_to_watch, attributes_to_watch=attributes_to_watch,
entity_description=schema.entity_description, entity_description=schema.entity_description,
entity_class=schema.entity_class, entity_class=schema.entity_class,
should_poll=schema.should_poll,
) )
# prevent re-discovery of the same attributes # prevent re-discovery of the primary attribute if not allowed
if not schema.allow_multi: if not schema.allow_multi:
discovered_attributes.update(attributes_to_watch) discovered_attributes.update(schema.required_attributes)

View file

@ -5,6 +5,7 @@ from abc import abstractmethod
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
import logging import logging
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue
from matter_server.common.helpers.util import create_attribute_path from matter_server.common.helpers.util import create_attribute_path
from matter_server.common.models import EventType, ServerInfoMessage from matter_server.common.models import EventType, ServerInfoMessage
from homeassistant.core import callback from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.event import async_call_later
from .const import DOMAIN, ID_TYPE_DEVICE_ID from .const import DOMAIN, ID_TYPE_DEVICE_ID
from .helpers import get_device_id from .helpers import get_device_id
@ -27,6 +29,13 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
# For some manually polled values (e.g. custom clusters) we perform
# an additional poll as soon as a secondary value changes.
# For example update the energy consumption meter when a relay is toggled
# of an energy metering powerplug. The below constant defined the delay after
# which we poll the primary value (debounced).
EXTRA_POLL_DELAY = 3.0
@dataclass @dataclass
class MatterEntityDescription(EntityDescription): class MatterEntityDescription(EntityDescription):
@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription):
class MatterEntity(Entity): class MatterEntity(Entity):
"""Entity class for Matter devices.""" """Entity class for Matter devices."""
_attr_should_poll = False
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
@ -71,6 +79,8 @@ class MatterEntity(Entity):
identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
) )
self._attr_available = self._endpoint.node.available self._attr_available = self._endpoint.node.available
self._attr_should_poll = entity_info.should_poll
self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle being added to Home Assistant.""" """Handle being added to Home Assistant."""
@ -110,15 +120,35 @@ class MatterEntity(Entity):
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.""" """Run when entity will be removed from hass."""
if self._extra_poll_timer_unsub:
self._extra_poll_timer_unsub()
for unsub in self._unsubscribes: for unsub in self._unsubscribes:
with suppress(ValueError): with suppress(ValueError):
# suppress ValueError to prevent race conditions # suppress ValueError to prevent race conditions
unsub() unsub()
async def async_update(self) -> None:
"""Call when the entity needs to be updated."""
# manually poll/refresh the primary value
await self.matter_client.refresh_attribute(
self._endpoint.node.node_id,
self.get_matter_attribute_path(self._entity_info.primary_attribute),
)
self._update_from_device()
@callback @callback
def _on_matter_event(self, event: EventType, data: Any = None) -> None: def _on_matter_event(self, event: EventType, data: Any = None) -> None:
"""Call on update.""" """Call on update from the device."""
self._attr_available = self._endpoint.node.available self._attr_available = self._endpoint.node.available
if self._attr_should_poll:
# secondary attribute updated of a polled primary value
# enforce poll of the primary value a few seconds later
if self._extra_poll_timer_unsub:
self._extra_poll_timer_unsub()
self._extra_poll_timer_unsub = async_call_later(
self.hass, EXTRA_POLL_DELAY, self._do_extra_poll
)
return
self._update_from_device() self._update_from_device()
self.async_write_ha_state() self.async_write_ha_state()
@ -145,3 +175,9 @@ class MatterEntity(Entity):
return create_attribute_path( return create_attribute_path(
self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id
) )
@callback
def _do_extra_poll(self, called_at: datetime) -> None:
"""Perform (extra) poll of primary value."""
# scheduling the regulat update is enough to perform a poll/refresh
self.async_schedule_update_ha_state(True)

View file

@ -89,10 +89,7 @@ class MatterLock(MatterEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock with pin if needed.""" """Lock the lock with pin if needed."""
code: str = kwargs.get( code: str | None = kwargs.get(ATTR_CODE)
ATTR_CODE,
self._lock_option_default_code,
)
code_bytes = code.encode() if code else None code_bytes = code.encode() if code else None
await self.send_device_command( await self.send_device_command(
command=clusters.DoorLock.Commands.LockDoor(code_bytes) command=clusters.DoorLock.Commands.LockDoor(code_bytes)
@ -100,10 +97,7 @@ class MatterLock(MatterEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock with pin if needed.""" """Unlock the lock with pin if needed."""
code: str = kwargs.get( code: str | None = kwargs.get(ATTR_CODE)
ATTR_CODE,
self._lock_option_default_code,
)
code_bytes = code.encode() if code else None code_bytes = code.encode() if code else None
if self.supports_unbolt: if self.supports_unbolt:
# if the lock reports it has separate unbolt support, # if the lock reports it has separate unbolt support,
@ -119,10 +113,7 @@ class MatterLock(MatterEntity, LockEntity):
async def async_open(self, **kwargs: Any) -> None: async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch.""" """Open the door latch."""
code: str = kwargs.get( code: str | None = kwargs.get(ATTR_CODE)
ATTR_CODE,
self._lock_option_default_code,
)
code_bytes = code.encode() if code else None code_bytes = code.encode() if code else None
await self.send_device_command( await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)

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