Compare commits

..

10 Commits

Author SHA1 Message Date
J. Nick Koston 87a00eb80f merge 2024-09-09 11:40:01 -05:00
J. Nick Koston 72efcf0e94 Merge branch 'dev' into lutron_caseta_event_rework 2024-09-09 11:37:24 -05:00
J. Nick Koston 148bb05dea loop 2024-07-29 21:47:55 -05:00
J. Nick Koston 4d01e0a773 cleanup 2024-07-29 21:43:08 -05:00
J. Nick Koston 15d8b84074 adjust 2024-07-29 21:31:36 -05:00
J. Nick Koston 2208262ca5 debug 2024-07-29 21:27:17 -05:00
J. Nick Koston 0c2a0118e2 event types 2024-07-29 21:23:10 -05:00
J. Nick Koston 93fe46509f lint 2024-07-29 21:19:07 -05:00
J. Nick Koston 581efad5a7 lint 2024-07-29 21:18:01 -05:00
J. Nick Koston 0492639d51 Add support for event entities to lutron_caseta 2024-07-29 21:03:48 -05:00
2976 changed files with 27256 additions and 86874 deletions
-3
View File
@@ -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/**
+2 -3
View File
@@ -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 }}
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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"
+10 -19
View File
@@ -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:
+2 -2
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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/
+1 -5
View File
@@ -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:
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "aqara",
"name": "Aqara",
"iot_standards": ["matter", "zigbee"]
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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",
+270 -24
View File
@@ -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
+6 -14
View File
@@ -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
-41
View File
@@ -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"
+26 -25
View File
@@ -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:
-70
View File
@@ -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
-151
View File
@@ -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)
+13 -17
View File
@@ -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 -1
View File
@@ -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"],
-86
View File
@@ -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()
+28 -64
View File
@@ -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."""
+4 -5
View File
@@ -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)])
-84
View File
@@ -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__)
+1 -23
View File
@@ -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]
+12 -17
View File
@@ -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,
+42 -1
View File
@@ -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
+1 -2
View File
@@ -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": {
+204 -5
View File
@@ -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()
-206
View File
@@ -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,
+1 -1
View File
@@ -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
+70 -11
View File
@@ -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__)
+2 -3
View File
@@ -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
View File
@@ -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)
@@ -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."""
+1 -1
View File
@@ -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(
+31 -1
View File
@@ -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,
)
+1 -2
View File
@@ -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