Merge branch 'dev' into bangolufsen
This commit is contained in:
commit
5d1071cff5
466 changed files with 11378 additions and 3623 deletions
|
@ -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
|
||||||
|
|
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
|
@ -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 }}
|
||||||
|
|
||||||
|
|
24
.github/workflows/ci.yaml
vendored
24
.github/workflows/ci.yaml
vendored
|
@ -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
|
||||||
|
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
@ -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"
|
||||||
|
|
18
.github/workflows/stale.yml
vendored
18
.github/workflows/stale.yml
vendored
|
@ -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"
|
||||||
|
|
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
|
@ -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 }}
|
||||||
|
|
||||||
|
|
|
@ -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.*
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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__ = ()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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."]
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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]),
|
||||||
|
]
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
1
homeassistant/components/fujitsu_anywair/__init__.py
Normal file
1
homeassistant/components/fujitsu_anywair/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Fujitsu anywAIR virtual integration for Home Assistant."""
|
6
homeassistant/components/fujitsu_anywair/manifest.json
Normal file
6
homeassistant/components/fujitsu_anywair/manifest.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"domain": "fujitsu_anywair",
|
||||||
|
"name": "Fujitsu anywAIR",
|
||||||
|
"integration_type": "virtual",
|
||||||
|
"supported_by": "advantage_air"
|
||||||
|
}
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)},
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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(
|
||||||
|
|
20
homeassistant/components/holiday/__init__.py
Normal file
20
homeassistant/components/holiday/__init__.py
Normal 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)
|
134
homeassistant/components/holiday/calendar.py
Normal file
134
homeassistant/components/holiday/calendar.py
Normal 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
|
101
homeassistant/components/holiday/config_flow.py
Normal file
101
homeassistant/components/holiday/config_flow.py
Normal 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)
|
6
homeassistant/components/holiday/const.py
Normal file
6
homeassistant/components/holiday/const.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
"""Constants for the Holiday integration."""
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "holiday"
|
||||||
|
|
||||||
|
CONF_PROVINCE: Final = "province"
|
9
homeassistant/components/holiday/manifest.json
Normal file
9
homeassistant/components/holiday/manifest.json
Normal 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"]
|
||||||
|
}
|
19
homeassistant/components/holiday/strings.json
Normal file
19
homeassistant/components/holiday/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,6 +38,7 @@ DEFAULT_EXPOSED_DOMAINS = {
|
||||||
"scene",
|
"scene",
|
||||||
"script",
|
"script",
|
||||||
"switch",
|
"switch",
|
||||||
|
"todo",
|
||||||
"vacuum",
|
"vacuum",
|
||||||
"water_heater",
|
"water_heater",
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -135,6 +135,7 @@ PLATFORMS = [
|
||||||
Platform.DEVICE_TRACKER,
|
Platform.DEVICE_TRACKER,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
|
Platform.SELECT,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
132
homeassistant/components/huawei_lte/select.py
Normal file
132
homeassistant/components/huawei_lte/select.py
Normal 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)
|
|
@ -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"
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue