mirror of
https://github.com/home-assistant/core.git
synced 2026-04-28 18:12:37 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87a00eb80f | |||
| 72efcf0e94 | |||
| 148bb05dea | |||
| 4d01e0a773 | |||
| 15d8b84074 | |||
| 2208262ca5 | |||
| 0c2a0118e2 | |||
| 93fe46509f | |||
| 581efad5a7 | |||
| 0492639d51 |
@@ -111,7 +111,6 @@ components: &components
|
||||
- homeassistant/components/tag/**
|
||||
- homeassistant/components/template/**
|
||||
- homeassistant/components/timer/**
|
||||
- homeassistant/components/trace/**
|
||||
- homeassistant/components/usb/**
|
||||
- homeassistant/components/webhook/**
|
||||
- homeassistant/components/websocket_api/**
|
||||
@@ -127,11 +126,9 @@ tests: &tests
|
||||
- tests/*.py
|
||||
- tests/auth/**
|
||||
- tests/backports/**
|
||||
- tests/components/diagnostics/**
|
||||
- tests/components/history/**
|
||||
- tests/components/logbook/**
|
||||
- tests/components/recorder/**
|
||||
- tests/components/repairs/**
|
||||
- tests/components/sensor/**
|
||||
- tests/hassfest/**
|
||||
- tests/helpers/**
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
env:
|
||||
UV_PRERELEASE: allow
|
||||
run: |
|
||||
python3 -m pip install "$(grep '^uv' < requirements.txt)"
|
||||
python3 -m pip install "$(grep '^uv' < requirements_test.txt)"
|
||||
uv pip install packaging tomli
|
||||
uv pip install .
|
||||
python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}"
|
||||
@@ -316,7 +316,6 @@ jobs:
|
||||
packages: write
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
@@ -531,7 +530,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3
|
||||
uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -37,9 +37,9 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 11
|
||||
CACHE_VERSION: 10
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
MYPY_CACHE_VERSION: 8
|
||||
HA_SHORT_VERSION: "2024.10"
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
ALL_PYTHON_VERSIONS: "['3.12']"
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
pip install "$(grep '^uv' < requirements_test.txt)"
|
||||
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -476,7 +476,7 @@ jobs:
|
||||
- name: Generate partial uv restore key
|
||||
id: generate-uv-key
|
||||
run: |
|
||||
uv_version=$(cat requirements.txt | grep uv | cut -d '=' -f 3)
|
||||
uv_version=$(cat requirements_test.txt | grep uv | cut -d '=' -f 3)
|
||||
echo "version=${uv_version}" >> $GITHUB_OUTPUT
|
||||
echo "key=uv-${{ env.UV_CACHE_VERSION }}-${uv_version}-${{
|
||||
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
@@ -525,7 +525,7 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
pip install "$(grep '^uv' < requirements_test.txt)"
|
||||
uv pip install -U "pip>=21.3.1" setuptools wheel
|
||||
uv pip install -r requirements.txt
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.26.9
|
||||
uses: github/codeql-action/init@v3.26.6
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.26.9
|
||||
uses: github/codeql-action/analyze@v3.26.6
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
pip install "$(grep '^uv' < requirements_test.txt)"
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
- name: Get information
|
||||
@@ -131,12 +131,6 @@ jobs:
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Adjust build env
|
||||
run: |
|
||||
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2024.07.1
|
||||
with:
|
||||
@@ -180,18 +174,6 @@ jobs:
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
- name: Adjust build env
|
||||
run: |
|
||||
if [ "${{ matrix.arch }}" = "i386" ]; then
|
||||
echo "NPY_DISABLE_SVML=1" >> .env_file
|
||||
fi
|
||||
|
||||
# Do not pin numpy in wheels building
|
||||
sed -i "/numpy/d" homeassistant/package_constraints.txt
|
||||
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Split requirements all
|
||||
run: |
|
||||
# We split requirements all into multiple files.
|
||||
@@ -212,6 +194,15 @@ jobs:
|
||||
cat homeassistant/package_constraints.txt | grep 'grpcio==' >> requirements_old-cython.txt
|
||||
cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
|
||||
|
||||
- name: Adjust build env
|
||||
run: |
|
||||
if [ "${{ matrix.arch }}" = "i386" ]; then
|
||||
echo "NPY_DISABLE_SVML=1" >> .env_file
|
||||
fi
|
||||
|
||||
# Do not pin numpy in wheels building
|
||||
sed -i "/numpy/d" homeassistant/package_constraints.txt
|
||||
|
||||
- name: Build wheels (old cython)
|
||||
uses: home-assistant/wheels@2024.07.1
|
||||
with:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.6
|
||||
rev: v0.6.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -83,7 +83,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements\.txt)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements_test.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata
|
||||
|
||||
@@ -415,7 +415,6 @@ homeassistant.components.skybell.*
|
||||
homeassistant.components.slack.*
|
||||
homeassistant.components.sleepiq.*
|
||||
homeassistant.components.smhi.*
|
||||
homeassistant.components.smlight.*
|
||||
homeassistant.components.snooz.*
|
||||
homeassistant.components.solarlog.*
|
||||
homeassistant.components.sonarr.*
|
||||
@@ -481,7 +480,6 @@ homeassistant.components.update.*
|
||||
homeassistant.components.uptime.*
|
||||
homeassistant.components.uptimerobot.*
|
||||
homeassistant.components.usb.*
|
||||
homeassistant.components.uvc.*
|
||||
homeassistant.components.vacuum.*
|
||||
homeassistant.components.vallox.*
|
||||
homeassistant.components.valve.*
|
||||
|
||||
+12
-21
@@ -48,7 +48,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/adax/ @danielhiversen
|
||||
/homeassistant/components/adguard/ @frenck
|
||||
/tests/components/adguard/ @frenck
|
||||
/homeassistant/components/ads/ @mrpasztoradam
|
||||
/homeassistant/components/advantage_air/ @Bre77
|
||||
/tests/components/advantage_air/ @Bre77
|
||||
/homeassistant/components/aemet/ @Noltari
|
||||
@@ -239,8 +238,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/button/ @home-assistant/core
|
||||
/homeassistant/components/calendar/ @home-assistant/core
|
||||
/tests/components/calendar/ @home-assistant/core
|
||||
/homeassistant/components/cambridge_audio/ @noahhusby
|
||||
/tests/components/cambridge_audio/ @noahhusby
|
||||
/homeassistant/components/camera/ @home-assistant/core
|
||||
/tests/components/camera/ @home-assistant/core
|
||||
/homeassistant/components/cast/ @emontnemery
|
||||
@@ -360,8 +357,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dsmr/ @Robbie1221
|
||||
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/homeassistant/components/duke_energy/ @hunterjm
|
||||
/tests/components/duke_energy/ @hunterjm
|
||||
/homeassistant/components/duotecno/ @cereal2nd
|
||||
/tests/components/duotecno/ @cereal2nd
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
|
||||
@@ -817,6 +812,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/lifx/ @Djelibeybi
|
||||
@@ -861,8 +858,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lupusec/ @majuss @suaveolent
|
||||
/homeassistant/components/lutron/ @cdheiser @wilburCForce
|
||||
/tests/components/lutron/ @cdheiser @wilburCForce
|
||||
/homeassistant/components/lutron_caseta/ @swails @danaues @eclair4151
|
||||
/tests/components/lutron_caseta/ @swails @danaues @eclair4151
|
||||
/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
|
||||
/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
|
||||
/homeassistant/components/lyric/ @timmo001
|
||||
/tests/components/lyric/ @timmo001
|
||||
/homeassistant/components/madvr/ @iloveicedgreentea
|
||||
@@ -925,8 +922,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/modern_forms/ @wonderslug
|
||||
/homeassistant/components/moehlenhoff_alpha2/ @j-a-n
|
||||
/tests/components/moehlenhoff_alpha2/ @j-a-n
|
||||
/homeassistant/components/monarch_money/ @jeeftor
|
||||
/tests/components/monarch_money/ @jeeftor
|
||||
/homeassistant/components/monoprice/ @etsinko @OnFreund
|
||||
/tests/components/monoprice/ @etsinko @OnFreund
|
||||
/homeassistant/components/monzo/ @jakemartin-icl
|
||||
@@ -1024,8 +1019,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/homeassistant/components/nws/ @MatthewFlamm @kamiyo
|
||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||
/homeassistant/components/nyt_games/ @joostlek
|
||||
/tests/components/nyt_games/ @joostlek
|
||||
/homeassistant/components/nzbget/ @chriscla
|
||||
/tests/components/nzbget/ @chriscla
|
||||
/homeassistant/components/obihai/ @dshokouhi @ejpenney
|
||||
@@ -1104,6 +1097,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/pi_hole/ @shenxn
|
||||
/homeassistant/components/picnic/ @corneyl
|
||||
/tests/components/picnic/ @corneyl
|
||||
/homeassistant/components/pilight/ @trekky12
|
||||
/tests/components/pilight/ @trekky12
|
||||
/homeassistant/components/ping/ @jpbede
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
@@ -1133,8 +1128,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/proximity/ @mib1185
|
||||
/tests/components/proximity/ @mib1185
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
|
||||
/homeassistant/components/prusalink/ @balloob
|
||||
/tests/components/prusalink/ @balloob
|
||||
/homeassistant/components/prusalink/ @balloob @Skaronator
|
||||
/tests/components/prusalink/ @balloob @Skaronator
|
||||
/homeassistant/components/ps4/ @ktnrg45
|
||||
/tests/components/ps4/ @ktnrg45
|
||||
/homeassistant/components/pure_energie/ @klaasnicolaas
|
||||
@@ -1434,10 +1429,10 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/tests/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland
|
||||
/homeassistant/components/switcher_kis/ @thecode
|
||||
/tests/components/switcher_kis/ @thecode
|
||||
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
|
||||
/homeassistant/components/syncthing/ @zhulik
|
||||
/tests/components/syncthing/ @zhulik
|
||||
@@ -1541,8 +1536,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins
|
||||
/homeassistant/components/trend/ @jpbede
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
@@ -1666,8 +1659,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/wiz/ @sbidy
|
||||
/homeassistant/components/wled/ @frenck
|
||||
/tests/components/wled/ @frenck
|
||||
/homeassistant/components/wmspro/ @mback2k
|
||||
/tests/components/wmspro/ @mback2k
|
||||
/homeassistant/components/wolflink/ @adamkrol93 @mtielen
|
||||
/tests/components/wolflink/ @adamkrol93 @mtielen
|
||||
/homeassistant/components/workday/ @fabaff @gjohansson-ST
|
||||
|
||||
+10
-4
@@ -12,7 +12,7 @@ ENV \
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.4.15
|
||||
RUN pip3 install uv==0.2.27
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
@@ -29,9 +29,15 @@ RUN \
|
||||
if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \
|
||||
uv pip install homeassistant/home_assistant_*.whl; \
|
||||
fi \
|
||||
&& uv pip install \
|
||||
--no-build \
|
||||
-r homeassistant/requirements_all.txt
|
||||
&& if [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
linux32 uv pip install \
|
||||
--no-build \
|
||||
-r homeassistant/requirements_all.txt; \
|
||||
else \
|
||||
uv pip install \
|
||||
--no-build \
|
||||
-r homeassistant/requirements_all.txt; \
|
||||
fi
|
||||
|
||||
## Setup Home Assistant Core
|
||||
COPY . homeassistant/
|
||||
|
||||
@@ -127,11 +127,7 @@ class AuthManagerFlowManager(
|
||||
flow: data_entry_flow.FlowHandler[AuthFlowResult, tuple[str, str]],
|
||||
result: AuthFlowResult,
|
||||
) -> AuthFlowResult:
|
||||
"""Return a user as result of login flow.
|
||||
|
||||
This method is called when a flow step returns FlowResultType.ABORT or
|
||||
FlowResultType.CREATE_ENTRY.
|
||||
"""
|
||||
"""Return a user as result of login flow."""
|
||||
flow = cast(LoginFlow, flow)
|
||||
|
||||
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "aqara",
|
||||
"name": "Aqara",
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "lg",
|
||||
"name": "LG",
|
||||
"integrations": ["lg_netcast", "lg_soundbar", "webostv"]
|
||||
"integrations": ["lg_netcast", "lg_thinq", "lg_soundbar", "webostv"]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from jaraco.abode.client import Client as Abode
|
||||
import jaraco.abode.config
|
||||
from jaraco.abode.exceptions import (
|
||||
AuthenticationException as AbodeAuthenticationException,
|
||||
Exception as AbodeException,
|
||||
@@ -95,9 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
polling = entry.data[CONF_POLLING]
|
||||
|
||||
# Configure abode library to use config directory for storing data
|
||||
jaraco.abode.config.paths.override(user_data=Path(hass.config.path("Abode")))
|
||||
|
||||
# For previous config entries where unique_id is None
|
||||
if entry.unique_id is None:
|
||||
hass.config_entries.async_update_entry(
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from jaraco.abode.devices.binary_sensor import BinarySensor
|
||||
from jaraco.abode.devices.sensor import BinarySensor
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["jaraco.abode", "lomond"],
|
||||
"requirements": ["jaraco.abode==6.2.1"]
|
||||
"requirements": ["jaraco.abode==5.2.1"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from .const import ACMEDA_ENTITY_REMOVE, DOMAIN, LOGGER
|
||||
|
||||
|
||||
class AcmedaEntity(entity.Entity):
|
||||
class AcmedaBase(entity.Entity):
|
||||
"""Base representation of an Acmeda roller."""
|
||||
|
||||
_attr_should_poll = False
|
||||
@@ -14,8 +14,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AcmedaConfigEntry
|
||||
from .base import AcmedaBase
|
||||
from .const import ACMEDA_HUB_UPDATE
|
||||
from .entity import AcmedaEntity
|
||||
from .helpers import async_add_acmeda_entities
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class AcmedaCover(AcmedaEntity, CoverEntity):
|
||||
class AcmedaCover(AcmedaBase, CoverEntity):
|
||||
"""Representation of an Acmeda cover device."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
@@ -9,8 +9,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AcmedaConfigEntry
|
||||
from .base import AcmedaBase
|
||||
from .const import ACMEDA_HUB_UPDATE
|
||||
from .entity import AcmedaEntity
|
||||
from .helpers import async_add_acmeda_entities
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class AcmedaBattery(AcmedaEntity, SensorEntity):
|
||||
class AcmedaBattery(AcmedaBase, SensorEntity):
|
||||
"""Representation of an Acmeda cover sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Final
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
DOMAIN,
|
||||
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
|
||||
DeviceScanner,
|
||||
)
|
||||
@@ -36,7 +36,7 @@ def get_scanner(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ActiontecDeviceScanner | None:
|
||||
"""Validate the configuration and return an Actiontec scanner."""
|
||||
scanner = ActiontecDeviceScanner(config[DEVICE_TRACKER_DOMAIN])
|
||||
scanner = ActiontecDeviceScanner(config[DOMAIN])
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class ActiontecDeviceScanner(DeviceScanner):
|
||||
self.last_results: list[Device] = []
|
||||
data = self.get_actiontec_data()
|
||||
self.success_init = data is not None
|
||||
_LOGGER.info("Scanner initialized")
|
||||
|
||||
def scan_devices(self) -> list[str]:
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
@@ -69,7 +70,7 @@ class ActiontecDeviceScanner(DeviceScanner):
|
||||
|
||||
Return boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.debug("Scanning")
|
||||
_LOGGER.info("Scanning")
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
@@ -78,7 +79,7 @@ class ActiontecDeviceScanner(DeviceScanner):
|
||||
self.last_results = [
|
||||
device for device in actiontec_data if device.timevalid > -60
|
||||
]
|
||||
_LOGGER.debug("Scan successful")
|
||||
_LOGGER.info("Scan successful")
|
||||
return True
|
||||
|
||||
def get_actiontec_data(self) -> list[Device] | None:
|
||||
|
||||
@@ -130,7 +130,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async_get_clientsession(self.hass), account_id, password
|
||||
)
|
||||
if token is None:
|
||||
_LOGGER.debug("Adax: Failed to login to retrieve token")
|
||||
_LOGGER.info("Adax: Failed to login to retrieve token")
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="cloud",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Support for Automation Device Specification (ADS)."""
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from collections import namedtuple
|
||||
import ctypes
|
||||
import logging
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pyads
|
||||
import voluptuous as vol
|
||||
@@ -13,38 +19,64 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType
|
||||
from .hub import AdsHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_ADS = "data_ads"
|
||||
|
||||
# Supported Types
|
||||
ADSTYPE_BOOL = "bool"
|
||||
ADSTYPE_BYTE = "byte"
|
||||
ADSTYPE_INT = "int"
|
||||
ADSTYPE_UINT = "uint"
|
||||
ADSTYPE_SINT = "sint"
|
||||
ADSTYPE_USINT = "usint"
|
||||
ADSTYPE_DINT = "dint"
|
||||
ADSTYPE_UDINT = "udint"
|
||||
ADSTYPE_WORD = "word"
|
||||
ADSTYPE_DWORD = "dword"
|
||||
ADSTYPE_LREAL = "lreal"
|
||||
ADSTYPE_REAL = "real"
|
||||
ADSTYPE_STRING = "string"
|
||||
ADSTYPE_TIME = "time"
|
||||
ADSTYPE_DATE = "date"
|
||||
ADSTYPE_DATE_AND_TIME = "dt"
|
||||
ADSTYPE_TOD = "tod"
|
||||
|
||||
ADS_TYPEMAP = {
|
||||
AdsType.BOOL: pyads.PLCTYPE_BOOL,
|
||||
AdsType.BYTE: pyads.PLCTYPE_BYTE,
|
||||
AdsType.INT: pyads.PLCTYPE_INT,
|
||||
AdsType.UINT: pyads.PLCTYPE_UINT,
|
||||
AdsType.SINT: pyads.PLCTYPE_SINT,
|
||||
AdsType.USINT: pyads.PLCTYPE_USINT,
|
||||
AdsType.DINT: pyads.PLCTYPE_DINT,
|
||||
AdsType.UDINT: pyads.PLCTYPE_UDINT,
|
||||
AdsType.WORD: pyads.PLCTYPE_WORD,
|
||||
AdsType.DWORD: pyads.PLCTYPE_DWORD,
|
||||
AdsType.REAL: pyads.PLCTYPE_REAL,
|
||||
AdsType.LREAL: pyads.PLCTYPE_LREAL,
|
||||
AdsType.STRING: pyads.PLCTYPE_STRING,
|
||||
AdsType.TIME: pyads.PLCTYPE_TIME,
|
||||
AdsType.DATE: pyads.PLCTYPE_DATE,
|
||||
AdsType.DATE_AND_TIME: pyads.PLCTYPE_DT,
|
||||
AdsType.TOD: pyads.PLCTYPE_TOD,
|
||||
ADSTYPE_BOOL: pyads.PLCTYPE_BOOL,
|
||||
ADSTYPE_BYTE: pyads.PLCTYPE_BYTE,
|
||||
ADSTYPE_INT: pyads.PLCTYPE_INT,
|
||||
ADSTYPE_UINT: pyads.PLCTYPE_UINT,
|
||||
ADSTYPE_SINT: pyads.PLCTYPE_SINT,
|
||||
ADSTYPE_USINT: pyads.PLCTYPE_USINT,
|
||||
ADSTYPE_DINT: pyads.PLCTYPE_DINT,
|
||||
ADSTYPE_UDINT: pyads.PLCTYPE_UDINT,
|
||||
ADSTYPE_WORD: pyads.PLCTYPE_WORD,
|
||||
ADSTYPE_DWORD: pyads.PLCTYPE_DWORD,
|
||||
ADSTYPE_REAL: pyads.PLCTYPE_REAL,
|
||||
ADSTYPE_LREAL: pyads.PLCTYPE_LREAL,
|
||||
ADSTYPE_STRING: pyads.PLCTYPE_STRING,
|
||||
ADSTYPE_TIME: pyads.PLCTYPE_TIME,
|
||||
ADSTYPE_DATE: pyads.PLCTYPE_DATE,
|
||||
ADSTYPE_DATE_AND_TIME: pyads.PLCTYPE_DT,
|
||||
ADSTYPE_TOD: pyads.PLCTYPE_TOD,
|
||||
}
|
||||
|
||||
CONF_ADS_FACTOR = "factor"
|
||||
CONF_ADS_TYPE = "adstype"
|
||||
CONF_ADS_VALUE = "value"
|
||||
CONF_ADS_VAR = "adsvar"
|
||||
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
|
||||
CONF_ADS_VAR_POSITION = "adsvar_position"
|
||||
|
||||
STATE_KEY_STATE = "state"
|
||||
STATE_KEY_BRIGHTNESS = "brightness"
|
||||
STATE_KEY_POSITION = "position"
|
||||
|
||||
DOMAIN = "ads"
|
||||
|
||||
SERVICE_WRITE_DATA_BY_NAME = "write_data_by_name"
|
||||
|
||||
@@ -63,7 +95,27 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADS_TYPE): vol.Coerce(AdsType),
|
||||
vol.Required(CONF_ADS_TYPE): vol.In(
|
||||
[
|
||||
ADSTYPE_BOOL,
|
||||
ADSTYPE_BYTE,
|
||||
ADSTYPE_INT,
|
||||
ADSTYPE_UINT,
|
||||
ADSTYPE_SINT,
|
||||
ADSTYPE_USINT,
|
||||
ADSTYPE_DINT,
|
||||
ADSTYPE_UDINT,
|
||||
ADSTYPE_WORD,
|
||||
ADSTYPE_DWORD,
|
||||
ADSTYPE_REAL,
|
||||
ADSTYPE_LREAL,
|
||||
ADSTYPE_STRING,
|
||||
ADSTYPE_TIME,
|
||||
ADSTYPE_DATE,
|
||||
ADSTYPE_DATE_AND_TIME,
|
||||
ADSTYPE_TOD,
|
||||
]
|
||||
),
|
||||
vol.Required(CONF_ADS_VALUE): vol.Coerce(int),
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
}
|
||||
@@ -97,9 +149,9 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
def handle_write_data_by_name(call: ServiceCall) -> None:
|
||||
"""Write a value to the connected ADS device."""
|
||||
ads_var: str = call.data[CONF_ADS_VAR]
|
||||
ads_type: AdsType = call.data[CONF_ADS_TYPE]
|
||||
value: int = call.data[CONF_ADS_VALUE]
|
||||
ads_var = call.data[CONF_ADS_VAR]
|
||||
ads_type = call.data[CONF_ADS_TYPE]
|
||||
value = call.data[CONF_ADS_VALUE]
|
||||
|
||||
try:
|
||||
ads.write_by_name(ads_var, value, ADS_TYPEMAP[ads_type])
|
||||
@@ -114,3 +166,197 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Tuple to hold data needed for notification
|
||||
NotificationItem = namedtuple( # noqa: PYI024
|
||||
"NotificationItem", "hnotify huser name plc_datatype callback"
|
||||
)
|
||||
|
||||
|
||||
class AdsHub:
|
||||
"""Representation of an ADS connection."""
|
||||
|
||||
def __init__(self, ads_client):
|
||||
"""Initialize the ADS hub."""
|
||||
self._client = ads_client
|
||||
self._client.open()
|
||||
|
||||
# All ADS devices are registered here
|
||||
self._devices = []
|
||||
self._notification_items = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
"""Shutdown ADS connection."""
|
||||
|
||||
_LOGGER.debug("Shutting down ADS")
|
||||
for notification_item in self._notification_items.values():
|
||||
_LOGGER.debug(
|
||||
"Deleting device notification %d, %d",
|
||||
notification_item.hnotify,
|
||||
notification_item.huser,
|
||||
)
|
||||
try:
|
||||
self._client.del_device_notification(
|
||||
notification_item.hnotify, notification_item.huser
|
||||
)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error(err)
|
||||
try:
|
||||
self._client.close()
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
def register_device(self, device):
|
||||
"""Register a new device."""
|
||||
self._devices.append(device)
|
||||
|
||||
def write_by_name(self, name, value, plc_datatype):
|
||||
"""Write a value to the device."""
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
return self._client.write_by_name(name, value, plc_datatype)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error("Error writing %s: %s", name, err)
|
||||
|
||||
def read_by_name(self, name, plc_datatype):
|
||||
"""Read a value from the device."""
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
return self._client.read_by_name(name, plc_datatype)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error("Error reading %s: %s", name, err)
|
||||
|
||||
def add_device_notification(self, name, plc_datatype, callback):
|
||||
"""Add a notification to the ADS devices."""
|
||||
|
||||
attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype))
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
hnotify, huser = self._client.add_device_notification(
|
||||
name, attr, self._device_notification_callback
|
||||
)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error("Error subscribing to %s: %s", name, err)
|
||||
else:
|
||||
hnotify = int(hnotify)
|
||||
self._notification_items[hnotify] = NotificationItem(
|
||||
hnotify, huser, name, plc_datatype, callback
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Added device notification %d for variable %s", hnotify, name
|
||||
)
|
||||
|
||||
def _device_notification_callback(self, notification, name):
|
||||
"""Handle device notifications."""
|
||||
contents = notification.contents
|
||||
hnotify = int(contents.hNotification)
|
||||
_LOGGER.debug("Received notification %d", hnotify)
|
||||
|
||||
# Get dynamically sized data array
|
||||
data_size = contents.cbSampleSize
|
||||
data_address = (
|
||||
ctypes.addressof(contents)
|
||||
+ pyads.structs.SAdsNotificationHeader.data.offset
|
||||
)
|
||||
data = (ctypes.c_ubyte * data_size).from_address(data_address)
|
||||
|
||||
# Acquire notification item
|
||||
with self._lock:
|
||||
notification_item = self._notification_items.get(hnotify)
|
||||
|
||||
if not notification_item:
|
||||
_LOGGER.error("Unknown device notification handle: %d", hnotify)
|
||||
return
|
||||
|
||||
# Data parsing based on PLC data type
|
||||
plc_datatype = notification_item.plc_datatype
|
||||
unpack_formats = {
|
||||
pyads.PLCTYPE_BYTE: "<b",
|
||||
pyads.PLCTYPE_INT: "<h",
|
||||
pyads.PLCTYPE_UINT: "<H",
|
||||
pyads.PLCTYPE_SINT: "<b",
|
||||
pyads.PLCTYPE_USINT: "<B",
|
||||
pyads.PLCTYPE_DINT: "<i",
|
||||
pyads.PLCTYPE_UDINT: "<I",
|
||||
pyads.PLCTYPE_WORD: "<H",
|
||||
pyads.PLCTYPE_DWORD: "<I",
|
||||
pyads.PLCTYPE_LREAL: "<d",
|
||||
pyads.PLCTYPE_REAL: "<f",
|
||||
pyads.PLCTYPE_TOD: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_DATE: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_DT: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_TIME: "<i", # Treat as DINT
|
||||
}
|
||||
|
||||
if plc_datatype == pyads.PLCTYPE_BOOL:
|
||||
value = bool(struct.unpack("<?", bytearray(data))[0])
|
||||
elif plc_datatype == pyads.PLCTYPE_STRING:
|
||||
value = (
|
||||
bytearray(data).split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
|
||||
)
|
||||
elif plc_datatype in unpack_formats:
|
||||
value = struct.unpack(unpack_formats[plc_datatype], bytearray(data))[0]
|
||||
else:
|
||||
value = bytearray(data)
|
||||
_LOGGER.warning("No callback available for this datatype")
|
||||
|
||||
notification_item.callback(notification_item.name, value)
|
||||
|
||||
|
||||
class AdsEntity(Entity):
|
||||
"""Representation of ADS entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, ads_hub, name, ads_var):
|
||||
"""Initialize ADS binary sensor."""
|
||||
self._state_dict = {}
|
||||
self._state_dict[STATE_KEY_STATE] = None
|
||||
self._ads_hub = ads_hub
|
||||
self._ads_var = ads_var
|
||||
self._event = None
|
||||
self._attr_unique_id = ads_var
|
||||
self._attr_name = name
|
||||
|
||||
async def async_initialize_device(
|
||||
self, ads_var, plctype, state_key=STATE_KEY_STATE, factor=None
|
||||
):
|
||||
"""Register device notification."""
|
||||
|
||||
def update(name, value):
|
||||
"""Handle device notifications."""
|
||||
_LOGGER.debug("Variable %s changed its value to %d", name, value)
|
||||
|
||||
if factor is None:
|
||||
self._state_dict[state_key] = value
|
||||
else:
|
||||
self._state_dict[state_key] = value / factor
|
||||
|
||||
asyncio.run_coroutine_threadsafe(async_event_set(), self.hass.loop)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_event_set():
|
||||
"""Set event in async context."""
|
||||
self._event.set()
|
||||
|
||||
self._event = asyncio.Event()
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._ads_hub.add_device_notification, ads_var, plctype, update
|
||||
)
|
||||
try:
|
||||
async with timeout(10):
|
||||
await self._event.wait()
|
||||
except TimeoutError:
|
||||
_LOGGER.debug("Variable %s: Timeout during first update", ads_var)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False if state has not been updated yet."""
|
||||
return self._state_dict[STATE_KEY_STATE] is not None
|
||||
|
||||
@@ -17,9 +17,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE
|
||||
from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity
|
||||
|
||||
DEFAULT_NAME = "ADS binary sensor"
|
||||
PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
|
||||
@@ -38,11 +36,11 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Binary Sensor platform for ADS."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
ads_hub = hass.data.get(DATA_ADS)
|
||||
|
||||
ads_var: str = config[CONF_ADS_VAR]
|
||||
name: str = config[CONF_NAME]
|
||||
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
ads_var = config[CONF_ADS_VAR]
|
||||
name = config[CONF_NAME]
|
||||
device_class = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
ads_sensor = AdsBinarySensor(ads_hub, name, ads_var, device_class)
|
||||
add_entities([ads_sensor])
|
||||
@@ -51,13 +49,7 @@ def setup_platform(
|
||||
class AdsBinarySensor(AdsEntity, BinarySensorEntity):
|
||||
"""Representation of ADS binary sensors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ads_hub: AdsHub,
|
||||
name: str,
|
||||
ads_var: str,
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
) -> None:
|
||||
def __init__(self, ads_hub, name, ads_var, device_class):
|
||||
"""Initialize ADS binary sensor."""
|
||||
super().__init__(ads_hub, name, ads_var)
|
||||
self._attr_device_class = device_class or BinarySensorDeviceClass.MOVING
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Support for Automation Device Specification (ADS)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .hub import AdsHub
|
||||
|
||||
DOMAIN = "ads"
|
||||
|
||||
DATA_ADS: HassKey[AdsHub] = HassKey(DOMAIN)
|
||||
|
||||
CONF_ADS_VAR = "adsvar"
|
||||
|
||||
STATE_KEY_STATE = "state"
|
||||
|
||||
|
||||
class AdsType(StrEnum):
|
||||
"""Supported Types."""
|
||||
|
||||
BOOL = "bool"
|
||||
BYTE = "byte"
|
||||
INT = "int"
|
||||
UINT = "uint"
|
||||
SINT = "sint"
|
||||
USINT = "usint"
|
||||
DINT = "dint"
|
||||
UDINT = "udint"
|
||||
WORD = "word"
|
||||
DWORD = "dword"
|
||||
LREAL = "lreal"
|
||||
REAL = "real"
|
||||
STRING = "string"
|
||||
TIME = "time"
|
||||
DATE = "date"
|
||||
DATE_AND_TIME = "dt"
|
||||
TOD = "tod"
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
@@ -21,9 +20,14 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE
|
||||
from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
from . import (
|
||||
CONF_ADS_VAR,
|
||||
CONF_ADS_VAR_POSITION,
|
||||
DATA_ADS,
|
||||
STATE_KEY_POSITION,
|
||||
STATE_KEY_STATE,
|
||||
AdsEntity,
|
||||
)
|
||||
|
||||
DEFAULT_NAME = "ADS Cover"
|
||||
|
||||
@@ -31,9 +35,6 @@ CONF_ADS_VAR_SET_POS = "adsvar_set_position"
|
||||
CONF_ADS_VAR_OPEN = "adsvar_open"
|
||||
CONF_ADS_VAR_CLOSE = "adsvar_close"
|
||||
CONF_ADS_VAR_STOP = "adsvar_stop"
|
||||
CONF_ADS_VAR_POSITION = "adsvar_position"
|
||||
|
||||
STATE_KEY_POSITION = "position"
|
||||
|
||||
PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -58,14 +59,14 @@ def setup_platform(
|
||||
"""Set up the cover platform for ADS."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
|
||||
ads_var_is_closed: str = config[CONF_ADS_VAR]
|
||||
ads_var_position: str | None = config.get(CONF_ADS_VAR_POSITION)
|
||||
ads_var_pos_set: str | None = config.get(CONF_ADS_VAR_SET_POS)
|
||||
ads_var_open: str | None = config.get(CONF_ADS_VAR_OPEN)
|
||||
ads_var_close: str | None = config.get(CONF_ADS_VAR_CLOSE)
|
||||
ads_var_stop: str | None = config.get(CONF_ADS_VAR_STOP)
|
||||
name: str = config[CONF_NAME]
|
||||
device_class: CoverDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
ads_var_is_closed = config.get(CONF_ADS_VAR)
|
||||
ads_var_position = config.get(CONF_ADS_VAR_POSITION)
|
||||
ads_var_pos_set = config.get(CONF_ADS_VAR_SET_POS)
|
||||
ads_var_open = config.get(CONF_ADS_VAR_OPEN)
|
||||
ads_var_close = config.get(CONF_ADS_VAR_CLOSE)
|
||||
ads_var_stop = config.get(CONF_ADS_VAR_STOP)
|
||||
name = config[CONF_NAME]
|
||||
device_class = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
add_entities(
|
||||
[
|
||||
@@ -89,16 +90,16 @@ class AdsCover(AdsEntity, CoverEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ads_hub: AdsHub,
|
||||
ads_var_is_closed: str,
|
||||
ads_var_position: str | None,
|
||||
ads_var_pos_set: str | None,
|
||||
ads_var_open: str | None,
|
||||
ads_var_close: str | None,
|
||||
ads_var_stop: str | None,
|
||||
name: str,
|
||||
device_class: CoverDeviceClass | None,
|
||||
) -> None:
|
||||
ads_hub,
|
||||
ads_var_is_closed,
|
||||
ads_var_position,
|
||||
ads_var_pos_set,
|
||||
ads_var_open,
|
||||
ads_var_close,
|
||||
ads_var_stop,
|
||||
name,
|
||||
device_class,
|
||||
):
|
||||
"""Initialize AdsCover entity."""
|
||||
super().__init__(ads_hub, name, ads_var_is_closed)
|
||||
if self._attr_unique_id is None:
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Support for Automation Device Specification (ADS)."""
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import STATE_KEY_STATE
|
||||
from .hub import AdsHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdsEntity(Entity):
|
||||
"""Representation of ADS entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, ads_hub: AdsHub, name: str, ads_var: str) -> None:
|
||||
"""Initialize ADS binary sensor."""
|
||||
self._state_dict: dict[str, Any] = {}
|
||||
self._state_dict[STATE_KEY_STATE] = None
|
||||
self._ads_hub = ads_hub
|
||||
self._ads_var = ads_var
|
||||
self._event: asyncio.Event | None = None
|
||||
self._attr_unique_id = ads_var
|
||||
self._attr_name = name
|
||||
|
||||
async def async_initialize_device(
|
||||
self,
|
||||
ads_var: str,
|
||||
plctype: type,
|
||||
state_key: str = STATE_KEY_STATE,
|
||||
factor: int | None = None,
|
||||
) -> None:
|
||||
"""Register device notification."""
|
||||
|
||||
def update(name, value):
|
||||
"""Handle device notifications."""
|
||||
_LOGGER.debug("Variable %s changed its value to %d", name, value)
|
||||
|
||||
if factor is None:
|
||||
self._state_dict[state_key] = value
|
||||
else:
|
||||
self._state_dict[state_key] = value / factor
|
||||
|
||||
asyncio.run_coroutine_threadsafe(async_event_set(), self.hass.loop)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_event_set():
|
||||
"""Set event in async context."""
|
||||
self._event.set()
|
||||
|
||||
self._event = asyncio.Event()
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._ads_hub.add_device_notification, ads_var, plctype, update
|
||||
)
|
||||
try:
|
||||
async with timeout(10):
|
||||
await self._event.wait()
|
||||
except TimeoutError:
|
||||
_LOGGER.debug("Variable %s: Timeout during first update", ads_var)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False if state has not been updated yet."""
|
||||
return self._state_dict[STATE_KEY_STATE] is not None
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Support for Automation Device Specification (ADS)."""
|
||||
|
||||
from collections import namedtuple
|
||||
import ctypes
|
||||
import logging
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pyads
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Tuple to hold data needed for notification
|
||||
NotificationItem = namedtuple( # noqa: PYI024
|
||||
"NotificationItem", "hnotify huser name plc_datatype callback"
|
||||
)
|
||||
|
||||
|
||||
class AdsHub:
|
||||
"""Representation of an ADS connection."""
|
||||
|
||||
def __init__(self, ads_client):
|
||||
"""Initialize the ADS hub."""
|
||||
self._client = ads_client
|
||||
self._client.open()
|
||||
|
||||
# All ADS devices are registered here
|
||||
self._devices = []
|
||||
self._notification_items = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
"""Shutdown ADS connection."""
|
||||
|
||||
_LOGGER.debug("Shutting down ADS")
|
||||
for notification_item in self._notification_items.values():
|
||||
_LOGGER.debug(
|
||||
"Deleting device notification %d, %d",
|
||||
notification_item.hnotify,
|
||||
notification_item.huser,
|
||||
)
|
||||
try:
|
||||
self._client.del_device_notification(
|
||||
notification_item.hnotify, notification_item.huser
|
||||
)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error(err)
|
||||
try:
|
||||
self._client.close()
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
def register_device(self, device):
|
||||
"""Register a new device."""
|
||||
self._devices.append(device)
|
||||
|
||||
def write_by_name(self, name, value, plc_datatype):
|
||||
"""Write a value to the device."""
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
return self._client.write_by_name(name, value, plc_datatype)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error("Error writing %s: %s", name, err)
|
||||
|
||||
def read_by_name(self, name, plc_datatype):
|
||||
"""Read a value from the device."""
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
return self._client.read_by_name(name, plc_datatype)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error("Error reading %s: %s", name, err)
|
||||
|
||||
def add_device_notification(self, name, plc_datatype, callback):
|
||||
"""Add a notification to the ADS devices."""
|
||||
|
||||
attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype))
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
hnotify, huser = self._client.add_device_notification(
|
||||
name, attr, self._device_notification_callback
|
||||
)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error("Error subscribing to %s: %s", name, err)
|
||||
else:
|
||||
hnotify = int(hnotify)
|
||||
self._notification_items[hnotify] = NotificationItem(
|
||||
hnotify, huser, name, plc_datatype, callback
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Added device notification %d for variable %s", hnotify, name
|
||||
)
|
||||
|
||||
def _device_notification_callback(self, notification, name):
|
||||
"""Handle device notifications."""
|
||||
contents = notification.contents
|
||||
hnotify = int(contents.hNotification)
|
||||
_LOGGER.debug("Received notification %d", hnotify)
|
||||
|
||||
# Get dynamically sized data array
|
||||
data_size = contents.cbSampleSize
|
||||
data_address = (
|
||||
ctypes.addressof(contents)
|
||||
+ pyads.structs.SAdsNotificationHeader.data.offset
|
||||
)
|
||||
data = (ctypes.c_ubyte * data_size).from_address(data_address)
|
||||
|
||||
# Acquire notification item
|
||||
with self._lock:
|
||||
notification_item = self._notification_items.get(hnotify)
|
||||
|
||||
if not notification_item:
|
||||
_LOGGER.error("Unknown device notification handle: %d", hnotify)
|
||||
return
|
||||
|
||||
# Data parsing based on PLC data type
|
||||
plc_datatype = notification_item.plc_datatype
|
||||
unpack_formats = {
|
||||
pyads.PLCTYPE_BYTE: "<b",
|
||||
pyads.PLCTYPE_INT: "<h",
|
||||
pyads.PLCTYPE_UINT: "<H",
|
||||
pyads.PLCTYPE_SINT: "<b",
|
||||
pyads.PLCTYPE_USINT: "<B",
|
||||
pyads.PLCTYPE_DINT: "<i",
|
||||
pyads.PLCTYPE_UDINT: "<I",
|
||||
pyads.PLCTYPE_WORD: "<H",
|
||||
pyads.PLCTYPE_DWORD: "<I",
|
||||
pyads.PLCTYPE_LREAL: "<d",
|
||||
pyads.PLCTYPE_REAL: "<f",
|
||||
pyads.PLCTYPE_TOD: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_DATE: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_DT: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_TIME: "<i", # Treat as DINT
|
||||
}
|
||||
|
||||
if plc_datatype == pyads.PLCTYPE_BOOL:
|
||||
value = bool(struct.unpack("<?", bytearray(data))[0])
|
||||
elif plc_datatype == pyads.PLCTYPE_STRING:
|
||||
value = (
|
||||
bytearray(data).split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
|
||||
)
|
||||
elif plc_datatype in unpack_formats:
|
||||
value = struct.unpack(unpack_formats[plc_datatype], bytearray(data))[0]
|
||||
else:
|
||||
value = bytearray(data)
|
||||
_LOGGER.warning("No callback available for this datatype")
|
||||
|
||||
notification_item.callback(notification_item.name, value)
|
||||
@@ -19,12 +19,14 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE
|
||||
from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
|
||||
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
|
||||
STATE_KEY_BRIGHTNESS = "brightness"
|
||||
from . import (
|
||||
CONF_ADS_VAR,
|
||||
CONF_ADS_VAR_BRIGHTNESS,
|
||||
DATA_ADS,
|
||||
STATE_KEY_BRIGHTNESS,
|
||||
STATE_KEY_STATE,
|
||||
AdsEntity,
|
||||
)
|
||||
|
||||
DEFAULT_NAME = "ADS Light"
|
||||
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
|
||||
@@ -43,11 +45,11 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the light platform for ADS."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
ads_hub = hass.data.get(DATA_ADS)
|
||||
|
||||
ads_var_enable: str = config[CONF_ADS_VAR]
|
||||
ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS)
|
||||
name: str = config[CONF_NAME]
|
||||
ads_var_enable = config[CONF_ADS_VAR]
|
||||
ads_var_brightness = config.get(CONF_ADS_VAR_BRIGHTNESS)
|
||||
name = config[CONF_NAME]
|
||||
|
||||
add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)])
|
||||
|
||||
@@ -55,13 +57,7 @@ def setup_platform(
|
||||
class AdsLight(AdsEntity, LightEntity):
|
||||
"""Representation of ADS light."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ads_hub: AdsHub,
|
||||
ads_var_enable: str,
|
||||
ads_var_brightness: str | None,
|
||||
name: str,
|
||||
) -> None:
|
||||
def __init__(self, ads_hub, ads_var_enable, ads_var_brightness, name):
|
||||
"""Initialize AdsLight entity."""
|
||||
super().__init__(ads_hub, name, ads_var_enable)
|
||||
self._state_dict[STATE_KEY_BRIGHTNESS] = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "ads",
|
||||
"name": "ADS",
|
||||
"codeowners": ["@mrpasztoradam"],
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ads",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyads"],
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Support for ADS select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pyads
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.select import (
|
||||
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
||||
SelectEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS
|
||||
from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Required(CONF_OPTIONS): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up an ADS select device."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
|
||||
ads_var: str = config[CONF_ADS_VAR]
|
||||
name: str = config[CONF_NAME]
|
||||
options: list[str] = config[CONF_OPTIONS]
|
||||
|
||||
entity = AdsSelect(ads_hub, ads_var, name, options)
|
||||
|
||||
add_entities([entity])
|
||||
|
||||
|
||||
class AdsSelect(AdsEntity, SelectEntity):
|
||||
"""Representation of an ADS select entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ads_hub: AdsHub,
|
||||
ads_var: str,
|
||||
name: str,
|
||||
options: list[str],
|
||||
) -> None:
|
||||
"""Initialize the AdsSelect entity."""
|
||||
super().__init__(ads_hub, name, ads_var)
|
||||
self._attr_options = options
|
||||
self._attr_current_option = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register device notification."""
|
||||
await self.async_initialize_device(self._ads_var, pyads.PLCTYPE_INT)
|
||||
self._ads_hub.add_device_notification(
|
||||
self._ads_var, pyads.PLCTYPE_INT, self._handle_ads_value
|
||||
)
|
||||
|
||||
def select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
if option in self._attr_options:
|
||||
index = self._attr_options.index(option)
|
||||
self._ads_hub.write_by_name(self._ads_var, index, pyads.PLCTYPE_INT)
|
||||
self._attr_current_option = option
|
||||
|
||||
def _handle_ads_value(self, name: str, value: int) -> None:
|
||||
"""Handle the value update from ADS."""
|
||||
if 0 <= value < len(self._attr_options):
|
||||
self._attr_current_option = self._attr_options[value]
|
||||
self.schedule_update_ha_state()
|
||||
@@ -5,54 +5,41 @@ from __future__ import annotations
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
|
||||
from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
|
||||
from . import ADS_TYPEMAP, CONF_ADS_FACTOR, CONF_ADS_TYPE
|
||||
from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsType
|
||||
from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
from .. import ads
|
||||
from . import (
|
||||
ADS_TYPEMAP,
|
||||
CONF_ADS_FACTOR,
|
||||
CONF_ADS_TYPE,
|
||||
CONF_ADS_VAR,
|
||||
STATE_KEY_STATE,
|
||||
AdsEntity,
|
||||
)
|
||||
|
||||
DEFAULT_NAME = "ADS sensor"
|
||||
|
||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
vol.Optional(CONF_ADS_FACTOR): cv.positive_int,
|
||||
vol.Optional(CONF_ADS_TYPE, default=AdsType.INT): vol.All(
|
||||
vol.Coerce(AdsType),
|
||||
vol.In(
|
||||
[
|
||||
AdsType.BOOL,
|
||||
AdsType.BYTE,
|
||||
AdsType.INT,
|
||||
AdsType.UINT,
|
||||
AdsType.SINT,
|
||||
AdsType.USINT,
|
||||
AdsType.DINT,
|
||||
AdsType.UDINT,
|
||||
AdsType.WORD,
|
||||
AdsType.DWORD,
|
||||
AdsType.LREAL,
|
||||
AdsType.REAL,
|
||||
]
|
||||
),
|
||||
vol.Optional(CONF_ADS_TYPE, default=ads.ADSTYPE_INT): vol.In(
|
||||
[
|
||||
ads.ADSTYPE_INT,
|
||||
ads.ADSTYPE_UINT,
|
||||
ads.ADSTYPE_BYTE,
|
||||
ads.ADSTYPE_DINT,
|
||||
ads.ADSTYPE_UDINT,
|
||||
]
|
||||
),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=""): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -64,26 +51,15 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up an ADS sensor device."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
ads_hub = hass.data.get(ads.DATA_ADS)
|
||||
|
||||
ads_var: str = config[CONF_ADS_VAR]
|
||||
ads_type: AdsType = config[CONF_ADS_TYPE]
|
||||
name: str = config[CONF_NAME]
|
||||
factor: int | None = config.get(CONF_ADS_FACTOR)
|
||||
device_class: SensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
state_class: SensorStateClass | None = config.get(CONF_STATE_CLASS)
|
||||
unit_of_measurement: str | None = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
ads_var = config[CONF_ADS_VAR]
|
||||
ads_type = config[CONF_ADS_TYPE]
|
||||
name = config[CONF_NAME]
|
||||
unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
factor = config.get(CONF_ADS_FACTOR)
|
||||
|
||||
entity = AdsSensor(
|
||||
ads_hub,
|
||||
ads_var,
|
||||
ads_type,
|
||||
name,
|
||||
factor,
|
||||
device_class,
|
||||
state_class,
|
||||
unit_of_measurement,
|
||||
)
|
||||
entity = AdsSensor(ads_hub, ads_var, ads_type, name, unit_of_measurement, factor)
|
||||
|
||||
add_entities([entity])
|
||||
|
||||
@@ -91,24 +67,12 @@ def setup_platform(
|
||||
class AdsSensor(AdsEntity, SensorEntity):
|
||||
"""Representation of an ADS sensor entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ads_hub: AdsHub,
|
||||
ads_var: str,
|
||||
ads_type: AdsType,
|
||||
name: str,
|
||||
factor: int | None,
|
||||
device_class: SensorDeviceClass | None,
|
||||
state_class: SensorStateClass | None,
|
||||
unit_of_measurement: str | None,
|
||||
) -> None:
|
||||
def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, factor):
|
||||
"""Initialize AdsSensor entity."""
|
||||
super().__init__(ads_hub, name, ads_var)
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
self._ads_type = ads_type
|
||||
self._factor = factor
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register device notification."""
|
||||
|
||||
@@ -17,8 +17,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE
|
||||
from .entity import AdsEntity
|
||||
from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity
|
||||
|
||||
DEFAULT_NAME = "ADS Switch"
|
||||
|
||||
@@ -37,10 +36,10 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up switch platform for ADS."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
ads_hub = hass.data.get(DATA_ADS)
|
||||
|
||||
name: str = config[CONF_NAME]
|
||||
ads_var: str = config[CONF_ADS_VAR]
|
||||
name = config[CONF_NAME]
|
||||
ads_var = config[CONF_ADS_VAR]
|
||||
|
||||
add_entities([AdsSwitch(ads_hub, name, ads_var)])
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Support for ADS valves."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pyads
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
DEVICE_CLASSES_SCHEMA as VALVE_DEVICE_CLASSES_SCHEMA,
|
||||
PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA,
|
||||
ValveDeviceClass,
|
||||
ValveEntity,
|
||||
ValveEntityFeature,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ADS_VAR, DATA_ADS
|
||||
from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS valve"
|
||||
|
||||
PLATFORM_SCHEMA = VALVE_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): VALVE_DEVICE_CLASSES_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up an ADS valve device."""
|
||||
ads_hub = hass.data[DATA_ADS]
|
||||
|
||||
ads_var: str = config[CONF_ADS_VAR]
|
||||
name: str = config[CONF_NAME]
|
||||
device_class: ValveDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
entity = AdsValve(ads_hub, ads_var, name, device_class)
|
||||
|
||||
add_entities([entity])
|
||||
|
||||
|
||||
class AdsValve(AdsEntity, ValveEntity):
|
||||
"""Representation of an ADS valve entity."""
|
||||
|
||||
_attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ads_hub: AdsHub,
|
||||
ads_var: str,
|
||||
name: str,
|
||||
device_class: ValveDeviceClass | None,
|
||||
) -> None:
|
||||
"""Initialize AdsValve entity."""
|
||||
super().__init__(ads_hub, name, ads_var)
|
||||
self._attr_device_class = device_class
|
||||
self._attr_reports_position = False
|
||||
self._attr_is_closed = True
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register device notification."""
|
||||
await self.async_initialize_device(self._ads_var, pyads.PLCTYPE_BOOL)
|
||||
|
||||
def open_valve(self, **kwargs) -> None:
|
||||
"""Open the valve."""
|
||||
self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL)
|
||||
self._attr_is_closed = False
|
||||
|
||||
def close_valve(self, **kwargs) -> None:
|
||||
"""Close the valve."""
|
||||
self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL)
|
||||
self._attr_is_closed = True
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from aemet_opendata.const import AOD_COORDS
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.diagnostics.util import async_redact_data
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
|
||||
@@ -13,13 +13,11 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[AirQualityEntity]] = HassKey(DOMAIN)
|
||||
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
@@ -56,7 +54,7 @@ PROP_TO_ATTR: Final[dict[str, str]] = {
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the air quality component."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[AirQualityEntity](
|
||||
component = hass.data[DOMAIN] = EntityComponent[AirQualityEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
)
|
||||
await component.async_setup(config)
|
||||
@@ -65,12 +63,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
component: EntityComponent[AirQualityEntity] = hass.data[DOMAIN]
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
component: EntityComponent[AirQualityEntity] = hass.data[DOMAIN]
|
||||
return await component.async_unload_entry(entry)
|
||||
|
||||
|
||||
class AirQualityEntity(Entity):
|
||||
|
||||
@@ -9,10 +9,9 @@ from typing import TYPE_CHECKING
|
||||
from airgradient import AirGradientClient, AirGradientError, Config, Measures
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AirGradientConfigEntry
|
||||
@@ -30,7 +29,6 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
config_entry: AirGradientConfigEntry
|
||||
_current_version: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None:
|
||||
"""Initialize coordinator."""
|
||||
@@ -44,27 +42,11 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
|
||||
assert self.config_entry.unique_id
|
||||
self.serial_number = self.config_entry.unique_id
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
self._current_version = (
|
||||
await self.client.get_current_measures()
|
||||
).firmware_version
|
||||
|
||||
async def _async_update_data(self) -> AirGradientData:
|
||||
try:
|
||||
measures = await self.client.get_current_measures()
|
||||
config = await self.client.get_config()
|
||||
except AirGradientError as error:
|
||||
raise UpdateFailed(error) from error
|
||||
if measures.firmware_version != self._current_version:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self.serial_number)}
|
||||
)
|
||||
assert device_entry
|
||||
device_registry.async_update_device(
|
||||
device_entry.id,
|
||||
sw_version=measures.firmware_version,
|
||||
)
|
||||
self._current_version = measures.firmware_version
|
||||
return AirGradientData(measures, config)
|
||||
else:
|
||||
return AirGradientData(measures, config)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Diagnostics support for Airgradient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AirGradientConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AirGradientConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
return asdict(entry.runtime_data.data)
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.9.1"],
|
||||
"requirements": ["airgradient==0.8.0"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN # noqa: F401
|
||||
from .coordinator import AirNowDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,32 +14,10 @@ ATTR_API_POLLUTANT = "Pollutant"
|
||||
ATTR_API_REPORT_DATE = "DateObserved"
|
||||
ATTR_API_REPORT_HOUR = "HourObserved"
|
||||
ATTR_API_REPORT_TZ = "LocalTimeZone"
|
||||
ATTR_API_REPORT_TZINFO = "LocalTimeZoneInfo"
|
||||
ATTR_API_STATE = "StateCode"
|
||||
ATTR_API_STATION = "ReportingArea"
|
||||
ATTR_API_STATION_LATITUDE = "Latitude"
|
||||
ATTR_API_STATION_LONGITUDE = "Longitude"
|
||||
DEFAULT_NAME = "AirNow"
|
||||
DOMAIN = "airnow"
|
||||
|
||||
SECONDS_PER_HOUR = 3600
|
||||
|
||||
# AirNow seems to only use standard time zones,
|
||||
# but we include daylight savings for completeness/futureproofing.
|
||||
US_TZ_OFFSETS = {
|
||||
"HST": -10 * SECONDS_PER_HOUR,
|
||||
"HDT": -9 * SECONDS_PER_HOUR,
|
||||
# AirNow returns AKT instead of AKST or AKDT, use standard
|
||||
"AKT": -9 * SECONDS_PER_HOUR,
|
||||
"AKST": -9 * SECONDS_PER_HOUR,
|
||||
"AKDT": -8 * SECONDS_PER_HOUR,
|
||||
"PST": -8 * SECONDS_PER_HOUR,
|
||||
"PDT": -7 * SECONDS_PER_HOUR,
|
||||
"MST": -7 * SECONDS_PER_HOUR,
|
||||
"MDT": -6 * SECONDS_PER_HOUR,
|
||||
"CST": -6 * SECONDS_PER_HOUR,
|
||||
"CDT": -5 * SECONDS_PER_HOUR,
|
||||
"EST": -5 * SECONDS_PER_HOUR,
|
||||
"EDT": -4 * SECONDS_PER_HOUR,
|
||||
"AST": -4 * SECONDS_PER_HOUR,
|
||||
"ADT": -3 * SECONDS_PER_HOUR,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from pyairnow.errors import AirNowError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_API_AQI,
|
||||
@@ -26,6 +27,7 @@ from .const import (
|
||||
ATTR_API_REPORT_DATE,
|
||||
ATTR_API_REPORT_HOUR,
|
||||
ATTR_API_REPORT_TZ,
|
||||
ATTR_API_REPORT_TZINFO,
|
||||
ATTR_API_STATE,
|
||||
ATTR_API_STATION,
|
||||
ATTR_API_STATION_LATITUDE,
|
||||
@@ -96,7 +98,9 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Copy Report Details
|
||||
data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
|
||||
data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
|
||||
data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
|
||||
data[ATTR_API_REPORT_TZINFO] = await dt_util.async_get_time_zone(
|
||||
obv[ATTR_API_REPORT_TZ]
|
||||
)
|
||||
|
||||
# Copy Station Details
|
||||
data[ATTR_API_STATE] = obv[ATTR_API_STATE]
|
||||
|
||||
@@ -4,10 +4,9 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from dateutil import parser
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -35,13 +34,12 @@ from .const import (
|
||||
ATTR_API_PM25,
|
||||
ATTR_API_REPORT_DATE,
|
||||
ATTR_API_REPORT_HOUR,
|
||||
ATTR_API_REPORT_TZ,
|
||||
ATTR_API_REPORT_TZINFO,
|
||||
ATTR_API_STATION,
|
||||
ATTR_API_STATION_LATITUDE,
|
||||
ATTR_API_STATION_LONGITUDE,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
US_TZ_OFFSETS,
|
||||
)
|
||||
|
||||
ATTRIBUTION = "Data provided by AirNow"
|
||||
@@ -71,18 +69,6 @@ def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def aqi_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Process extra attributes for main AQI sensor."""
|
||||
return {
|
||||
ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION],
|
||||
ATTR_LEVEL: data[ATTR_API_AQI_LEVEL],
|
||||
ATTR_TIME: parser.parse(
|
||||
f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}:00 {data[ATTR_API_REPORT_TZ]}",
|
||||
tzinfos=US_TZ_OFFSETS,
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
|
||||
AirNowEntityDescription(
|
||||
key=ATTR_API_AQI,
|
||||
@@ -90,7 +76,16 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
value_fn=lambda data: data.get(ATTR_API_AQI),
|
||||
extra_state_attributes_fn=aqi_extra_attrs,
|
||||
extra_state_attributes_fn=lambda data: {
|
||||
ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION],
|
||||
ATTR_LEVEL: data[ATTR_API_AQI_LEVEL],
|
||||
ATTR_TIME: datetime.strptime(
|
||||
f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}",
|
||||
"%Y-%m-%d %H",
|
||||
)
|
||||
.replace(tzinfo=data[ATTR_API_REPORT_TZINFO])
|
||||
.isoformat(),
|
||||
},
|
||||
),
|
||||
AirNowEntityDescription(
|
||||
key=ATTR_API_PM10,
|
||||
|
||||
@@ -34,8 +34,13 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_CITY,
|
||||
@@ -398,3 +403,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> None:
|
||||
"""Handle an options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class AirVisualEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_extra_state_attributes = {}
|
||||
self._entry = entry
|
||||
self.entity_description = description
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def update() -> None:
|
||||
"""Update the state."""
|
||||
self.update_from_latest_data()
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(self.coordinator.async_add_listener(update))
|
||||
|
||||
self.update_from_latest_data()
|
||||
|
||||
@callback
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""The AirVisual component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
class AirVisualEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_extra_state_attributes = {}
|
||||
self._entry = entry
|
||||
self.entity_description = description
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def update() -> None:
|
||||
"""Update the state."""
|
||||
self.update_from_latest_data()
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(self.coordinator.async_add_listener(update))
|
||||
|
||||
self.update_from_latest_data()
|
||||
|
||||
@callback
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
raise NotImplementedError
|
||||
@@ -26,9 +26,8 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import AirVisualConfigEntry
|
||||
from . import AirVisualConfigEntry, AirVisualEntity
|
||||
from .const import CONF_CITY
|
||||
from .entity import AirVisualEntity
|
||||
|
||||
ATTR_CITY = "city"
|
||||
ATTR_COUNTRY = "country"
|
||||
|
||||
@@ -24,9 +24,15 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
@@ -114,3 +120,28 @@ async def async_unload_entry(
|
||||
await entry.runtime_data.node.async_disconnect()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
class AirVisualProEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual Pro entity."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: DataUpdateCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
hw_version=self.coordinator.data["status"]["system_version"],
|
||||
sw_version=self.coordinator.data["status"]["app_version"],
|
||||
)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AirVisualProEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual Pro entity."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: DataUpdateCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
hw_version=self.coordinator.data["status"]["system_version"],
|
||||
sw_version=self.coordinator.data["status"]["app_version"],
|
||||
)
|
||||
@@ -22,8 +22,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AirVisualProConfigEntry
|
||||
from .entity import AirVisualProEntity
|
||||
from . import AirVisualProConfigEntry, AirVisualProEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from aioairzone.const import API_MAC, AZD_MAC
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.diagnostics.util import async_redact_data
|
||||
from homeassistant.const import CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==0.9.3"]
|
||||
"requirements": ["aioairzone==0.9.1"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ from aioairzone_cloud.const import (
|
||||
RAW_WEBSERVERS,
|
||||
)
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.diagnostics.util import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.6"]
|
||||
"requirements": ["aioairzone-cloud==0.6.5"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ from homeassistant.helpers.deprecation import (
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import ( # noqa: F401
|
||||
_DEPRECATED_FORMAT_NUMBER,
|
||||
@@ -53,7 +52,6 @@ from .const import ( # noqa: F401
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN)
|
||||
ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE
|
||||
@@ -71,7 +69,7 @@ ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Track states and offer events for sensors."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[AlarmControlPanelEntity](
|
||||
component = hass.data[DOMAIN] = EntityComponent[AlarmControlPanelEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
)
|
||||
|
||||
@@ -124,12 +122,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
component: EntityComponent[AlarmControlPanelEntity] = hass.data[DOMAIN]
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
component: EntityComponent[AlarmControlPanelEntity] = hass.data[DOMAIN]
|
||||
return await component.async_unload_entry(entry)
|
||||
|
||||
|
||||
class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "Successfully connected to AlarmDecoder."
|
||||
@@ -38,7 +37,7 @@
|
||||
"title": "Configure AlarmDecoder",
|
||||
"description": "What would you like to edit?",
|
||||
"data": {
|
||||
"edit_selection": "Edit"
|
||||
"edit_select": "Edit"
|
||||
}
|
||||
},
|
||||
"arm_settings": {
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TITLE,
|
||||
DOMAIN as DOMAIN_NOTIFY,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
@@ -12,12 +22,22 @@ from homeassistant.const import (
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import (
|
||||
CONF_ALERT_MESSAGE,
|
||||
@@ -32,7 +52,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .entity import AlertEntity
|
||||
|
||||
ALERT_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -64,9 +83,9 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Alert component."""
|
||||
component = EntityComponent[AlertEntity](LOGGER, DOMAIN, hass)
|
||||
component = EntityComponent[Alert](LOGGER, DOMAIN, hass)
|
||||
|
||||
entities: list[AlertEntity] = []
|
||||
entities: list[Alert] = []
|
||||
|
||||
for object_id, cfg in config[DOMAIN].items():
|
||||
if not cfg:
|
||||
@@ -85,7 +104,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
data = cfg.get(CONF_DATA)
|
||||
|
||||
entities.append(
|
||||
AlertEntity(
|
||||
Alert(
|
||||
hass,
|
||||
object_id,
|
||||
name,
|
||||
@@ -112,3 +131,183 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
await component.async_add_entities(entities)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Alert(Entity):
|
||||
"""Representation of an alert."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
name: str,
|
||||
watched_entity_id: str,
|
||||
state: str,
|
||||
repeat: list[float],
|
||||
skip_first: bool,
|
||||
message_template: Template | None,
|
||||
done_message_template: Template | None,
|
||||
notifiers: list[str],
|
||||
can_ack: bool,
|
||||
title_template: Template | None,
|
||||
data: dict[Any, Any],
|
||||
) -> None:
|
||||
"""Initialize the alert."""
|
||||
self.hass = hass
|
||||
self._attr_name = name
|
||||
self._alert_state = state
|
||||
self._skip_first = skip_first
|
||||
self._data = data
|
||||
|
||||
self._message_template = message_template
|
||||
self._done_message_template = done_message_template
|
||||
self._title_template = title_template
|
||||
|
||||
self._notifiers = notifiers
|
||||
self._can_ack = can_ack
|
||||
|
||||
self._delay = [timedelta(minutes=val) for val in repeat]
|
||||
self._next_delay = 0
|
||||
|
||||
self._firing = False
|
||||
self._ack = False
|
||||
self._cancel: Callable[[], None] | None = None
|
||||
self._send_done_message = False
|
||||
self.entity_id = f"{DOMAIN}.{entity_id}"
|
||||
|
||||
async_track_state_change_event(
|
||||
hass, [watched_entity_id], self.watched_entity_change
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return the alert status."""
|
||||
if self._firing:
|
||||
if self._ack:
|
||||
return STATE_OFF
|
||||
return STATE_ON
|
||||
return STATE_IDLE
|
||||
|
||||
async def watched_entity_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Determine if the alert should start or stop."""
|
||||
if (to_state := event.data["new_state"]) is None:
|
||||
return
|
||||
LOGGER.debug("Watched entity (%s) has changed", event.data["entity_id"])
|
||||
if to_state.state == self._alert_state and not self._firing:
|
||||
await self.begin_alerting()
|
||||
if to_state.state != self._alert_state and self._firing:
|
||||
await self.end_alerting()
|
||||
|
||||
async def begin_alerting(self) -> None:
|
||||
"""Begin the alert procedures."""
|
||||
LOGGER.debug("Beginning Alert: %s", self._attr_name)
|
||||
self._ack = False
|
||||
self._firing = True
|
||||
self._next_delay = 0
|
||||
|
||||
if not self._skip_first:
|
||||
await self._notify()
|
||||
else:
|
||||
await self._schedule_notify()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def end_alerting(self) -> None:
|
||||
"""End the alert procedures."""
|
||||
LOGGER.debug("Ending Alert: %s", self._attr_name)
|
||||
if self._cancel is not None:
|
||||
self._cancel()
|
||||
self._cancel = None
|
||||
|
||||
self._ack = False
|
||||
self._firing = False
|
||||
if self._send_done_message:
|
||||
await self._notify_done_message()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _schedule_notify(self) -> None:
|
||||
"""Schedule a notification."""
|
||||
delay = self._delay[self._next_delay]
|
||||
next_msg = now() + delay
|
||||
self._cancel = async_track_point_in_time(
|
||||
self.hass,
|
||||
HassJob(
|
||||
self._notify, name="Schedule notify alert", cancel_on_shutdown=True
|
||||
),
|
||||
next_msg,
|
||||
)
|
||||
self._next_delay = min(self._next_delay + 1, len(self._delay) - 1)
|
||||
|
||||
async def _notify(self, *args: Any) -> None:
|
||||
"""Send the alert notification."""
|
||||
if not self._firing:
|
||||
return
|
||||
|
||||
if not self._ack:
|
||||
LOGGER.info("Alerting: %s", self._attr_name)
|
||||
self._send_done_message = True
|
||||
|
||||
if self._message_template is not None:
|
||||
message = self._message_template.async_render(parse_result=False)
|
||||
else:
|
||||
message = self._attr_name
|
||||
|
||||
await self._send_notification_message(message)
|
||||
await self._schedule_notify()
|
||||
|
||||
async def _notify_done_message(self) -> None:
|
||||
"""Send notification of complete alert."""
|
||||
LOGGER.info("Alerting: %s", self._done_message_template)
|
||||
self._send_done_message = False
|
||||
|
||||
if self._done_message_template is None:
|
||||
return
|
||||
|
||||
message = self._done_message_template.async_render(parse_result=False)
|
||||
|
||||
await self._send_notification_message(message)
|
||||
|
||||
async def _send_notification_message(self, message: Any) -> None:
|
||||
if not self._notifiers:
|
||||
return
|
||||
|
||||
msg_payload = {ATTR_MESSAGE: message}
|
||||
|
||||
if self._title_template is not None:
|
||||
title = self._title_template.async_render(parse_result=False)
|
||||
msg_payload[ATTR_TITLE] = title
|
||||
if self._data:
|
||||
msg_payload[ATTR_DATA] = self._data
|
||||
|
||||
LOGGER.debug(msg_payload)
|
||||
|
||||
for target in self._notifiers:
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN_NOTIFY, target, msg_payload, context=self._context
|
||||
)
|
||||
except ServiceNotFound:
|
||||
LOGGER.error(
|
||||
"Failed to call notify.%s, retrying at next notification interval",
|
||||
target,
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Async Unacknowledge alert."""
|
||||
LOGGER.debug("Reset Alert: %s", self._attr_name)
|
||||
self._ack = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Async Acknowledge alert."""
|
||||
LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
|
||||
self._ack = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Async toggle alert."""
|
||||
if self._ack:
|
||||
return await self.async_turn_on()
|
||||
return await self.async_turn_off()
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"""Support for repeating alerts when conditions are met."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TITLE,
|
||||
DOMAIN as DOMAIN_NOTIFY,
|
||||
)
|
||||
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
class AlertEntity(Entity):
|
||||
"""Representation of an alert."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
name: str,
|
||||
watched_entity_id: str,
|
||||
state: str,
|
||||
repeat: list[float],
|
||||
skip_first: bool,
|
||||
message_template: Template | None,
|
||||
done_message_template: Template | None,
|
||||
notifiers: list[str],
|
||||
can_ack: bool,
|
||||
title_template: Template | None,
|
||||
data: dict[Any, Any],
|
||||
) -> None:
|
||||
"""Initialize the alert."""
|
||||
self.hass = hass
|
||||
self._attr_name = name
|
||||
self._alert_state = state
|
||||
self._skip_first = skip_first
|
||||
self._data = data
|
||||
|
||||
self._message_template = message_template
|
||||
self._done_message_template = done_message_template
|
||||
self._title_template = title_template
|
||||
|
||||
self._notifiers = notifiers
|
||||
self._can_ack = can_ack
|
||||
|
||||
self._delay = [timedelta(minutes=val) for val in repeat]
|
||||
self._next_delay = 0
|
||||
|
||||
self._firing = False
|
||||
self._ack = False
|
||||
self._cancel: Callable[[], None] | None = None
|
||||
self._send_done_message = False
|
||||
self.entity_id = f"{DOMAIN}.{entity_id}"
|
||||
|
||||
async_track_state_change_event(
|
||||
hass, [watched_entity_id], self.watched_entity_change
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return the alert status."""
|
||||
if self._firing:
|
||||
if self._ack:
|
||||
return STATE_OFF
|
||||
return STATE_ON
|
||||
return STATE_IDLE
|
||||
|
||||
async def watched_entity_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Determine if the alert should start or stop."""
|
||||
if (to_state := event.data["new_state"]) is None:
|
||||
return
|
||||
LOGGER.debug("Watched entity (%s) has changed", event.data["entity_id"])
|
||||
if to_state.state == self._alert_state and not self._firing:
|
||||
await self.begin_alerting()
|
||||
if to_state.state != self._alert_state and self._firing:
|
||||
await self.end_alerting()
|
||||
|
||||
async def begin_alerting(self) -> None:
|
||||
"""Begin the alert procedures."""
|
||||
LOGGER.debug("Beginning Alert: %s", self._attr_name)
|
||||
self._ack = False
|
||||
self._firing = True
|
||||
self._next_delay = 0
|
||||
|
||||
if not self._skip_first:
|
||||
await self._notify()
|
||||
else:
|
||||
await self._schedule_notify()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def end_alerting(self) -> None:
|
||||
"""End the alert procedures."""
|
||||
LOGGER.debug("Ending Alert: %s", self._attr_name)
|
||||
if self._cancel is not None:
|
||||
self._cancel()
|
||||
self._cancel = None
|
||||
|
||||
self._ack = False
|
||||
self._firing = False
|
||||
if self._send_done_message:
|
||||
await self._notify_done_message()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _schedule_notify(self) -> None:
|
||||
"""Schedule a notification."""
|
||||
delay = self._delay[self._next_delay]
|
||||
next_msg = now() + delay
|
||||
self._cancel = async_track_point_in_time(
|
||||
self.hass,
|
||||
HassJob(
|
||||
self._notify, name="Schedule notify alert", cancel_on_shutdown=True
|
||||
),
|
||||
next_msg,
|
||||
)
|
||||
self._next_delay = min(self._next_delay + 1, len(self._delay) - 1)
|
||||
|
||||
async def _notify(self, *args: Any) -> None:
|
||||
"""Send the alert notification."""
|
||||
if not self._firing:
|
||||
return
|
||||
|
||||
if not self._ack:
|
||||
LOGGER.info("Alerting: %s", self._attr_name)
|
||||
self._send_done_message = True
|
||||
|
||||
if self._message_template is not None:
|
||||
message = self._message_template.async_render(parse_result=False)
|
||||
else:
|
||||
message = self._attr_name
|
||||
|
||||
await self._send_notification_message(message)
|
||||
await self._schedule_notify()
|
||||
|
||||
async def _notify_done_message(self) -> None:
|
||||
"""Send notification of complete alert."""
|
||||
LOGGER.info("Alerting: %s", self._done_message_template)
|
||||
self._send_done_message = False
|
||||
|
||||
if self._done_message_template is None:
|
||||
return
|
||||
|
||||
message = self._done_message_template.async_render(parse_result=False)
|
||||
|
||||
await self._send_notification_message(message)
|
||||
|
||||
async def _send_notification_message(self, message: Any) -> None:
|
||||
if not self._notifiers:
|
||||
return
|
||||
|
||||
msg_payload = {ATTR_MESSAGE: message}
|
||||
|
||||
if self._title_template is not None:
|
||||
title = self._title_template.async_render(parse_result=False)
|
||||
msg_payload[ATTR_TITLE] = title
|
||||
if self._data:
|
||||
msg_payload[ATTR_DATA] = self._data
|
||||
|
||||
LOGGER.debug(msg_payload)
|
||||
|
||||
for target in self._notifiers:
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN_NOTIFY, target, msg_payload, context=self._context
|
||||
)
|
||||
except ServiceNotFound:
|
||||
LOGGER.error(
|
||||
"Failed to call notify.%s, retrying at next notification interval",
|
||||
target,
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Async Unacknowledge alert."""
|
||||
LOGGER.debug("Reset Alert: %s", self._attr_name)
|
||||
self._ack = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Async Acknowledge alert."""
|
||||
LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
|
||||
self._ack = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Async toggle alert."""
|
||||
if self._ack:
|
||||
return await self.async_turn_on()
|
||||
return await self.async_turn_off()
|
||||
@@ -29,7 +29,6 @@ from homeassistant.components.alarm_control_panel import (
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.components.lock import LockState
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE_FORMAT,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
@@ -41,12 +40,16 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_IDLE,
|
||||
STATE_LOCKED,
|
||||
STATE_LOCKING,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
STATE_UNLOCKED,
|
||||
STATE_UNLOCKING,
|
||||
UnitOfLength,
|
||||
UnitOfMass,
|
||||
UnitOfTemperature,
|
||||
@@ -497,10 +500,10 @@ class AlexaLockController(AlexaCapability):
|
||||
raise UnsupportedProperty(name)
|
||||
|
||||
# If its unlocking its still locked and not unlocked yet
|
||||
if self.entity.state in (LockState.UNLOCKING, LockState.LOCKED):
|
||||
if self.entity.state in (STATE_UNLOCKING, STATE_LOCKED):
|
||||
return "LOCKED"
|
||||
# If its locking its still unlocked and not locked yet
|
||||
if self.entity.state in (LockState.LOCKING, LockState.UNLOCKED):
|
||||
if self.entity.state in (STATE_LOCKING, STATE_UNLOCKED):
|
||||
return "UNLOCKED"
|
||||
return "JAMMED"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"site": {
|
||||
"data": {
|
||||
"site_id": "Site NMI",
|
||||
"site_nmi": "Site NMI",
|
||||
"site_name": "Site Name"
|
||||
},
|
||||
"description": "Select the NMI of the site you would like to add"
|
||||
|
||||
@@ -261,19 +261,18 @@ class Analytics:
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if supervisor_info is not None:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(
|
||||
supervisor_client.addons.addon_info(addon[ATTR_SLUG])
|
||||
hassio.async_get_addon_info(hass, addon[ATTR_SLUG])
|
||||
for addon in supervisor_info[ATTR_ADDONS]
|
||||
)
|
||||
)
|
||||
addons.extend(
|
||||
{
|
||||
ATTR_SLUG: addon.slug,
|
||||
ATTR_PROTECTED: addon.protected,
|
||||
ATTR_VERSION: addon.version,
|
||||
ATTR_AUTO_UPDATE: addon.auto_update,
|
||||
ATTR_SLUG: addon[ATTR_SLUG],
|
||||
ATTR_PROTECTED: addon[ATTR_PROTECTED],
|
||||
ATTR_VERSION: addon[ATTR_VERSION],
|
||||
ATTR_AUTO_UPDATE: addon[ATTR_AUTO_UPDATE],
|
||||
}
|
||||
for addon in installed_addons
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"no_integrations_selected": "You must select at least one integration to track"
|
||||
"no_integration_selected": "You must select at least one integration to track"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"no_integrations_selected": "[%key:component::analytics_insights::config::error::no_integrations_selected%]"
|
||||
"no_integration_selected": "[%key:component::analytics_insights::config::error::no_integration_selected%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -131,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return RESULT_CONN_ERROR, None
|
||||
|
||||
dev_prop = aftv.device_properties
|
||||
_LOGGER.debug(
|
||||
_LOGGER.info(
|
||||
"Android device at %s: %s = %r, %s = %r",
|
||||
user_input[CONF_HOST],
|
||||
PROP_ETHMAC,
|
||||
|
||||
@@ -67,7 +67,7 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R](
|
||||
return await func(self, *args, **kwargs)
|
||||
except LockNotAcquiredException:
|
||||
# If the ADB lock could not be acquired, skip this command
|
||||
_LOGGER.debug(
|
||||
_LOGGER.info(
|
||||
(
|
||||
"ADB command %s not executed because the connection is"
|
||||
" currently in use"
|
||||
|
||||
@@ -306,7 +306,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
msg,
|
||||
title="Android Debug Bridge",
|
||||
)
|
||||
_LOGGER.debug("%s", msg)
|
||||
_LOGGER.info("%s", msg)
|
||||
|
||||
@adb_decorator()
|
||||
async def service_download(self, device_path: str, local_path: str) -> None:
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
@@ -16,6 +15,7 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.components.media_player.browse_media import BrowseMedia
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
@@ -32,16 +32,14 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import (
|
||||
CONF_CREDENTIALS,
|
||||
CONF_IDENTIFIERS,
|
||||
CONF_START_OFF,
|
||||
DOMAIN,
|
||||
SIGNAL_CONNECTED,
|
||||
SIGNAL_DISCONNECTED,
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +49,9 @@ DEFAULT_NAME_HP = "HomePod"
|
||||
BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
|
||||
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||
|
||||
SIGNAL_CONNECTED = "apple_tv_connected"
|
||||
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
AUTH_EXCEPTIONS = (
|
||||
@@ -119,6 +120,64 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
class AppleTVEntity(Entity):
|
||||
"""Device that sends commands to an Apple TV."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
atv: AppleTVInterface | None = None
|
||||
|
||||
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
|
||||
"""Initialize device."""
|
||||
self.manager = manager
|
||||
self._attr_unique_id = identifier
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
name=name,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when an entity is about to be added to Home Assistant."""
|
||||
|
||||
@callback
|
||||
def _async_connected(atv: AppleTVInterface) -> None:
|
||||
"""Handle that a connection was made to a device."""
|
||||
self.atv = atv
|
||||
self.async_device_connected(atv)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_disconnected() -> None:
|
||||
"""Handle that a connection to a device was lost."""
|
||||
self.async_device_disconnected()
|
||||
self.atv = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
if self.manager.atv:
|
||||
# ATV is already connected
|
||||
_async_connected(self.manager.atv)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{SIGNAL_DISCONNECTED}_{self.unique_id}",
|
||||
_async_disconnected,
|
||||
)
|
||||
)
|
||||
|
||||
def async_device_connected(self, atv: AppleTVInterface) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
|
||||
def async_device_disconnected(self) -> None:
|
||||
"""Handle when connection was lost to device."""
|
||||
|
||||
|
||||
class AppleTVManager(DeviceListener):
|
||||
"""Connection and power manager for an Apple TV.
|
||||
|
||||
@@ -316,7 +375,7 @@ class AppleTVManager(DeviceListener):
|
||||
f"Protocol(s) {missing_protocols_str} not yet found for {name},"
|
||||
" waiting for discovery."
|
||||
)
|
||||
_LOGGER.debug(
|
||||
_LOGGER.info(
|
||||
"Protocol(s) %s not yet found for %s, trying later",
|
||||
missing_protocols_str,
|
||||
name,
|
||||
@@ -335,7 +394,7 @@ class AppleTVManager(DeviceListener):
|
||||
|
||||
self._connection_attempts = 0
|
||||
if self._connection_was_lost:
|
||||
_LOGGER.warning(
|
||||
_LOGGER.info(
|
||||
'Connection was re-established to device "%s"',
|
||||
self.config_entry.data[CONF_NAME],
|
||||
)
|
||||
|
||||
@@ -6,6 +6,3 @@ CONF_CREDENTIALS = "credentials"
|
||||
CONF_IDENTIFIERS = "identifiers"
|
||||
|
||||
CONF_START_OFF = "start_off"
|
||||
|
||||
SIGNAL_CONNECTED = "apple_tv_connected"
|
||||
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""The Apple TV integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.interface import AppleTV as AppleTVInterface
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import AppleTVManager
|
||||
from .const import DOMAIN, SIGNAL_CONNECTED, SIGNAL_DISCONNECTED
|
||||
|
||||
|
||||
class AppleTVEntity(Entity):
|
||||
"""Device that sends commands to an Apple TV."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
atv: AppleTVInterface | None = None
|
||||
|
||||
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
|
||||
"""Initialize device."""
|
||||
self.manager = manager
|
||||
self._attr_unique_id = identifier
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
name=name,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when an entity is about to be added to Home Assistant."""
|
||||
|
||||
@callback
|
||||
def _async_connected(atv: AppleTVInterface) -> None:
|
||||
"""Handle that a connection was made to a device."""
|
||||
self.atv = atv
|
||||
self.async_device_connected(atv)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_disconnected() -> None:
|
||||
"""Handle that a connection to a device was lost."""
|
||||
self.async_device_disconnected()
|
||||
self.atv = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
if self.manager.atv:
|
||||
# ATV is already connected
|
||||
_async_connected(self.manager.atv)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{SIGNAL_DISCONNECTED}_{self.unique_id}",
|
||||
_async_disconnected,
|
||||
)
|
||||
)
|
||||
|
||||
def async_device_connected(self, atv: AppleTVInterface) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
|
||||
def async_device_disconnected(self) -> None:
|
||||
"""Handle when connection was lost to device."""
|
||||
@@ -42,9 +42,8 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import AppleTvConfigEntry, AppleTVManager
|
||||
from . import AppleTvConfigEntry, AppleTVEntity, AppleTVManager
|
||||
from .browse_media import build_app_list
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AppleTvConfigEntry
|
||||
from .entity import AppleTVEntity
|
||||
from . import AppleTvConfigEntry, AppleTVEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,7 +85,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
|
||||
if not attr_value:
|
||||
raise ValueError("Command not found. Exiting sequence")
|
||||
|
||||
_LOGGER.debug("Sending command %s", single_command)
|
||||
_LOGGER.info("Sending command %s", single_command)
|
||||
|
||||
if hold_secs >= 1:
|
||||
await attr_value(action=InputAction.Hold)
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any, Protocol
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.websocket_api import ActiveConnection
|
||||
from homeassistant.components.websocket_api.connection import ActiveConnection
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
|
||||
@@ -159,7 +159,7 @@ class AprsListenerThread(threading.Thread):
|
||||
self.ais.set_filter(self.server_filter)
|
||||
|
||||
try:
|
||||
_LOGGER.debug(
|
||||
_LOGGER.info(
|
||||
"Opening connection to %s with callsign %s", self.host, self.callsign
|
||||
)
|
||||
self.ais.connect()
|
||||
@@ -170,7 +170,7 @@ class AprsListenerThread(threading.Thread):
|
||||
except (AprsConnectionError, LoginError) as err:
|
||||
self.start_complete(False, str(err))
|
||||
except OSError:
|
||||
_LOGGER.debug(
|
||||
_LOGGER.info(
|
||||
"Closing connection to %s with callsign %s", self.host, self.callsign
|
||||
)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Arizona Public Service (APS)."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "aps",
|
||||
"name": "Arizona Public Service (APS)",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
}
|
||||
@@ -11,7 +11,6 @@ from arcam.fmj import ConnectionFailed, SourceCodes
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerEntity,
|
||||
@@ -19,6 +18,7 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -7,7 +7,7 @@ from arris_tg2492lg import ConnectBox, Device
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
DOMAIN,
|
||||
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
|
||||
DeviceScanner,
|
||||
)
|
||||
@@ -31,7 +31,7 @@ async def async_get_scanner(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ArrisDeviceScanner | None:
|
||||
"""Return the Arris device scanner if successful."""
|
||||
conf = config[DEVICE_TRACKER_DOMAIN]
|
||||
conf = config[DOMAIN]
|
||||
url = f"http://{conf[CONF_HOST]}"
|
||||
websession = async_get_clientsession(hass)
|
||||
connect_box = ConnectBox(websession, url, conf[CONF_PASSWORD])
|
||||
|
||||
@@ -10,7 +10,7 @@ import pexpect
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
DOMAIN,
|
||||
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
|
||||
DeviceScanner,
|
||||
)
|
||||
@@ -38,7 +38,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
|
||||
|
||||
def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | None:
|
||||
"""Validate the configuration and return a Aruba scanner."""
|
||||
scanner = ArubaDeviceScanner(config[DEVICE_TRACKER_DOMAIN])
|
||||
scanner = ArubaDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from aioaseko import Aseko, AsekoNotLoggedIn
|
||||
from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AsekoDataUpdateCoordinator
|
||||
@@ -21,17 +22,28 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Aseko Pool Live from a config entry."""
|
||||
aseko = Aseko(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
|
||||
account = MobileAccount(
|
||||
async_get_clientsession(hass),
|
||||
username=entry.data[CONF_EMAIL],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
await aseko.login()
|
||||
except AsekoNotLoggedIn as err:
|
||||
units = await account.get_units()
|
||||
except InvalidAuthCredentials as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except APIUnavailable as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = []
|
||||
|
||||
for unit in units:
|
||||
coordinator = AsekoDataUpdateCoordinator(hass, unit)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[DOMAIN][entry.entry_id].append((unit, coordinator))
|
||||
|
||||
coordinator = AsekoDataUpdateCoordinator(hass, aseko)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -39,6 +51,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
||||
from aioaseko import Unit
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
@@ -24,14 +25,26 @@ from .entity import AsekoEntity
|
||||
class AsekoBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Aseko binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[Unit], bool | None]
|
||||
value_fn: Callable[[Unit], bool]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = (
|
||||
UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = (
|
||||
AsekoBinarySensorEntityDescription(
|
||||
key="water_flow",
|
||||
translation_key="water_flow_to_probes",
|
||||
value_fn=lambda unit: unit.water_flow_to_probes,
|
||||
translation_key="water_flow",
|
||||
value_fn=lambda unit: unit.water_flow,
|
||||
),
|
||||
AsekoBinarySensorEntityDescription(
|
||||
key="has_alarm",
|
||||
translation_key="alarm",
|
||||
value_fn=lambda unit: unit.has_alarm,
|
||||
device_class=BinarySensorDeviceClass.SAFETY,
|
||||
),
|
||||
AsekoBinarySensorEntityDescription(
|
||||
key="has_error",
|
||||
translation_key="error",
|
||||
value_fn=lambda unit: unit.has_error,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -42,22 +55,33 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Aseko Pool Live binary sensors."""
|
||||
coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
units = coordinator.data.values()
|
||||
data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
async_add_entities(
|
||||
AsekoBinarySensorEntity(unit, coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
for unit in units
|
||||
if description.value_fn(unit) is not None
|
||||
AsekoUnitBinarySensorEntity(unit, coordinator, description)
|
||||
for unit, coordinator in data
|
||||
for description in UNIT_BINARY_SENSORS
|
||||
)
|
||||
|
||||
|
||||
class AsekoBinarySensorEntity(AsekoEntity, BinarySensorEntity):
|
||||
"""Representation of an Aseko binary sensor entity."""
|
||||
class AsekoUnitBinarySensorEntity(AsekoEntity, BinarySensorEntity):
|
||||
"""Representation of a unit water flow binary sensor entity."""
|
||||
|
||||
entity_description: AsekoBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unit: Unit,
|
||||
coordinator: AsekoDataUpdateCoordinator,
|
||||
entity_description: AsekoBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the unit binary sensor."""
|
||||
super().__init__(unit, coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self._unit.serial_number}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.unit)
|
||||
return self.entity_description.value_fn(self._unit)
|
||||
|
||||
@@ -6,11 +6,12 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioaseko import Aseko, AsekoAPIError, AsekoInvalidCredentials
|
||||
from aioaseko import APIUnavailable, InvalidAuthCredentials, WebAccount
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -33,12 +34,15 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def get_account_info(self, email: str, password: str) -> dict:
|
||||
"""Get account info from the mobile API and the web API."""
|
||||
aseko = Aseko(email, password)
|
||||
user = await aseko.login()
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
web_account = WebAccount(session, email, password)
|
||||
web_account_info = await web_account.login()
|
||||
|
||||
return {
|
||||
CONF_EMAIL: email,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_UNIQUE_ID: user.user_id,
|
||||
CONF_UNIQUE_ID: web_account_info.user_id,
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
@@ -54,9 +58,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
info = await self.get_account_info(
|
||||
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except AsekoAPIError:
|
||||
except APIUnavailable:
|
||||
errors["base"] = "cannot_connect"
|
||||
except AsekoInvalidCredentials:
|
||||
except InvalidAuthCredentials:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
@@ -118,9 +122,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
info = await self.get_account_info(
|
||||
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except AsekoAPIError:
|
||||
except APIUnavailable:
|
||||
errors["base"] = "cannot_connect"
|
||||
except AsekoInvalidCredentials:
|
||||
except InvalidAuthCredentials:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
|
||||
@@ -5,31 +5,34 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aioaseko import Aseko, Unit
|
||||
from aioaseko import Unit, Variable
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Unit]]):
|
||||
class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Variable]]):
|
||||
"""Class to manage fetching Aseko unit data from single endpoint."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, aseko: Aseko) -> None:
|
||||
def __init__(self, hass: HomeAssistant, unit: Unit) -> None:
|
||||
"""Initialize global Aseko unit data updater."""
|
||||
self._aseko = aseko
|
||||
self._unit = unit
|
||||
|
||||
if self._unit.name:
|
||||
name = self._unit.name
|
||||
else:
|
||||
name = f"{self._unit.type}-{self._unit.serial_number}"
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
name=name,
|
||||
update_interval=timedelta(minutes=2),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Unit]:
|
||||
async def _async_update_data(self) -> dict[str, Variable]:
|
||||
"""Fetch unit data."""
|
||||
units = await self._aseko.get_units()
|
||||
return {unit.serial_number: unit for unit in units}
|
||||
await self._unit.get_state()
|
||||
return {variable.type: variable for variable in self._unit.variables}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from aioaseko import Unit
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -15,44 +14,20 @@ class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unit: Unit,
|
||||
coordinator: AsekoDataUpdateCoordinator,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None:
|
||||
"""Initialize the aseko entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._unit = unit
|
||||
self._attr_unique_id = f"{self.unit.serial_number}{self.entity_description.key}"
|
||||
|
||||
if self._unit.type == "Remote":
|
||||
self._device_model = "ASIN Pool"
|
||||
else:
|
||||
self._device_model = f"ASIN AQUA {self._unit.type}"
|
||||
self._device_name = self._unit.name if self._unit.name else self._device_model
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unit.serial_number)},
|
||||
serial_number=self.unit.serial_number,
|
||||
name=unit.name or unit.serial_number,
|
||||
manufacturer=(
|
||||
self.unit.brand_name.primary
|
||||
if self.unit.brand_name is not None
|
||||
else None
|
||||
),
|
||||
model=(
|
||||
self.unit.brand_name.secondary
|
||||
if self.unit.brand_name is not None
|
||||
else None
|
||||
),
|
||||
configuration_url=f"https://aseko.cloud/unit/{self.unit.serial_number}",
|
||||
)
|
||||
|
||||
@property
|
||||
def unit(self) -> Unit:
|
||||
"""Return the aseko unit."""
|
||||
return self.coordinator.data[self._unit.serial_number]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.unit.serial_number in self.coordinator.data
|
||||
and self.unit.online
|
||||
name=self._device_name,
|
||||
identifiers={(DOMAIN, str(self._unit.serial_number))},
|
||||
manufacturer="Aseko",
|
||||
model=self._device_model,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"water_flow_to_probes": {
|
||||
"water_flow": {
|
||||
"default": "mdi:waves-arrow-right"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"air_temperature": {
|
||||
"default": "mdi:thermometer-lines"
|
||||
},
|
||||
"free_chlorine": {
|
||||
"default": "mdi:pool"
|
||||
},
|
||||
"redox": {
|
||||
"default": "mdi:pool"
|
||||
},
|
||||
"salinity": {
|
||||
"default": "mdi:pool"
|
||||
"default": "mdi:flask"
|
||||
},
|
||||
"water_temperature": {
|
||||
"default": "mdi:pool-thermometer"
|
||||
"default": "mdi:coolant-temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioaseko"],
|
||||
"requirements": ["aioaseko==1.0.0"]
|
||||
"requirements": ["aioaseko==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -2,104 +2,77 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioaseko import Unit
|
||||
from aioaseko import Unit, Variable
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AsekoDataUpdateCoordinator
|
||||
from .entity import AsekoEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AsekoSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Aseko sensor entity."""
|
||||
|
||||
value_fn: Callable[[Unit], StateType]
|
||||
|
||||
|
||||
SENSORS: list[AsekoSensorEntityDescription] = [
|
||||
AsekoSensorEntityDescription(
|
||||
key="airTemp",
|
||||
translation_key="air_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda unit: unit.air_temperature,
|
||||
),
|
||||
AsekoSensorEntityDescription(
|
||||
key="free_chlorine",
|
||||
translation_key="free_chlorine",
|
||||
native_unit_of_measurement="mg/l",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda unit: unit.cl_free,
|
||||
),
|
||||
AsekoSensorEntityDescription(
|
||||
key="ph",
|
||||
device_class=SensorDeviceClass.PH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda unit: unit.ph,
|
||||
),
|
||||
AsekoSensorEntityDescription(
|
||||
key="rx",
|
||||
translation_key="redox",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda unit: unit.redox,
|
||||
),
|
||||
AsekoSensorEntityDescription(
|
||||
key="salinity",
|
||||
translation_key="salinity",
|
||||
native_unit_of_measurement="kg/m³",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda unit: unit.salinity,
|
||||
),
|
||||
AsekoSensorEntityDescription(
|
||||
key="waterTemp",
|
||||
translation_key="water_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda unit: unit.water_temperature,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Aseko Pool Live sensors."""
|
||||
coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
units = coordinator.data.values()
|
||||
data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
|
||||
async_add_entities(
|
||||
AsekoSensorEntity(unit, coordinator, description)
|
||||
for description in SENSORS
|
||||
for unit in units
|
||||
if description.value_fn(unit) is not None
|
||||
VariableSensorEntity(unit, variable, coordinator)
|
||||
for unit, coordinator in data
|
||||
for variable in unit.variables
|
||||
)
|
||||
|
||||
|
||||
class AsekoSensorEntity(AsekoEntity, SensorEntity):
|
||||
"""Representation of an Aseko unit sensor entity."""
|
||||
class VariableSensorEntity(AsekoEntity, SensorEntity):
|
||||
"""Representation of a unit variable sensor entity."""
|
||||
|
||||
entity_description: AsekoSensorEntityDescription
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self, unit: Unit, variable: Variable, coordinator: AsekoDataUpdateCoordinator
|
||||
) -> None:
|
||||
"""Initialize the variable sensor."""
|
||||
super().__init__(unit, coordinator)
|
||||
self._variable = variable
|
||||
|
||||
translation_key = {
|
||||
"Air temp.": "air_temperature",
|
||||
"Cl free": "free_chlorine",
|
||||
"Water temp.": "water_temperature",
|
||||
}.get(self._variable.name)
|
||||
if translation_key is not None:
|
||||
self._attr_translation_key = translation_key
|
||||
else:
|
||||
self._attr_name = self._variable.name
|
||||
|
||||
self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}"
|
||||
self._attr_native_unit_of_measurement = self._variable.unit
|
||||
|
||||
self._attr_icon = {
|
||||
"rx": "mdi:test-tube",
|
||||
"waterLevel": "mdi:waves",
|
||||
}.get(self._variable.type)
|
||||
|
||||
self._attr_device_class = {
|
||||
"airTemp": SensorDeviceClass.TEMPERATURE,
|
||||
"waterTemp": SensorDeviceClass.TEMPERATURE,
|
||||
"ph": SensorDeviceClass.PH,
|
||||
}.get(self._variable.type)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.unit)
|
||||
variable = self.coordinator.data[self._variable.type]
|
||||
return variable.current_value
|
||||
|
||||
@@ -26,8 +26,11 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"water_flow_to_probes": {
|
||||
"name": "Water flow to probes"
|
||||
"water_flow": {
|
||||
"name": "Water flow"
|
||||
},
|
||||
"alarm": {
|
||||
"name": "Alarm"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -37,12 +40,6 @@
|
||||
"free_chlorine": {
|
||||
"name": "Free chlorine"
|
||||
},
|
||||
"redox": {
|
||||
"name": "Redox potential"
|
||||
},
|
||||
"salinity": {
|
||||
"name": "Salinity"
|
||||
},
|
||||
"water_temperature": {
|
||||
"name": "Water temperature"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"domain": "assist_pipeline",
|
||||
"name": "Assist pipeline",
|
||||
"after_dependencies": ["repairs"],
|
||||
"codeowners": ["@balloob", "@synesthesiam"],
|
||||
"dependencies": ["conversation", "stt", "tts", "wake_word"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
|
||||
|
||||
@@ -26,7 +26,7 @@ from homeassistant.components import (
|
||||
wake_word,
|
||||
websocket_api,
|
||||
)
|
||||
from homeassistant.components.tts import (
|
||||
from homeassistant.components.tts.media_source import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Repairs implementation for the cloud integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
|
||||
|
||||
|
||||
class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
def __init__(self, data: dict[str, str | int | float | None] | None) -> None:
|
||||
"""Initialize."""
|
||||
if not data or any(key not in data for key in REQUIRED_KEYS):
|
||||
raise ValueError("Missing data")
|
||||
self._data = data
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_disable_entity()
|
||||
|
||||
async def async_step_confirm_disable_entity(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entity_entry = entity_registry.async_get(
|
||||
cast(str, self._data["entity_uuid"])
|
||||
)
|
||||
if entity_entry:
|
||||
entity_registry.async_update_entity(
|
||||
entity_entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER
|
||||
)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
description_placeholders: dict[str, str] = {
|
||||
"assist_satellite_domain": ASSIST_SATELLITE_DOMAIN,
|
||||
"entity_id": cast(str, self._data["entity_id"]),
|
||||
"integration_name": cast(str, self._data["integration_name"]),
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="confirm_disable_entity",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant",
|
||||
"name": "Assist pipeline",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
@@ -21,17 +21,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"assist_in_progress_deprecated": {
|
||||
"title": "{integration_name} in progress binary sensors are deprecated",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm_disable_entity": {
|
||||
"description": "The {integration_name} in progress binary sensor `{entity_id}` is deprecated.\n\nMigrate your configuration to use the corresponding `{assist_satellite_domain}` entity and then click SUBMIT to disable the in progress binary sensor and fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,31 +10,16 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .connection_test import ConnectionTestView
|
||||
from .const import (
|
||||
CONNECTION_TEST_DATA,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
AssistSatelliteEntityFeature,
|
||||
)
|
||||
from .entity import (
|
||||
AssistSatelliteAnnouncement,
|
||||
AssistSatelliteConfiguration,
|
||||
AssistSatelliteEntity,
|
||||
AssistSatelliteEntityDescription,
|
||||
AssistSatelliteWakeWord,
|
||||
)
|
||||
from .const import DOMAIN, AssistSatelliteEntityFeature
|
||||
from .entity import AssistSatelliteEntity, AssistSatelliteEntityDescription
|
||||
from .errors import SatelliteBusyError
|
||||
from .websocket_api import async_register_websocket_api
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"AssistSatelliteAnnouncement",
|
||||
"AssistSatelliteEntity",
|
||||
"AssistSatelliteConfiguration",
|
||||
"AssistSatelliteEntityDescription",
|
||||
"AssistSatelliteEntityFeature",
|
||||
"AssistSatelliteWakeWord",
|
||||
"SatelliteBusyError",
|
||||
]
|
||||
|
||||
@@ -44,7 +29,7 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[AssistSatelliteEntity](
|
||||
component = hass.data[DOMAIN] = EntityComponent[AssistSatelliteEntity](
|
||||
_LOGGER, DOMAIN, hass
|
||||
)
|
||||
await component.async_setup(config)
|
||||
@@ -63,18 +48,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
hass.data[CONNECTION_TEST_DATA] = {}
|
||||
async_register_websocket_api(hass)
|
||||
hass.http.register_view(ConnectionTestView())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
|
||||
return await component.async_unload_entry(entry)
|
||||
|
||||
Binary file not shown.
@@ -1,43 +0,0 @@
|
||||
"""Assist satellite connection test."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
|
||||
from .const import CONNECTION_TEST_DATA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONNECTION_TEST_CONTENT_TYPE = "audio/mpeg"
|
||||
CONNECTION_TEST_FILENAME = "connection_test.mp3"
|
||||
CONNECTION_TEST_URL_BASE = "/api/assist_satellite/connection_test"
|
||||
|
||||
|
||||
class ConnectionTestView(HomeAssistantView):
|
||||
"""View to serve an audio sample for connection test."""
|
||||
|
||||
requires_auth = False
|
||||
url = f"{CONNECTION_TEST_URL_BASE}/{{connection_id}}"
|
||||
name = "api:assist_satellite_connection_test"
|
||||
|
||||
async def get(self, request: web.Request, connection_id: str) -> web.Response:
|
||||
"""Start a get request."""
|
||||
_LOGGER.debug("Request for connection test with id %s", connection_id)
|
||||
|
||||
hass = request.app[KEY_HASS]
|
||||
connection_test_data = hass.data[CONNECTION_TEST_DATA]
|
||||
|
||||
connection_test_event = connection_test_data.pop(connection_id, None)
|
||||
|
||||
if connection_test_event is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
connection_test_event.set()
|
||||
|
||||
audio_path = Path(__file__).parent / CONNECTION_TEST_FILENAME
|
||||
audio_data = await hass.async_add_executor_job(audio_path.read_bytes)
|
||||
|
||||
return web.Response(body=audio_data, content_type=CONNECTION_TEST_CONTENT_TYPE)
|
||||
@@ -1,25 +1,9 @@
|
||||
"""Constants for assist satellite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from .entity import AssistSatelliteEntity
|
||||
|
||||
DOMAIN = "assist_satellite"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[AssistSatelliteEntity]] = HassKey(DOMAIN)
|
||||
CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey(
|
||||
f"{DOMAIN}_connection_tests"
|
||||
)
|
||||
|
||||
|
||||
class AssistSatelliteEntityFeature(IntFlag):
|
||||
"""Supported features of Assist satellite entity."""
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Final, Literal, final
|
||||
from typing import Any, Final, final
|
||||
|
||||
from homeassistant.components import media_source, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
@@ -23,12 +21,13 @@ from homeassistant.components.assist_pipeline import (
|
||||
vad,
|
||||
)
|
||||
from homeassistant.components.media_player import async_process_play_media_url
|
||||
from homeassistant.components.tts import (
|
||||
from homeassistant.components.tts.media_source import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.core import Context, callback
|
||||
from homeassistant.helpers import entity
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from .const import AssistSatelliteEntityFeature
|
||||
from .errors import AssistSatelliteError, SatelliteBusyError
|
||||
@@ -41,10 +40,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AssistSatelliteState(StrEnum):
|
||||
"""Valid states of an Assist satellite entity."""
|
||||
|
||||
IDLE = "idle"
|
||||
"""Device is waiting for user input, such as a wake word or a button press."""
|
||||
LISTENING_WAKE_WORD = "listening_wake_word"
|
||||
"""Device is streaming audio for wake word detection to Home Assistant."""
|
||||
|
||||
LISTENING = "listening"
|
||||
LISTENING_COMMAND = "listening_command"
|
||||
"""Device is streaming audio with the voice command to Home Assistant."""
|
||||
|
||||
PROCESSING = "processing"
|
||||
@@ -58,47 +57,6 @@ class AssistSatelliteEntityDescription(EntityDescription, frozen_or_thawed=True)
|
||||
"""A class that describes Assist satellite entities."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistSatelliteWakeWord:
|
||||
"""Available wake word model."""
|
||||
|
||||
id: str
|
||||
"""Unique id for wake word model."""
|
||||
|
||||
wake_word: str
|
||||
"""Wake word phrase."""
|
||||
|
||||
trained_languages: list[str]
|
||||
"""List of languages that the wake word was trained on."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistSatelliteConfiguration:
|
||||
"""Satellite configuration."""
|
||||
|
||||
available_wake_words: list[AssistSatelliteWakeWord]
|
||||
"""List of available available wake word models."""
|
||||
|
||||
active_wake_words: list[str]
|
||||
"""List of active wake word ids."""
|
||||
|
||||
max_active_wake_words: int
|
||||
"""Maximum number of simultaneous wake words allowed (0 for no limit)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistSatelliteAnnouncement:
|
||||
"""Announcement to be made."""
|
||||
|
||||
message: str
|
||||
"""Message to be spoken."""
|
||||
|
||||
media_id: str
|
||||
"""Media ID to be played."""
|
||||
|
||||
media_id_source: Literal["url", "media_id", "tts"]
|
||||
|
||||
|
||||
class AssistSatelliteEntity(entity.Entity):
|
||||
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
||||
|
||||
@@ -115,9 +73,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
_is_announcing = False
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_attr_tts_options: dict[str, Any] | None = None
|
||||
_pipeline_task: asyncio.Task | None = None
|
||||
|
||||
__assist_satellite_state = AssistSatelliteState.IDLE
|
||||
__assist_satellite_state = AssistSatelliteState.LISTENING_WAKE_WORD
|
||||
|
||||
@final
|
||||
@property
|
||||
@@ -140,17 +97,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
"""Options passed for text-to-speech."""
|
||||
return self._attr_tts_options
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def async_get_configuration(self) -> AssistSatelliteConfiguration:
|
||||
"""Get the current satellite configuration."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_set_configuration(
|
||||
self, config: AssistSatelliteConfiguration
|
||||
) -> None:
|
||||
"""Set the current satellite configuration."""
|
||||
|
||||
async def async_intercept_wake_word(self) -> str | None:
|
||||
"""Intercept the next wake word from the satellite.
|
||||
|
||||
@@ -185,15 +131,10 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
|
||||
Calls async_announce with message and media id.
|
||||
"""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
media_id_source: Literal["url", "media_id", "tts"] | None = None
|
||||
|
||||
if message is None:
|
||||
message = ""
|
||||
|
||||
if not media_id:
|
||||
media_id_source = "tts"
|
||||
# Synthesize audio and get URL
|
||||
pipeline_id = self._resolve_pipeline()
|
||||
pipeline = async_get_pipeline(self.hass, pipeline_id)
|
||||
@@ -214,8 +155,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
)
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
if not media_id_source:
|
||||
media_id_source = "media_id"
|
||||
media = await media_source.async_resolve_media(
|
||||
self.hass,
|
||||
media_id,
|
||||
@@ -223,9 +162,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
)
|
||||
media_id = media.url
|
||||
|
||||
if not media_id_source:
|
||||
media_id_source = "url"
|
||||
|
||||
# Resolve to full URL
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
|
||||
@@ -233,18 +169,14 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
raise SatelliteBusyError
|
||||
|
||||
self._is_announcing = True
|
||||
self._set_state(AssistSatelliteState.RESPONDING)
|
||||
|
||||
try:
|
||||
# Block until announcement is finished
|
||||
await self.async_announce(
|
||||
AssistSatelliteAnnouncement(message, media_id, media_id_source)
|
||||
)
|
||||
await self.async_announce(message, media_id)
|
||||
finally:
|
||||
self._is_announcing = False
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
|
||||
async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None:
|
||||
async def async_announce(self, message: str, media_id: str) -> None:
|
||||
"""Announce media on the satellite.
|
||||
|
||||
Should block until the announcement is done playing.
|
||||
@@ -259,8 +191,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
wake_word_phrase: str | None = None,
|
||||
) -> None:
|
||||
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
if self._wake_word_intercept_future and start_stage in (
|
||||
PipelineStage.WAKE_WORD,
|
||||
PipelineStage.STT,
|
||||
@@ -302,59 +232,45 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
assert self._context is not None
|
||||
|
||||
# Reset conversation id if necessary
|
||||
if self._conversation_id_time and (
|
||||
if (self._conversation_id_time is None) or (
|
||||
(time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC
|
||||
):
|
||||
self._conversation_id = None
|
||||
self._conversation_id_time = None
|
||||
|
||||
if self._conversation_id is None:
|
||||
self._conversation_id = ulid.ulid()
|
||||
|
||||
# Update timeout
|
||||
self._conversation_id_time = time.monotonic()
|
||||
|
||||
# Set entity state based on pipeline events
|
||||
self._run_has_tts = False
|
||||
|
||||
assert self.platform.config_entry is not None
|
||||
self._pipeline_task = self.platform.config_entry.async_create_background_task(
|
||||
await async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self._context,
|
||||
event_callback=self._internal_on_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=audio_stream,
|
||||
pipeline_id=self._resolve_pipeline(),
|
||||
conversation_id=self._conversation_id,
|
||||
device_id=device_id,
|
||||
tts_audio_output=self.tts_options,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
audio_settings=AudioSettings(
|
||||
silence_seconds=self._resolve_vad_sensitivity()
|
||||
),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
context=self._context,
|
||||
event_callback=self._internal_on_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
f"{self.entity_id}_pipeline",
|
||||
stt_stream=audio_stream,
|
||||
pipeline_id=self._resolve_pipeline(),
|
||||
conversation_id=self._conversation_id,
|
||||
device_id=device_id,
|
||||
tts_audio_output=self.tts_options,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
audio_settings=AudioSettings(
|
||||
silence_seconds=self._resolve_vad_sensitivity()
|
||||
),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._pipeline_task
|
||||
finally:
|
||||
self._pipeline_task = None
|
||||
|
||||
async def _cancel_running_pipeline(self) -> None:
|
||||
"""Cancel the current pipeline if it's running."""
|
||||
if self._pipeline_task is not None:
|
||||
self._pipeline_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._pipeline_task
|
||||
|
||||
self._pipeline_task = None
|
||||
|
||||
@abstractmethod
|
||||
def on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
@@ -363,23 +279,18 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
def _internal_on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Set state based on pipeline stage."""
|
||||
if event.type is PipelineEventType.WAKE_WORD_START:
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD)
|
||||
elif event.type is PipelineEventType.STT_START:
|
||||
self._set_state(AssistSatelliteState.LISTENING)
|
||||
self._set_state(AssistSatelliteState.LISTENING_COMMAND)
|
||||
elif event.type is PipelineEventType.INTENT_START:
|
||||
self._set_state(AssistSatelliteState.PROCESSING)
|
||||
elif event.type is PipelineEventType.INTENT_END:
|
||||
assert event.data is not None
|
||||
# Update timeout
|
||||
self._conversation_id_time = time.monotonic()
|
||||
self._conversation_id = event.data["intent_output"]["conversation_id"]
|
||||
elif event.type is PipelineEventType.TTS_START:
|
||||
# Wait until tts_response_finished is called to return to waiting state
|
||||
self._run_has_tts = True
|
||||
self._set_state(AssistSatelliteState.RESPONDING)
|
||||
elif event.type is PipelineEventType.RUN_END:
|
||||
if not self._run_has_tts:
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD)
|
||||
|
||||
self.on_pipeline_event(event)
|
||||
|
||||
@@ -392,7 +303,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
@callback
|
||||
def tts_response_finished(self) -> None:
|
||||
"""Tell entity that the text-to-speech response has finished playing."""
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD)
|
||||
|
||||
@callback
|
||||
def _resolve_pipeline(self) -> str | None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "assist_satellite",
|
||||
"name": "Assist Satellite",
|
||||
"codeowners": ["@home-assistant/core", "@synesthesiam"],
|
||||
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
||||
"dependencies": ["assist_pipeline", "stt", "tts"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"_": {
|
||||
"name": "Assist satellite",
|
||||
"state": {
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"listening": "Listening",
|
||||
"listening_wake_word": "Wake word",
|
||||
"listening_command": "Voice command",
|
||||
"responding": "Responding",
|
||||
"processing": "Processing"
|
||||
}
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
"""Assist satellite Websocket API."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict, replace
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.util import uuid as uuid_util
|
||||
|
||||
from .connection_test import CONNECTION_TEST_URL_BASE
|
||||
from .const import (
|
||||
CONNECTION_TEST_DATA,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
AssistSatelliteEntityFeature,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
from .entity import AssistSatelliteEntity
|
||||
|
||||
CONNECTION_TEST_TIMEOUT = 30
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_api(hass: HomeAssistant) -> None:
|
||||
"""Register the websocket API."""
|
||||
websocket_api.async_register_command(hass, websocket_intercept_wake_word)
|
||||
websocket_api.async_register_command(hass, websocket_get_configuration)
|
||||
websocket_api.async_register_command(hass, websocket_set_wake_words)
|
||||
websocket_api.async_register_command(hass, websocket_test_connection)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_satellite/intercept_wake_word",
|
||||
@@ -48,125 +34,6 @@ async def websocket_intercept_wake_word(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Intercept the next wake word from a satellite."""
|
||||
satellite = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
|
||||
if satellite is None:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
|
||||
)
|
||||
return
|
||||
|
||||
async def intercept_wake_word() -> None:
|
||||
"""Push an intercepted wake word to websocket."""
|
||||
try:
|
||||
wake_word_phrase = await satellite.async_intercept_wake_word()
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
{"wake_word_phrase": wake_word_phrase},
|
||||
)
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
connection.send_error(msg["id"], "home_assistant_error", str(err))
|
||||
|
||||
task = hass.async_create_task(intercept_wake_word(), "intercept_wake_word")
|
||||
connection.subscriptions[msg["id"]] = task.cancel
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_satellite/get_configuration",
|
||||
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
|
||||
}
|
||||
)
|
||||
def websocket_get_configuration(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the current satellite configuration."""
|
||||
satellite = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
|
||||
if satellite is None:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
|
||||
)
|
||||
return
|
||||
|
||||
config_dict = asdict(satellite.async_get_configuration())
|
||||
config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id
|
||||
config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id
|
||||
|
||||
connection.send_result(msg["id"], config_dict)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_satellite/set_wake_words",
|
||||
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
|
||||
vol.Required("wake_word_ids"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_set_wake_words(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set the active wake words for the satellite."""
|
||||
satellite = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
|
||||
if satellite is None:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
|
||||
)
|
||||
return
|
||||
|
||||
config = satellite.async_get_configuration()
|
||||
|
||||
# Don't set too many active wake words
|
||||
actual_ids = msg["wake_word_ids"]
|
||||
if len(actual_ids) > config.max_active_wake_words:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
f"Maximum number of active wake words is {config.max_active_wake_words}",
|
||||
)
|
||||
return
|
||||
|
||||
# Verify all ids are available
|
||||
available_ids = {ww.id for ww in config.available_wake_words}
|
||||
for ww_id in actual_ids:
|
||||
if ww_id not in available_ids:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
f"Wake word id is not supported: {ww_id}",
|
||||
)
|
||||
return
|
||||
|
||||
await satellite.async_set_configuration(
|
||||
replace(config, active_wake_words=actual_ids)
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_satellite/test_connection",
|
||||
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_test_connection(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test the connection between the device and Home Assistant.
|
||||
|
||||
Send an announcement to the device with a special media id.
|
||||
"""
|
||||
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
|
||||
satellite = component.get_entity(msg["entity_id"])
|
||||
if satellite is None:
|
||||
@@ -174,32 +41,6 @@ async def websocket_test_connection(
|
||||
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
|
||||
)
|
||||
return
|
||||
if not (satellite.supported_features or 0) & AssistSatelliteEntityFeature.ANNOUNCE:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
"Entity does not support announce",
|
||||
)
|
||||
return
|
||||
|
||||
# Announce and wait for event
|
||||
connection_test_data = hass.data[CONNECTION_TEST_DATA]
|
||||
connection_id = uuid_util.random_uuid_hex()
|
||||
connection_test_event = asyncio.Event()
|
||||
connection_test_data[connection_id] = connection_test_event
|
||||
|
||||
hass.async_create_background_task(
|
||||
satellite.async_internal_announce(
|
||||
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}"
|
||||
),
|
||||
f"assist_satellite_connection_test_{msg['entity_id']}",
|
||||
)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(CONNECTION_TEST_TIMEOUT):
|
||||
await connection_test_event.wait()
|
||||
connection.send_result(msg["id"], {"status": "success"})
|
||||
except TimeoutError:
|
||||
connection.send_result(msg["id"], {"status": "timeout"})
|
||||
finally:
|
||||
connection_test_data.pop(connection_id, None)
|
||||
wake_word_phrase = await satellite.async_intercept_wake_word()
|
||||
connection.send_result(msg["id"], {"wake_word_phrase": wake_word_phrase})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.device_tracker import ScannerEntity
|
||||
from homeassistant.components.device_tracker import ScannerEntity, SourceType
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -71,6 +71,11 @@ class AsusWrtDevice(ScannerEntity):
|
||||
"""Return true if the device is connected to the network."""
|
||||
return self._device.is_connected
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type."""
|
||||
return SourceType.ROUTER
|
||||
|
||||
@property
|
||||
def hostname(self) -> str | None:
|
||||
"""Return the hostname of device."""
|
||||
|
||||
@@ -290,7 +290,7 @@ class AsusWrtRouter:
|
||||
|
||||
if self._connect_error:
|
||||
self._connect_error = False
|
||||
_LOGGER.warning("Reconnected to ASUS router %s", self.host)
|
||||
_LOGGER.info("Reconnected to ASUS router %s", self.host)
|
||||
|
||||
self._connected_devices = len(wrt_devices)
|
||||
consider_home: int = self._options.get(
|
||||
|
||||
@@ -10,7 +10,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,3 +64,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
class AtagEntity(CoordinatorEntity[DataUpdateCoordinator[AtagOne]]):
|
||||
"""Defines a base Atag entity."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: DataUpdateCoordinator[AtagOne], atag_id: str
|
||||
) -> None:
|
||||
"""Initialize the Atag entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._id = atag_id
|
||||
self._attr_name = DOMAIN.title()
|
||||
self._attr_unique_id = f"{coordinator.data.id}-{atag_id}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return info for device registry."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data.id)},
|
||||
manufacturer="Atag",
|
||||
model="Atag One",
|
||||
name="Atag Thermostat",
|
||||
sw_version=self.coordinator.data.apiversion,
|
||||
)
|
||||
|
||||
@@ -18,8 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from . import DOMAIN
|
||||
from .entity import AtagEntity
|
||||
from . import DOMAIN, AtagEntity
|
||||
|
||||
PRESET_MAP = {
|
||||
"Manual": "manual",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user