forked from home-assistant/core
Compare commits
215 Commits
entity_com
...
ollama-too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27be570b69 | ||
|
|
4879e02839 | ||
|
|
eaeca423d4 | ||
|
|
8f688ee079 | ||
|
|
975cfa6457 | ||
|
|
5d3c57ecfe | ||
|
|
f4125eaf4c | ||
|
|
96de0a4c94 | ||
|
|
d0ba5534cd | ||
|
|
9b2118e556 | ||
|
|
42716723e6 | ||
|
|
ba276a5cb6 | ||
|
|
3df6b34a03 | ||
|
|
ee30510b23 | ||
|
|
489457c47b | ||
|
|
a1cdd91d23 | ||
|
|
fed17a4905 | ||
|
|
c61efe931a | ||
|
|
d3df903d1e | ||
|
|
db6704271c | ||
|
|
3dc36cf068 | ||
|
|
20fc5233a1 | ||
|
|
4c853803f1 | ||
|
|
19d9a91392 | ||
|
|
76cd53a864 | ||
|
|
02c34ba3f8 | ||
|
|
b14e8d1609 | ||
|
|
e8b88557ee | ||
|
|
c73e7ae178 | ||
|
|
7ec41275d5 | ||
|
|
debebcfd25 | ||
|
|
186ca49b16 | ||
|
|
243a68fb1f | ||
|
|
bd97a09cae | ||
|
|
d421525f1b | ||
|
|
7ec332f857 | ||
|
|
31d3b3b675 | ||
|
|
ea94cdb668 | ||
|
|
cbe94c4706 | ||
|
|
5612e3a92b | ||
|
|
33f0840a26 | ||
|
|
8d538fcd52 | ||
|
|
9793aa0a5e | ||
|
|
064d7261b4 | ||
|
|
0c6dc9e43b | ||
|
|
c70e611822 | ||
|
|
02c64c7861 | ||
|
|
5b32efb6d6 | ||
|
|
34e72ea16a | ||
|
|
f30c6e01f9 | ||
|
|
db9fc27a5c | ||
|
|
ac1ad9680b | ||
|
|
4eb096cb0a | ||
|
|
bc5849e4ef | ||
|
|
9a3c7111f7 | ||
|
|
c98c80ce69 | ||
|
|
453848bcdc | ||
|
|
7d46890804 | ||
|
|
7e1fb88e4e | ||
|
|
94ce02f406 | ||
|
|
d418a40856 | ||
|
|
890b54e36f | ||
|
|
6de824e875 | ||
|
|
273dc0998f | ||
|
|
5f4dedb4a8 | ||
|
|
6f4a8a4a14 | ||
|
|
39068bb786 | ||
|
|
7e82b3ecdb | ||
|
|
8d01ad98eb | ||
|
|
a78d6b8c36 | ||
|
|
e8796cd725 | ||
|
|
b0a4140b4d | ||
|
|
30373a668c | ||
|
|
272f0bc21c | ||
|
|
7f852d0f73 | ||
|
|
8994c18f73 | ||
|
|
874b1ae873 | ||
|
|
7f82fb8cb8 | ||
|
|
a8cbfe5159 | ||
|
|
0ab1ccc5ae | ||
|
|
48661054d9 | ||
|
|
87e377cf84 | ||
|
|
8da630f8c6 | ||
|
|
f629364dc4 | ||
|
|
fcca475e36 | ||
|
|
5075f0aac8 | ||
|
|
537a76d049 | ||
|
|
b3698a59e1 | ||
|
|
9b9db86f1c | ||
|
|
1e28ae49f9 | ||
|
|
24b6f71f94 | ||
|
|
ae4360b0e5 | ||
|
|
769d7214a3 | ||
|
|
90e7d82049 | ||
|
|
5e8b022246 | ||
|
|
43aeaf7a9b | ||
|
|
63b0feeae7 | ||
|
|
0f079454bb | ||
|
|
6be4ef8a1f | ||
|
|
5fd3b929f4 | ||
|
|
55abbc51a4 | ||
|
|
651fb95010 | ||
|
|
c6713edc8b | ||
|
|
ee49c57e95 | ||
|
|
2f47312eeb | ||
|
|
293ad99dae | ||
|
|
0fe7aa1a43 | ||
|
|
b54b08479d | ||
|
|
ab2f38216d | ||
|
|
13da20ddf4 | ||
|
|
436a38c1d2 | ||
|
|
2b93de1348 | ||
|
|
ecffae0b4f | ||
|
|
6f9e39cd3f | ||
|
|
221480add1 | ||
|
|
153b69c971 | ||
|
|
d1d2ce1270 | ||
|
|
a6068dcdf2 | ||
|
|
0637e342f6 | ||
|
|
e9f5c4188e | ||
|
|
24b12bc509 | ||
|
|
f0b9a806d1 | ||
|
|
f8c4ffc060 | ||
|
|
768d20c645 | ||
|
|
a0332d049b | ||
|
|
4ee2c445d1 | ||
|
|
458c81cdae | ||
|
|
8e024ad20f | ||
|
|
a46fffd550 | ||
|
|
288faf48e7 | ||
|
|
7e0970e917 | ||
|
|
e6e748ae0a | ||
|
|
75b1700ed3 | ||
|
|
099110767a | ||
|
|
cafff3eddf | ||
|
|
c0732fbb1d | ||
|
|
7b5b6c7b85 | ||
|
|
12ec66c2c2 | ||
|
|
72d37036b9 | ||
|
|
e029dad0eb | ||
|
|
0cde518a89 | ||
|
|
dab66990c0 | ||
|
|
d3029af888 | ||
|
|
419bf0165a | ||
|
|
978ee918cb | ||
|
|
8bca9a3449 | ||
|
|
87ecf5d85e | ||
|
|
2f8dfb424b | ||
|
|
53c85a5c9b | ||
|
|
281c66b6c2 | ||
|
|
6788c43775 | ||
|
|
f006716173 | ||
|
|
de5b5f6d36 | ||
|
|
e9ea90dc82 | ||
|
|
0be68dcd7f | ||
|
|
c92d9dcb74 | ||
|
|
2b486c3bd5 | ||
|
|
c28a138dfe | ||
|
|
3ddcffb7b7 | ||
|
|
8cb7e9785f | ||
|
|
16434b5306 | ||
|
|
c1c5cff993 | ||
|
|
4620a54582 | ||
|
|
0b691f9393 | ||
|
|
bcf4c73f32 | ||
|
|
ca4c617d4b | ||
|
|
172778053c | ||
|
|
f5f9480b5a | ||
|
|
e50802aca3 | ||
|
|
de18be235d | ||
|
|
7810dc213a | ||
|
|
ba5e3ca44b | ||
|
|
e2c6b7915e | ||
|
|
5b4dd07189 | ||
|
|
362c772d67 | ||
|
|
339b5117c5 | ||
|
|
c8a6c6a5c1 | ||
|
|
53870617e8 | ||
|
|
24144c5855 | ||
|
|
978de5b8b0 | ||
|
|
1e59ce2909 | ||
|
|
fb5443fe2f | ||
|
|
dae23a8153 | ||
|
|
a2c2488c8b | ||
|
|
474e8b7a43 | ||
|
|
243c16d194 | ||
|
|
a0a5f640dc | ||
|
|
d2cc25cee6 | ||
|
|
cf0aef079b | ||
|
|
6d725b5e34 | ||
|
|
3d3bc1cab1 | ||
|
|
bf0e5baa76 | ||
|
|
f479b64ff9 | ||
|
|
ec937781ca | ||
|
|
37426f7366 | ||
|
|
d983e3b25d | ||
|
|
58d4e72996 | ||
|
|
02bb1ec8e7 | ||
|
|
f551130d65 | ||
|
|
42610f4e09 | ||
|
|
41d75e159b | ||
|
|
b06d3fe3b7 | ||
|
|
e4ef4b81ba | ||
|
|
0927dd9090 | ||
|
|
e2276458ed | ||
|
|
454ca0ce95 | ||
|
|
4ae6e38800 | ||
|
|
55cee89392 | ||
|
|
fa0a5451b9 | ||
|
|
52b90621c7 | ||
|
|
843fae825f | ||
|
|
e6dec7c856 | ||
|
|
7a4e40ade0 | ||
|
|
10c084c6e0 | ||
|
|
8ae4c4445d |
36
.github/workflows/ci.yaml
vendored
36
.github/workflows/ci.yaml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
tests_glob: ${{ steps.info.outputs.tests_glob }}
|
||||
tests: ${{ steps.info.outputs.tests }}
|
||||
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
|
||||
pre-commit:
|
||||
name: Prepare pre-commit base
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
github.event.inputs.pylint-only != 'true'
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
|
||||
lint-ruff-format:
|
||||
name: Check ruff-format
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- pre-commit
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
|
||||
lint-ruff:
|
||||
name: Check ruff
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- pre-commit
|
||||
@@ -345,7 +345,7 @@ jobs:
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
lint-other:
|
||||
name: Check other linters
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- pre-commit
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
|
||||
base:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs: info
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -514,7 +514,7 @@ jobs:
|
||||
|
||||
hassfest:
|
||||
name: Check hassfest
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
github.event.inputs.pylint-only != 'true'
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
|
||||
gen-requirements-all:
|
||||
name: Check all requirements
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
github.event.inputs.pylint-only != 'true'
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
@@ -584,7 +584,7 @@ jobs:
|
||||
|
||||
audit-licenses:
|
||||
name: Audit licenses
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
@@ -624,7 +624,7 @@ jobs:
|
||||
|
||||
pylint:
|
||||
name: Check pylint
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
if: |
|
||||
github.event.inputs.mypy-only != 'true'
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
|
||||
pylint-tests:
|
||||
name: Check pylint on tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
if: |
|
||||
(github.event.inputs.mypy-only != 'true' || github.event.inputs.pylint-only == 'true')
|
||||
@@ -714,7 +714,7 @@ jobs:
|
||||
|
||||
mypy:
|
||||
name: Check mypy
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
github.event.inputs.pylint-only != 'true'
|
||||
|| github.event.inputs.mypy-only == 'true'
|
||||
@@ -775,7 +775,7 @@ jobs:
|
||||
mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
|
||||
prepare-pytest-full:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
|
||||
&& github.event.inputs.lint-only != 'true'
|
||||
@@ -825,7 +825,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
pytest-full:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
|
||||
&& github.event.inputs.lint-only != 'true'
|
||||
@@ -936,7 +936,7 @@ jobs:
|
||||
./script/check_dirty
|
||||
|
||||
pytest-mariadb:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
services:
|
||||
mariadb:
|
||||
image: ${{ matrix.mariadb-group }}
|
||||
@@ -1189,7 +1189,7 @@ jobs:
|
||||
coverage-full:
|
||||
name: Upload test coverage to Codecov (full suite)
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- pytest-full
|
||||
@@ -1213,7 +1213,7 @@ jobs:
|
||||
version: v0.6.0
|
||||
|
||||
pytest-partial:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
|
||||
&& github.event.inputs.lint-only != 'true'
|
||||
@@ -1328,7 +1328,7 @@ jobs:
|
||||
coverage-partial:
|
||||
name: Upload test coverage to Codecov (partial suite)
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- pytest-partial
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.25.12
|
||||
uses: github/codeql-action/init@v3.25.13
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.25.12
|
||||
uses: github/codeql-action/analyze@v3.25.13
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.5.2
|
||||
rev: v0.5.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
||||
@@ -255,6 +255,7 @@ homeassistant.components.integration.*
|
||||
homeassistant.components.intent.*
|
||||
homeassistant.components.intent_script.*
|
||||
homeassistant.components.ios.*
|
||||
homeassistant.components.iotty.*
|
||||
homeassistant.components.ipp.*
|
||||
homeassistant.components.iqvia.*
|
||||
homeassistant.components.islamic_prayer_times.*
|
||||
|
||||
@@ -505,6 +505,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/generic_hygrostat/ @Shulyaka
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -695,6 +696,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ios/ @robbiet480
|
||||
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
|
||||
/tests/components/iotawatt/ @gtdiehl @jyavenard
|
||||
/homeassistant/components/iotty/ @pburgio
|
||||
/tests/components/iotty/ @pburgio
|
||||
/homeassistant/components/iperf3/ @rohankapoorcom
|
||||
/homeassistant/components/ipma/ @dgomes
|
||||
/tests/components/ipma/ @dgomes
|
||||
@@ -1432,6 +1435,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
||||
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
||||
/homeassistant/components/tesla_fleet/ @Bre77
|
||||
/tests/components/tesla_fleet/ @Bre77
|
||||
/homeassistant/components/tesla_wall_connector/ @einarhauks
|
||||
/tests/components/tesla_wall_connector/ @einarhauks
|
||||
/homeassistant/components/teslemetry/ @Bre77
|
||||
|
||||
@@ -12,7 +12,7 @@ ENV \
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.2.13
|
||||
RUN pip3 install uv==0.2.27
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -223,8 +223,10 @@ CRITICAL_INTEGRATIONS = {
|
||||
SETUP_ORDER = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
|
||||
# Setup frontend and recorder
|
||||
("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}),
|
||||
# Setup frontend
|
||||
("frontend", FRONTEND_INTEGRATIONS),
|
||||
# Setup recorder
|
||||
("recorder", RECORDER_INTEGRATIONS),
|
||||
# Start up debuggers. Start these first in case they want to wait.
|
||||
("debugger", DEBUGGER_INTEGRATIONS),
|
||||
)
|
||||
@@ -906,7 +908,13 @@ async def _async_resolve_domains_to_setup(
|
||||
await asyncio.gather(*resolve_dependencies_tasks)
|
||||
|
||||
for itg in integrations_to_process:
|
||||
for dep in itg.all_dependencies:
|
||||
try:
|
||||
all_deps = itg.all_dependencies
|
||||
except RuntimeError:
|
||||
# Integration.all_dependencies raises RuntimeError if
|
||||
# dependencies could not be resolved
|
||||
continue
|
||||
for dep in all_deps:
|
||||
if dep in domains_to_setup:
|
||||
continue
|
||||
domains_to_setup.add(dep)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "tesla",
|
||||
"name": "Tesla",
|
||||
"integrations": ["powerwall", "tesla_wall_connector"]
|
||||
"integrations": ["powerwall", "tesla_wall_connector", "tesla_fleet"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from airgradient import AirGradientClient
|
||||
from airgradient import AirGradientClient, get_model_name
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
@@ -35,7 +35,7 @@ class AirGradientData:
|
||||
type AirGradientConfigEntry = ConfigEntry[AirGradientData]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool:
|
||||
"""Set up Airgradient from a config entry."""
|
||||
|
||||
client = AirGradientClient(
|
||||
@@ -53,7 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, measurement_coordinator.serial_number)},
|
||||
manufacturer="AirGradient",
|
||||
model=measurement_coordinator.data.model,
|
||||
model=get_model_name(measurement_coordinator.data.model),
|
||||
model_id=measurement_coordinator.data.model,
|
||||
serial_number=measurement_coordinator.data.serial_number,
|
||||
sw_version=measurement_coordinator.data.firmware_version,
|
||||
)
|
||||
@@ -68,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AirGradientConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -19,7 +19,6 @@ if TYPE_CHECKING:
|
||||
class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
_update_interval: timedelta
|
||||
config_entry: AirGradientConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None:
|
||||
@@ -28,7 +27,7 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=f"AirGradient {client.host}",
|
||||
update_interval=self._update_interval,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
self.client = client
|
||||
assert self.config_entry.unique_id
|
||||
@@ -47,8 +46,6 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
_update_interval = timedelta(minutes=1)
|
||||
|
||||
async def _update_data(self) -> Measures:
|
||||
return await self.client.get_current_measures()
|
||||
|
||||
@@ -56,7 +53,5 @@ class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
|
||||
class AirGradientConfigCoordinator(AirGradientCoordinator[Config]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
_update_interval = timedelta(minutes=5)
|
||||
|
||||
async def _update_data(self) -> Config:
|
||||
return await self.client.get_config()
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.6.1"],
|
||||
"requirements": ["airgradient==0.7.0"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ from dataclasses import dataclass
|
||||
from APsystemsEZ1 import APsystemsEZ1M
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, Platform
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DEFAULT_PORT
|
||||
from .coordinator import ApSystemsDataCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
|
||||
@@ -28,7 +29,11 @@ type ApSystemsConfigEntry = ConfigEntry[ApSystemsData]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
api = APsystemsEZ1M(ip_address=entry.data[CONF_IP_ADDRESS], timeout=8)
|
||||
api = APsystemsEZ1M(
|
||||
ip_address=entry.data[CONF_IP_ADDRESS],
|
||||
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
timeout=8,
|
||||
)
|
||||
coordinator = ApSystemsDataCoordinator(hass, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
assert entry.unique_id
|
||||
|
||||
@@ -7,14 +7,16 @@ from APsystemsEZ1 import APsystemsEZ1M
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): str,
|
||||
vol.Required(CONF_IP_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,7 +34,11 @@ class APsystemsLocalAPIFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass, False)
|
||||
api = APsystemsEZ1M(user_input[CONF_IP_ADDRESS], session=session)
|
||||
api = APsystemsEZ1M(
|
||||
ip_address=user_input[CONF_IP_ADDRESS],
|
||||
port=user_input.get(CONF_PORT, DEFAULT_PORT),
|
||||
session=session,
|
||||
)
|
||||
try:
|
||||
device_info = await api.get_device_info()
|
||||
except (TimeoutError, ClientConnectionError):
|
||||
|
||||
@@ -4,3 +4,4 @@ from logging import Logger, getLogger
|
||||
|
||||
LOGGER: Logger = getLogger(__package__)
|
||||
DOMAIN = "apsystems"
|
||||
DEFAULT_PORT = 8050
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]"
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"port": "The integration will default to 8050, if not set, which should be suitable for most installs"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,8 +6,11 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Final, cast
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SAMPLE_RATE: Final = 16000 # Hz
|
||||
_SAMPLE_WIDTH: Final = 2 # bytes
|
||||
|
||||
@@ -159,6 +162,10 @@ class VoiceCommandSegmenter:
|
||||
"""
|
||||
self._timeout_seconds_left -= chunk_seconds
|
||||
if self._timeout_seconds_left <= 0:
|
||||
_LOGGER.warning(
|
||||
"VAD end of speech detection timed out after %s seconds",
|
||||
self.timeout_seconds,
|
||||
)
|
||||
self.reset()
|
||||
return False
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ SENSOR_TYPES_VIDEO_DOORBELL = (
|
||||
SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = (
|
||||
AugustDoorbellBinarySensorEntityDescription(
|
||||
key="ding",
|
||||
translation_key="ding",
|
||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||
value_fn=retrieve_ding_activity,
|
||||
is_time_based=True,
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"ding": {
|
||||
"name": "Doorbell ding"
|
||||
},
|
||||
"image_capture": {
|
||||
"name": "Image capture"
|
||||
}
|
||||
|
||||
@@ -333,7 +333,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def reload_service_handler(service_call: ServiceCall) -> None:
|
||||
"""Remove all automations and load new ones from config."""
|
||||
await async_get_blueprints(hass).async_reset_cache()
|
||||
conf = await component.async_prepare_reload(skip_reset=True)
|
||||
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
|
||||
return
|
||||
if automation_id := service_call.data.get(CONF_ID):
|
||||
await _async_process_single_config(hass, conf, component, automation_id)
|
||||
else:
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant, State
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.helpers.json import ExtendedJSONEncoder
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
@@ -203,9 +203,7 @@ class AzureDataExplorer:
|
||||
return None, dropped
|
||||
if (utcnow() - time_fired).seconds > DEFAULT_MAX_DELAY + self._send_interval:
|
||||
return None, dropped + 1
|
||||
if "\n" in state.state:
|
||||
return None, dropped + 1
|
||||
|
||||
json_event = json.dumps(obj=state, cls=JSONEncoder)
|
||||
json_event = json.dumps(obj=state, cls=ExtendedJSONEncoder)
|
||||
|
||||
return (json_event, dropped)
|
||||
|
||||
@@ -68,7 +68,7 @@ class AzureDataExplorerClient:
|
||||
# Queued is the only option supported on free tier of ADX
|
||||
self.write_client = QueuedIngestClient(kcsb_ingest)
|
||||
else:
|
||||
self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb_ingest)
|
||||
self.write_client = ManagedStreamingIngestClient(kcsb_ingest)
|
||||
|
||||
self.query_client = KustoClient(kcsb_query)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_data_explorer",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["azure"],
|
||||
"requirements": ["azure-kusto-ingest==3.1.0", "azure-kusto-data[aio]==3.1.0"]
|
||||
"requirements": ["azure-kusto-ingest==4.5.1", "azure-kusto-data[aio]==4.5.1"]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ class BAFFan(BAFEntity, FanEntity):
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.DIRECTION
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_preset_modes = [PRESET_MODE_AUTO]
|
||||
_attr_speed_count = SPEED_COUNT
|
||||
_attr_name = None
|
||||
|
||||
@@ -32,7 +32,12 @@ async def async_setup_entry(
|
||||
class BalboaPumpFanEntity(BalboaEntity, FanEntity):
|
||||
"""Representation of a Balboa Spa pump fan entity."""
|
||||
|
||||
_attr_supported_features = FanEntityFeature.SET_SPEED
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_translation_key = "pump"
|
||||
|
||||
def __init__(self, control: SpaControl) -> None:
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"codeowners": ["@thrawnarn"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["xmltodict==0.13.0"]
|
||||
"requirements": ["pyblu==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -3,18 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import CancelledError, timeout
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from asyncio import CancelledError
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any, NamedTuple
|
||||
from urllib import parse
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from aiohttp.hdrs import CONNECTION, KEEP_ALIVE
|
||||
from pyblu import Input, Player, Preset, Status, SyncStatus
|
||||
import voluptuous as vol
|
||||
import xmltodict
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -36,6 +33,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
@@ -109,7 +107,7 @@ SERVICE_TO_METHOD = {
|
||||
}
|
||||
|
||||
|
||||
def _add_player(hass, async_add_entities, host, port=None, name=None):
|
||||
def _add_player(hass: HomeAssistant, async_add_entities, host, port=None, name=None):
|
||||
"""Add Bluesound players."""
|
||||
|
||||
@callback
|
||||
@@ -123,7 +121,7 @@ def _add_player(hass, async_add_entities, host, port=None, name=None):
|
||||
player.start_polling()
|
||||
|
||||
@callback
|
||||
def _stop_polling():
|
||||
def _stop_polling(event=None):
|
||||
"""Stop polling."""
|
||||
player.stop_polling()
|
||||
|
||||
@@ -213,38 +211,38 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
_attr_media_content_type = MediaType.MUSIC
|
||||
|
||||
def __init__(self, hass, host, port=None, name=None, init_callback=None):
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, host, port=None, name=None, init_callback=None
|
||||
) -> None:
|
||||
"""Initialize the media player."""
|
||||
self.host = host
|
||||
self._hass = hass
|
||||
self.port = port
|
||||
self._polling_session = async_get_clientsession(hass)
|
||||
self._polling_task = None # The actual polling task.
|
||||
self._name = name
|
||||
self._id = None
|
||||
self._capture_items = []
|
||||
self._services_items = []
|
||||
self._preset_items = []
|
||||
self._sync_status = {}
|
||||
self._status = None
|
||||
self._last_status_update = None
|
||||
self._sync_status: SyncStatus | None = None
|
||||
self._status: Status | None = None
|
||||
self._inputs: list[Input] = []
|
||||
self._presets: list[Preset] = []
|
||||
self._is_online = False
|
||||
self._retry_remove = None
|
||||
self._muted = False
|
||||
self._master = None
|
||||
self._master: BluesoundPlayer | None = None
|
||||
self._is_master = False
|
||||
self._group_name = None
|
||||
self._group_list = []
|
||||
self._group_list: list[str] = []
|
||||
self._bluesound_device_name = None
|
||||
self._player = Player(
|
||||
host, port, async_get_clientsession(hass), default_timeout=10
|
||||
)
|
||||
|
||||
self._init_callback = init_callback
|
||||
|
||||
if self.port is None:
|
||||
self.port = DEFAULT_PORT
|
||||
|
||||
class _TimeoutException(Exception):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _try_get_index(string, search_string):
|
||||
"""Get the index."""
|
||||
@@ -253,28 +251,22 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
async def force_update_sync_status(self, on_updated_cb=None, raise_timeout=False):
|
||||
async def force_update_sync_status(self, on_updated_cb=None) -> bool:
|
||||
"""Update the internal status."""
|
||||
resp = await self.send_bluesound_command(
|
||||
"SyncStatus", raise_timeout, raise_timeout
|
||||
)
|
||||
sync_status = await self._player.sync_status()
|
||||
|
||||
if not resp:
|
||||
return None
|
||||
self._sync_status = resp["SyncStatus"].copy()
|
||||
self._sync_status = sync_status
|
||||
|
||||
if not self._name:
|
||||
self._name = self._sync_status.get("@name", self.host)
|
||||
self._name = sync_status.name if sync_status.name else self.host
|
||||
if not self._id:
|
||||
self._id = self._sync_status.get("@id", None)
|
||||
self._id = sync_status.id
|
||||
if not self._bluesound_device_name:
|
||||
self._bluesound_device_name = self._sync_status.get("@name", self.host)
|
||||
self._bluesound_device_name = self._name
|
||||
|
||||
if (master := self._sync_status.get("master")) is not None:
|
||||
if sync_status.master is not None:
|
||||
self._is_master = False
|
||||
master_host = master.get("#text")
|
||||
master_port = master.get("@port", "11000")
|
||||
master_id = f"{master_host}:{master_port}"
|
||||
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
|
||||
master_device = [
|
||||
device
|
||||
for device in self._hass.data[DATA_BLUESOUND]
|
||||
@@ -289,7 +281,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
else:
|
||||
if self._master is not None:
|
||||
self._master = None
|
||||
slaves = self._sync_status.get("slave")
|
||||
slaves = self._sync_status.slaves
|
||||
self._is_master = slaves is not None
|
||||
|
||||
if on_updated_cb:
|
||||
@@ -302,7 +294,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
while True:
|
||||
await self.async_update_status()
|
||||
|
||||
except (TimeoutError, ClientError, BluesoundPlayer._TimeoutException):
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
self.start_polling()
|
||||
@@ -328,7 +320,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
self._retry_remove()
|
||||
self._retry_remove = None
|
||||
|
||||
await self.force_update_sync_status(self._init_callback, True)
|
||||
await self.force_update_sync_status(self._init_callback)
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
|
||||
self._retry_remove = async_track_time_interval(
|
||||
@@ -345,110 +337,48 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if not self._is_online:
|
||||
return
|
||||
|
||||
await self.async_update_sync_status()
|
||||
await self.async_update_presets()
|
||||
await self.async_update_captures()
|
||||
await self.async_update_services()
|
||||
|
||||
async def send_bluesound_command(
|
||||
self, method, raise_timeout=False, allow_offline=False
|
||||
):
|
||||
"""Send command to the player."""
|
||||
if not self._is_online and not allow_offline:
|
||||
return None
|
||||
|
||||
if method[0] == "/":
|
||||
method = method[1:]
|
||||
url = f"http://{self.host}:{self.port}/{method}"
|
||||
|
||||
_LOGGER.debug("Calling URL: %s", url)
|
||||
response = None
|
||||
|
||||
try:
|
||||
websession = async_get_clientsession(self._hass)
|
||||
async with timeout(10):
|
||||
response = await websession.get(url)
|
||||
|
||||
if response.status == HTTPStatus.OK:
|
||||
result = await response.text()
|
||||
if result:
|
||||
data = xmltodict.parse(result)
|
||||
else:
|
||||
data = None
|
||||
elif response.status == 595:
|
||||
_LOGGER.info("Status 595 returned, treating as timeout")
|
||||
raise BluesoundPlayer._TimeoutException
|
||||
else:
|
||||
_LOGGER.error("Error %s on %s", response.status, url)
|
||||
return None
|
||||
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
if raise_timeout:
|
||||
_LOGGER.info("Timeout: %s:%s", self.host, self.port)
|
||||
raise
|
||||
_LOGGER.debug("Failed communicating: %s:%s", self.host, self.port)
|
||||
return None
|
||||
|
||||
return data
|
||||
with suppress(TimeoutError):
|
||||
await self.async_update_sync_status()
|
||||
await self.async_update_presets()
|
||||
await self.async_update_captures()
|
||||
|
||||
async def async_update_status(self):
|
||||
"""Use the poll session to always get the status of the player."""
|
||||
response = None
|
||||
|
||||
url = "Status"
|
||||
etag = ""
|
||||
etag = None
|
||||
if self._status is not None:
|
||||
etag = self._status.get("@etag", "")
|
||||
|
||||
if etag != "":
|
||||
url = f"Status?etag={etag}&timeout=120.0"
|
||||
url = f"http://{self.host}:{self.port}/{url}"
|
||||
|
||||
_LOGGER.debug("Calling URL: %s", url)
|
||||
etag = self._status.etag
|
||||
|
||||
try:
|
||||
async with timeout(125):
|
||||
response = await self._polling_session.get(
|
||||
url, headers={CONNECTION: KEEP_ALIVE}
|
||||
)
|
||||
status = await self._player.status(etag=etag, poll_timeout=120, timeout=125)
|
||||
|
||||
if response.status == HTTPStatus.OK:
|
||||
result = await response.text()
|
||||
self._is_online = True
|
||||
self._last_status_update = dt_util.utcnow()
|
||||
self._status = xmltodict.parse(result)["status"].copy()
|
||||
self._is_online = True
|
||||
self._last_status_update = dt_util.utcnow()
|
||||
self._status = status
|
||||
|
||||
group_name = self._status.get("groupName")
|
||||
if group_name != self._group_name:
|
||||
_LOGGER.debug("Group name change detected on device: %s", self.id)
|
||||
self._group_name = group_name
|
||||
group_name = status.group_name
|
||||
if group_name != self._group_name:
|
||||
_LOGGER.debug("Group name change detected on device: %s", self.id)
|
||||
self._group_name = group_name
|
||||
|
||||
# rebuild ordered list of entity_ids that are in the group, master is first
|
||||
self._group_list = self.rebuild_bluesound_group()
|
||||
# rebuild ordered list of entity_ids that are in the group, master is first
|
||||
self._group_list = self.rebuild_bluesound_group()
|
||||
|
||||
# the sleep is needed to make sure that the
|
||||
# devices is synced
|
||||
await asyncio.sleep(1)
|
||||
await self.async_trigger_sync_on_all()
|
||||
elif self.is_grouped:
|
||||
# when player is grouped we need to fetch volume from
|
||||
# sync_status. We will force an update if the player is
|
||||
# grouped this isn't a foolproof solution. A better
|
||||
# solution would be to fetch sync_status more often when
|
||||
# the device is playing. This would solve a lot of
|
||||
# problems. This change will be done when the
|
||||
# communication is moved to a separate library
|
||||
# the sleep is needed to make sure that the
|
||||
# devices is synced
|
||||
await asyncio.sleep(1)
|
||||
await self.async_trigger_sync_on_all()
|
||||
elif self.is_grouped:
|
||||
# when player is grouped we need to fetch volume from
|
||||
# sync_status. We will force an update if the player is
|
||||
# grouped this isn't a foolproof solution. A better
|
||||
# solution would be to fetch sync_status more often when
|
||||
# the device is playing. This would solve a lot of
|
||||
# problems. This change will be done when the
|
||||
# communication is moved to a separate library
|
||||
with suppress(TimeoutError):
|
||||
await self.force_update_sync_status()
|
||||
|
||||
self.async_write_ha_state()
|
||||
elif response.status == 595:
|
||||
_LOGGER.info("Status 595 returned, treating as timeout")
|
||||
raise BluesoundPlayer._TimeoutException
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Error %s on %s. Trying one more time", response.status, url
|
||||
)
|
||||
|
||||
self.async_write_ha_state()
|
||||
except (TimeoutError, ClientError):
|
||||
self._is_online = False
|
||||
self._last_status_update = None
|
||||
@@ -458,9 +388,10 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
raise
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return an unique ID."""
|
||||
return f"{format_mac(self._sync_status['@mac'])}-{self.port}"
|
||||
assert self._sync_status is not None
|
||||
return f"{format_mac(self._sync_status.mac)}-{self.port}"
|
||||
|
||||
async def async_trigger_sync_on_all(self):
|
||||
"""Trigger sync status update on all devices."""
|
||||
@@ -470,95 +401,25 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
await player.force_update_sync_status()
|
||||
|
||||
@Throttle(SYNC_STATUS_INTERVAL)
|
||||
async def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False):
|
||||
async def async_update_sync_status(self, on_updated_cb=None):
|
||||
"""Update sync status."""
|
||||
await self.force_update_sync_status(on_updated_cb, raise_timeout=False)
|
||||
await self.force_update_sync_status(on_updated_cb)
|
||||
|
||||
@Throttle(UPDATE_CAPTURE_INTERVAL)
|
||||
async def async_update_captures(self):
|
||||
async def async_update_captures(self) -> list[Input] | None:
|
||||
"""Update Capture sources."""
|
||||
resp = await self.send_bluesound_command("RadioBrowse?service=Capture")
|
||||
if not resp:
|
||||
return None
|
||||
self._capture_items = []
|
||||
inputs = await self._player.inputs()
|
||||
self._inputs = inputs
|
||||
|
||||
def _create_capture_item(item):
|
||||
self._capture_items.append(
|
||||
{
|
||||
"title": item.get("@text", ""),
|
||||
"name": item.get("@text", ""),
|
||||
"type": item.get("@serviceType", "Capture"),
|
||||
"image": item.get("@image", ""),
|
||||
"url": item.get("@URL", ""),
|
||||
}
|
||||
)
|
||||
|
||||
if "radiotime" in resp and "item" in resp["radiotime"]:
|
||||
if isinstance(resp["radiotime"]["item"], list):
|
||||
for item in resp["radiotime"]["item"]:
|
||||
_create_capture_item(item)
|
||||
else:
|
||||
_create_capture_item(resp["radiotime"]["item"])
|
||||
|
||||
return self._capture_items
|
||||
return inputs
|
||||
|
||||
@Throttle(UPDATE_PRESETS_INTERVAL)
|
||||
async def async_update_presets(self):
|
||||
async def async_update_presets(self) -> list[Preset] | None:
|
||||
"""Update Presets."""
|
||||
resp = await self.send_bluesound_command("Presets")
|
||||
if not resp:
|
||||
return None
|
||||
self._preset_items = []
|
||||
presets = await self._player.presets()
|
||||
self._presets = presets
|
||||
|
||||
def _create_preset_item(item):
|
||||
self._preset_items.append(
|
||||
{
|
||||
"title": item.get("@name", ""),
|
||||
"name": item.get("@name", ""),
|
||||
"type": "preset",
|
||||
"image": item.get("@image", ""),
|
||||
"is_raw_url": True,
|
||||
"url2": item.get("@url", ""),
|
||||
"url": f"Preset?id={item.get('@id', '')}",
|
||||
}
|
||||
)
|
||||
|
||||
if "presets" in resp and "preset" in resp["presets"]:
|
||||
if isinstance(resp["presets"]["preset"], list):
|
||||
for item in resp["presets"]["preset"]:
|
||||
_create_preset_item(item)
|
||||
else:
|
||||
_create_preset_item(resp["presets"]["preset"])
|
||||
|
||||
return self._preset_items
|
||||
|
||||
@Throttle(UPDATE_SERVICES_INTERVAL)
|
||||
async def async_update_services(self):
|
||||
"""Update Services."""
|
||||
resp = await self.send_bluesound_command("Services")
|
||||
if not resp:
|
||||
return None
|
||||
self._services_items = []
|
||||
|
||||
def _create_service_item(item):
|
||||
self._services_items.append(
|
||||
{
|
||||
"title": item.get("@displayname", ""),
|
||||
"name": item.get("@name", ""),
|
||||
"type": item.get("@type", ""),
|
||||
"image": item.get("@icon", ""),
|
||||
"url": item.get("@name", ""),
|
||||
}
|
||||
)
|
||||
|
||||
if "services" in resp and "service" in resp["services"]:
|
||||
if isinstance(resp["services"]["service"], list):
|
||||
for item in resp["services"]["service"]:
|
||||
_create_service_item(item)
|
||||
else:
|
||||
_create_service_item(resp["services"]["service"])
|
||||
|
||||
return self._services_items
|
||||
return presets
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
@@ -569,7 +430,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if self.is_grouped and not self.is_master:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
status = self._status.get("state")
|
||||
status = self._status.state
|
||||
if status in ("pause", "stop"):
|
||||
return MediaPlayerState.PAUSED
|
||||
if status in ("stream", "play"):
|
||||
@@ -577,15 +438,15 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
def media_title(self) -> str | None:
|
||||
"""Title of current playing media."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
|
||||
return self._status.get("title1")
|
||||
return self._status.name
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
def media_artist(self) -> str | None:
|
||||
"""Artist of current playing media (Music track only)."""
|
||||
if self._status is None:
|
||||
return None
|
||||
@@ -593,35 +454,33 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if self.is_grouped and not self.is_master:
|
||||
return self._group_name
|
||||
|
||||
if not (artist := self._status.get("artist")):
|
||||
artist = self._status.get("title2")
|
||||
return artist
|
||||
return self._status.artist
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Artist of current playing media (Music track only)."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
|
||||
if not (album := self._status.get("album")):
|
||||
album = self._status.get("title3")
|
||||
return album
|
||||
return self._status.album
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Image url of current playing media."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
|
||||
if not (url := self._status.get("image")):
|
||||
url = self._status.image
|
||||
if url is None:
|
||||
return None
|
||||
|
||||
if url[0] == "/":
|
||||
url = f"http://{self.host}:{self.port}{url}"
|
||||
|
||||
return url
|
||||
|
||||
@property
|
||||
def media_position(self):
|
||||
def media_position(self) -> int | None:
|
||||
"""Position of current playing media in seconds."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
@@ -630,154 +489,101 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if self._last_status_update is None or mediastate == MediaPlayerState.IDLE:
|
||||
return None
|
||||
|
||||
if (position := self._status.get("secs")) is None:
|
||||
position = self._status.seconds
|
||||
if position is None:
|
||||
return None
|
||||
|
||||
position = float(position)
|
||||
if mediastate == MediaPlayerState.PLAYING:
|
||||
position += (dt_util.utcnow() - self._last_status_update).total_seconds()
|
||||
|
||||
return position
|
||||
return int(position)
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration of current playing media in seconds."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
|
||||
if (duration := self._status.get("totlen")) is None:
|
||||
duration = self._status.total_seconds
|
||||
if duration is None:
|
||||
return None
|
||||
return float(duration)
|
||||
|
||||
return duration
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self):
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""Last time status was updated."""
|
||||
return self._last_status_update
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
def volume_level(self) -> float | None:
|
||||
"""Volume level of the media player (0..1)."""
|
||||
volume = self._status.get("volume")
|
||||
if self.is_grouped:
|
||||
volume = self._sync_status.get("@volume")
|
||||
volume = None
|
||||
|
||||
if volume is not None:
|
||||
return int(volume) / 100
|
||||
return None
|
||||
if self._status is not None:
|
||||
volume = self._status.volume
|
||||
if self.is_grouped and self._sync_status is not None:
|
||||
volume = self._sync_status.volume
|
||||
|
||||
if volume is None:
|
||||
return None
|
||||
|
||||
return volume / 100
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
def is_volume_muted(self) -> bool:
|
||||
"""Boolean if volume is currently muted."""
|
||||
mute = self._status.get("mute")
|
||||
if self.is_grouped:
|
||||
mute = self._sync_status.get("@mute")
|
||||
mute = False
|
||||
|
||||
if self._status is not None:
|
||||
mute = self._status.mute
|
||||
if self.is_grouped and self._sync_status is not None:
|
||||
mute = self._sync_status.mute_volume is not None
|
||||
|
||||
if mute is not None:
|
||||
mute = bool(int(mute))
|
||||
return mute
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
def id(self) -> str | None:
|
||||
"""Get id of device."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def bluesound_device_name(self):
|
||||
def bluesound_device_name(self) -> str | None:
|
||||
"""Return the device name as returned by the device."""
|
||||
return self._bluesound_device_name
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
def source_list(self) -> list[str] | None:
|
||||
"""List of available input sources."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
|
||||
sources = [source["title"] for source in self._preset_items]
|
||||
|
||||
sources.extend(
|
||||
source["title"]
|
||||
for source in self._services_items
|
||||
if source["type"] in ("LocalMusic", "RadioService")
|
||||
)
|
||||
|
||||
sources.extend(source["title"] for source in self._capture_items)
|
||||
sources = [x.text for x in self._inputs]
|
||||
sources += [x.name for x in self._presets]
|
||||
|
||||
return sources
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
def source(self) -> str | None:
|
||||
"""Name of the current input source."""
|
||||
if self._status is None or (self.is_grouped and not self.is_master):
|
||||
return None
|
||||
|
||||
if (current_service := self._status.get("service", "")) == "":
|
||||
return ""
|
||||
stream_url = self._status.get("streamUrl", "")
|
||||
if self._status.input_id is not None:
|
||||
for input_ in self._inputs:
|
||||
if input_.id == self._status.input_id:
|
||||
return input_.text
|
||||
|
||||
if self._status.get("is_preset", "") == "1" and stream_url != "":
|
||||
# This check doesn't work with all presets, for example playlists.
|
||||
# But it works with radio service_items will catch playlists.
|
||||
items = [
|
||||
x
|
||||
for x in self._preset_items
|
||||
if "url2" in x and parse.unquote(x["url2"]) == stream_url
|
||||
]
|
||||
if items:
|
||||
return items[0]["title"]
|
||||
for preset in self._presets:
|
||||
if preset.url == self._status.stream_url:
|
||||
return preset.name
|
||||
|
||||
# This could be a bit difficult to detect. Bluetooth could be named
|
||||
# different things and there is not any way to match chooses in
|
||||
# capture list to current playing. It's a bit of guesswork.
|
||||
# This method will be needing some tweaking over time.
|
||||
title = self._status.get("title1", "").lower()
|
||||
if title == "bluetooth" or stream_url == "Capture:hw:2,0/44100/16/2":
|
||||
items = [
|
||||
x
|
||||
for x in self._capture_items
|
||||
if x["url"] == "Capture%3Abluez%3Abluetooth"
|
||||
]
|
||||
if items:
|
||||
return items[0]["title"]
|
||||
|
||||
items = [x for x in self._capture_items if x["url"] == stream_url]
|
||||
if items:
|
||||
return items[0]["title"]
|
||||
|
||||
if stream_url[:8] == "Capture:":
|
||||
stream_url = stream_url[8:]
|
||||
|
||||
idx = BluesoundPlayer._try_get_index(stream_url, ":")
|
||||
if idx > 0:
|
||||
stream_url = stream_url[:idx]
|
||||
for item in self._capture_items:
|
||||
url = parse.unquote(item["url"])
|
||||
if url[:8] == "Capture:":
|
||||
url = url[8:]
|
||||
idx = BluesoundPlayer._try_get_index(url, ":")
|
||||
if idx > 0:
|
||||
url = url[:idx]
|
||||
if url.lower() == stream_url.lower():
|
||||
return item["title"]
|
||||
|
||||
items = [x for x in self._capture_items if x["name"] == current_service]
|
||||
if items:
|
||||
return items[0]["title"]
|
||||
|
||||
items = [x for x in self._services_items if x["name"] == current_service]
|
||||
if items:
|
||||
return items[0]["title"]
|
||||
|
||||
if self._status.get("streamUrl", "") != "":
|
||||
_LOGGER.debug(
|
||||
"Couldn't find source of stream URL: %s",
|
||||
self._status.get("streamUrl", ""),
|
||||
)
|
||||
return None
|
||||
return self._status.service
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
@@ -797,7 +603,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
)
|
||||
|
||||
if self._status.get("indexing", "0") == "0":
|
||||
if not self._status.indexing:
|
||||
supported = (
|
||||
supported
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
@@ -819,25 +625,29 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
)
|
||||
|
||||
if self._status.get("canSeek", "") == "1":
|
||||
if self._status.can_seek:
|
||||
supported = supported | MediaPlayerEntityFeature.SEEK
|
||||
|
||||
return supported
|
||||
|
||||
@property
|
||||
def is_master(self):
|
||||
def is_master(self) -> bool:
|
||||
"""Return true if player is a coordinator."""
|
||||
return self._is_master
|
||||
|
||||
@property
|
||||
def is_grouped(self):
|
||||
def is_grouped(self) -> bool:
|
||||
"""Return true if player is a coordinator."""
|
||||
return self._master is not None or self._is_master
|
||||
|
||||
@property
|
||||
def shuffle(self):
|
||||
def shuffle(self) -> bool:
|
||||
"""Return true if shuffle is active."""
|
||||
return self._status.get("shuffle", "0") == "1"
|
||||
shuffle = False
|
||||
if self._status is not None:
|
||||
shuffle = self._status.shuffle
|
||||
|
||||
return shuffle
|
||||
|
||||
async def async_join(self, master):
|
||||
"""Join the player to a group."""
|
||||
@@ -847,7 +657,10 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if device.entity_id == master
|
||||
]
|
||||
|
||||
if master_device:
|
||||
if len(master_device) > 0:
|
||||
if self.id == master_device[0].id:
|
||||
raise ServiceValidationError("Cannot join player to itself")
|
||||
|
||||
_LOGGER.debug(
|
||||
"Trying to join player: %s to master: %s",
|
||||
self.id,
|
||||
@@ -859,9 +672,9 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
_LOGGER.error("Master not found %s", master_device)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""List members in group."""
|
||||
attributes = {}
|
||||
attributes: dict[str, Any] = {}
|
||||
if self._group_list:
|
||||
attributes = {ATTR_BLUESOUND_GROUP: self._group_list}
|
||||
|
||||
@@ -869,10 +682,10 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
return attributes
|
||||
|
||||
def rebuild_bluesound_group(self):
|
||||
def rebuild_bluesound_group(self) -> list[str]:
|
||||
"""Rebuild the list of entities in speaker group."""
|
||||
if self._group_name is None:
|
||||
return None
|
||||
return []
|
||||
|
||||
device_group = self._group_name.split("+")
|
||||
|
||||
@@ -895,121 +708,92 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
_LOGGER.debug("Trying to unjoin player: %s", self.id)
|
||||
await self._master.async_remove_slave(self)
|
||||
|
||||
async def async_add_slave(self, slave_device):
|
||||
async def async_add_slave(self, slave_device: BluesoundPlayer):
|
||||
"""Add slave to master."""
|
||||
return await self.send_bluesound_command(
|
||||
f"/AddSlave?slave={slave_device.host}&port={slave_device.port}"
|
||||
)
|
||||
await self._player.add_slave(slave_device.host, slave_device.port)
|
||||
|
||||
async def async_remove_slave(self, slave_device):
|
||||
async def async_remove_slave(self, slave_device: BluesoundPlayer):
|
||||
"""Remove slave to master."""
|
||||
return await self.send_bluesound_command(
|
||||
f"/RemoveSlave?slave={slave_device.host}&port={slave_device.port}"
|
||||
)
|
||||
await self._player.remove_slave(slave_device.host, slave_device.port)
|
||||
|
||||
async def async_increase_timer(self):
|
||||
async def async_increase_timer(self) -> int:
|
||||
"""Increase sleep time on player."""
|
||||
sleep_time = await self.send_bluesound_command("/Sleep")
|
||||
if sleep_time is None:
|
||||
_LOGGER.error("Error while increasing sleep time on player: %s", self.id)
|
||||
return 0
|
||||
|
||||
return int(sleep_time.get("sleep", "0"))
|
||||
return await self._player.sleep_timer()
|
||||
|
||||
async def async_clear_timer(self):
|
||||
"""Clear sleep timer on player."""
|
||||
sleep = 1
|
||||
while sleep > 0:
|
||||
sleep = await self.async_increase_timer()
|
||||
sleep = await self._player.sleep_timer()
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable or disable shuffle mode."""
|
||||
value = "1" if shuffle else "0"
|
||||
return await self.send_bluesound_command(f"/Shuffle?state={value}")
|
||||
await self._player.shuffle(shuffle)
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
items = [x for x in self._preset_items if x["title"] == source]
|
||||
# presets and inputs might have the same name; presets have priority
|
||||
url: str | None = None
|
||||
for input_ in self._inputs:
|
||||
if input_.text == source:
|
||||
url = input_.url
|
||||
for preset in self._presets:
|
||||
if preset.name == source:
|
||||
url = preset.url
|
||||
|
||||
if not items:
|
||||
items = [x for x in self._services_items if x["title"] == source]
|
||||
if not items:
|
||||
items = [x for x in self._capture_items if x["title"] == source]
|
||||
|
||||
if not items:
|
||||
return
|
||||
|
||||
selected_source = items[0]
|
||||
url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}"
|
||||
|
||||
if selected_source.get("is_raw_url"):
|
||||
url = selected_source["url"]
|
||||
|
||||
await self.send_bluesound_command(url)
|
||||
await self._player.play_url(url)
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
"""Clear players playlist."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
await self.send_bluesound_command("Clear")
|
||||
await self._player.clear()
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send media_next command to media player."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
cmd = "Skip"
|
||||
if self._status and "actions" in self._status:
|
||||
for action in self._status["actions"]["action"]:
|
||||
if "@name" in action and "@url" in action and action["@name"] == "skip":
|
||||
cmd = action["@url"]
|
||||
|
||||
await self.send_bluesound_command(cmd)
|
||||
await self._player.skip()
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send media_previous command to media player."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
cmd = "Back"
|
||||
if self._status and "actions" in self._status:
|
||||
for action in self._status["actions"]["action"]:
|
||||
if "@name" in action and "@url" in action and action["@name"] == "back":
|
||||
cmd = action["@url"]
|
||||
|
||||
await self.send_bluesound_command(cmd)
|
||||
await self._player.back()
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send media_play command to media player."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
await self.send_bluesound_command("Play")
|
||||
await self._player.play()
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send media_pause command to media player."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
await self.send_bluesound_command("Pause")
|
||||
await self._player.pause()
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
await self.send_bluesound_command("Pause")
|
||||
await self._player.stop()
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Send media_seek command to media player."""
|
||||
if self.is_grouped and not self.is_master:
|
||||
return
|
||||
|
||||
await self.send_bluesound_command(f"Play?seek={float(position)}")
|
||||
await self._player.play(seek=int(position))
|
||||
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
@@ -1024,39 +808,39 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
url = async_process_play_media_url(self.hass, media_id)
|
||||
|
||||
url = f"Play?url={media_id}"
|
||||
|
||||
await self.send_bluesound_command(url)
|
||||
await self._player.play_url(url)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
current_vol = self.volume_level
|
||||
if not current_vol or current_vol >= 1:
|
||||
return
|
||||
await self.async_set_volume_level(current_vol + 0.01)
|
||||
if self.volume_level is None:
|
||||
return None
|
||||
|
||||
new_volume = self.volume_level + 0.01
|
||||
new_volume = min(1, new_volume)
|
||||
return await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down the media player."""
|
||||
current_vol = self.volume_level
|
||||
if not current_vol or current_vol <= 0:
|
||||
return
|
||||
await self.async_set_volume_level(current_vol - 0.01)
|
||||
if self.volume_level is None:
|
||||
return None
|
||||
|
||||
new_volume = self.volume_level - 0.01
|
||||
new_volume = max(0, new_volume)
|
||||
return await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Send volume_up command to media player."""
|
||||
if volume < 0:
|
||||
volume = 0
|
||||
elif volume > 1:
|
||||
volume = 1
|
||||
await self.send_bluesound_command(f"Volume?level={float(volume) * 100}")
|
||||
volume = int(volume * 100)
|
||||
volume = min(100, volume)
|
||||
volume = max(0, volume)
|
||||
|
||||
await self._player.volume(level=volume)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command to media player."""
|
||||
if mute:
|
||||
await self.send_bluesound_command("Volume?mute=1")
|
||||
await self.send_bluesound_command("Volume?mute=0")
|
||||
await self._player.volume(mute=mute)
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
|
||||
@@ -69,7 +69,7 @@ class BondFan(BondEntity, FanEntity):
|
||||
super().__init__(data, device)
|
||||
if self._device.has_action(Action.BREEZE_ON):
|
||||
self._attr_preset_modes = [PRESET_MODE_BREEZE]
|
||||
features = FanEntityFeature(0)
|
||||
features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
|
||||
if self._device.supports_speed():
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
if self._device.supports_direction():
|
||||
|
||||
@@ -58,7 +58,7 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=self.info["name"] or user_input[CONF_EMAIL], data=user_input
|
||||
title=self.info.get("name") or user_input[CONF_EMAIL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate
|
||||
@@ -12,7 +13,6 @@ from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -29,6 +29,7 @@ from .const import (
|
||||
BTHomeBleEvent,
|
||||
)
|
||||
from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
|
||||
from .types import BTHomeConfigEntry
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR]
|
||||
|
||||
@@ -37,16 +38,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def process_service_info(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
data: BTHomeBluetoothDeviceData,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
entry: BTHomeConfigEntry,
|
||||
device_registry: DeviceRegistry,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
) -> SensorUpdate:
|
||||
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
|
||||
coordinator = entry.runtime_data
|
||||
data = coordinator.device_data
|
||||
update = data.update(service_info)
|
||||
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
discovered_event_classes = coordinator.discovered_event_classes
|
||||
if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device:
|
||||
hass.config_entries.async_update_entry(
|
||||
@@ -117,7 +116,7 @@ def format_discovered_event_class(address: str) -> SignalType[str, BTHomeBleEven
|
||||
return SignalType(f"{DOMAIN}_discovered_event_class_{address}")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool:
|
||||
"""Set up BTHome Bluetooth from a config entry."""
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
@@ -128,34 +127,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
data = BTHomeBluetoothDeviceData(**kwargs)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
|
||||
BTHomePassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
update_method=lambda service_info: process_service_info(
|
||||
hass, entry, data, service_info, device_registry
|
||||
),
|
||||
device_data=data,
|
||||
discovered_event_classes=set(
|
||||
entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])
|
||||
),
|
||||
connectable=False,
|
||||
entry=entry,
|
||||
)
|
||||
event_classes = set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, ()))
|
||||
coordinator = BTHomePassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
update_method=partial(process_service_info, hass, entry, device_registry),
|
||||
device_data=data,
|
||||
discovered_event_classes=event_classes,
|
||||
connectable=False,
|
||||
entry=entry,
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(
|
||||
coordinator.async_start()
|
||||
) # only start after all platforms have had a chance to subscribe
|
||||
# only start after all platforms have had a chance to subscribe
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -7,7 +7,6 @@ from bthome_ble import (
|
||||
SensorUpdate,
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
@@ -21,12 +20,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
BTHomePassiveBluetoothDataProcessor,
|
||||
BTHomePassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from .coordinator import BTHomePassiveBluetoothDataProcessor
|
||||
from .device import device_key_to_bluetooth_entity_key
|
||||
from .types import BTHomeConfigEntry
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS = {
|
||||
BTHomeBinarySensorDeviceClass.BATTERY: BinarySensorEntityDescription(
|
||||
@@ -172,13 +168,11 @@ def sensor_update_to_bluetooth_data_update(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: config_entries.ConfigEntry,
|
||||
entry: BTHomeConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BTHome BLE binary sensors."""
|
||||
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
coordinator = entry.runtime_data
|
||||
processor = BTHomePassiveBluetoothDataProcessor(
|
||||
sensor_update_to_bluetooth_data_update
|
||||
)
|
||||
|
||||
@@ -13,10 +13,10 @@ from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_SLEEPY_DEVICE
|
||||
from .types import BTHomeConfigEntry
|
||||
|
||||
|
||||
class BTHomePassiveBluetoothProcessorCoordinator(
|
||||
@@ -33,7 +33,7 @@ class BTHomePassiveBluetoothProcessorCoordinator(
|
||||
update_method: Callable[[BluetoothServiceInfoBleak], SensorUpdate],
|
||||
device_data: BTHomeBluetoothDeviceData,
|
||||
discovered_event_classes: set[str],
|
||||
entry: ConfigEntry,
|
||||
entry: BTHomeConfigEntry,
|
||||
connectable: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the BTHome Bluetooth Passive Update Processor Coordinator."""
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components.event import (
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -24,7 +23,7 @@ from .const import (
|
||||
EVENT_TYPE,
|
||||
BTHomeBleEvent,
|
||||
)
|
||||
from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
|
||||
from .types import BTHomeConfigEntry
|
||||
|
||||
DESCRIPTIONS_BY_EVENT_CLASS = {
|
||||
EVENT_CLASS_BUTTON: EventEntityDescription(
|
||||
@@ -103,13 +102,11 @@ class BTHomeEventEntity(EventEntity):
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: BTHomeConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up BTHome event."""
|
||||
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
coordinator = entry.runtime_data
|
||||
address = coordinator.address
|
||||
ent_reg = er.async_get(hass)
|
||||
async_add_entities(
|
||||
|
||||
@@ -9,7 +9,6 @@ from bthome_ble.const import (
|
||||
ExtendedSensorDeviceClass as BTHomeExtendedSensorDeviceClass,
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
@@ -45,12 +44,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
BTHomePassiveBluetoothDataProcessor,
|
||||
BTHomePassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from .coordinator import BTHomePassiveBluetoothDataProcessor
|
||||
from .device import device_key_to_bluetooth_entity_key
|
||||
from .types import BTHomeConfigEntry
|
||||
|
||||
SENSOR_DESCRIPTIONS = {
|
||||
# Acceleration (m/s²)
|
||||
@@ -394,13 +390,11 @@ def sensor_update_to_bluetooth_data_update(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: config_entries.ConfigEntry,
|
||||
entry: BTHomeConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BTHome BLE sensors."""
|
||||
coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
coordinator = entry.runtime_data
|
||||
processor = BTHomePassiveBluetoothDataProcessor(
|
||||
sensor_update_to_bluetooth_data_update
|
||||
)
|
||||
|
||||
10
homeassistant/components/bthome/types.py
Normal file
10
homeassistant/components/bthome/types.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""The BTHome Bluetooth integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
|
||||
|
||||
type BTHomeConfigEntry = ConfigEntry[BTHomePassiveBluetoothProcessorCoordinator]
|
||||
@@ -19,9 +19,9 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"country_code": "Country code of the country to display camera images.",
|
||||
"delta": "Time interval in seconds between camera image updates",
|
||||
"timeframe": "Minutes to look ahead for precipitation forecast"
|
||||
"country_code": "Country to display camera images for.",
|
||||
"delta": "Interval between camera image updates",
|
||||
"timeframe": "Time to look ahead for precipitation forecast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
|
||||
from .const import (
|
||||
ATTR_AUX_HEAT,
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_MODE,
|
||||
@@ -20,7 +19,6 @@ from .const import (
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
DOMAIN,
|
||||
HVAC_MODES,
|
||||
SERVICE_SET_AUX_HEAT,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_HUMIDITY,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
@@ -56,9 +54,6 @@ async def _async_reproduce_states(
|
||||
if state.state in HVAC_MODES:
|
||||
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
|
||||
|
||||
if ATTR_AUX_HEAT in state.attributes:
|
||||
await call_service(SERVICE_SET_AUX_HEAT, [ATTR_AUX_HEAT])
|
||||
|
||||
if (
|
||||
(ATTR_TEMPERATURE in state.attributes)
|
||||
or (ATTR_TARGET_TEMP_HIGH in state.attributes)
|
||||
|
||||
@@ -62,7 +62,13 @@ class ComfoConnectFan(FanEntity):
|
||||
|
||||
_attr_icon = "mdi:air-conditioner"
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_preset_modes = PRESET_MODES
|
||||
current_speed: float | None = None
|
||||
|
||||
|
||||
@@ -138,10 +138,14 @@ async def websocket_remove_config_entry_from_device(
|
||||
"Failed to remove device entry, rejected by integration"
|
||||
)
|
||||
|
||||
entry = registry.async_update_device(
|
||||
device_id, remove_config_entry_id=config_entry_id
|
||||
)
|
||||
# Integration might have removed the config entry already, that is fine.
|
||||
if registry.async_get(device_id):
|
||||
entry = registry.async_update_device(
|
||||
device_id, remove_config_entry_id=config_entry_id
|
||||
)
|
||||
|
||||
entry_as_dict = entry.dict_repr if entry else None
|
||||
entry_as_dict = entry.dict_repr if entry else None
|
||||
else:
|
||||
entry_as_dict = None
|
||||
|
||||
connection.send_message(websocket_api.result_message(msg["id"], entry_as_dict))
|
||||
|
||||
@@ -132,8 +132,10 @@ def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
|
||||
"""Convert entry to API format."""
|
||||
return {
|
||||
"aliases": list(entry.aliases),
|
||||
"created_at": entry.created_at.timestamp(),
|
||||
"floor_id": entry.floor_id,
|
||||
"icon": entry.icon,
|
||||
"level": entry.level,
|
||||
"name": entry.name,
|
||||
"modified_at": entry.modified_at.timestamp(),
|
||||
}
|
||||
|
||||
@@ -157,8 +157,10 @@ def _entry_dict(entry: LabelEntry) -> dict[str, Any]:
|
||||
"""Convert entry to API format."""
|
||||
return {
|
||||
"color": entry.color,
|
||||
"created_at": entry.created_at.timestamp(),
|
||||
"description": entry.description,
|
||||
"icon": entry.icon,
|
||||
"label_id": entry.label_id,
|
||||
"name": entry.name,
|
||||
"modified_at": entry.modified_at.timestamp(),
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ def async_conversation_trace_append(
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_conversation_trace() -> Generator[ConversationTrace, None]:
|
||||
def async_conversation_trace() -> Generator[ConversationTrace]:
|
||||
"""Create a new active ConversationTrace."""
|
||||
trace = ConversationTrace()
|
||||
token = _current_trace.set(trace)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycoolmasternet_async"],
|
||||
"requirements": ["pycoolmasternet-async==0.1.5"]
|
||||
"requirements": ["pycoolmasternet-async==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,12 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
|
||||
TYPE = DOMAIN
|
||||
_default_on_speed = LightFanSpeed.PERCENT_50
|
||||
|
||||
_attr_supported_features = FanEntityFeature.SET_SPEED
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, device: Light, hub: DeconzHub) -> None:
|
||||
"""Set up fan."""
|
||||
|
||||
@@ -15,9 +15,15 @@ PRESET_MODE_SLEEP = "sleep"
|
||||
PRESET_MODE_ON = "on"
|
||||
|
||||
FULL_SUPPORT = (
|
||||
FanEntityFeature.SET_SPEED | FanEntityFeature.OSCILLATE | FanEntityFeature.DIRECTION
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.OSCILLATE
|
||||
| FanEntityFeature.DIRECTION
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
LIMITED_SUPPORT = (
|
||||
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
|
||||
)
|
||||
LIMITED_SUPPORT = FanEntityFeature.SET_SPEED
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -75,7 +81,9 @@ async def async_setup_entry(
|
||||
hass,
|
||||
"fan5",
|
||||
"Preset Only Limited Fan",
|
||||
FanEntityFeature.PRESET_MODE,
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON,
|
||||
[
|
||||
PRESET_MODE_AUTO,
|
||||
PRESET_MODE_SMART,
|
||||
@@ -92,6 +100,7 @@ class BaseDemoFan(FanEntity):
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_translation_key = "demo"
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.39.0", "getmac==0.9.4"],
|
||||
"requirements": ["async-upnp-client==0.40.0", "getmac==0.9.4"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["async-upnp-client==0.39.0"],
|
||||
"requirements": ["async-upnp-client==0.40.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/doods",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydoods"],
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==10.3.0"]
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==10.4.0"]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import CancelledError
|
||||
from asyncio import CancelledError, Task
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from dsmr_parser.objects import Telegram
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import CONF_DSMR_VERSION, DATA_TASK, DOMAIN, PLATFORMS
|
||||
from .const import CONF_DSMR_VERSION, PLATFORMS
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@dataclass
|
||||
class DsmrState:
|
||||
"""State of integration."""
|
||||
|
||||
task: Task | None = None
|
||||
telegram: Telegram | None = None
|
||||
|
||||
|
||||
type DsmrConfigEntry = ConfigEntry[DsmrState]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DsmrConfigEntry) -> bool:
|
||||
"""Set up DSMR from a config entry."""
|
||||
|
||||
@callback
|
||||
@@ -25,32 +39,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await er.async_migrate_entries(hass, entry.entry_id, _async_migrate_entity_entry)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {}
|
||||
|
||||
entry.runtime_data = DsmrState()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DsmrConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
task = hass.data[DOMAIN][entry.entry_id][DATA_TASK]
|
||||
|
||||
# Cancel the reconnect task
|
||||
task.cancel()
|
||||
with suppress(CancelledError):
|
||||
await task
|
||||
if task := entry.runtime_data.task:
|
||||
task.cancel()
|
||||
with suppress(CancelledError):
|
||||
await task
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
async def async_update_options(hass: HomeAssistant, entry: DsmrConfigEntry) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ DEFAULT_PRECISION = 3
|
||||
DEFAULT_RECONNECT_INTERVAL = 30
|
||||
DEFAULT_TIME_BETWEEN_UPDATE = 30
|
||||
|
||||
DATA_TASK = "task"
|
||||
|
||||
DEVICE_NAME_ELECTRICITY = "Electricity Meter"
|
||||
DEVICE_NAME_GAS = "Gas Meter"
|
||||
DEVICE_NAME_WATER = "Water Meter"
|
||||
|
||||
28
homeassistant/components/dsmr/diagnostics.py
Normal file
28
homeassistant/components/dsmr/diagnostics.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Diagnostics support for DSMR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from . import DsmrConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: DsmrConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
return {
|
||||
"entry": {
|
||||
"data": {
|
||||
**config_entry.data,
|
||||
},
|
||||
"unique_id": config_entry.unique_id,
|
||||
},
|
||||
"data": json_loads(config_entry.runtime_data.telegram.to_json())
|
||||
if config_entry.runtime_data.telegram
|
||||
else None,
|
||||
}
|
||||
@@ -10,7 +10,6 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
|
||||
from dsmr_parser import obis_references
|
||||
from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader
|
||||
from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
create_rfxtrx_dsmr_reader,
|
||||
@@ -46,12 +45,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import DsmrConfigEntry
|
||||
from .const import (
|
||||
CONF_DSMR_VERSION,
|
||||
CONF_SERIAL_ID,
|
||||
CONF_SERIAL_ID_GAS,
|
||||
CONF_TIME_BETWEEN_UPDATE,
|
||||
DATA_TASK,
|
||||
DEFAULT_PRECISION,
|
||||
DEFAULT_RECONNECT_INTERVAL,
|
||||
DEFAULT_TIME_BETWEEN_UPDATE,
|
||||
@@ -81,7 +80,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
|
||||
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="timestamp",
|
||||
obis_reference=obis_references.P1_MESSAGE_TIMESTAMP,
|
||||
obis_reference="P1_MESSAGE_TIMESTAMP",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -89,21 +88,21 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="current_electricity_usage",
|
||||
translation_key="current_electricity_usage",
|
||||
obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE,
|
||||
obis_reference="CURRENT_ELECTRICITY_USAGE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="current_electricity_delivery",
|
||||
translation_key="current_electricity_delivery",
|
||||
obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY,
|
||||
obis_reference="CURRENT_ELECTRICITY_DELIVERY",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_active_tariff",
|
||||
translation_key="electricity_active_tariff",
|
||||
obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF,
|
||||
obis_reference="ELECTRICITY_ACTIVE_TARIFF",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["low", "normal"],
|
||||
@@ -111,7 +110,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_used_tariff_1",
|
||||
translation_key="electricity_used_tariff_1",
|
||||
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1,
|
||||
obis_reference="ELECTRICITY_USED_TARIFF_1",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -119,7 +118,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_used_tariff_2",
|
||||
translation_key="electricity_used_tariff_2",
|
||||
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2,
|
||||
obis_reference="ELECTRICITY_USED_TARIFF_2",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -127,7 +126,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_delivered_tariff_1",
|
||||
translation_key="electricity_delivered_tariff_1",
|
||||
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1,
|
||||
obis_reference="ELECTRICITY_DELIVERED_TARIFF_1",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -135,7 +134,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_delivered_tariff_2",
|
||||
translation_key="electricity_delivered_tariff_2",
|
||||
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2,
|
||||
obis_reference="ELECTRICITY_DELIVERED_TARIFF_2",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -143,7 +142,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_active_power_l1_positive",
|
||||
translation_key="instantaneous_active_power_l1_positive",
|
||||
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE,
|
||||
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -151,7 +150,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_active_power_l2_positive",
|
||||
translation_key="instantaneous_active_power_l2_positive",
|
||||
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE,
|
||||
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -159,7 +158,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_active_power_l3_positive",
|
||||
translation_key="instantaneous_active_power_l3_positive",
|
||||
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE,
|
||||
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -167,7 +166,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_active_power_l1_negative",
|
||||
translation_key="instantaneous_active_power_l1_negative",
|
||||
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE,
|
||||
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -175,7 +174,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_active_power_l2_negative",
|
||||
translation_key="instantaneous_active_power_l2_negative",
|
||||
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE,
|
||||
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -183,7 +182,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_active_power_l3_negative",
|
||||
translation_key="instantaneous_active_power_l3_negative",
|
||||
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE,
|
||||
obis_reference="INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -191,7 +190,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="short_power_failure_count",
|
||||
translation_key="short_power_failure_count",
|
||||
obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT,
|
||||
obis_reference="SHORT_POWER_FAILURE_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -199,7 +198,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="long_power_failure_count",
|
||||
translation_key="long_power_failure_count",
|
||||
obis_reference=obis_references.LONG_POWER_FAILURE_COUNT,
|
||||
obis_reference="LONG_POWER_FAILURE_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -207,7 +206,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="voltage_sag_l1_count",
|
||||
translation_key="voltage_sag_l1_count",
|
||||
obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT,
|
||||
obis_reference="VOLTAGE_SAG_L1_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -215,7 +214,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="voltage_sag_l2_count",
|
||||
translation_key="voltage_sag_l2_count",
|
||||
obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT,
|
||||
obis_reference="VOLTAGE_SAG_L2_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -223,7 +222,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="voltage_sag_l3_count",
|
||||
translation_key="voltage_sag_l3_count",
|
||||
obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT,
|
||||
obis_reference="VOLTAGE_SAG_L3_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -231,7 +230,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="voltage_swell_l1_count",
|
||||
translation_key="voltage_swell_l1_count",
|
||||
obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT,
|
||||
obis_reference="VOLTAGE_SWELL_L1_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -239,7 +238,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="voltage_swell_l2_count",
|
||||
translation_key="voltage_swell_l2_count",
|
||||
obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT,
|
||||
obis_reference="VOLTAGE_SWELL_L2_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -247,7 +246,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="voltage_swell_l3_count",
|
||||
translation_key="voltage_swell_l3_count",
|
||||
obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT,
|
||||
obis_reference="VOLTAGE_SWELL_L3_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -255,7 +254,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_voltage_l1",
|
||||
translation_key="instantaneous_voltage_l1",
|
||||
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1,
|
||||
obis_reference="INSTANTANEOUS_VOLTAGE_L1",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -264,7 +263,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_voltage_l2",
|
||||
translation_key="instantaneous_voltage_l2",
|
||||
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2,
|
||||
obis_reference="INSTANTANEOUS_VOLTAGE_L2",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -273,7 +272,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_voltage_l3",
|
||||
translation_key="instantaneous_voltage_l3",
|
||||
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3,
|
||||
obis_reference="INSTANTANEOUS_VOLTAGE_L3",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -282,7 +281,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_current_l1",
|
||||
translation_key="instantaneous_current_l1",
|
||||
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1,
|
||||
obis_reference="INSTANTANEOUS_CURRENT_L1",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -291,7 +290,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_current_l2",
|
||||
translation_key="instantaneous_current_l2",
|
||||
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2,
|
||||
obis_reference="INSTANTANEOUS_CURRENT_L2",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -300,7 +299,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="instantaneous_current_l3",
|
||||
translation_key="instantaneous_current_l3",
|
||||
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3,
|
||||
obis_reference="INSTANTANEOUS_CURRENT_L3",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -309,7 +308,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_max_power_per_phase",
|
||||
translation_key="max_power_per_phase",
|
||||
obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE,
|
||||
obis_reference="ACTUAL_TRESHOLD_ELECTRICITY",
|
||||
dsmr_versions={"5B"},
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -319,7 +318,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_max_current_per_phase",
|
||||
translation_key="max_current_per_phase",
|
||||
obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE,
|
||||
obis_reference="BELGIUM_MAX_CURRENT_PER_PHASE",
|
||||
dsmr_versions={"5B"},
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -329,7 +328,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_imported_total",
|
||||
translation_key="electricity_imported_total",
|
||||
obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL,
|
||||
obis_reference="ELECTRICITY_IMPORTED_TOTAL",
|
||||
dsmr_versions={"5L", "5S", "Q3D"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -337,7 +336,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_exported_total",
|
||||
translation_key="electricity_exported_total",
|
||||
obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL,
|
||||
obis_reference="ELECTRICITY_EXPORTED_TOTAL",
|
||||
dsmr_versions={"5L", "5S", "Q3D"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -345,7 +344,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_current_average_demand",
|
||||
translation_key="current_average_demand",
|
||||
obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND,
|
||||
obis_reference="BELGIUM_CURRENT_AVERAGE_DEMAND",
|
||||
dsmr_versions={"5B"},
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -353,7 +352,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_maximum_demand_current_month",
|
||||
translation_key="maximum_demand_current_month",
|
||||
obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH,
|
||||
obis_reference="BELGIUM_MAXIMUM_DEMAND_MONTH",
|
||||
dsmr_versions={"5B"},
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -361,7 +360,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="hourly_gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=obis_references.HOURLY_GAS_METER_READING,
|
||||
obis_reference="HOURLY_GAS_METER_READING",
|
||||
dsmr_versions={"4", "5", "5L"},
|
||||
is_gas=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
@@ -370,7 +369,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="gas_meter_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=obis_references.GAS_METER_READING,
|
||||
obis_reference="GAS_METER_READING",
|
||||
dsmr_versions={"2.2"},
|
||||
is_gas=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
@@ -383,36 +382,20 @@ def create_mbus_entity(
|
||||
mbus: int, mtype: int, telegram: Telegram
|
||||
) -> DSMRSensorEntityDescription | None:
|
||||
"""Create a new MBUS Entity."""
|
||||
if (
|
||||
mtype == 3
|
||||
and (
|
||||
obis_reference := getattr(
|
||||
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2"
|
||||
)
|
||||
)
|
||||
in telegram
|
||||
):
|
||||
if mtype == 3 and hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING2"):
|
||||
return DSMRSensorEntityDescription(
|
||||
key=f"mbus{mbus}_gas_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=obis_reference,
|
||||
obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING2",
|
||||
is_gas=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
if (
|
||||
mtype == 7
|
||||
and (
|
||||
obis_reference := getattr(
|
||||
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1"
|
||||
)
|
||||
)
|
||||
in telegram
|
||||
):
|
||||
if mtype == 7 and (hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING1")):
|
||||
return DSMRSensorEntityDescription(
|
||||
key=f"mbus{mbus}_water_reading",
|
||||
translation_key="water_meter_reading",
|
||||
obis_reference=obis_reference,
|
||||
obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING1",
|
||||
is_water=True,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -425,7 +408,7 @@ def device_class_and_uom(
|
||||
entity_description: DSMRSensorEntityDescription,
|
||||
) -> tuple[SensorDeviceClass | None, str | None]:
|
||||
"""Get native unit of measurement from telegram,."""
|
||||
dsmr_object = telegram[entity_description.obis_reference]
|
||||
dsmr_object = getattr(telegram, entity_description.obis_reference)
|
||||
uom: str | None = getattr(dsmr_object, "unit") or None
|
||||
with suppress(ValueError):
|
||||
if entity_description.device_class == SensorDeviceClass.GAS and (
|
||||
@@ -484,18 +467,15 @@ def create_mbus_entities(
|
||||
entities = []
|
||||
for idx in range(1, 5):
|
||||
if (
|
||||
device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE")
|
||||
) not in telegram:
|
||||
device_type := getattr(telegram, f"BELGIUM_MBUS{idx}_DEVICE_TYPE", None)
|
||||
) is None:
|
||||
continue
|
||||
if (type_ := int(telegram[device_type].value)) not in (3, 7):
|
||||
if (type_ := int(device_type.value)) not in (3, 7):
|
||||
continue
|
||||
if (
|
||||
identifier := getattr(
|
||||
obis_references,
|
||||
f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER",
|
||||
)
|
||||
) in telegram:
|
||||
serial_ = telegram[identifier].value
|
||||
if identifier := getattr(
|
||||
telegram, f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", None
|
||||
):
|
||||
serial_ = identifier.value
|
||||
rename_old_gas_to_mbus(hass, entry, serial_)
|
||||
else:
|
||||
serial_ = ""
|
||||
@@ -514,7 +494,7 @@ def create_mbus_entities(
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant, entry: DsmrConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the DSMR sensor."""
|
||||
dsmr_version = entry.data[CONF_DSMR_VERSION]
|
||||
@@ -547,7 +527,7 @@ async def async_setup_entry(
|
||||
or dsmr_version in description.dsmr_versions
|
||||
)
|
||||
and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data)
|
||||
and description.obis_reference in telegram
|
||||
and hasattr(telegram, description.obis_reference)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
@@ -567,6 +547,8 @@ async def async_setup_entry(
|
||||
for entity in entities:
|
||||
entity.update_data(telegram)
|
||||
|
||||
entry.runtime_data.telegram = telegram
|
||||
|
||||
if not initialized and telegram:
|
||||
initialized = True
|
||||
async_dispatcher_send(
|
||||
@@ -695,7 +677,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
# Save the task to be able to cancel it when unloading
|
||||
hass.data[DOMAIN][entry.entry_id][DATA_TASK] = task
|
||||
entry.runtime_data.task = task
|
||||
|
||||
|
||||
class DSMREntity(SensorEntity):
|
||||
@@ -754,21 +736,21 @@ class DSMREntity(SensorEntity):
|
||||
"""Update data."""
|
||||
self.telegram = telegram
|
||||
if self.hass and (
|
||||
telegram is None or self.entity_description.obis_reference in telegram
|
||||
telegram is None
|
||||
or hasattr(telegram, self.entity_description.obis_reference)
|
||||
):
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_dsmr_object_attr(self, attribute: str) -> str | None:
|
||||
"""Read attribute from last received telegram for this DSMR object."""
|
||||
# Make sure telegram contains an object for this entities obis
|
||||
if (
|
||||
self.telegram is None
|
||||
or self.entity_description.obis_reference not in self.telegram
|
||||
if self.telegram is None or not hasattr(
|
||||
self.telegram, self.entity_description.obis_reference
|
||||
):
|
||||
return None
|
||||
|
||||
# Get the attribute value if the object has it
|
||||
dsmr_object = self.telegram[self.entity_description.obis_reference]
|
||||
dsmr_object = getattr(self.telegram, self.entity_description.obis_reference)
|
||||
attr: str | None = getattr(dsmr_object, attribute)
|
||||
return attr
|
||||
|
||||
@@ -784,10 +766,7 @@ class DSMREntity(SensorEntity):
|
||||
if (value := self.get_dsmr_object_attr("value")) is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
self.entity_description.obis_reference
|
||||
== obis_references.ELECTRICITY_ACTIVE_TARIFF
|
||||
):
|
||||
if self.entity_description.obis_reference == "ELECTRICITY_ACTIVE_TARIFF":
|
||||
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
|
||||
|
||||
with suppress(TypeError):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyecoforest.api import EcoforestApi
|
||||
from pyecoforest.models.device import Device
|
||||
@@ -61,12 +62,12 @@ class EcoforestSwitchEntity(EcoforestEntity, SwitchEntity):
|
||||
"""Return the state of the ecoforest device."""
|
||||
return self.entity_description.value_fn(self.data)
|
||||
|
||||
async def async_turn_on(self):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the ecoforest device."""
|
||||
await self.entity_description.switch_fn(self.coordinator.api, True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self):
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the ecoforest device."""
|
||||
await self.entity_description.switch_fn(self.coordinator.api, False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
11
homeassistant/components/emoncms/const.py
Normal file
11
homeassistant/components/emoncms/const.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Constants for the emoncms integration."""
|
||||
|
||||
import logging
|
||||
|
||||
CONF_EXCLUDE_FEEDID = "exclude_feed_id"
|
||||
CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
|
||||
CONF_MESSAGE = "message"
|
||||
CONF_SUCCESS = "success"
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
@@ -1,15 +1,14 @@
|
||||
"""DataUpdateCoordinator for the emoncms integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyemoncms import EmoncmsClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import CONF_MESSAGE, CONF_SUCCESS, LOGGER
|
||||
|
||||
|
||||
class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]):
|
||||
@@ -24,8 +23,15 @@ class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]):
|
||||
"""Initialize the emoncms data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
LOGGER,
|
||||
name="emoncms_coordinator",
|
||||
update_method=emoncms_client.async_list_feeds,
|
||||
update_interval=scan_interval,
|
||||
)
|
||||
self.emoncms_client = emoncms_client
|
||||
|
||||
async def _async_update_data(self) -> list[dict[str, Any]]:
|
||||
"""Fetch data from API endpoint."""
|
||||
data = await self.emoncms_client.async_request("/feed/list.json")
|
||||
if not data[CONF_SUCCESS]:
|
||||
raise UpdateFailed
|
||||
return data[CONF_MESSAGE]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyemoncms import EmoncmsClient
|
||||
@@ -33,10 +32,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID
|
||||
from .coordinator import EmoncmsCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_FEEDID = "FeedId"
|
||||
ATTR_FEEDNAME = "FeedName"
|
||||
ATTR_LASTUPDATETIME = "LastUpdated"
|
||||
@@ -45,8 +43,6 @@ ATTR_SIZE = "Size"
|
||||
ATTR_TAG = "Tag"
|
||||
ATTR_USERID = "UserId"
|
||||
|
||||
CONF_EXCLUDE_FEEDID = "exclude_feed_id"
|
||||
CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
|
||||
CONF_SENSOR_NAMES = "sensor_names"
|
||||
|
||||
DECIMALS = 2
|
||||
@@ -98,7 +94,7 @@ async def async_setup_platform(
|
||||
coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval)
|
||||
await coordinator.async_refresh()
|
||||
elems = coordinator.data
|
||||
if elems is None:
|
||||
if not elems:
|
||||
return
|
||||
|
||||
sensors: list[EmonCmsSensor] = []
|
||||
@@ -208,7 +204,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
|
||||
self._attr_native_value = None
|
||||
if self._value_template is not None:
|
||||
self._attr_native_value = (
|
||||
self._value_template.render_with_possible_json_value(
|
||||
self._value_template.async_render_with_possible_json_value(
|
||||
elem["value"], STATE_UNKNOWN
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,45 +1,23 @@
|
||||
"""Support for Enigma2 devices."""
|
||||
|
||||
from openwebif.api import OpenWebIfDevice
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_SOURCE_BOUQUET
|
||||
from .coordinator import Enigma2UpdateCoordinator
|
||||
|
||||
type Enigma2ConfigEntry = ConfigEntry[OpenWebIfDevice]
|
||||
type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator]
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> bool:
|
||||
"""Set up Enigma2 from a config entry."""
|
||||
base_url = URL.build(
|
||||
scheme="http" if not entry.data[CONF_SSL] else "https",
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
user=entry.data.get(CONF_USERNAME),
|
||||
password=entry.data.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, verify_ssl=entry.data[CONF_VERIFY_SSL], base_url=base_url
|
||||
)
|
||||
coordinator = Enigma2UpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
entry.runtime_data = OpenWebIfDevice(
|
||||
session, source_bouquet=entry.options.get(CONF_SOURCE_BOUQUET)
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -68,8 +68,9 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Get the options schema."""
|
||||
entry = cast(SchemaOptionsFlowHandler, handler.parent_handler).config_entry
|
||||
device: OpenWebIfDevice = entry.runtime_data
|
||||
bouquets = [b[1] for b in (await device.get_all_bouquets())["bouquets"]]
|
||||
bouquets = [
|
||||
b[1] for b in (await entry.runtime_data.device.get_all_bouquets())["bouquets"]
|
||||
]
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
|
||||
84
homeassistant/components/enigma2/coordinator.py
Normal file
84
homeassistant/components/enigma2/coordinator.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Data update coordinator for the Enigma2 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from openwebif.api import OpenWebIfDevice, OpenWebIfStatus
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
ATTR_IDENTIFIERS,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import CONF_SOURCE_BOUQUET, DOMAIN
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
|
||||
class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
|
||||
"""The Enigma2 data update coordinator."""
|
||||
|
||||
device: OpenWebIfDevice
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the Enigma2 data update coordinator."""
|
||||
|
||||
super().__init__(
|
||||
hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
|
||||
base_url = URL.build(
|
||||
scheme="http" if not config_entry.data[CONF_SSL] else "https",
|
||||
host=config_entry.data[CONF_HOST],
|
||||
port=config_entry.data[CONF_PORT],
|
||||
user=config_entry.data.get(CONF_USERNAME),
|
||||
password=config_entry.data.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL], base_url=base_url
|
||||
)
|
||||
|
||||
self.device = OpenWebIfDevice(
|
||||
session, source_bouquet=config_entry.options.get(CONF_SOURCE_BOUQUET)
|
||||
)
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
configuration_url=base_url,
|
||||
name=config_entry.data[CONF_HOST],
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Provide needed data to the device info."""
|
||||
|
||||
about = await self.device.get_about()
|
||||
self.device.mac_address = about["info"]["ifaces"][0]["mac"]
|
||||
self.device_info["model"] = about["info"]["model"]
|
||||
self.device_info["manufacturer"] = about["info"]["brand"]
|
||||
self.device_info[ATTR_IDENTIFIERS] = {
|
||||
(DOMAIN, format_mac(iface["mac"])) for iface in about["info"]["ifaces"]
|
||||
}
|
||||
self.device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
|
||||
for iface in about["info"]["ifaces"]
|
||||
}
|
||||
|
||||
async def _async_update_data(self) -> OpenWebIfStatus:
|
||||
await self.device.update()
|
||||
return self.device.status
|
||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from logging import getLogger
|
||||
from typing import cast
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedError
|
||||
from openwebif.api import OpenWebIfDevice
|
||||
from aiohttp.client_exceptions import ServerDisconnectedError
|
||||
from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -26,11 +26,11 @@ from homeassistant.const import (
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import Enigma2ConfigEntry
|
||||
from .const import (
|
||||
@@ -49,6 +49,7 @@ from .const import (
|
||||
DEFAULT_USERNAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import Enigma2UpdateCoordinator
|
||||
|
||||
ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording"
|
||||
ATTR_MEDIA_DESCRIPTION = "media_description"
|
||||
@@ -107,15 +108,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Enigma2 media player platform."""
|
||||
|
||||
device = entry.runtime_data
|
||||
about = await device.get_about()
|
||||
device.mac_address = about["info"]["ifaces"][0]["mac"]
|
||||
entity = Enigma2Device(entry, device, about)
|
||||
async_add_entities([entity])
|
||||
async_add_entities([Enigma2Device(entry.runtime_data)])
|
||||
|
||||
|
||||
class Enigma2Device(MediaPlayerEntity):
|
||||
class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEntity):
|
||||
"""Representation of an Enigma2 box."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -135,118 +131,125 @@ class Enigma2Device(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, entry: ConfigEntry, device: OpenWebIfDevice, about: dict
|
||||
) -> None:
|
||||
def __init__(self, coordinator: Enigma2UpdateCoordinator) -> None:
|
||||
"""Initialize the Enigma2 device."""
|
||||
self._device: OpenWebIfDevice = device
|
||||
self._entry = entry
|
||||
|
||||
self._attr_unique_id = device.mac_address or entry.entry_id
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
manufacturer=about["info"]["brand"],
|
||||
model=about["info"]["model"],
|
||||
configuration_url=device.base,
|
||||
name=entry.data[CONF_HOST],
|
||||
self._attr_unique_id = (
|
||||
coordinator.device.mac_address
|
||||
or cast(ConfigEntry, coordinator.config_entry).entry_id
|
||||
)
|
||||
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off media player."""
|
||||
if self._device.turn_off_to_deep:
|
||||
if self.coordinator.device.turn_off_to_deep:
|
||||
with contextlib.suppress(ServerDisconnectedError):
|
||||
await self._device.set_powerstate(PowerState.DEEP_STANDBY)
|
||||
await self.coordinator.device.set_powerstate(PowerState.DEEP_STANDBY)
|
||||
self._attr_available = False
|
||||
else:
|
||||
await self._device.set_powerstate(PowerState.STANDBY)
|
||||
await self.coordinator.device.set_powerstate(PowerState.STANDBY)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the media player on."""
|
||||
await self._device.turn_on()
|
||||
await self.coordinator.device.turn_on()
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
await self._device.set_volume(int(volume * 100))
|
||||
await self.coordinator.device.set_volume(int(volume * 100))
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
await self._device.set_volume(SetVolumeOption.UP)
|
||||
await self.coordinator.device.set_volume(SetVolumeOption.UP)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
await self._device.set_volume(SetVolumeOption.DOWN)
|
||||
await self.coordinator.device.set_volume(SetVolumeOption.DOWN)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._device.send_remote_control_action(RemoteControlCodes.STOP)
|
||||
await self.coordinator.device.send_remote_control_action(
|
||||
RemoteControlCodes.STOP
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Play media."""
|
||||
await self._device.send_remote_control_action(RemoteControlCodes.PLAY)
|
||||
await self.coordinator.device.send_remote_control_action(
|
||||
RemoteControlCodes.PLAY
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Pause the media player."""
|
||||
await self._device.send_remote_control_action(RemoteControlCodes.PAUSE)
|
||||
await self.coordinator.device.send_remote_control_action(
|
||||
RemoteControlCodes.PAUSE
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_UP)
|
||||
await self.coordinator.device.send_remote_control_action(
|
||||
RemoteControlCodes.CHANNEL_UP
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN)
|
||||
await self.coordinator.device.send_remote_control_action(
|
||||
RemoteControlCodes.CHANNEL_DOWN
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute."""
|
||||
if mute != self._device.status.muted:
|
||||
await self._device.toggle_mute()
|
||||
if mute != self.coordinator.data.muted:
|
||||
await self.coordinator.device.toggle_mute()
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
await self._device.zap(self._device.sources[source])
|
||||
await self.coordinator.device.zap(self.coordinator.device.sources[source])
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update state of the media_player."""
|
||||
try:
|
||||
await self._device.update()
|
||||
except ClientConnectorError as err:
|
||||
if self._attr_available:
|
||||
_LOGGER.warning(
|
||||
"%s is unavailable. Error: %s", self._device.base.host, err
|
||||
)
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
if not self._attr_available:
|
||||
_LOGGER.debug("%s is available", self._device.base.host)
|
||||
self._attr_available = True
|
||||
|
||||
if not self._device.status.in_standby:
|
||||
if not self.coordinator.data.in_standby:
|
||||
self._attr_extra_state_attributes = {
|
||||
ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording,
|
||||
ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription,
|
||||
ATTR_MEDIA_START_TIME: self._device.status.currservice.begin,
|
||||
ATTR_MEDIA_END_TIME: self._device.status.currservice.end,
|
||||
ATTR_MEDIA_CURRENTLY_RECORDING: self.coordinator.data.is_recording,
|
||||
ATTR_MEDIA_DESCRIPTION: self.coordinator.data.currservice.fulldescription,
|
||||
ATTR_MEDIA_START_TIME: self.coordinator.data.currservice.begin,
|
||||
ATTR_MEDIA_END_TIME: self.coordinator.data.currservice.end,
|
||||
}
|
||||
else:
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
self._attr_media_title = self._device.status.currservice.station
|
||||
self._attr_media_series_title = self._device.status.currservice.name
|
||||
self._attr_media_channel = self._device.status.currservice.station
|
||||
self._attr_is_volume_muted = self._device.status.muted
|
||||
self._attr_media_content_id = self._device.status.currservice.serviceref
|
||||
self._attr_media_image_url = self._device.picon_url
|
||||
self._attr_source = self._device.status.currservice.station
|
||||
self._attr_source_list = self._device.source_list
|
||||
self._attr_media_title = self.coordinator.data.currservice.station
|
||||
self._attr_media_series_title = self.coordinator.data.currservice.name
|
||||
self._attr_media_channel = self.coordinator.data.currservice.station
|
||||
self._attr_is_volume_muted = self.coordinator.data.muted
|
||||
self._attr_media_content_id = self.coordinator.data.currservice.serviceref
|
||||
self._attr_media_image_url = self.coordinator.device.picon_url
|
||||
self._attr_source = self.coordinator.data.currservice.station
|
||||
self._attr_source_list = self.coordinator.device.source_list
|
||||
|
||||
if self._device.status.in_standby:
|
||||
if self.coordinator.data.in_standby:
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
else:
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
|
||||
if (volume_level := self._device.status.volume) is not None:
|
||||
if (volume_level := self.coordinator.data.volume) is not None:
|
||||
self._attr_volume_level = volume_level / 100
|
||||
else:
|
||||
self._attr_volume_level = None
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from pyenphase import Envoy
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -12,10 +11,10 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool:
|
||||
"""Set up Enphase Envoy from a config entry."""
|
||||
|
||||
host = entry.data[CONF_HOST]
|
||||
@@ -37,29 +36,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
f"found {envoy.serial_number}"
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: EnphaseUpdateCoordinator = entry.runtime_data
|
||||
coordinator.async_cancel_token_refresh()
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
hass: HomeAssistant, config_entry: EnphaseConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove an enphase_envoy config entry from a device."""
|
||||
dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN}
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
envoy_serial_num = config_entry.unique_id
|
||||
if envoy_serial_num in dev_ids:
|
||||
|
||||
@@ -13,14 +13,13 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
|
||||
|
||||
@@ -74,11 +73,11 @@ ENPOWER_SENSORS = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: EnphaseConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up envoy binary sensor platform."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
assert envoy_data is not None
|
||||
entities: list[BinarySensorEntity] = []
|
||||
|
||||
@@ -28,12 +28,17 @@ STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type EnphaseConfigEntry = ConfigEntry[EnphaseUpdateCoordinator]
|
||||
|
||||
|
||||
class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""DataUpdateCoordinator to gather data from any envoy."""
|
||||
|
||||
envoy_serial_number: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, envoy: Envoy, entry: ConfigEntry) -> None:
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry
|
||||
) -> None:
|
||||
"""Initialize DataUpdateCoordinator for the envoy."""
|
||||
self.envoy = envoy
|
||||
entry_data = entry.data
|
||||
|
||||
@@ -10,7 +10,6 @@ from pyenphase.envoy import Envoy
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -23,8 +22,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .const import OPTION_DIAGNOSTICS_INCLUDE_FIXTURES
|
||||
from .coordinator import EnphaseConfigEntry
|
||||
|
||||
CONF_TITLE = "title"
|
||||
CLEAN_TEXT = "<<envoyserial>>"
|
||||
@@ -81,10 +80,10 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
hass: HomeAssistant, entry: EnphaseConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.envoy.data
|
||||
|
||||
@@ -16,14 +16,13 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
|
||||
|
||||
@@ -71,11 +70,11 @@ STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: EnphaseConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Enphase Envoy number platform."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
assert envoy_data is not None
|
||||
entities: list[NumberEntity] = []
|
||||
|
||||
@@ -12,13 +12,12 @@ from pyenphase.models.dry_contacts import DryContactAction, DryContactMode
|
||||
from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
|
||||
|
||||
@@ -126,11 +125,11 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: EnphaseConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Enphase Envoy select platform."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
assert envoy_data is not None
|
||||
entities: list[SelectEntity] = []
|
||||
|
||||
@@ -33,7 +33,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfApparentPower,
|
||||
@@ -50,7 +49,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
|
||||
ICON = "mdi:flash"
|
||||
@@ -579,11 +578,11 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: EnphaseConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up envoy sensor platform."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
assert envoy_data is not None
|
||||
_LOGGER.debug("Envoy data: %s", envoy_data)
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyenphase import Envoy, EnvoyDryContactStatus, EnvoyEnpower
|
||||
@@ -13,17 +12,14 @@ from pyenphase.models.dry_contacts import DryContactStatus
|
||||
from pyenphase.models.tariff import EnvoyStorageSettings
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EnvoyEnpowerSwitchEntityDescription(SwitchEntityDescription):
|
||||
@@ -78,11 +74,11 @@ CHARGE_FROM_GRID_SWITCH = EnvoyStorageSettingsSwitchEntityDescription(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: EnphaseConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Enphase Envoy switch platform."""
|
||||
coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
envoy_data = coordinator.envoy.data
|
||||
assert envoy_data is not None
|
||||
entities: list[SwitchEntity] = []
|
||||
|
||||
@@ -45,6 +45,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
"""A fan implementation for ESPHome."""
|
||||
|
||||
_supports_speed_levels: bool = True
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
@@ -148,7 +149,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
||||
api_version = self._api_version
|
||||
supports_speed_levels = api_version.major == 1 and api_version.minor > 3
|
||||
self._supports_speed_levels = supports_speed_levels
|
||||
flags = FanEntityFeature(0)
|
||||
flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
|
||||
if static_info.supports_oscillation:
|
||||
flags |= FanEntityFeature.OSCILLATE
|
||||
if static_info.supports_speed:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from enum import IntFlag
|
||||
import functools as ft
|
||||
@@ -30,6 +31,7 @@ from homeassistant.helpers.deprecation import (
|
||||
)
|
||||
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.percentage import (
|
||||
@@ -53,6 +55,8 @@ class FanEntityFeature(IntFlag):
|
||||
OSCILLATE = 2
|
||||
DIRECTION = 4
|
||||
PRESET_MODE = 8
|
||||
TURN_OFF = 16
|
||||
TURN_ON = 32
|
||||
|
||||
|
||||
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
|
||||
@@ -132,9 +136,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Optional(ATTR_PRESET_MODE): cv.string,
|
||||
},
|
||||
"async_handle_turn_on_service",
|
||||
[FanEntityFeature.TURN_ON],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_TURN_OFF, {}, "async_turn_off", [FanEntityFeature.TURN_OFF]
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_TOGGLE,
|
||||
{},
|
||||
"async_toggle",
|
||||
[FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON],
|
||||
)
|
||||
component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
|
||||
component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
|
||||
component.async_register_entity_service(
|
||||
SERVICE_INCREASE_SPEED,
|
||||
{
|
||||
@@ -228,6 +240,99 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_speed_count: int
|
||||
_attr_supported_features: FanEntityFeature = FanEntityFeature(0)
|
||||
|
||||
__mod_supported_features: FanEntityFeature = FanEntityFeature(0)
|
||||
# Integrations should set `_enable_turn_on_off_backwards_compatibility` to False
|
||||
# once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
|
||||
_enable_turn_on_off_backwards_compatibility: bool = True
|
||||
|
||||
def __getattribute__(self, __name: str) -> Any:
|
||||
"""Get attribute.
|
||||
|
||||
Modify return of `supported_features` to
|
||||
include `_mod_supported_features` if attribute is set.
|
||||
"""
|
||||
if __name != "supported_features":
|
||||
return super().__getattribute__(__name)
|
||||
|
||||
# Convert the supported features to ClimateEntityFeature.
|
||||
# Remove this compatibility shim in 2025.1 or later.
|
||||
_supported_features: FanEntityFeature = super().__getattribute__(
|
||||
"supported_features"
|
||||
)
|
||||
_mod_supported_features: FanEntityFeature = super().__getattribute__(
|
||||
"_FanEntity__mod_supported_features"
|
||||
)
|
||||
if type(_supported_features) is int: # noqa: E721
|
||||
_features = FanEntityFeature(_supported_features)
|
||||
self._report_deprecated_supported_features_values(_features)
|
||||
else:
|
||||
_features = _supported_features
|
||||
|
||||
if not _mod_supported_features:
|
||||
return _features
|
||||
|
||||
# Add automatically calculated FanEntityFeature.TURN_OFF/TURN_ON to
|
||||
# supported features and return it
|
||||
return _features | _mod_supported_features
|
||||
|
||||
@callback
|
||||
def add_to_platform_start(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
platform: EntityPlatform,
|
||||
parallel_updates: asyncio.Semaphore | None,
|
||||
) -> None:
|
||||
"""Start adding an entity to a platform."""
|
||||
super().add_to_platform_start(hass, platform, parallel_updates)
|
||||
|
||||
def _report_turn_on_off(feature: str, method: str) -> None:
|
||||
"""Log warning not implemented turn on/off feature."""
|
||||
report_issue = self._suggest_report_issue()
|
||||
message = (
|
||||
"Entity %s (%s) does not set FanEntityFeature.%s"
|
||||
" but implements the %s method. Please %s"
|
||||
)
|
||||
_LOGGER.warning(
|
||||
message,
|
||||
self.entity_id,
|
||||
type(self),
|
||||
feature,
|
||||
method,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
# Adds FanEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented
|
||||
# This should be removed in 2025.2.
|
||||
if self._enable_turn_on_off_backwards_compatibility is False:
|
||||
# Return if integration has migrated already
|
||||
return
|
||||
|
||||
supported_features = self.supported_features
|
||||
if supported_features & (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF):
|
||||
# The entity supports both turn_on and turn_off, the backwards compatibility
|
||||
# checks are not needed
|
||||
return
|
||||
|
||||
if not supported_features & FanEntityFeature.TURN_OFF and (
|
||||
type(self).async_turn_off is not ToggleEntity.async_turn_off
|
||||
or type(self).turn_off is not ToggleEntity.turn_off
|
||||
):
|
||||
# turn_off implicitly supported by implementing turn_off method
|
||||
_report_turn_on_off("TURN_OFF", "turn_off")
|
||||
self.__mod_supported_features |= ( # pylint: disable=unused-private-member
|
||||
FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
if not supported_features & FanEntityFeature.TURN_ON and (
|
||||
type(self).async_turn_on is not FanEntity.async_turn_on
|
||||
or type(self).turn_on is not FanEntity.turn_on
|
||||
):
|
||||
# turn_on implicitly supported by implementing turn_on method
|
||||
_report_turn_on_off("TURN_ON", "turn_on")
|
||||
self.__mod_supported_features |= ( # pylint: disable=unused-private-member
|
||||
FanEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
raise NotImplementedError
|
||||
@@ -388,7 +493,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def capability_attributes(self) -> dict[str, list[str] | None]:
|
||||
"""Return capability attributes."""
|
||||
attrs = {}
|
||||
supported_features = self.supported_features_compat
|
||||
supported_features = self.supported_features
|
||||
|
||||
if (
|
||||
FanEntityFeature.SET_SPEED in supported_features
|
||||
@@ -403,7 +508,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def state_attributes(self) -> dict[str, float | str | None]:
|
||||
"""Return optional state attributes."""
|
||||
data: dict[str, float | str | None] = {}
|
||||
supported_features = self.supported_features_compat
|
||||
supported_features = self.supported_features
|
||||
|
||||
if FanEntityFeature.DIRECTION in supported_features:
|
||||
data[ATTR_DIRECTION] = self.current_direction
|
||||
@@ -427,19 +532,6 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def supported_features_compat(self) -> FanEntityFeature:
|
||||
"""Return the supported features as FanEntityFeature.
|
||||
|
||||
Remove this compatibility shim in 2025.1 or later.
|
||||
"""
|
||||
features = self.supported_features
|
||||
if type(features) is int: # noqa: E721
|
||||
new_features = FanEntityFeature(features)
|
||||
self._report_deprecated_supported_features_values(new_features)
|
||||
return new_features
|
||||
return features
|
||||
|
||||
@cached_property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., auto, smart, interval, favorite.
|
||||
|
||||
@@ -31,6 +31,8 @@ turn_on:
|
||||
target:
|
||||
entity:
|
||||
domain: fan
|
||||
supported_features:
|
||||
- fan.FanEntityFeature.TURN_ON
|
||||
fields:
|
||||
percentage:
|
||||
filter:
|
||||
@@ -53,6 +55,8 @@ turn_off:
|
||||
target:
|
||||
entity:
|
||||
domain: fan
|
||||
supported_features:
|
||||
- fan.FanEntityFeature.TURN_OFF
|
||||
|
||||
oscillate:
|
||||
target:
|
||||
|
||||
@@ -6,6 +6,7 @@ from calendar import timegm
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from time import gmtime, struct_time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import URLError
|
||||
|
||||
import feedparser
|
||||
@@ -120,10 +121,13 @@ class FeedReaderCoordinator(
|
||||
len(self._feed.entries),
|
||||
self.url,
|
||||
)
|
||||
if not isinstance(self._feed.entries, list):
|
||||
if not self._feed.entries:
|
||||
self._log_no_entries()
|
||||
return None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self._feed.entries, list)
|
||||
|
||||
self._filter_entries()
|
||||
self._publish_new_entries()
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up event entities for feedreader."""
|
||||
coordinator: FeedReaderCoordinator = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([FeedReaderEvent(coordinator)])
|
||||
|
||||
@@ -76,8 +76,6 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
|
||||
if content := feed_data.get("content"):
|
||||
if isinstance(content, list) and isinstance(content[0], dict):
|
||||
content = content[0].get("value")
|
||||
else:
|
||||
content = feed_data.get("summary")
|
||||
|
||||
self._trigger_event(
|
||||
EVENT_FEEDREADER,
|
||||
|
||||
@@ -65,7 +65,13 @@ async def async_setup_entry(
|
||||
class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity):
|
||||
"""Fan entity."""
|
||||
|
||||
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit
|
||||
_attr_name = None
|
||||
_attr_is_on = False
|
||||
_attr_percentage = 0
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -62,8 +63,11 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit
|
||||
model=device["type"],
|
||||
name=device["name"],
|
||||
)
|
||||
self._attr_supported_features = (
|
||||
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
|
||||
)
|
||||
if "rotationSpeed" in self._characteristics:
|
||||
self._attr_supported_features = FanEntityFeature.SET_SPEED
|
||||
self._attr_supported_features |= FanEntityFeature.SET_SPEED
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
|
||||
@@ -398,7 +398,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
static_paths_configs: list[StaticPathConfig] = []
|
||||
|
||||
for path, should_cache in (
|
||||
("service_worker.js", False),
|
||||
("sw-modern.js", False),
|
||||
("sw-modern.js.map", False),
|
||||
("sw-legacy.js", False),
|
||||
("sw-legacy.js.map", False),
|
||||
("robots.txt", False),
|
||||
("onboarding.html", not is_dev),
|
||||
("static", not is_dev),
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20240710.0"]
|
||||
"requirements": ["home-assistant-frontend==20240719.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["ha-av==10.1.1", "Pillow==10.3.0"]
|
||||
"requirements": ["ha-av==10.1.1", "Pillow==10.4.0"]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""The generic_thermostat component."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import (
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
|
||||
CONF_HEATER = "heater"
|
||||
DOMAIN = "generic_thermostat"
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
from .const import CONF_HEATER, PLATFORMS
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -14,13 +14,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_PRESET_MODE,
|
||||
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_NONE,
|
||||
PRESET_SLEEP,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
@@ -64,36 +58,31 @@ from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
|
||||
|
||||
from . import CONF_HEATER, DOMAIN, PLATFORMS
|
||||
from .const import (
|
||||
CONF_AC_MODE,
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_MIN_DUR,
|
||||
CONF_PRESETS,
|
||||
CONF_SENSOR,
|
||||
DEFAULT_TOLERANCE,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TOLERANCE = 0.3
|
||||
DEFAULT_NAME = "Generic Thermostat"
|
||||
CONF_SENSOR = "target_sensor"
|
||||
|
||||
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
CONF_MIN_TEMP = "min_temp"
|
||||
CONF_MAX_TEMP = "max_temp"
|
||||
CONF_TARGET_TEMP = "target_temp"
|
||||
CONF_AC_MODE = "ac_mode"
|
||||
CONF_MIN_DUR = "min_cycle_duration"
|
||||
CONF_COLD_TOLERANCE = "cold_tolerance"
|
||||
CONF_HOT_TOLERANCE = "hot_tolerance"
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_TARGET_TEMP = "target_temp"
|
||||
CONF_TEMP_STEP = "target_temp_step"
|
||||
|
||||
CONF_PRESETS = {
|
||||
p: f"{p}_temp"
|
||||
for p in (
|
||||
PRESET_AWAY,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_SLEEP,
|
||||
PRESET_ACTIVITY,
|
||||
)
|
||||
}
|
||||
|
||||
PRESETS_SCHEMA: VolDictType = {
|
||||
vol.Optional(v): vol.Coerce(float) for v in CONF_PRESETS.values()
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaFlowFormStep,
|
||||
)
|
||||
|
||||
from .climate import (
|
||||
from .const import (
|
||||
CONF_AC_MODE,
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_HEATER,
|
||||
|
||||
34
homeassistant/components/generic_thermostat/const.py
Normal file
34
homeassistant/components/generic_thermostat/const.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Constants for the Generic Thermostat helper."""
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_SLEEP,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "generic_thermostat"
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
|
||||
CONF_AC_MODE = "ac_mode"
|
||||
CONF_COLD_TOLERANCE = "cold_tolerance"
|
||||
CONF_HEATER = "heater"
|
||||
CONF_HOT_TOLERANCE = "hot_tolerance"
|
||||
CONF_MIN_DUR = "min_cycle_duration"
|
||||
CONF_PRESETS = {
|
||||
p: f"{p}_temp"
|
||||
for p in (
|
||||
PRESET_AWAY,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_SLEEP,
|
||||
PRESET_ACTIVITY,
|
||||
)
|
||||
}
|
||||
CONF_SENSOR = "target_sensor"
|
||||
DEFAULT_TOLERANCE = 0.3
|
||||
@@ -10,6 +10,8 @@ import aiohttp
|
||||
from geniushubclient import GeniusHub
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
@@ -21,23 +23,29 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import (
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.service import verify_domain_control
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN
|
||||
|
||||
DOMAIN = "geniushub"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# temperature is repeated here, as it gives access to high-precision temps
|
||||
GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"]
|
||||
@@ -54,13 +62,15 @@ SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$"
|
||||
|
||||
V1_API_SCHEMA = vol.Schema(
|
||||
CLOUD_API_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): cv.string,
|
||||
vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
|
||||
}
|
||||
)
|
||||
V3_API_SCHEMA = vol.Schema(
|
||||
|
||||
|
||||
LOCAL_API_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
@@ -68,8 +78,9 @@ V3_API_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{DOMAIN: vol.Any(V3_API_SCHEMA, V1_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
|
||||
{DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
ATTR_ZONE_MODE = "mode"
|
||||
@@ -106,20 +117,78 @@ PLATFORMS = (
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None:
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=base_config[DOMAIN],
|
||||
)
|
||||
if (
|
||||
result["type"] is FlowResultType.CREATE_ENTRY
|
||||
or result["reason"] == "already_configured"
|
||||
):
|
||||
async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2024.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Genius Hub",
|
||||
},
|
||||
)
|
||||
return
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
breaks_in_ha_version="2024.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Genius Hub",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
|
||||
"""Set up a Genius Hub system."""
|
||||
if DOMAIN in base_config:
|
||||
hass.async_create_task(_async_import(hass, base_config))
|
||||
return True
|
||||
|
||||
|
||||
type GeniusHubConfigEntry = ConfigEntry[GeniusBroker]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool:
|
||||
"""Create a Genius Hub system."""
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
kwargs = dict(config[DOMAIN])
|
||||
if CONF_HOST in kwargs:
|
||||
args = (kwargs.pop(CONF_HOST),)
|
||||
session = async_get_clientsession(hass)
|
||||
if CONF_HOST in entry.data:
|
||||
client = GeniusHub(
|
||||
entry.data[CONF_HOST],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
else:
|
||||
args = (kwargs.pop(CONF_TOKEN),)
|
||||
hub_uid = kwargs.pop(CONF_MAC, None)
|
||||
client = GeniusHub(entry.data[CONF_TOKEN], session=session)
|
||||
|
||||
client = GeniusHub(*args, **kwargs, session=async_get_clientsession(hass))
|
||||
unique_id = entry.unique_id or entry.entry_id
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"] = GeniusBroker(hass, client, hub_uid)
|
||||
broker = entry.runtime_data = GeniusBroker(
|
||||
hass, client, entry.data.get(CONF_MAC, unique_id)
|
||||
)
|
||||
|
||||
try:
|
||||
await client.update()
|
||||
@@ -130,11 +199,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL)
|
||||
|
||||
for platform in PLATFORMS:
|
||||
hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config))
|
||||
|
||||
setup_service_functions(hass, broker)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -175,20 +243,13 @@ def setup_service_functions(hass: HomeAssistant, broker):
|
||||
class GeniusBroker:
|
||||
"""Container for geniushub client and data."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, client: GeniusHub, hub_uid: str | None
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, client: GeniusHub, hub_uid: str) -> None:
|
||||
"""Initialize the geniushub client."""
|
||||
self.hass = hass
|
||||
self.client = client
|
||||
self._hub_uid = hub_uid
|
||||
self.hub_uid = hub_uid
|
||||
self._connect_error = False
|
||||
|
||||
@property
|
||||
def hub_uid(self) -> str:
|
||||
"""Return the Hub UID (MAC address)."""
|
||||
return self._hub_uid if self._hub_uid is not None else self.client.uid
|
||||
|
||||
async def async_update(self, now, **kwargs) -> None:
|
||||
"""Update the geniushub client's data."""
|
||||
try:
|
||||
|
||||
@@ -5,33 +5,27 @@ from __future__ import annotations
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, GeniusDevice
|
||||
from . import GeniusDevice, GeniusHubConfigEntry
|
||||
|
||||
GH_STATE_ATTR = "outputOnOff"
|
||||
GH_TYPE = "Receiver"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
entry: GeniusHubConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Genius Hub sensor entities."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Set up the Genius Hub binary sensor entities."""
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"]
|
||||
broker = entry.runtime_data
|
||||
|
||||
switches = [
|
||||
async_add_entities(
|
||||
GeniusBinarySensor(broker, d, GH_STATE_ATTR)
|
||||
for d in broker.client.device_objs
|
||||
if GH_TYPE in d.data["type"]
|
||||
]
|
||||
|
||||
async_add_entities(switches, update_before_add=True)
|
||||
)
|
||||
|
||||
|
||||
class GeniusBinarySensor(GeniusDevice, BinarySensorEntity):
|
||||
|
||||
@@ -12,9 +12,8 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, GeniusHeatingZone
|
||||
from . import GeniusHeatingZone, GeniusHubConfigEntry
|
||||
|
||||
# GeniusHub Zones support: Off, Timer, Override/Boost, Footprint & Linked modes
|
||||
HA_HVAC_TO_GH = {HVACMode.OFF: "off", HVACMode.HEAT: "timer"}
|
||||
@@ -26,24 +25,19 @@ GH_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_GH.items()}
|
||||
GH_ZONES = ["radiator", "wet underfloor"]
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
entry: GeniusHubConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Genius Hub climate entities."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"]
|
||||
broker = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
GeniusClimateZone(broker, z)
|
||||
for z in broker.client.zone_objs
|
||||
if z.data.get("type") in GH_ZONES
|
||||
]
|
||||
GeniusClimateZone(broker, z)
|
||||
for z in broker.client.zone_objs
|
||||
if z.data.get("type") in GH_ZONES
|
||||
)
|
||||
|
||||
|
||||
|
||||
136
homeassistant/components/geniushub/config_flow.py
Normal file
136
homeassistant/components/geniushub/config_flow.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Config flow for Geniushub integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from geniushubclient import GeniusService
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CLOUD_API_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOCAL_API_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Geniushub."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User config step for determine cloud or local."""
|
||||
return self.async_show_menu(
|
||||
step_id="user",
|
||||
menu_options=["local_api", "cloud_api"],
|
||||
)
|
||||
|
||||
async def async_step_local_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Version 3 configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
}
|
||||
)
|
||||
service = GeniusService(
|
||||
user_input[CONF_HOST],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
response = await service.request("GET", "auth/release")
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if err.status == HTTPStatus.UNAUTHORIZED:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "invalid_host"
|
||||
except (TimeoutError, aiohttp.ClientConnectionError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(response["data"]["UID"])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="local_api", errors=errors, data_schema=LOCAL_API_SCHEMA
|
||||
)
|
||||
|
||||
async def async_step_cloud_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Version 1 configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
service = GeniusService(
|
||||
user_input[CONF_TOKEN], session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
await service.request("GET", "version")
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if err.status == HTTPStatus.UNAUTHORIZED:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "invalid_host"
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except (TimeoutError, aiohttp.ClientConnectionError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(title="Genius hub", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import the yaml config."""
|
||||
if CONF_HOST in user_input:
|
||||
result = await self.async_step_local_api(user_input)
|
||||
else:
|
||||
result = await self.async_step_cloud_api(user_input)
|
||||
if result["type"] is FlowResultType.FORM:
|
||||
assert result["errors"]
|
||||
return self.async_abort(reason=result["errors"]["base"])
|
||||
return result
|
||||
19
homeassistant/components/geniushub/const.py
Normal file
19
homeassistant/components/geniushub/const.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Constants for Genius Hub."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "geniushub"
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
SENSOR_PREFIX = "Genius"
|
||||
|
||||
PLATFORMS = (
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.WATER_HEATER,
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "geniushub",
|
||||
"name": "Genius Hub",
|
||||
"codeowners": ["@manzanotti"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/geniushub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["geniushubclient"],
|
||||
|
||||
@@ -9,10 +9,9 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import DOMAIN, GeniusDevice, GeniusEntity
|
||||
from . import GeniusDevice, GeniusEntity, GeniusHubConfigEntry
|
||||
|
||||
GH_STATE_ATTR = "batteryLevel"
|
||||
|
||||
@@ -23,17 +22,14 @@ GH_LEVEL_MAPPING = {
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
entry: GeniusHubConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Genius Hub sensor entities."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"]
|
||||
broker = entry.runtime_data
|
||||
|
||||
entities: list[GeniusBattery | GeniusIssue] = [
|
||||
GeniusBattery(broker, d, GH_STATE_ATTR)
|
||||
@@ -42,7 +38,7 @@ async def async_setup_platform(
|
||||
]
|
||||
entities.extend([GeniusIssue(broker, i) for i in list(GH_LEVEL_MAPPING)])
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GeniusBattery(GeniusDevice, SensorEntity):
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Genius Hub configuration",
|
||||
"menu_options": {
|
||||
"local_api": "Local: IP address and user credentials",
|
||||
"cloud_api": "Cloud: API token"
|
||||
}
|
||||
},
|
||||
"local_api": {
|
||||
"title": "Genius Hub local configuration",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
}
|
||||
},
|
||||
"cloud_api": {
|
||||
"title": "Genius Hub cloud configuration",
|
||||
"data": {
|
||||
"token": "[%key:common::config_flow::data::access_token%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
|
||||
"services": {
|
||||
"set_zone_mode": {
|
||||
"name": "Set zone mode",
|
||||
|
||||
@@ -11,9 +11,9 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from . import ATTR_DURATION, DOMAIN, GeniusZone
|
||||
from . import ATTR_DURATION, GeniusHubConfigEntry, GeniusZone
|
||||
|
||||
GH_ON_OFF_ZONE = "on / off"
|
||||
|
||||
@@ -27,24 +27,19 @@ SET_SWITCH_OVERRIDE_SCHEMA: VolDictType = {
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
entry: GeniusHubConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Genius Hub switch entities."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"]
|
||||
broker = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
GeniusSwitch(broker, z)
|
||||
for z in broker.client.zone_objs
|
||||
if z.data.get("type") == GH_ON_OFF_ZONE
|
||||
]
|
||||
GeniusSwitch(broker, z)
|
||||
for z in broker.client.zone_objs
|
||||
if z.data.get("type") == GH_ON_OFF_ZONE
|
||||
)
|
||||
|
||||
# Register custom services
|
||||
|
||||
@@ -9,9 +9,8 @@ from homeassistant.components.water_heater import (
|
||||
from homeassistant.const import STATE_OFF
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, GeniusHeatingZone
|
||||
from . import GeniusHeatingZone, GeniusHubConfigEntry
|
||||
|
||||
STATE_AUTO = "auto"
|
||||
STATE_MANUAL = "manual"
|
||||
@@ -33,24 +32,19 @@ GH_STATE_TO_HA = {
|
||||
GH_HEATERS = ["hot water temperature"]
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
entry: GeniusHubConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Genius Hub water_heater entities."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Set up the Genius Hub water heater entities."""
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"]
|
||||
broker = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
GeniusWaterHeater(broker, z)
|
||||
for z in broker.client.zone_objs
|
||||
if z.data.get("type") in GH_HEATERS
|
||||
]
|
||||
GeniusWaterHeater(broker, z)
|
||||
for z in broker.client.zone_objs
|
||||
if z.data.get("type") in GH_HEATERS
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,14 @@ SUPPORTED_SCHEMA_KEYS = {
|
||||
|
||||
def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Format the schema to protobuf."""
|
||||
if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")):
|
||||
for subschema in subschemas: # Gemini API does not support anyOf and allOf keys
|
||||
if "type" in subschema: # Fallback to first subschema with 'type' field
|
||||
return _format_schema(subschema)
|
||||
return _format_schema(
|
||||
subschemas[0]
|
||||
) # Or, if not found, to any of the subschemas
|
||||
|
||||
result = {}
|
||||
for key, val in schema.items():
|
||||
if key not in SUPPORTED_SCHEMA_KEYS:
|
||||
@@ -81,12 +89,22 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
key = "type_"
|
||||
val = val.upper()
|
||||
elif key == "format":
|
||||
if (schema.get("type") == "string" and val != "enum") or (
|
||||
schema.get("type") not in ("number", "integer", "string")
|
||||
):
|
||||
continue
|
||||
key = "format_"
|
||||
elif key == "items":
|
||||
val = _format_schema(val)
|
||||
elif key == "properties":
|
||||
val = {k: _format_schema(v) for k, v in val.items()}
|
||||
result[key] = val
|
||||
|
||||
if result.get("type_") == "OBJECT" and not result.get("properties"):
|
||||
# An object with undefined properties is not supported by Gemini API.
|
||||
# Fallback to JSON string. This will probably fail for most tools that want it,
|
||||
# but we don't have a better fallback strategy so far.
|
||||
result["properties"] = {"json": {"type_": "STRING"}}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -2,38 +2,40 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from govee_ble import GoveeBluetoothDeviceData, SensorUpdate
|
||||
from govee_ble import GoveeBluetoothDeviceData
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
from .coordinator import (
|
||||
GoveeBLEBluetoothProcessorCoordinator,
|
||||
GoveeBLEConfigEntry,
|
||||
process_service_info,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GoveeBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator[SensorUpdate]]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoveeBLEConfigEntry) -> bool:
|
||||
"""Set up Govee BLE device from a config entry."""
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
data = GoveeBluetoothDeviceData()
|
||||
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||
entry.runtime_data = coordinator = GoveeBLEBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=data.update,
|
||||
update_method=partial(process_service_info, hass, entry),
|
||||
device_data=data,
|
||||
entry=entry,
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
# only start after all platforms have had a chance to subscribe
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
|
||||
104
homeassistant/components/govee_ble/binary_sensor.py
Normal file
104
homeassistant/components/govee_ble/binary_sensor.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Support for govee-ble binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from govee_ble import (
|
||||
BinarySensorDeviceClass as GoveeBLEBinarySensorDeviceClass,
|
||||
SensorUpdate,
|
||||
)
|
||||
from govee_ble.parser import ERROR
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
|
||||
|
||||
from .coordinator import GoveeBLEPassiveBluetoothDataProcessor
|
||||
from .device import device_key_to_bluetooth_entity_key
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS = {
|
||||
GoveeBLEBinarySensorDeviceClass.WINDOW: BinarySensorEntityDescription(
|
||||
key=GoveeBLEBinarySensorDeviceClass.WINDOW,
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def sensor_update_to_bluetooth_data_update(
|
||||
sensor_update: SensorUpdate,
|
||||
) -> PassiveBluetoothDataUpdate:
|
||||
"""Convert a sensor update to a bluetooth data update."""
|
||||
return PassiveBluetoothDataUpdate(
|
||||
devices={
|
||||
device_id: sensor_device_info_to_hass_device_info(device_info)
|
||||
for device_id, device_info in sensor_update.devices.items()
|
||||
},
|
||||
entity_descriptions={
|
||||
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
|
||||
description.device_class
|
||||
]
|
||||
for device_key, description in sensor_update.binary_entity_descriptions.items()
|
||||
if description.device_class
|
||||
},
|
||||
entity_data={
|
||||
device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
|
||||
for device_key, sensor_values in sensor_update.binary_entity_values.items()
|
||||
},
|
||||
entity_names={
|
||||
device_key_to_bluetooth_entity_key(device_key): sensor_values.name
|
||||
for device_key, sensor_values in sensor_update.binary_entity_values.items()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: config_entries.ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the govee-ble BLE sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
|
||||
entry.async_on_unload(
|
||||
processor.async_add_entities_listener(
|
||||
GoveeBluetoothBinarySensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, BinarySensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class GoveeBluetoothBinarySensorEntity(
|
||||
PassiveBluetoothProcessorEntity[
|
||||
PassiveBluetoothDataProcessor[bool | None, SensorUpdate]
|
||||
],
|
||||
BinarySensorEntity,
|
||||
):
|
||||
"""Representation of a govee-ble binary sensor."""
|
||||
|
||||
processor: GoveeBLEPassiveBluetoothDataProcessor
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False if sensor is in error."""
|
||||
coordinator = self.processor.coordinator
|
||||
return self.processor.entity_data.get(self.entity_key) != ERROR and (
|
||||
((model_info := coordinator.model_info) and model_info.sleepy)
|
||||
or super().available
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the native value."""
|
||||
return self.processor.entity_data.get(self.entity_key)
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import (
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_DEVICE_TYPE, DOMAIN
|
||||
|
||||
|
||||
class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -26,7 +26,9 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_device: DeviceData | None = None
|
||||
self._discovered_devices: dict[str, str] = {}
|
||||
self._discovered_devices: dict[
|
||||
str, tuple[DeviceData, BluetoothServiceInfoBleak]
|
||||
] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
@@ -51,7 +53,9 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
discovery_info = self._discovery_info
|
||||
title = device.title or device.get_device_name() or discovery_info.name
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title=title, data={})
|
||||
return self.async_create_entry(
|
||||
title=title, data={CONF_DEVICE_TYPE: device.device_type}
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
placeholders = {"name": title}
|
||||
@@ -68,8 +72,10 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
address = user_input[CONF_ADDRESS]
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
device, service_info = self._discovered_devices[address]
|
||||
title = device.title or device.get_device_name() or service_info.name
|
||||
return self.async_create_entry(
|
||||
title=self._discovered_devices[address], data={}
|
||||
title=title, data={CONF_DEVICE_TYPE: device.device_type}
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
@@ -79,9 +85,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
continue
|
||||
device = DeviceData()
|
||||
if device.supported(discovery_info):
|
||||
self._discovered_devices[address] = (
|
||||
device.title or device.get_device_name() or discovery_info.name
|
||||
)
|
||||
self._discovered_devices[address] = (device, discovery_info)
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
@@ -89,6 +93,16 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
address: f"{device.get_device_name(None) or discovery_info.name} ({address})"
|
||||
for address, (
|
||||
device,
|
||||
discovery_info,
|
||||
) in self._discovered_devices.items()
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Constants for the Govee Bluetooth integration."""
|
||||
|
||||
DOMAIN = "govee_ble"
|
||||
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
88
homeassistant/components/govee_ble/coordinator.py
Normal file
88
homeassistant/components/govee_ble/coordinator.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""The govee Bluetooth integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from logging import Logger
|
||||
|
||||
from govee_ble import GoveeBluetoothDeviceData, ModelInfo, SensorUpdate, get_model_info
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, DOMAIN
|
||||
|
||||
type GoveeBLEConfigEntry = ConfigEntry[GoveeBLEBluetoothProcessorCoordinator]
|
||||
|
||||
|
||||
def process_service_info(
|
||||
hass: HomeAssistant,
|
||||
entry: GoveeBLEConfigEntry,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
) -> SensorUpdate:
|
||||
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
|
||||
coordinator = entry.runtime_data
|
||||
data = coordinator.device_data
|
||||
update = data.update(service_info)
|
||||
if not coordinator.model_info and (device_type := data.device_type):
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_DEVICE_TYPE: device_type}
|
||||
)
|
||||
coordinator.set_model_info(device_type)
|
||||
if update.events and hass.state is CoreState.running:
|
||||
# Do not fire events on data restore
|
||||
address = service_info.device.address
|
||||
for event in update.events.values():
|
||||
key = event.device_key.key
|
||||
signal = format_event_dispatcher_name(address, key)
|
||||
async_dispatcher_send(hass, signal)
|
||||
|
||||
return update
|
||||
|
||||
|
||||
def format_event_dispatcher_name(address: str, key: str) -> str:
|
||||
"""Format an event dispatcher name."""
|
||||
return f"{DOMAIN}_{address}_{key}"
|
||||
|
||||
|
||||
class GoveeBLEBluetoothProcessorCoordinator(
|
||||
PassiveBluetoothProcessorCoordinator[SensorUpdate]
|
||||
):
|
||||
"""Define a govee ble Bluetooth Passive Update Processor Coordinator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
address: str,
|
||||
mode: BluetoothScanningMode,
|
||||
update_method: Callable[[BluetoothServiceInfoBleak], SensorUpdate],
|
||||
device_data: GoveeBluetoothDeviceData,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the Govee BLE Bluetooth Passive Update Processor Coordinator."""
|
||||
super().__init__(hass, logger, address, mode, update_method)
|
||||
self.device_data = device_data
|
||||
self.entry = entry
|
||||
self.model_info: ModelInfo | None = None
|
||||
if device_type := entry.data.get(CONF_DEVICE_TYPE):
|
||||
self.set_model_info(device_type)
|
||||
|
||||
def set_model_info(self, device_type: str) -> None:
|
||||
"""Set the model info."""
|
||||
self.model_info = get_model_info(device_type)
|
||||
|
||||
|
||||
class GoveeBLEPassiveBluetoothDataProcessor[_T](
|
||||
PassiveBluetoothDataProcessor[_T, SensorUpdate]
|
||||
):
|
||||
"""Define a govee-ble Bluetooth Passive Update Data Processor."""
|
||||
|
||||
coordinator: GoveeBLEBluetoothProcessorCoordinator
|
||||
16
homeassistant/components/govee_ble/device.py
Normal file
16
homeassistant/components/govee_ble/device.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Support for govee-ble devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from govee_ble import DeviceKey
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothEntityKey,
|
||||
)
|
||||
|
||||
|
||||
def device_key_to_bluetooth_entity_key(
|
||||
device_key: DeviceKey,
|
||||
) -> PassiveBluetoothEntityKey:
|
||||
"""Convert a device key to an entity key."""
|
||||
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
|
||||
107
homeassistant/components/govee_ble/event.py
Normal file
107
homeassistant/components/govee_ble/event.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Support for govee_ble event entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from govee_ble import ModelInfo, SensorType
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_last_service_info,
|
||||
)
|
||||
from homeassistant.components.event import (
|
||||
EventDeviceClass,
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GoveeBLEConfigEntry, format_event_dispatcher_name
|
||||
|
||||
BUTTON_DESCRIPTIONS = [
|
||||
EventEntityDescription(
|
||||
key=f"button_{i}",
|
||||
translation_key=f"button_{i}",
|
||||
event_types=["press"],
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
MOTION_DESCRIPTION = EventEntityDescription(
|
||||
key="motion",
|
||||
event_types=["motion"],
|
||||
device_class=EventDeviceClass.MOTION,
|
||||
)
|
||||
|
||||
|
||||
class GoveeBluetoothEventEntity(EventEntity):
|
||||
"""Representation of a govee ble event entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_info: ModelInfo,
|
||||
service_info: BluetoothServiceInfoBleak | None,
|
||||
address: str,
|
||||
description: EventEntityDescription,
|
||||
) -> None:
|
||||
"""Initialise a govee ble event entity."""
|
||||
self.entity_description = description
|
||||
# Matches logic in PassiveBluetoothProcessorEntity
|
||||
name = service_info.name if service_info else model_info.model_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
name=name,
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
)
|
||||
self._attr_unique_id = f"{address}-{description.key}"
|
||||
self._address = address
|
||||
self._signal = format_event_dispatcher_name(
|
||||
self._address, self.entity_description.key
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Entity added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
self._signal,
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self) -> None:
|
||||
self._trigger_event(self.event_types[0])
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GoveeBLEConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a govee ble event."""
|
||||
coordinator = entry.runtime_data
|
||||
if not (model_info := coordinator.model_info):
|
||||
return
|
||||
address = coordinator.address
|
||||
sensor_type = model_info.sensor_type
|
||||
if sensor_type is SensorType.MOTION:
|
||||
descriptions = [MOTION_DESCRIPTION]
|
||||
elif sensor_type is SensorType.BUTTON:
|
||||
button_count = model_info.button_count
|
||||
descriptions = BUTTON_DESCRIPTIONS[0:button_count]
|
||||
else:
|
||||
return
|
||||
last_service_info = async_last_service_info(hass, address, False)
|
||||
async_add_entities(
|
||||
GoveeBluetoothEventEntity(model_info, last_service_info, address, description)
|
||||
for description in descriptions
|
||||
)
|
||||
@@ -114,5 +114,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-ble==0.37.0"]
|
||||
"requirements": ["govee-ble==0.38.0"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user