mirror of
https://github.com/home-assistant/core.git
synced 2026-03-07 14:34:56 +01:00
Compare commits
6 Commits
bluetooth_
...
gha-builde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36cb3e21fe | ||
|
|
f645b232f9 | ||
|
|
e8454d9b2c | ||
|
|
02ae9b2f71 | ||
|
|
f6f7390063 | ||
|
|
bfa1fd7f1b |
230
.github/workflows/builder.yml
vendored
230
.github/workflows/builder.yml
vendored
@@ -35,6 +35,7 @@ jobs:
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -74,43 +75,8 @@ jobs:
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build_base:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -121,7 +87,7 @@ jobs:
|
||||
name: wheels
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -131,18 +97,12 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
name: package
|
||||
|
||||
- name: Set up Python
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Adjust nightly version
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
UV_PRERELEASE: allow
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
python3 -m pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install packaging tomli
|
||||
@@ -180,92 +140,72 @@ jobs:
|
||||
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Write meta info file
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
- name: Upload build context overlay
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: build-context
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
homeassistant/components/*/translations/
|
||||
rootfs/OFFICIAL_IMAGE
|
||||
home_assistant_frontend-*.whl
|
||||
home_assistant_intents-*.whl
|
||||
homeassistant/const.py
|
||||
homeassistant/components/frontend/manifest.json
|
||||
homeassistant/components/conversation/manifest.json
|
||||
homeassistant/package_constraints.txt
|
||||
requirements_all.txt
|
||||
requirements.txt
|
||||
pyproject.toml
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
build_base:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify base image signature
|
||||
env:
|
||||
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
|
||||
"${BASE_IMAGE}"
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
env:
|
||||
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"${CACHE_IMAGE}"
|
||||
- name: Download build context overlay
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: build-context
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: home-assistant/builder/actions/build-image@gha-builder # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ steps.vars.outputs.platform }}
|
||||
push: true
|
||||
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
|
||||
org.opencontainers.image.version=${{ needs.init.outputs.version }}
|
||||
|
||||
- name: Sign image
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
|
||||
image-tags: ${{ needs.init.outputs.version }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
@@ -314,35 +254,38 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set build additional args
|
||||
- name: Compute extra tags
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@gha-builder # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
--cosign \
|
||||
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: machine/
|
||||
cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
file: machine/${{ matrix.machine }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
|
||||
image-tags: |
|
||||
${{ needs.init.outputs.version }}
|
||||
${{ steps.tags.outputs.extra_tags }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
@@ -542,15 +485,10 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Download translations
|
||||
- name: Download build context overlay
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
name: build-context
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
@@ -614,7 +552,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
13
CODEOWNERS
generated
13
CODEOWNERS
generated
@@ -281,8 +281,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cert_expiry/ @jjlawren
|
||||
/homeassistant/components/chacon_dio/ @cnico
|
||||
/tests/components/chacon_dio/ @cnico
|
||||
/homeassistant/components/chess_com/ @joostlek
|
||||
/tests/components/chess_com/ @joostlek
|
||||
/homeassistant/components/cisco_ios/ @fbradyirl
|
||||
/homeassistant/components/cisco_mobility_express/ @fbradyirl
|
||||
/homeassistant/components/cisco_webex_teams/ @fbradyirl
|
||||
@@ -1202,8 +1200,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/opendisplay/ @g4bri3lDev
|
||||
/tests/components/opendisplay/ @g4bri3lDev
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
/tests/components/openerz/ @misialq
|
||||
/homeassistant/components/openevse/ @c00w @firstof9
|
||||
@@ -1309,8 +1305,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/prosegur/ @dgomes
|
||||
/homeassistant/components/proximity/ @mib1185
|
||||
/tests/components/proximity/ @mib1185
|
||||
/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
|
||||
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/homeassistant/components/ps4/ @ktnrg45
|
||||
/tests/components/ps4/ @ktnrg45
|
||||
/homeassistant/components/pterodactyl/ @elmurato
|
||||
@@ -1654,8 +1650,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/systemnexa2/ @konsulten
|
||||
/tests/components/systemnexa2/ @konsulten
|
||||
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
|
||||
/tests/components/systemnexa2/ @konsulten @slangstrom
|
||||
/homeassistant/components/tado/ @erwindouna
|
||||
/tests/components/tado/ @erwindouna
|
||||
/homeassistant/components/tag/ @home-assistant/core
|
||||
@@ -1695,6 +1691,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tessie/ @Bre77
|
||||
/homeassistant/components/text/ @home-assistant/core
|
||||
/tests/components/text/ @home-assistant/core
|
||||
/homeassistant/components/tfiac/ @fredrike @mellado
|
||||
/homeassistant/components/thermobeacon/ @bdraco
|
||||
/tests/components/thermobeacon/ @bdraco
|
||||
/homeassistant/components/thermopro/ @bdraco @h3ss
|
||||
|
||||
31
Dockerfile
generated
31
Dockerfile
generated
@@ -1,19 +1,9 @@
|
||||
# Automatically generated by hassfest.
|
||||
#
|
||||
# To update, run python3 -m script.hassfest -p docker
|
||||
ARG BUILD_FROM
|
||||
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-homeassistant-base:latest
|
||||
FROM ${BUILD_FROM}
|
||||
|
||||
LABEL \
|
||||
io.hass.type="core" \
|
||||
org.opencontainers.image.authors="The Home Assistant Authors" \
|
||||
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
|
||||
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/home-assistant/core" \
|
||||
org.opencontainers.image.title="Home Assistant" \
|
||||
org.opencontainers.image.url="https://www.home-assistant.io/"
|
||||
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=240000 \
|
||||
@@ -60,3 +50,22 @@ RUN \
|
||||
homeassistant/homeassistant
|
||||
|
||||
WORKDIR /config
|
||||
|
||||
ARG BUILD_ARCH=amd64
|
||||
ARG BUILD_DATE="1970-01-01 00:00:00+00:00"
|
||||
ARG BUILD_REPOSITORY
|
||||
ARG BUILD_VERSION=0.0.0-local
|
||||
|
||||
LABEL \
|
||||
io.hass.type="core" \
|
||||
io.hass.arch="${BUILD_ARCH}" \
|
||||
io.hass.version="${BUILD_VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}" \
|
||||
org.opencontainers.image.source="${BUILD_REPOSITORY}" \
|
||||
org.opencontainers.image.authors="The Home Assistant Authors" \
|
||||
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
|
||||
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.title="Home Assistant" \
|
||||
org.opencontainers.image.url="https://www.home-assistant.io/"
|
||||
|
||||
@@ -236,9 +236,6 @@ DEFAULT_INTEGRATIONS = {
|
||||
"input_text",
|
||||
"schedule",
|
||||
"timer",
|
||||
#
|
||||
# Base platforms:
|
||||
*BASE_PLATFORMS,
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -18,10 +18,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.helpers.selector import BooleanSelector
|
||||
from homeassistant.helpers.service_info.zeroconf import (
|
||||
ATTR_PROPERTIES_ID,
|
||||
ZeroconfServiceInfo,
|
||||
)
|
||||
|
||||
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN
|
||||
|
||||
@@ -50,9 +46,6 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_discovered_host: str
|
||||
_discovered_name: str
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -97,58 +90,6 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery of an air-Q device."""
|
||||
self._discovered_host = discovery_info.host
|
||||
self._discovered_name = discovery_info.properties.get("devicename", "air-Q")
|
||||
device_id = discovery_info.properties.get(ATTR_PROPERTIES_ID)
|
||||
|
||||
if not device_id:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_IP_ADDRESS: self._discovered_host},
|
||||
reload_on_update=True,
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {"name": self._discovered_name}
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user confirmation of a discovered air-Q device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
airq = AirQ(self._discovered_host, user_input[CONF_PASSWORD], session)
|
||||
try:
|
||||
await airq.validate()
|
||||
except ClientConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=self._discovered_name,
|
||||
data={
|
||||
CONF_IP_ADDRESS: self._discovered_host,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={"name": self._discovered_name},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
|
||||
@@ -7,13 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.7"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
"device": "air-q"
|
||||
},
|
||||
"type": "_http._tcp.local."
|
||||
}
|
||||
]
|
||||
"requirements": ["aioairq==0.4.7"]
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_input": "[%key:common::config_flow::error::invalid_host%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "Do you want to set up **{name}**?",
|
||||
"title": "Set up air-Q"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
|
||||
@@ -117,23 +117,23 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._unit.Temperature
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
def fan_mode(self):
|
||||
"""Return fan mode of the AC this group belongs to."""
|
||||
return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number)
|
||||
return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds]
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self):
|
||||
"""Return hvac target hvac state."""
|
||||
is_off = self._unit.PowerState == "Off"
|
||||
if is_off:
|
||||
@@ -236,17 +236,17 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._unit.Temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> int:
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we are trying to reach."""
|
||||
return self._unit.TargetSetpoint
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self):
|
||||
"""Return hvac target hvac state."""
|
||||
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
|
||||
is_off = self._unit.PowerState == "Off"
|
||||
@@ -272,12 +272,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
def fan_mode(self):
|
||||
"""Return fan mode of the AC this group belongs to."""
|
||||
return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup(
|
||||
self._group_number
|
||||
|
||||
@@ -7,7 +7,13 @@ from datetime import timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.cloud_api import CloudAPI
|
||||
from pyairvisual.cloud_api import (
|
||||
CloudAPI,
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyairvisual.errors import AirVisualError
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
@@ -22,12 +28,14 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_CITY,
|
||||
@@ -39,7 +47,8 @@ from .const import (
|
||||
INTEGRATION_TYPE_NODE_PRO,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator
|
||||
|
||||
type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator]
|
||||
|
||||
# We use a raw string for the airvisual_pro domain (instead of importing the actual
|
||||
# constant) so that we can avoid listing it as a dependency:
|
||||
@@ -76,8 +85,8 @@ def async_get_cloud_api_update_interval(
|
||||
@callback
|
||||
def async_get_cloud_coordinators_by_api_key(
|
||||
hass: HomeAssistant, api_key: str
|
||||
) -> list[AirVisualDataUpdateCoordinator]:
|
||||
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
|
||||
) -> list[DataUpdateCoordinator]:
|
||||
"""Get all DataUpdateCoordinator objects related to a particular API key."""
|
||||
return [
|
||||
entry.runtime_data
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
@@ -171,11 +180,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) ->
|
||||
websession = aiohttp_client.async_get_clientsession(hass)
|
||||
cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession)
|
||||
|
||||
coordinator = AirVisualDataUpdateCoordinator(
|
||||
async def async_update_data() -> dict[str, Any]:
|
||||
"""Get new data from the API."""
|
||||
if CONF_CITY in entry.data:
|
||||
api_coro = cloud_api.air_quality.city(
|
||||
entry.data[CONF_CITY],
|
||||
entry.data[CONF_STATE],
|
||||
entry.data[CONF_COUNTRY],
|
||||
)
|
||||
else:
|
||||
api_coro = cloud_api.air_quality.nearest_city(
|
||||
entry.data[CONF_LATITUDE],
|
||||
entry.data[CONF_LONGITUDE],
|
||||
)
|
||||
|
||||
try:
|
||||
return await api_coro
|
||||
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except AirVisualError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
cloud_api,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=async_get_geography_id(entry.data),
|
||||
# We give a placeholder update interval in order to create the coordinator;
|
||||
# then, below, we use the coordinator's presence (along with any other
|
||||
# coordinators using the same API key) to calculate an actual, leveled
|
||||
# update interval:
|
||||
update_interval=timedelta(minutes=5),
|
||||
update_method=async_update_data,
|
||||
)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Define an AirVisual data coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.cloud_api import (
|
||||
CloudAPI,
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from pyairvisual.errors import AirVisualError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_CITY, LOGGER
|
||||
|
||||
type AirVisualConfigEntry = ConfigEntry[AirVisualDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirVisualDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Class to manage fetching AirVisual data."""
|
||||
|
||||
config_entry: AirVisualConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AirVisualConfigEntry,
|
||||
cloud_api: CloudAPI,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self._cloud_api = cloud_api
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=name,
|
||||
# We give a placeholder update interval in order to create the coordinator;
|
||||
# then, in async_setup_entry, we use the coordinator's presence (along with
|
||||
# any other coordinators using the same API key) to calculate an actual,
|
||||
# leveled update interval:
|
||||
update_interval=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get new data from the API."""
|
||||
if CONF_CITY in self.config_entry.data:
|
||||
api_coro = self._cloud_api.air_quality.city(
|
||||
self.config_entry.data[CONF_CITY],
|
||||
self.config_entry.data[CONF_STATE],
|
||||
self.config_entry.data[CONF_COUNTRY],
|
||||
)
|
||||
else:
|
||||
api_coro = self._cloud_api.air_quality.nearest_city(
|
||||
self.config_entry.data[CONF_LATITUDE],
|
||||
self.config_entry.data[CONF_LONGITUDE],
|
||||
)
|
||||
|
||||
try:
|
||||
return await api_coro
|
||||
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except AirVisualError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
@@ -15,8 +15,8 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AirVisualConfigEntry
|
||||
from .const import CONF_CITY
|
||||
from .coordinator import AirVisualConfigEntry
|
||||
|
||||
CONF_COORDINATES = "coordinates"
|
||||
CONF_TITLE = "title"
|
||||
|
||||
@@ -2,25 +2,29 @@
|
||||
|
||||
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
|
||||
|
||||
from .coordinator import AirVisualDataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
class AirVisualEntity(CoordinatorEntity[AirVisualDataUpdateCoordinator]):
|
||||
class AirVisualEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirVisualDataUpdateCoordinator,
|
||||
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:
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
@@ -23,9 +24,10 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import AirVisualConfigEntry
|
||||
from .const import CONF_CITY
|
||||
from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator
|
||||
from .entity import AirVisualEntity
|
||||
|
||||
ATTR_CITY = "city"
|
||||
@@ -111,7 +113,7 @@ async def async_setup_entry(
|
||||
"""Set up AirVisual sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AirVisualGeographySensor(coordinator, description, locale)
|
||||
AirVisualGeographySensor(coordinator, entry, description, locale)
|
||||
for locale in GEOGRAPHY_SENSOR_LOCALES
|
||||
for description in GEOGRAPHY_SENSOR_DESCRIPTIONS
|
||||
)
|
||||
@@ -122,14 +124,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirVisualDataUpdateCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: SensorEntityDescription,
|
||||
locale: str,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator, description)
|
||||
super().__init__(coordinator, entry, description)
|
||||
|
||||
entry = coordinator.config_entry
|
||||
self._attr_extra_state_attributes.update(
|
||||
{
|
||||
ATTR_CITY: entry.data.get(CONF_CITY),
|
||||
@@ -180,16 +182,16 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
|
||||
#
|
||||
# We use any coordinates in the config entry and, in the case of a geography by
|
||||
# name, we fall back to the latitude longitude provided in the coordinator data:
|
||||
latitude = self.coordinator.config_entry.data.get(
|
||||
latitude = self._entry.data.get(
|
||||
CONF_LATITUDE,
|
||||
self.coordinator.data["location"]["coordinates"][1],
|
||||
)
|
||||
longitude = self.coordinator.config_entry.data.get(
|
||||
longitude = self._entry.data.get(
|
||||
CONF_LONGITUDE,
|
||||
self.coordinator.data["location"]["coordinates"][0],
|
||||
)
|
||||
|
||||
if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]:
|
||||
if self._entry.options[CONF_SHOW_ON_MAP]:
|
||||
self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude
|
||||
self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude
|
||||
self._attr_extra_state_attributes.pop("lati", None)
|
||||
|
||||
@@ -4,9 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.node import NodeProError, NodeSamba
|
||||
from pyairvisual.node import (
|
||||
InvalidAuthenticationError,
|
||||
NodeConnectionError,
|
||||
NodeProError,
|
||||
NodeSamba,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
@@ -14,16 +23,25 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .coordinator import (
|
||||
AirVisualProConfigEntry,
|
||||
AirVisualProCoordinator,
|
||||
AirVisualProData,
|
||||
)
|
||||
from .const import LOGGER
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
type AirVisualProConfigEntry = ConfigEntry[AirVisualProData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirVisualProData:
|
||||
"""Define a data class."""
|
||||
|
||||
coordinator: DataUpdateCoordinator
|
||||
node: NodeSamba
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AirVisualProConfigEntry
|
||||
@@ -36,15 +54,48 @@ async def async_setup_entry(
|
||||
except NodeProError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinator = AirVisualProCoordinator(hass, entry, node)
|
||||
reload_task: asyncio.Task | None = None
|
||||
|
||||
async def async_get_data() -> dict[str, Any]:
|
||||
"""Get data from the device."""
|
||||
try:
|
||||
data = await node.async_get_latest_measurements()
|
||||
data["history"] = {}
|
||||
if data["settings"].get("follow_mode") == "device":
|
||||
history = await node.async_get_history(include_trends=False)
|
||||
data["history"] = history.get("measurements", [])[-1]
|
||||
except InvalidAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid Samba password") from err
|
||||
except NodeConnectionError as err:
|
||||
nonlocal reload_task
|
||||
if not reload_task:
|
||||
reload_task = hass.async_create_task(
|
||||
hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err
|
||||
except NodeProError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
return data
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name="Node/Pro data",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
update_method=async_get_data,
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = AirVisualProData(coordinator=coordinator, node=node)
|
||||
|
||||
async def async_shutdown(_: Event) -> None:
|
||||
"""Define an event handler to disconnect from the websocket."""
|
||||
if coordinator.reload_task:
|
||||
nonlocal reload_task
|
||||
if reload_task:
|
||||
with suppress(asyncio.CancelledError):
|
||||
coordinator.reload_task.cancel()
|
||||
reload_task.cancel()
|
||||
await node.async_disconnect()
|
||||
|
||||
entry.async_on_unload(
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""DataUpdateCoordinator for the AirVisual Pro integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual.node import (
|
||||
InvalidAuthenticationError,
|
||||
NodeConnectionError,
|
||||
NodeProError,
|
||||
NodeSamba,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirVisualProData:
|
||||
"""Define a data class."""
|
||||
|
||||
coordinator: AirVisualProCoordinator
|
||||
node: NodeSamba
|
||||
|
||||
|
||||
type AirVisualProConfigEntry = ConfigEntry[AirVisualProData]
|
||||
|
||||
|
||||
class AirVisualProCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for AirVisual Pro data."""
|
||||
|
||||
config_entry: AirVisualProConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirVisualProConfigEntry,
|
||||
node: NodeSamba,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="Node/Pro data",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self._node = node
|
||||
self.reload_task: asyncio.Task[bool] | None = None
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get data from the device."""
|
||||
try:
|
||||
data = await self._node.async_get_latest_measurements()
|
||||
data["history"] = {}
|
||||
if data["settings"].get("follow_mode") == "device":
|
||||
history = await self._node.async_get_history(include_trends=False)
|
||||
data["history"] = history.get("measurements", [])[-1]
|
||||
except InvalidAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid Samba password") from err
|
||||
except NodeConnectionError as err:
|
||||
if self.reload_task is None:
|
||||
self.reload_task = self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
)
|
||||
raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err
|
||||
except NodeProError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
return data
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirVisualProConfigEntry
|
||||
from . import AirVisualProConfigEntry
|
||||
|
||||
CONF_MAC_ADDRESS = "mac_address"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
|
||||
@@ -4,17 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirVisualProCoordinator
|
||||
|
||||
|
||||
class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
class AirVisualProEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual Pro entity."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: AirVisualProCoordinator, description: EntityDescription
|
||||
self, coordinator: DataUpdateCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AirVisualProConfigEntry
|
||||
from . import AirVisualProConfigEntry
|
||||
from .entity import AirVisualProEntity
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ rules:
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: We can automatically remove removed devices
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
|
||||
@@ -148,11 +148,9 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"number",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==2.1.1",
|
||||
"bleak-retry-connector==4.6.0",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.9.1"
|
||||
"habluetooth==5.8.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.1.2"],
|
||||
"requirements": ["python-bsblan==5.1.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""The Chess.com integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ChessConfigEntry, ChessCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool:
|
||||
"""Set up Chess.com from a config entry."""
|
||||
|
||||
coordinator = ChessCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Config flow for the Chess.com integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from chess_com_api import ChessComClient, NotFoundError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Chess.com."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = ChessComClient(session=session)
|
||||
try:
|
||||
user = await client.get_player(user_input[CONF_USERNAME])
|
||||
except NotFoundError:
|
||||
errors["base"] = "player_not_found"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(str(user.player_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=user.name, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the Chess.com integration."""
|
||||
|
||||
DOMAIN = "chess_com"
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Coordinator for Chess.com."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from chess_com_api import ChessComAPIError, ChessComClient, Player, PlayerStats
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ChessConfigEntry = ConfigEntry[ChessCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChessData:
|
||||
"""Data for Chess.com."""
|
||||
|
||||
player: Player
|
||||
stats: PlayerStats
|
||||
|
||||
|
||||
class ChessCoordinator(DataUpdateCoordinator[ChessData]):
|
||||
"""Coordinator for Chess.com."""
|
||||
|
||||
config_entry: ChessConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ChessConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.title,
|
||||
update_interval=timedelta(hours=1),
|
||||
)
|
||||
self.client = ChessComClient(session=async_get_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> ChessData:
|
||||
"""Update data from Chess.com."""
|
||||
try:
|
||||
player = await self.client.get_player(self.config_entry.data[CONF_USERNAME])
|
||||
stats = await self.client.get_player_stats(
|
||||
self.config_entry.data[CONF_USERNAME]
|
||||
)
|
||||
except ChessComAPIError as err:
|
||||
raise UpdateFailed(f"Error communicating with Chess.com: {err}") from err
|
||||
return ChessData(player=player, stats=stats)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Base entity for Chess.com integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ChessCoordinator
|
||||
|
||||
|
||||
class ChessEntity(CoordinatorEntity[ChessCoordinator]):
|
||||
"""Base entity for Chess.com integration."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: ChessCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.config_entry.unique_id is not None
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer="Chess.com",
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"chess_daily_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"total_daily_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"total_daily_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"total_daily_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "chess_com",
|
||||
"name": "Chess.com",
|
||||
"codeowners": ["@joostlek"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/chess_com",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["chess_com_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["chess-com-api==1.1.0"]
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: There are no custom actions
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: There are no custom actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities do not explicitly subscribe to events
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: There are no configuration parameters
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There are no repairable issues
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Sensor platform for Chess.com integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ChessConfigEntry
|
||||
from .coordinator import ChessCoordinator, ChessData
|
||||
from .entity import ChessEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ChessEntityDescription(SensorEntityDescription):
|
||||
"""Sensor description for Chess.com player."""
|
||||
|
||||
value_fn: Callable[[ChessData], float]
|
||||
|
||||
|
||||
SENSORS: tuple[ChessEntityDescription, ...] = (
|
||||
ChessEntityDescription(
|
||||
key="followers",
|
||||
translation_key="followers",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda state: state.player.followers,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="chess_daily_rating",
|
||||
translation_key="chess_daily_rating",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda state: state.stats.chess_daily["last"]["rating"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_won",
|
||||
translation_key="total_daily_won",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["win"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_lost",
|
||||
translation_key="total_daily_lost",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["loss"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_draw",
|
||||
translation_key="total_daily_draw",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["draw"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ChessConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Initialize the entries."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
ChessPlayerSensor(coordinator, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class ChessPlayerSensor(ChessEntity, SensorEntity):
|
||||
"""Chess.com sensor."""
|
||||
|
||||
entity_description: ChessEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ChessCoordinator,
|
||||
description: ChessEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}.{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"player_not_found": "Player not found.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add player"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The Chess.com username of the player to monitor."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"chess_daily_rating": {
|
||||
"name": "Daily chess rating"
|
||||
},
|
||||
"followers": {
|
||||
"name": "Followers",
|
||||
"unit_of_measurement": "followers"
|
||||
},
|
||||
"total_daily_draw": {
|
||||
"name": "Total chess games drawn",
|
||||
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
|
||||
},
|
||||
"total_daily_lost": {
|
||||
"name": "Total chess games lost",
|
||||
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
|
||||
},
|
||||
"total_daily_won": {
|
||||
"name": "Total chess games won",
|
||||
"unit_of_measurement": "games"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,17 +107,17 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._unit.temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we are trying to reach."""
|
||||
return self._unit.thermostat
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
def hvac_mode(self):
|
||||
"""Return hvac target hvac state."""
|
||||
mode = self._unit.mode
|
||||
if not self._unit.is_on:
|
||||
@@ -126,7 +126,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
return CM_TO_HA_STATE[mode]
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
|
||||
# Normalize to lowercase for lookup, and pass unknown lowercase values through.
|
||||
@@ -145,7 +145,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
return CM_TO_HA_FAN[fan_speed_lower]
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
return FAN_MODES
|
||||
|
||||
|
||||
@@ -30,16 +30,9 @@ async def async_setup_entry(
|
||||
async_add_entities(
|
||||
[
|
||||
DemoWaterHeater(
|
||||
"demo_water_heater",
|
||||
"Demo Water Heater",
|
||||
119,
|
||||
UnitOfTemperature.FAHRENHEIT,
|
||||
False,
|
||||
"eco",
|
||||
1,
|
||||
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
|
||||
),
|
||||
DemoWaterHeater(
|
||||
"demo_water_heater_celsius",
|
||||
"Demo Water Heater Celsius",
|
||||
45,
|
||||
UnitOfTemperature.CELSIUS,
|
||||
@@ -59,7 +52,6 @@ class DemoWaterHeater(WaterHeaterEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
target_temperature: int,
|
||||
unit_of_measurement: str,
|
||||
@@ -68,7 +60,6 @@ class DemoWaterHeater(WaterHeaterEntity):
|
||||
target_temperature_step: float,
|
||||
) -> None:
|
||||
"""Initialize the water_heater device."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
if target_temperature is not None:
|
||||
self._attr_supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.2.2"]
|
||||
"requirements": ["pyeconet==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry
|
||||
|
||||
TO_REDACT = {"emailAddr", "usrName", "api_usrName", "api_password"}
|
||||
TO_REDACT = {"emailAddr", "usrName"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -8,7 +8,6 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
from eheimdigital.types import HeaterUnit
|
||||
|
||||
from homeassistant.components.number import (
|
||||
@@ -45,47 +44,6 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
|
||||
uom_fn: Callable[[_DeviceT], str] | None = None
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
|
||||
key="daily_burn_time",
|
||||
translation_key="daily_burn_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=0,
|
||||
native_max_value=1440,
|
||||
value_fn=lambda device: device.daily_burn_time,
|
||||
set_value_fn=lambda device, value: device.set_daily_burn_time(int(value)),
|
||||
),
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
|
||||
key="booster_time",
|
||||
translation_key="booster_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=0,
|
||||
native_max_value=20160,
|
||||
value_fn=lambda device: device.booster_time,
|
||||
set_value_fn=lambda device, value: device.set_booster_time(int(value)),
|
||||
),
|
||||
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
|
||||
key="pause_time",
|
||||
translation_key="pause_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_min_value=0,
|
||||
native_max_value=20160,
|
||||
value_fn=lambda device: device.pause_time,
|
||||
set_value_fn=lambda device, value: device.set_pause_time(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalFilter](
|
||||
key="high_pulse_time",
|
||||
@@ -231,13 +189,6 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in HEATER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description)
|
||||
for description in GENERAL_DESCRIPTIONS
|
||||
|
||||
@@ -7,11 +7,9 @@ from typing import Any, Literal, override
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
from eheimdigital.types import (
|
||||
FilterMode,
|
||||
FilterModeProf,
|
||||
ReeflexMode,
|
||||
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
|
||||
)
|
||||
|
||||
@@ -38,20 +36,6 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
|
||||
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSelectDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalReeflexUV](
|
||||
key="mode",
|
||||
translation_key="mode",
|
||||
value_fn=lambda device: device.mode.name.lower(),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_mode(ReeflexMode[value.upper()])
|
||||
),
|
||||
options=[name.lower() for name in ReeflexMode.__members__],
|
||||
),
|
||||
)
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalFilter](
|
||||
key="filter_mode",
|
||||
@@ -192,13 +176,6 @@ async def async_setup_entry(
|
||||
EheimDigitalFilterSelect(coordinator, device, description)
|
||||
for description in FILTER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalSelect[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -58,12 +58,6 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"booster_time": {
|
||||
"name": "Booster duration"
|
||||
},
|
||||
"daily_burn_time": {
|
||||
"name": "Daily burn duration"
|
||||
},
|
||||
"day_speed": {
|
||||
"name": "Day speed"
|
||||
},
|
||||
@@ -82,7 +76,6 @@
|
||||
"night_temperature_offset": {
|
||||
"name": "Night temperature offset"
|
||||
},
|
||||
"pause_time": { "name": "Pause duration" },
|
||||
"system_led": {
|
||||
"name": "System LED brightness"
|
||||
},
|
||||
@@ -115,10 +108,6 @@
|
||||
"manual_speed": {
|
||||
"name": "Manual speed"
|
||||
},
|
||||
"mode": {
|
||||
"name": "Operation mode",
|
||||
"state": { "constant": "Constant", "daycycle": "Daycycle" }
|
||||
},
|
||||
"night_speed": {
|
||||
"name": "Night speed"
|
||||
}
|
||||
@@ -138,18 +127,9 @@
|
||||
"operating_time": {
|
||||
"name": "Operating time"
|
||||
},
|
||||
"remaining_booster_time": {
|
||||
"name": "Remaining booster time"
|
||||
},
|
||||
"remaining_pause_time": {
|
||||
"name": "Remaining pause time"
|
||||
},
|
||||
"service_hours": {
|
||||
"name": "Remaining hours until service"
|
||||
},
|
||||
"time_until_next_service": {
|
||||
"name": "Time until next service"
|
||||
},
|
||||
"turn_feeding_time": {
|
||||
"name": "Remaining off time after feeding"
|
||||
},
|
||||
@@ -157,26 +137,12 @@
|
||||
"name": "Remaining off time"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"booster": {
|
||||
"name": "Booster"
|
||||
},
|
||||
"expert": {
|
||||
"name": "Expert mode"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pause"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"day_start_time": {
|
||||
"name": "Day start time"
|
||||
},
|
||||
"night_start_time": {
|
||||
"name": "Night start time"
|
||||
},
|
||||
"start_time": {
|
||||
"name": "Start time"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""EHEIM Digital switches."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -21,50 +17,6 @@ from .entity import EheimDigitalEntity, exception_handler
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalSwitchDescription[_DeviceT: EheimDigitalDevice](
|
||||
SwitchEntityDescription
|
||||
):
|
||||
"""Class describing EHEIM Digital switch entities."""
|
||||
|
||||
is_on_fn: Callable[[_DeviceT], bool]
|
||||
set_fn: Callable[[_DeviceT, bool], Awaitable[None]]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="active",
|
||||
name=None,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.is_active,
|
||||
set_fn=lambda device, value: device.set_active(active=value),
|
||||
),
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="pause",
|
||||
translation_key="pause",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.pause,
|
||||
set_fn=lambda device, value: device.set_pause(pause=value),
|
||||
),
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="booster",
|
||||
translation_key="booster",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.booster,
|
||||
set_fn=lambda device, value: device.set_booster(active=value),
|
||||
),
|
||||
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
|
||||
key="expert",
|
||||
translation_key="expert",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda device: device.expert,
|
||||
set_fn=lambda device, value: device.set_expert(active=value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
@@ -80,14 +32,7 @@ async def async_setup_entry(
|
||||
entities: list[SwitchEntity] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
|
||||
entities.append(EheimDigitalFilterSwitch(coordinator, device))
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalSwitch[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -95,39 +40,6 @@ async def async_setup_entry(
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalSwitch[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], SwitchEntity
|
||||
):
|
||||
"""Represent a EHEIM Digital switch entity."""
|
||||
|
||||
entity_description: EheimDigitalSwitchDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalSwitchDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital switch entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
return await self.entity_description.set_fn(self._device, True)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
return await self.entity_description.set_fn(self._device, False)
|
||||
|
||||
@override
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_is_on = self.entity_description.is_on_fn(self._device)
|
||||
|
||||
|
||||
class EheimDigitalFilterSwitch(
|
||||
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
|
||||
):
|
||||
|
||||
@@ -9,7 +9,6 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.filter import EheimDigitalFilter
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -30,16 +29,6 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri
|
||||
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalReeflexUV], ...] = (
|
||||
EheimDigitalTimeDescription[EheimDigitalReeflexUV](
|
||||
key="start_time",
|
||||
translation_key="start_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda device: device.start_time,
|
||||
set_value_fn=lambda device, value: device.set_day_start_time(value),
|
||||
),
|
||||
)
|
||||
|
||||
FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = (
|
||||
EheimDigitalTimeDescription[EheimDigitalFilter](
|
||||
key="day_start_time",
|
||||
@@ -129,13 +118,6 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in HEATER_DESCRIPTIONS
|
||||
)
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities.extend(
|
||||
EheimDigitalTime[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for EnOcean devices."""
|
||||
|
||||
from enocean_async import Gateway
|
||||
from serial import SerialException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
@@ -8,15 +8,12 @@ from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
|
||||
from .const import DOMAIN
|
||||
from .dongle import EnOceanDongle
|
||||
|
||||
type EnOceanConfigEntry = ConfigEntry[Gateway]
|
||||
type EnOceanConfigEntry = ConfigEntry[EnOceanDongle]
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA
|
||||
@@ -30,7 +27,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
if hass.config_entries.async_entries(DOMAIN):
|
||||
# We can only have one gateway. If there is already one in the config,
|
||||
# We can only have one dongle. If there is already one in the config,
|
||||
# there is no need to import the yaml based config.
|
||||
return True
|
||||
|
||||
@@ -46,31 +43,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: EnOceanConfigEntry
|
||||
) -> bool:
|
||||
"""Set up an EnOcean gateway for the given entry."""
|
||||
gateway = Gateway(port=config_entry.data[CONF_DEVICE])
|
||||
|
||||
gateway.add_erp1_received_callback(
|
||||
lambda packet: async_dispatcher_send(hass, SIGNAL_RECEIVE_MESSAGE, packet)
|
||||
)
|
||||
|
||||
"""Set up an EnOcean dongle for the given entry."""
|
||||
try:
|
||||
await gateway.start()
|
||||
except ConnectionError as err:
|
||||
gateway.stop()
|
||||
raise ConfigEntryNotReady(f"Failed to start EnOcean gateway: {err}") from err
|
||||
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
|
||||
except SerialException as err:
|
||||
raise ConfigEntryNotReady(f"Failed to set up EnOcean dongle: {err}") from err
|
||||
await usb_dongle.async_setup()
|
||||
config_entry.runtime_data = usb_dongle
|
||||
|
||||
config_entry.runtime_data = gateway
|
||||
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, SIGNAL_SEND_MESSAGE, gateway.send_esp3_packet)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: EnOceanConfigEntry
|
||||
) -> bool:
|
||||
"""Unload EnOcean config entry: stop the gateway."""
|
||||
"""Unload EnOcean config entry."""
|
||||
|
||||
enocean_dongle = config_entry.runtime_data
|
||||
enocean_dongle.unload()
|
||||
|
||||
config_entry.runtime_data.stop()
|
||||
return True
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enocean_async import ERP1Telegram
|
||||
from enocean.utils import combine_hex
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .entity import EnOceanEntity, combine_hex
|
||||
from .entity import EnOceanEntity
|
||||
|
||||
DEFAULT_NAME = "EnOcean binary sensor"
|
||||
DEPENDENCIES = ["enocean"]
|
||||
@@ -68,25 +68,29 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity):
|
||||
self._attr_unique_id = f"{combine_hex(dev_id)}-{device_class}"
|
||||
self._attr_name = dev_name
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Fire an event with the data that have changed.
|
||||
|
||||
This method is called when there is an incoming packet associated
|
||||
with this platform.
|
||||
|
||||
Example packet data:
|
||||
- 2nd button pressed
|
||||
['0xf6', '0x10', '0x00', '0x2d', '0xcf', '0x45', '0x30']
|
||||
- button released
|
||||
['0xf6', '0x00', '0x00', '0x2d', '0xcf', '0x45', '0x20']
|
||||
"""
|
||||
if not self.address:
|
||||
return
|
||||
# Energy Bow
|
||||
pushed = None
|
||||
|
||||
if telegram.status == 0x30:
|
||||
if packet.data[6] == 0x30:
|
||||
pushed = 1
|
||||
elif telegram.status == 0x20:
|
||||
elif packet.data[6] == 0x20:
|
||||
pushed = 0
|
||||
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
action = telegram.telegram_data[0]
|
||||
action = packet.data[1]
|
||||
if action == 0x70:
|
||||
self.which = 0
|
||||
self.onoff = 0
|
||||
@@ -108,7 +112,7 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity):
|
||||
self.hass.bus.fire(
|
||||
EVENT_BUTTON_PRESSED,
|
||||
{
|
||||
"id": self.address.to_bytelist(),
|
||||
"id": self.dev_id,
|
||||
"pushed": pushed,
|
||||
"which": self.which,
|
||||
"onoff": self.onoff,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Config flows for the EnOcean integration."""
|
||||
|
||||
import glob
|
||||
from typing import Any
|
||||
|
||||
from enocean_async import Gateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -21,6 +19,7 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
|
||||
from . import dongle
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
|
||||
|
||||
MANUAL_SCHEMA = vol.Schema(
|
||||
@@ -30,24 +29,6 @@ MANUAL_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _detect_usb_dongle() -> list[str]:
|
||||
"""Return a list of candidate paths for USB EnOcean dongles.
|
||||
|
||||
This method is currently a bit simplistic, it may need to be
|
||||
improved to support more configurations and OS.
|
||||
"""
|
||||
globs_to_test = [
|
||||
"/dev/tty*FTOA2PV*",
|
||||
"/dev/serial/by-id/*EnOcean*",
|
||||
"/dev/tty.usbserial-*",
|
||||
]
|
||||
found_paths = []
|
||||
for current_glob in globs_to_test:
|
||||
found_paths.extend(glob.glob(current_glob))
|
||||
|
||||
return found_paths
|
||||
|
||||
|
||||
class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the enOcean config flows."""
|
||||
|
||||
@@ -126,7 +107,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_manual()
|
||||
return await self.async_step_manual(user_input)
|
||||
|
||||
devices = await self.hass.async_add_executor_job(_detect_usb_dongle)
|
||||
devices = await self.hass.async_add_executor_job(dongle.detect)
|
||||
if len(devices) == 0:
|
||||
return await self.async_step_manual()
|
||||
devices.append(self.MANUAL_PATH_VALUE)
|
||||
@@ -165,17 +146,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def validate_enocean_conf(self, user_input) -> bool:
|
||||
"""Return True if the user_input contains a valid dongle path."""
|
||||
dongle_path = user_input[CONF_DEVICE]
|
||||
try:
|
||||
# Starting the gateway will raise an exception if it can't connect
|
||||
gateway = Gateway(port=dongle_path)
|
||||
await gateway.start()
|
||||
except ConnectionError as exception:
|
||||
LOGGER.warning("Dongle path %s is invalid: %s", dongle_path, str(exception))
|
||||
return False
|
||||
finally:
|
||||
gateway.stop()
|
||||
|
||||
return True
|
||||
return await self.hass.async_add_executor_job(dongle.validate_path, dongle_path)
|
||||
|
||||
def create_enocean_entry(self, user_input):
|
||||
"""Create an entry for the provided configuration."""
|
||||
|
||||
88
homeassistant/components/enocean/dongle.py
Normal file
88
homeassistant/components/enocean/dongle.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Representation of an EnOcean dongle."""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
from os.path import basename, normpath
|
||||
|
||||
from enocean.communicators import SerialCommunicator
|
||||
from enocean.protocol.packet import RadioPacket
|
||||
import serial
|
||||
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||
|
||||
from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnOceanDongle:
|
||||
"""Representation of an EnOcean dongle.
|
||||
|
||||
The dongle is responsible for receiving the EnOcean frames,
|
||||
creating devices if needed, and dispatching messages to platforms.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, serial_path):
|
||||
"""Initialize the EnOcean dongle."""
|
||||
|
||||
self._communicator = SerialCommunicator(
|
||||
port=serial_path, callback=self.callback
|
||||
)
|
||||
self.serial_path = serial_path
|
||||
self.identifier = basename(normpath(serial_path))
|
||||
self.hass = hass
|
||||
self.dispatcher_disconnect_handle = None
|
||||
|
||||
async def async_setup(self):
|
||||
"""Finish the setup of the bridge and supported platforms."""
|
||||
self._communicator.start()
|
||||
self.dispatcher_disconnect_handle = async_dispatcher_connect(
|
||||
self.hass, SIGNAL_SEND_MESSAGE, self._send_message_callback
|
||||
)
|
||||
|
||||
def unload(self):
|
||||
"""Disconnect callbacks established at init time."""
|
||||
if self.dispatcher_disconnect_handle:
|
||||
self.dispatcher_disconnect_handle()
|
||||
self.dispatcher_disconnect_handle = None
|
||||
|
||||
def _send_message_callback(self, command):
|
||||
"""Send a command through the EnOcean dongle."""
|
||||
self._communicator.send(command)
|
||||
|
||||
def callback(self, packet):
|
||||
"""Handle EnOcean device's callback.
|
||||
|
||||
This is the callback function called by python-enocean whenever there
|
||||
is an incoming packet.
|
||||
"""
|
||||
|
||||
if isinstance(packet, RadioPacket):
|
||||
_LOGGER.debug("Received radio packet: %s", packet)
|
||||
dispatcher_send(self.hass, SIGNAL_RECEIVE_MESSAGE, packet)
|
||||
|
||||
|
||||
def detect():
|
||||
"""Return a list of candidate paths for USB EnOcean dongles.
|
||||
|
||||
This method is currently a bit simplistic, it may need to be
|
||||
improved to support more configurations and OS.
|
||||
"""
|
||||
globs_to_test = ["/dev/tty*FTOA2PV*", "/dev/serial/by-id/*EnOcean*"]
|
||||
found_paths = []
|
||||
for current_glob in globs_to_test:
|
||||
found_paths.extend(glob.glob(current_glob))
|
||||
|
||||
return found_paths
|
||||
|
||||
|
||||
def validate_path(path: str):
|
||||
"""Return True if the provided path points to a valid serial port, False otherwise."""
|
||||
try:
|
||||
# Creating the serial communicator will raise an exception
|
||||
# if it cannot connect
|
||||
SerialCommunicator(port=path)
|
||||
except serial.SerialException as exception:
|
||||
_LOGGER.warning("Dongle path %s is invalid: %s", path, str(exception))
|
||||
return False
|
||||
return True
|
||||
@@ -1,23 +1,12 @@
|
||||
"""Representation of an EnOcean device."""
|
||||
|
||||
from enocean_async import EURID, Address, BaseAddress, ERP1Telegram, SenderAddress
|
||||
from enocean_async.esp3.packet import ESP3Packet, ESP3PacketType
|
||||
from enocean.protocol.packet import Packet
|
||||
from enocean.utils import combine_hex
|
||||
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import LOGGER, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
|
||||
|
||||
|
||||
def combine_hex(dev_id: list[int]) -> int:
|
||||
"""Combine list of integer values to one big integer.
|
||||
|
||||
This function replaces the previously used function from the enocean library and is considered tech debt that will have to be replaced.
|
||||
"""
|
||||
value = 0
|
||||
for byte in dev_id:
|
||||
value = (value << 8) | (byte & 0xFF)
|
||||
return value
|
||||
from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
|
||||
|
||||
|
||||
class EnOceanEntity(Entity):
|
||||
@@ -25,16 +14,7 @@ class EnOceanEntity(Entity):
|
||||
|
||||
def __init__(self, dev_id: list[int]) -> None:
|
||||
"""Initialize the device."""
|
||||
self.address: SenderAddress | None = None
|
||||
|
||||
try:
|
||||
address = Address.from_bytelist(dev_id)
|
||||
if address.is_eurid():
|
||||
self.address = EURID.from_number(address.to_number())
|
||||
elif address.is_base_address():
|
||||
self.address = BaseAddress.from_number(address.to_number())
|
||||
except ValueError:
|
||||
self.address = None
|
||||
self.dev_id = dev_id
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -44,25 +24,17 @@ class EnOceanEntity(Entity):
|
||||
)
|
||||
)
|
||||
|
||||
def _message_received_callback(self, telegram: ERP1Telegram) -> None:
|
||||
def _message_received_callback(self, packet):
|
||||
"""Handle incoming packets."""
|
||||
if not self.address:
|
||||
return
|
||||
|
||||
if telegram.sender == self.address:
|
||||
self.value_changed(telegram)
|
||||
if packet.sender_int == combine_hex(self.dev_id):
|
||||
self.value_changed(packet)
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the device when a packet arrives."""
|
||||
|
||||
def send_command(
|
||||
self, data: list[int], optional: list[int], packet_type: ESP3PacketType
|
||||
) -> None:
|
||||
"""Send a command via the EnOcean dongle, if data and optional are valid bytes; otherwise, ignore."""
|
||||
try:
|
||||
packet = ESP3Packet(packet_type, data=bytes(data), optional=bytes(optional))
|
||||
dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet)
|
||||
except ValueError as err:
|
||||
LOGGER.warning(
|
||||
"Failed to send command: invalid data or optional bytes: %s", err
|
||||
)
|
||||
def send_command(self, data, optional, packet_type):
|
||||
"""Send a command via the EnOcean dongle."""
|
||||
|
||||
packet = Packet(packet_type, data=data, optional=optional)
|
||||
dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet)
|
||||
|
||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from enocean_async import ERP1Telegram
|
||||
from enocean_async.esp3.packet import ESP3PacketType
|
||||
from enocean.utils import combine_hex
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
@@ -21,7 +20,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .entity import EnOceanEntity, combine_hex
|
||||
from .entity import EnOceanEntity
|
||||
|
||||
CONF_SENDER_ID = "sender_id"
|
||||
|
||||
@@ -76,8 +75,7 @@ class EnOceanLight(EnOceanEntity, LightEntity):
|
||||
command = [0xA5, 0x02, bval, 0x01, 0x09]
|
||||
command.extend(self._sender_id)
|
||||
command.extend([0x00])
|
||||
packet_type = ESP3PacketType(0x01)
|
||||
self.send_command(command, [], packet_type)
|
||||
self.send_command(command, [], 0x01)
|
||||
self._attr_is_on = True
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -85,18 +83,17 @@ class EnOceanLight(EnOceanEntity, LightEntity):
|
||||
command = [0xA5, 0x02, 0x00, 0x01, 0x09]
|
||||
command.extend(self._sender_id)
|
||||
command.extend([0x00])
|
||||
packet_type = ESP3PacketType(0x01)
|
||||
self.send_command(command, [], packet_type)
|
||||
self.send_command(command, [], 0x01)
|
||||
self._attr_is_on = False
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of this device.
|
||||
|
||||
Dimmer devices like Eltako FUD61 send telegram in different RORGs.
|
||||
We only care about the 4BS (0xA5).
|
||||
"""
|
||||
if telegram.rorg == 0xA5 and telegram.telegram_data[0] == 0x02:
|
||||
val = telegram.telegram_data[1]
|
||||
if packet.data[0] == 0xA5 and packet.data[1] == 0x02:
|
||||
val = packet.data[2]
|
||||
self._attr_brightness = math.floor(val / 100.0 * 256.0)
|
||||
self._attr_is_on = bool(val != 0)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enocean",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["enocean_async"],
|
||||
"requirements": ["enocean-async==0.4.1"],
|
||||
"loggers": ["enocean"],
|
||||
"requirements": ["enocean==0.50"],
|
||||
"single_config_entry": true,
|
||||
"usb": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from enocean_async import EEP, EEP_SPECIFICATIONS, EEPHandler, EEPMessage, ERP1Telegram
|
||||
from enocean.utils import combine_hex
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -30,7 +30,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .entity import EnOceanEntity, combine_hex
|
||||
from .entity import EnOceanEntity
|
||||
|
||||
CONF_MAX_TEMP = "max_temp"
|
||||
CONF_MIN_TEMP = "min_temp"
|
||||
@@ -166,7 +166,7 @@ class EnOceanSensor(EnOceanEntity, RestoreSensor):
|
||||
if (sensor_data := await self.async_get_last_sensor_data()) is not None:
|
||||
self._attr_native_value = sensor_data.native_value
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the sensor."""
|
||||
|
||||
|
||||
@@ -177,19 +177,15 @@ class EnOceanPowerSensor(EnOceanSensor):
|
||||
- A5-12-01 (Automated Meter Reading, Electricity)
|
||||
"""
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the sensor."""
|
||||
if telegram.rorg != 0xA5:
|
||||
if packet.rorg != 0xA5:
|
||||
return
|
||||
|
||||
if (eep := EEP_SPECIFICATIONS.get(EEP(0xA5, 0x12, 0x01))) is None:
|
||||
return
|
||||
msg: EEPMessage = EEPHandler(eep).decode(telegram)
|
||||
|
||||
if "DT" in msg.values and msg.values["DT"].raw == 1:
|
||||
packet.parse_eep(0x12, 0x01)
|
||||
if packet.parsed["DT"]["raw_value"] == 1:
|
||||
# this packet reports the current value
|
||||
raw_val = msg.values["MR"].raw
|
||||
divisor = msg.values["DIV"].raw
|
||||
raw_val = packet.parsed["MR"]["raw_value"]
|
||||
divisor = packet.parsed["DIV"]["raw_value"]
|
||||
self._attr_native_value = raw_val / (10**divisor)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -230,13 +226,13 @@ class EnOceanTemperatureSensor(EnOceanSensor):
|
||||
self.range_from = range_from
|
||||
self.range_to = range_to
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the sensor."""
|
||||
if telegram.rorg != 0xA5:
|
||||
if packet.data[0] != 0xA5:
|
||||
return
|
||||
temp_scale = self._scale_max - self._scale_min
|
||||
temp_range = self.range_to - self.range_from
|
||||
raw_val = telegram.telegram_data[2]
|
||||
raw_val = packet.data[3]
|
||||
temperature = temp_scale / temp_range * (raw_val - self.range_from)
|
||||
temperature += self._scale_min
|
||||
self._attr_native_value = round(temperature, 1)
|
||||
@@ -252,11 +248,11 @@ class EnOceanHumiditySensor(EnOceanSensor):
|
||||
- A5-10-10 to A5-10-14 (Room Operating Panels)
|
||||
"""
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the sensor."""
|
||||
if telegram.rorg != 0xA5:
|
||||
if packet.rorg != 0xA5:
|
||||
return
|
||||
humidity = telegram.telegram_data[1] * 100 / 250
|
||||
humidity = packet.data[2] * 100 / 250
|
||||
self._attr_native_value = round(humidity, 1)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -268,9 +264,9 @@ class EnOceanWindowHandle(EnOceanSensor):
|
||||
- F6-10-00 (Mechanical handle / Hoppe AG)
|
||||
"""
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the sensor."""
|
||||
action = (telegram.telegram_data[0] & 0x70) >> 4
|
||||
action = (packet.data[1] & 0x70) >> 4
|
||||
|
||||
if action == 0x07:
|
||||
self._attr_native_value = STATE_CLOSED
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from enocean_async import EEP, EEP_SPECIFICATIONS, EEPHandler, EEPMessage, ERP1Telegram
|
||||
from enocean_async.esp3.packet import ESP3PacketType
|
||||
from enocean.utils import combine_hex
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
@@ -19,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .entity import EnOceanEntity, combine_hex
|
||||
from .entity import EnOceanEntity
|
||||
|
||||
CONF_CHANNEL = "channel"
|
||||
DEFAULT_NAME = "EnOcean Switch"
|
||||
@@ -87,68 +86,52 @@ class EnOceanSwitch(EnOceanEntity, SwitchEntity):
|
||||
"""Initialize the EnOcean switch device."""
|
||||
super().__init__(dev_id)
|
||||
self._light = None
|
||||
self.channel: int = channel
|
||||
self.channel = channel
|
||||
self._attr_unique_id = generate_unique_id(dev_id, channel)
|
||||
self._attr_name = dev_name
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
if not self.address:
|
||||
return
|
||||
|
||||
optional = [0x03]
|
||||
optional.extend(self.address.to_bytelist())
|
||||
optional.extend(self.dev_id)
|
||||
optional.extend([0xFF, 0x00])
|
||||
self.send_command(
|
||||
data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
optional=optional,
|
||||
packet_type=ESP3PacketType(0x01),
|
||||
packet_type=0x01,
|
||||
)
|
||||
self._attr_is_on = True
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
if not self.address:
|
||||
return
|
||||
optional = [0x03]
|
||||
optional.extend(self.address.to_bytelist())
|
||||
optional.extend(self.dev_id)
|
||||
optional.extend([0xFF, 0x00])
|
||||
self.send_command(
|
||||
data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
optional=optional,
|
||||
packet_type=ESP3PacketType(0x01),
|
||||
packet_type=0x01,
|
||||
)
|
||||
self._attr_is_on = False
|
||||
|
||||
def value_changed(self, telegram: ERP1Telegram) -> None:
|
||||
def value_changed(self, packet):
|
||||
"""Update the internal state of the switch."""
|
||||
if telegram.rorg == 0xA5:
|
||||
# power meter telegram, turn on if > 1 watts
|
||||
if (eep := EEP_SPECIFICATIONS.get(EEP(0xA5, 0x12, 0x01))) is None:
|
||||
LOGGER.warning("EEP A5-12-01 cannot be decoded")
|
||||
return
|
||||
|
||||
msg: EEPMessage = EEPHandler(eep).decode(telegram)
|
||||
|
||||
if "DT" in msg.values and msg.values["DT"].raw == 1:
|
||||
# this packet reports the current value
|
||||
raw_val = msg.values["MR"].raw
|
||||
divisor = msg.values["DIV"].raw
|
||||
if packet.data[0] == 0xA5:
|
||||
# power meter telegram, turn on if > 10 watts
|
||||
packet.parse_eep(0x12, 0x01)
|
||||
if packet.parsed["DT"]["raw_value"] == 1:
|
||||
raw_val = packet.parsed["MR"]["raw_value"]
|
||||
divisor = packet.parsed["DIV"]["raw_value"]
|
||||
watts = raw_val / (10**divisor)
|
||||
if watts > 1:
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
elif telegram.rorg == 0xD2:
|
||||
elif packet.data[0] == 0xD2:
|
||||
# actuator status telegram
|
||||
if (eep := EEP_SPECIFICATIONS.get(EEP(0xD2, 0x01, 0x01))) is None:
|
||||
LOGGER.warning("EEP D2-01-01 cannot be decoded")
|
||||
return
|
||||
|
||||
msg = EEPHandler(eep).decode(telegram)
|
||||
if msg.values["CMD"].raw == 4:
|
||||
channel = msg.values["I/O"].raw
|
||||
output = msg.values["OV"].raw
|
||||
packet.parse_eep(0x01, 0x01)
|
||||
if packet.parsed["CMD"]["raw_value"] == 4:
|
||||
channel = packet.parsed["IO"]["raw_value"]
|
||||
output = packet.parsed["OV"]["raw_value"]
|
||||
if channel == self.channel:
|
||||
self._attr_is_on = output > 0
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -24,64 +23,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADMIN_API_KEY): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ghost."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
admin_api_key = user_input[CONF_ADMIN_API_KEY]
|
||||
|
||||
if ":" not in admin_api_key:
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
try:
|
||||
await self._validate_credentials(
|
||||
reauth_entry.data[CONF_API_URL], admin_api_key
|
||||
)
|
||||
except GhostAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except GhostError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error during Ghost reauth")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"title": reauth_entry.title,
|
||||
"docs_url": "https://account.ghost.org/?r=settings/integrations/new",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -142,7 +89,7 @@ class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
site_title = site["title"]
|
||||
|
||||
await self.async_set_unique_id(site["site_uuid"])
|
||||
await self.async_set_unique_id(site["uuid"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioghost"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioghost==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Ghost site is already configured.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "This Ghost site is already configured."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Ghost. Please check your URL.",
|
||||
@@ -11,16 +10,6 @@
|
||||
"unknown": "An unexpected error occurred."
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]"
|
||||
},
|
||||
"description": "Your API key for {title} is invalid. [Create a new integration key]({docs_url}) to reauthenticate.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"admin_api_key": "Admin API key",
|
||||
|
||||
@@ -55,6 +55,8 @@ def setup_platform(
|
||||
) -> None:
|
||||
"""Set up the heatmiser thermostat."""
|
||||
|
||||
heatmiser_v3_thermostat = heatmiser.HeatmiserThermostat
|
||||
|
||||
host = config[CONF_HOST]
|
||||
port = config[CONF_PORT]
|
||||
|
||||
@@ -63,7 +65,10 @@ def setup_platform(
|
||||
uh1_hub = connection.HeatmiserUH1(host, port)
|
||||
|
||||
add_entities(
|
||||
[HeatmiserV3Thermostat(thermostat, uh1_hub) for thermostat in thermostats],
|
||||
[
|
||||
HeatmiserV3Thermostat(heatmiser_v3_thermostat, thermostat, uh1_hub)
|
||||
for thermostat in thermostats
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -78,31 +83,44 @@ class HeatmiserV3Thermostat(ClimateEntity):
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: dict[str, Any],
|
||||
uh1: connection.HeatmiserUH1,
|
||||
) -> None:
|
||||
def __init__(self, therm, device, uh1):
|
||||
"""Initialize the thermostat."""
|
||||
self.therm = heatmiser.HeatmiserThermostat(device[CONF_ID], "prt", uh1)
|
||||
self.therm = therm(device[CONF_ID], "prt", uh1)
|
||||
self.uh1 = uh1
|
||||
self._attr_name = device[CONF_NAME]
|
||||
self._name = device[CONF_NAME]
|
||||
self._current_temperature = None
|
||||
self._target_temperature = None
|
||||
self._id = device
|
||||
self.dcb = None
|
||||
self._attr_hvac_mode = HVACMode.HEAT
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the thermostat, if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
def set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
return
|
||||
self._attr_target_temperature = int(temperature)
|
||||
self.therm.set_target_temp(self._attr_target_temperature)
|
||||
self._target_temperature = int(temperature)
|
||||
self.therm.set_target_temp(self._target_temperature)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest data."""
|
||||
self.uh1.reopen()
|
||||
if not self.uh1.status:
|
||||
_LOGGER.error("Failed to update device %s", self.name)
|
||||
_LOGGER.error("Failed to update device %s", self._name)
|
||||
return
|
||||
self.dcb = self.therm.read_dcb()
|
||||
self._attr_temperature_unit = (
|
||||
@@ -110,8 +128,8 @@ class HeatmiserV3Thermostat(ClimateEntity):
|
||||
if (self.therm.get_temperature_format() == "C")
|
||||
else UnitOfTemperature.FAHRENHEIT
|
||||
)
|
||||
self._attr_current_temperature = int(self.therm.get_floor_temp())
|
||||
self._attr_target_temperature = int(self.therm.get_target_temp())
|
||||
self._current_temperature = int(self.therm.get_floor_temp())
|
||||
self._target_temperature = int(self.therm.get_target_temp())
|
||||
self._attr_hvac_mode = (
|
||||
HVACMode.OFF
|
||||
if (int(self.therm.get_current_state()) == 0)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Constants for the Home Connect integration."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
|
||||
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
|
||||
@@ -74,9 +76,9 @@ AFFECTS_TO_SELECTED_PROGRAM = "selected_program"
|
||||
|
||||
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP = {
|
||||
bsh_key_to_translation_key(program.value): program
|
||||
bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
|
||||
for program in ProgramKey
|
||||
if program not in (ProgramKey.UNKNOWN, ProgramKey.BSH_COMMON_FAVORITE_001)
|
||||
if program != ProgramKey.UNKNOWN
|
||||
}
|
||||
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP = {
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.30.0"],
|
||||
"requirements": ["aiohomeconnect==0.28.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
self._attr_options = [
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
|
||||
for program in self.appliance.programs
|
||||
if program.key in PROGRAMS_TRANSLATION_KEYS_MAP
|
||||
if program.key != ProgramKey.UNKNOWN
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
|
||||
@@ -91,14 +91,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
translation_key="energy_exported",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_imported",
|
||||
translation_key="energy_imported",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="frequency",
|
||||
|
||||
@@ -901,9 +901,7 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase):
|
||||
)
|
||||
|
||||
|
||||
class PowerViewShadeDualOverlappedCombinedTilt(
|
||||
PowerViewShadeDualOverlappedCombined, PowerViewShadeWithTiltBase
|
||||
):
|
||||
class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombined):
|
||||
"""Represent a shade that has a front sheer and rear opaque panel.
|
||||
|
||||
This equates to two shades being controlled by one motor.
|
||||
@@ -917,6 +915,26 @@ class PowerViewShadeDualOverlappedCombinedTilt(
|
||||
Type 10 - Duolite with 180° Tilt
|
||||
"""
|
||||
|
||||
# type
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PowerviewShadeUpdateCoordinator,
|
||||
device_info: PowerviewDeviceInfo,
|
||||
room_name: str,
|
||||
shade: BaseShade,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize the shade."""
|
||||
super().__init__(coordinator, device_info, room_name, shade, name)
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
| CoverEntityFeature.SET_TILT_POSITION
|
||||
)
|
||||
if self._shade.is_supported(MOTION_STOP):
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP_TILT
|
||||
self._max_tilt = self._shade.shade_limits.tilt_max
|
||||
|
||||
@property
|
||||
def transition_steps(self) -> int:
|
||||
"""Return the steps to make a move."""
|
||||
@@ -931,6 +949,26 @@ class PowerViewShadeDualOverlappedCombinedTilt(
|
||||
tilt = self.positions.tilt
|
||||
return ceil(primary + secondary + tilt)
|
||||
|
||||
@callback
|
||||
def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition:
|
||||
"""Return a ShadePosition."""
|
||||
return ShadePosition(
|
||||
tilt=target_hass_tilt_position,
|
||||
velocity=self.positions.velocity,
|
||||
)
|
||||
|
||||
@property
|
||||
def open_tilt_position(self) -> ShadePosition:
|
||||
"""Return the open tilt position and required additional positions."""
|
||||
return replace(self._shade.open_position_tilt, velocity=self.positions.velocity)
|
||||
|
||||
@property
|
||||
def close_tilt_position(self) -> ShadePosition:
|
||||
"""Return the open tilt position and required additional positions."""
|
||||
return replace(
|
||||
self._shade.close_position_tilt, velocity=self.positions.velocity
|
||||
)
|
||||
|
||||
|
||||
TYPE_TO_CLASSES = {
|
||||
0: (PowerViewShade,),
|
||||
|
||||
@@ -627,17 +627,13 @@ class IntentHandleView(http.HomeAssistantView):
|
||||
{
|
||||
vol.Required("name"): cv.string,
|
||||
vol.Optional("data"): vol.Schema({cv.string: object}),
|
||||
vol.Optional("language"): cv.string,
|
||||
vol.Optional("assistant"): vol.Any(cv.string, None),
|
||||
vol.Optional("device_id"): vol.Any(cv.string, None),
|
||||
vol.Optional("satellite_id"): vol.Any(cv.string, None),
|
||||
}
|
||||
)
|
||||
)
|
||||
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
|
||||
"""Handle intent with name/data."""
|
||||
hass = request.app[http.KEY_HASS]
|
||||
language = data.get("language", hass.config.language)
|
||||
language = hass.config.language
|
||||
|
||||
try:
|
||||
intent_name = data["name"]
|
||||
@@ -645,21 +641,14 @@ class IntentHandleView(http.HomeAssistantView):
|
||||
key: {"value": value} for key, value in data.get("data", {}).items()
|
||||
}
|
||||
intent_result = await intent.async_handle(
|
||||
hass,
|
||||
DOMAIN,
|
||||
intent_name,
|
||||
slots,
|
||||
"",
|
||||
self.context(request),
|
||||
language=language,
|
||||
assistant=data.get("assistant"),
|
||||
device_id=data.get("device_id"),
|
||||
satellite_id=data.get("satellite_id"),
|
||||
hass, DOMAIN, intent_name, slots, "", self.context(request)
|
||||
)
|
||||
except (intent.IntentHandleError, intent.MatchFailedError) as err:
|
||||
intent_result = intent.IntentResponse(language=language)
|
||||
intent_result.async_set_error(
|
||||
intent.IntentResponseErrorCode.FAILED_TO_HANDLE, str(err)
|
||||
)
|
||||
intent_result.async_set_speech(str(err))
|
||||
|
||||
if intent_result is None:
|
||||
intent_result = intent.IntentResponse(language=language) # type: ignore[unreachable]
|
||||
intent_result.async_set_speech("Sorry, I couldn't handle that")
|
||||
|
||||
return self.json(intent_result)
|
||||
|
||||
@@ -221,13 +221,13 @@ class IntesisAC(ClimateEntity):
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device specific state attributes."""
|
||||
attrs = {}
|
||||
if self._outdoor_temp is not None:
|
||||
if self._outdoor_temp:
|
||||
attrs["outdoor_temp"] = self._outdoor_temp
|
||||
if self._power_consumption_heat is not None:
|
||||
if self._power_consumption_heat:
|
||||
attrs["power_consumption_heat_kw"] = round(
|
||||
self._power_consumption_heat / 1000, 1
|
||||
)
|
||||
if self._power_consumption_cool is not None:
|
||||
if self._power_consumption_cool:
|
||||
attrs["power_consumption_cool_kw"] = round(
|
||||
self._power_consumption_cool / 1000, 1
|
||||
)
|
||||
@@ -244,7 +244,7 @@ class IntesisAC(ClimateEntity):
|
||||
if hvac_mode := kwargs.get(ATTR_HVAC_MODE):
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
|
||||
if temperature := kwargs.get(ATTR_TEMPERATURE):
|
||||
_LOGGER.debug("Setting %s to %s degrees", self._device_type, temperature)
|
||||
await self._controller.set_temperature(self._device_id, temperature)
|
||||
self._attr_target_temperature = temperature
|
||||
@@ -271,7 +271,7 @@ class IntesisAC(ClimateEntity):
|
||||
await self._controller.set_mode(self._device_id, MAP_HVAC_MODE_TO_IH[hvac_mode])
|
||||
|
||||
# Send the temperature again in case changing modes has changed it
|
||||
if self._attr_target_temperature is not None:
|
||||
if self._attr_target_temperature:
|
||||
await self._controller.set_temperature(
|
||||
self._device_id, self._attr_target_temperature
|
||||
)
|
||||
|
||||
@@ -358,7 +358,7 @@ PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription(
|
||||
native_max_value=MAX_TEMP,
|
||||
native_min_value_f=MIN_TEMP_F,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=1,
|
||||
native_step=5,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
|
||||
from xknx.dpt.dpt_16 import DPTString
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import UnitOfReactiveEnergy
|
||||
|
||||
HaDptClass = Literal["numeric", "enum", "complex", "string"]
|
||||
|
||||
@@ -37,7 +36,7 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]:
|
||||
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
|
||||
sub=dpt_class.dpt_sub_number,
|
||||
name=dpt_class.value_type,
|
||||
unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit),
|
||||
unit=dpt_class.unit,
|
||||
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
|
||||
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
|
||||
)
|
||||
@@ -78,13 +77,13 @@ _sensor_device_classes: Mapping[str, SensorDeviceClass] = {
|
||||
"12.1200": SensorDeviceClass.VOLUME,
|
||||
"12.1201": SensorDeviceClass.VOLUME,
|
||||
"13.002": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"13.010": SensorDeviceClass.ENERGY, # DPTActiveEnergy
|
||||
"13.012": SensorDeviceClass.REACTIVE_ENERGY, # DPTReactiveEnergy
|
||||
"13.013": SensorDeviceClass.ENERGY, # DPTActiveEnergykWh
|
||||
"13.015": SensorDeviceClass.REACTIVE_ENERGY, # DPTReactiveEnergykVARh
|
||||
"13.016": SensorDeviceClass.ENERGY, # DPTActiveEnergyMWh
|
||||
"13.1200": SensorDeviceClass.VOLUME, # DPTDeltaVolumeLiquidLitre
|
||||
"13.1201": SensorDeviceClass.VOLUME, # DPTDeltaVolumeM3
|
||||
"13.010": SensorDeviceClass.ENERGY,
|
||||
"13.012": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
"13.013": SensorDeviceClass.ENERGY,
|
||||
"13.015": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
"13.016": SensorDeviceClass.ENERGY,
|
||||
"13.1200": SensorDeviceClass.VOLUME,
|
||||
"13.1201": SensorDeviceClass.VOLUME,
|
||||
"14.010": SensorDeviceClass.AREA,
|
||||
"14.019": SensorDeviceClass.CURRENT,
|
||||
"14.027": SensorDeviceClass.VOLTAGE,
|
||||
@@ -92,7 +91,7 @@ _sensor_device_classes: Mapping[str, SensorDeviceClass] = {
|
||||
"14.030": SensorDeviceClass.VOLTAGE,
|
||||
"14.031": SensorDeviceClass.ENERGY,
|
||||
"14.033": SensorDeviceClass.FREQUENCY,
|
||||
"14.037": SensorDeviceClass.ENERGY_STORAGE, # DPTHeatQuantity
|
||||
"14.037": SensorDeviceClass.ENERGY_STORAGE,
|
||||
"14.039": SensorDeviceClass.DISTANCE,
|
||||
"14.051": SensorDeviceClass.WEIGHT,
|
||||
"14.056": SensorDeviceClass.POWER,
|
||||
@@ -102,7 +101,7 @@ _sensor_device_classes: Mapping[str, SensorDeviceClass] = {
|
||||
"14.068": SensorDeviceClass.TEMPERATURE,
|
||||
"14.069": SensorDeviceClass.TEMPERATURE,
|
||||
"14.070": SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
"14.076": SensorDeviceClass.VOLUME, # DPTVolume
|
||||
"14.076": SensorDeviceClass.VOLUME,
|
||||
"14.077": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"14.080": SensorDeviceClass.APPARENT_POWER,
|
||||
"14.1200": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
@@ -122,28 +121,17 @@ _sensor_state_class_overrides: Mapping[str, SensorStateClass | None] = {
|
||||
"13.010": SensorStateClass.TOTAL, # DPTActiveEnergy
|
||||
"13.011": SensorStateClass.TOTAL, # DPTApparantEnergy
|
||||
"13.012": SensorStateClass.TOTAL, # DPTReactiveEnergy
|
||||
"13.013": SensorStateClass.TOTAL, # DPTActiveEnergykWh
|
||||
"13.015": SensorStateClass.TOTAL, # DPTReactiveEnergykVARh
|
||||
"13.016": SensorStateClass.TOTAL, # DPTActiveEnergyMWh
|
||||
"13.1200": SensorStateClass.TOTAL, # DPTDeltaVolumeLiquidLitre
|
||||
"13.1201": SensorStateClass.TOTAL, # DPTDeltaVolumeM3
|
||||
"14.007": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngleDeg
|
||||
"14.037": SensorStateClass.TOTAL, # DPTHeatQuantity
|
||||
"14.051": SensorStateClass.TOTAL, # DPTMass
|
||||
"14.055": SensorStateClass.MEASUREMENT_ANGLE, # DPTPhaseAngleDeg
|
||||
"14.031": SensorStateClass.TOTAL_INCREASING, # DPTEnergy
|
||||
"14.076": SensorStateClass.TOTAL, # DPTVolume
|
||||
"17.001": None, # DPTSceneNumber
|
||||
"29.010": SensorStateClass.TOTAL, # DPTActiveEnergy8Byte
|
||||
"29.011": SensorStateClass.TOTAL, # DPTApparantEnergy8Byte
|
||||
"29.012": SensorStateClass.TOTAL, # DPTReactiveEnergy8Byte
|
||||
}
|
||||
|
||||
_sensor_unit_overrides: Mapping[str, str] = {
|
||||
"13.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergy (VARh in KNX)
|
||||
"13.015": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergykVARh (kVARh in KNX)
|
||||
"29.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergy8Byte (VARh in KNX)
|
||||
}
|
||||
|
||||
|
||||
def _get_sensor_state_class(
|
||||
ha_dpt_class: HaDptClass, dpt_number_str: str
|
||||
|
||||
@@ -2,16 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import krakenex
|
||||
import pykrakenapi
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DISPATCH_CONFIG_UPDATED, DOMAIN
|
||||
from .coordinator import KrakenData
|
||||
from .const import (
|
||||
CONF_TRACKED_ASSET_PAIRS,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TRACKED_ASSET_PAIR,
|
||||
DISPATCH_CONFIG_UPDATED,
|
||||
DOMAIN,
|
||||
KrakenResponse,
|
||||
)
|
||||
from .utils import get_tradable_asset_pairs
|
||||
|
||||
CALL_RATE_LIMIT_SLEEP = 1
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up kraken from a config entry."""
|
||||
@@ -34,6 +53,111 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
return unload_ok
|
||||
|
||||
|
||||
class KrakenData:
|
||||
"""Define an object to hold kraken data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self._hass = hass
|
||||
self._config_entry = config_entry
|
||||
self._api = pykrakenapi.KrakenAPI(krakenex.API(), retry=0, crl_sleep=0)
|
||||
self.tradable_asset_pairs: dict[str, str] = {}
|
||||
self.coordinator: DataUpdateCoordinator[KrakenResponse | None] | None = None
|
||||
|
||||
async def async_update(self) -> KrakenResponse | None:
|
||||
"""Get the latest data from the Kraken.com REST API.
|
||||
|
||||
All tradeable asset pairs are retrieved, not the tracked asset pairs
|
||||
selected by the user. This enables us to check for an unknown and
|
||||
thus likely removed asset pair in sensor.py and only log a warning
|
||||
once.
|
||||
"""
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
return await self._hass.async_add_executor_job(self._get_kraken_data)
|
||||
except pykrakenapi.pykrakenapi.KrakenAPIError as error:
|
||||
if "Unknown asset pair" in str(error):
|
||||
_LOGGER.warning(
|
||||
"Kraken.com reported an unknown asset pair. Refreshing list of"
|
||||
" tradable asset pairs"
|
||||
)
|
||||
await self._async_refresh_tradable_asset_pairs()
|
||||
else:
|
||||
raise UpdateFailed(
|
||||
f"Unable to fetch data from Kraken.com: {error}"
|
||||
) from error
|
||||
except pykrakenapi.pykrakenapi.CallRateLimitError:
|
||||
_LOGGER.warning(
|
||||
"Exceeded the Kraken.com call rate limit. Increase the update interval"
|
||||
" to prevent this error"
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_kraken_data(self) -> KrakenResponse:
|
||||
websocket_name_pairs = self._get_websocket_name_asset_pairs()
|
||||
ticker_df = self._api.get_ticker_information(websocket_name_pairs)
|
||||
# Rename columns to their full name
|
||||
ticker_df = ticker_df.rename(
|
||||
columns={
|
||||
"a": "ask",
|
||||
"b": "bid",
|
||||
"c": "last_trade_closed",
|
||||
"v": "volume",
|
||||
"p": "volume_weighted_average",
|
||||
"t": "number_of_trades",
|
||||
"l": "low",
|
||||
"h": "high",
|
||||
"o": "opening_price",
|
||||
}
|
||||
)
|
||||
response_dict: KrakenResponse = ticker_df.transpose().to_dict()
|
||||
return response_dict
|
||||
|
||||
async def _async_refresh_tradable_asset_pairs(self) -> None:
|
||||
self.tradable_asset_pairs = await self._hass.async_add_executor_job(
|
||||
get_tradable_asset_pairs, self._api
|
||||
)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the Kraken integration."""
|
||||
if not self._config_entry.options:
|
||||
options = {
|
||||
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
|
||||
CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR],
|
||||
}
|
||||
self._hass.config_entries.async_update_entry(
|
||||
self._config_entry, options=options
|
||||
)
|
||||
await self._async_refresh_tradable_asset_pairs()
|
||||
# Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter
|
||||
await asyncio.sleep(CALL_RATE_LIMIT_SLEEP)
|
||||
self.coordinator = DataUpdateCoordinator(
|
||||
self._hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=self._config_entry,
|
||||
update_method=self.async_update,
|
||||
update_interval=timedelta(
|
||||
seconds=self._config_entry.options[CONF_SCAN_INTERVAL]
|
||||
),
|
||||
)
|
||||
await self.coordinator.async_config_entry_first_refresh()
|
||||
# Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter
|
||||
await asyncio.sleep(CALL_RATE_LIMIT_SLEEP)
|
||||
|
||||
def _get_websocket_name_asset_pairs(self) -> str:
|
||||
return ",".join(
|
||||
pair
|
||||
for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS]
|
||||
if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None
|
||||
)
|
||||
|
||||
def set_update_interval(self, update_interval: int) -> None:
|
||||
"""Set the coordinator update_interval to the supplied update_interval."""
|
||||
if self.coordinator is not None:
|
||||
self.coordinator.update_interval = timedelta(seconds=update_interval)
|
||||
|
||||
|
||||
async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Triggered by config entry options updates."""
|
||||
hass.data[DOMAIN].set_update_interval(config_entry.options[CONF_SCAN_INTERVAL])
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"""Coordinator for the kraken integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import krakenex
|
||||
import pykrakenapi
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_ASSET_PAIRS,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TRACKED_ASSET_PAIR,
|
||||
DOMAIN,
|
||||
KrakenResponse,
|
||||
)
|
||||
from .utils import get_tradable_asset_pairs
|
||||
|
||||
CALL_RATE_LIMIT_SLEEP = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KrakenData:
|
||||
"""Define an object to hold kraken data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self._hass = hass
|
||||
self._config_entry = config_entry
|
||||
self._api = pykrakenapi.KrakenAPI(krakenex.API(), retry=0, crl_sleep=0)
|
||||
self.tradable_asset_pairs: dict[str, str] = {}
|
||||
self.coordinator: DataUpdateCoordinator[KrakenResponse | None] | None = None
|
||||
|
||||
async def async_update(self) -> KrakenResponse | None:
|
||||
"""Get the latest data from the Kraken.com REST API.
|
||||
|
||||
All tradeable asset pairs are retrieved, not the tracked asset pairs
|
||||
selected by the user. This enables us to check for an unknown and
|
||||
thus likely removed asset pair in sensor.py and only log a warning
|
||||
once.
|
||||
"""
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
return await self._hass.async_add_executor_job(self._get_kraken_data)
|
||||
except pykrakenapi.pykrakenapi.KrakenAPIError as error:
|
||||
if "Unknown asset pair" in str(error):
|
||||
_LOGGER.warning(
|
||||
"Kraken.com reported an unknown asset pair. Refreshing list of"
|
||||
" tradable asset pairs"
|
||||
)
|
||||
await self._async_refresh_tradable_asset_pairs()
|
||||
else:
|
||||
raise UpdateFailed(
|
||||
f"Unable to fetch data from Kraken.com: {error}"
|
||||
) from error
|
||||
except pykrakenapi.pykrakenapi.CallRateLimitError:
|
||||
_LOGGER.warning(
|
||||
"Exceeded the Kraken.com call rate limit. Increase the update interval"
|
||||
" to prevent this error"
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_kraken_data(self) -> KrakenResponse:
|
||||
websocket_name_pairs = self._get_websocket_name_asset_pairs()
|
||||
ticker_df = self._api.get_ticker_information(websocket_name_pairs)
|
||||
# Rename columns to their full name
|
||||
ticker_df = ticker_df.rename(
|
||||
columns={
|
||||
"a": "ask",
|
||||
"b": "bid",
|
||||
"c": "last_trade_closed",
|
||||
"v": "volume",
|
||||
"p": "volume_weighted_average",
|
||||
"t": "number_of_trades",
|
||||
"l": "low",
|
||||
"h": "high",
|
||||
"o": "opening_price",
|
||||
}
|
||||
)
|
||||
response_dict: KrakenResponse = ticker_df.transpose().to_dict()
|
||||
return response_dict
|
||||
|
||||
async def _async_refresh_tradable_asset_pairs(self) -> None:
|
||||
self.tradable_asset_pairs = await self._hass.async_add_executor_job(
|
||||
get_tradable_asset_pairs, self._api
|
||||
)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the Kraken integration."""
|
||||
if not self._config_entry.options:
|
||||
options = {
|
||||
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
|
||||
CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR],
|
||||
}
|
||||
self._hass.config_entries.async_update_entry(
|
||||
self._config_entry, options=options
|
||||
)
|
||||
await self._async_refresh_tradable_asset_pairs()
|
||||
# Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter
|
||||
await asyncio.sleep(CALL_RATE_LIMIT_SLEEP)
|
||||
self.coordinator = DataUpdateCoordinator(
|
||||
self._hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=self._config_entry,
|
||||
update_method=self.async_update,
|
||||
update_interval=timedelta(
|
||||
seconds=self._config_entry.options[CONF_SCAN_INTERVAL]
|
||||
),
|
||||
)
|
||||
await self.coordinator.async_config_entry_first_refresh()
|
||||
# Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter
|
||||
await asyncio.sleep(CALL_RATE_LIMIT_SLEEP)
|
||||
|
||||
def _get_websocket_name_asset_pairs(self) -> str:
|
||||
return ",".join(
|
||||
pair
|
||||
for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS]
|
||||
if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None
|
||||
)
|
||||
|
||||
def set_update_interval(self, update_interval: int) -> None:
|
||||
"""Set the coordinator update_interval to the supplied update_interval."""
|
||||
if self.coordinator is not None:
|
||||
self.coordinator.update_interval = timedelta(seconds=update_interval)
|
||||
@@ -22,13 +22,13 @@ from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import KrakenData
|
||||
from .const import (
|
||||
CONF_TRACKED_ASSET_PAIRS,
|
||||
DISPATCH_CONFIG_UPDATED,
|
||||
DOMAIN,
|
||||
KrakenResponse,
|
||||
)
|
||||
from .coordinator import KrakenData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -2,20 +2,61 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
from pylaunches import PyLaunches, PyLaunchesError
|
||||
from pylaunches.types import Launch, StarshipResponse
|
||||
|
||||
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 .const import DOMAIN
|
||||
from .coordinator import LaunchLibraryCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
class LaunchLibraryData(TypedDict):
|
||||
"""Typed dict representation of data returned from pylaunches."""
|
||||
|
||||
upcoming_launches: list[Launch]
|
||||
starship_events: StarshipResponse
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
|
||||
coordinator = LaunchLibraryCoordinator(hass, entry)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
launches = PyLaunches(session)
|
||||
|
||||
async def async_update() -> LaunchLibraryData:
|
||||
try:
|
||||
return LaunchLibraryData(
|
||||
upcoming_launches=await launches.launch_upcoming(
|
||||
filters={"limit": 1, "hide_recent_previous": "True"},
|
||||
),
|
||||
starship_events=await launches.dashboard_starship(),
|
||||
)
|
||||
except PyLaunchesError as ex:
|
||||
raise UpdateFailed(ex) from ex
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_method=async_update,
|
||||
update_interval=timedelta(hours=1),
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN] = coordinator
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""DataUpdateCoordinator for the launch_library integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
from pylaunches import PyLaunches, PyLaunchesError
|
||||
from pylaunches.types import Launch, StarshipResponse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaunchLibraryData(TypedDict):
|
||||
"""Typed dict representation of data returned from pylaunches."""
|
||||
|
||||
upcoming_launches: list[Launch]
|
||||
starship_events: StarshipResponse
|
||||
|
||||
|
||||
class LaunchLibraryCoordinator(DataUpdateCoordinator[LaunchLibraryData]):
|
||||
"""Class to manage fetching Launch Library data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(hours=1),
|
||||
)
|
||||
session = async_get_clientsession(hass)
|
||||
self._launches = PyLaunches(session)
|
||||
|
||||
async def _async_update_data(self) -> LaunchLibraryData:
|
||||
"""Fetch data from Launch Library."""
|
||||
try:
|
||||
return LaunchLibraryData(
|
||||
upcoming_launches=await self._launches.launch_upcoming(
|
||||
filters={"limit": 1, "hide_recent_previous": "True"},
|
||||
),
|
||||
starship_events=await self._launches.dashboard_starship(),
|
||||
)
|
||||
except PyLaunchesError as ex:
|
||||
raise UpdateFailed(ex) from ex
|
||||
@@ -8,9 +8,10 @@ from pylaunches.types import Event, Launch
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import LaunchLibraryData
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaunchLibraryCoordinator
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -19,7 +20,7 @@ async def async_get_config_entry_diagnostics(
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN]
|
||||
coordinator: DataUpdateCoordinator[LaunchLibraryData] = hass.data[DOMAIN]
|
||||
if coordinator.data is None:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -19,11 +19,14 @@ from homeassistant.const import CONF_NAME, PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.util.dt import parse_datetime
|
||||
|
||||
from . import LaunchLibraryData
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaunchLibraryCoordinator
|
||||
|
||||
DEFAULT_NEXT_LAUNCH_NAME = "Next launch"
|
||||
|
||||
@@ -123,7 +126,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
name = entry.data.get(CONF_NAME, DEFAULT_NEXT_LAUNCH_NAME)
|
||||
coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN]
|
||||
coordinator: DataUpdateCoordinator[LaunchLibraryData] = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
LaunchLibrarySensor(
|
||||
@@ -136,7 +139,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class LaunchLibrarySensor(CoordinatorEntity[LaunchLibraryCoordinator], SensorEntity):
|
||||
class LaunchLibrarySensor(
|
||||
CoordinatorEntity[DataUpdateCoordinator[LaunchLibraryData]], SensorEntity
|
||||
):
|
||||
"""Representation of the next launch sensors."""
|
||||
|
||||
_attr_attribution = "Data provided by Launch Library."
|
||||
@@ -146,7 +151,7 @@ class LaunchLibrarySensor(CoordinatorEntity[LaunchLibraryCoordinator], SensorEnt
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: LaunchLibraryCoordinator,
|
||||
coordinator: DataUpdateCoordinator[LaunchLibraryData],
|
||||
entry_id: str,
|
||||
description: LaunchLibrarySensorEntityDescription,
|
||||
name: str,
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, MODELS
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -47,14 +47,7 @@ class LaundrifyBaseSensor(SensorEntity):
|
||||
def __init__(self, device: LaundrifyDevice) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._device = device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODELS[device.model],
|
||||
sw_version=device.firmwareVersion,
|
||||
configuration_url=f"http://{device.internalIP}",
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.id)})
|
||||
self._attr_unique_id = f"{device.id}_{self._attr_device_class}"
|
||||
|
||||
|
||||
|
||||
@@ -44,9 +44,6 @@
|
||||
},
|
||||
"started_mowing": {
|
||||
"trigger": "mdi:play"
|
||||
},
|
||||
"started_returning": {
|
||||
"trigger": "mdi:home-import-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,16 +139,6 @@
|
||||
}
|
||||
},
|
||||
"name": "Lawn mower started mowing"
|
||||
},
|
||||
"started_returning": {
|
||||
"description": "Triggers after one or more lawn mowers start returning to dock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lawn mower started returning to dock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_mowing": make_entity_target_state_trigger(
|
||||
DOMAIN, LawnMowerActivity.MOWING
|
||||
),
|
||||
"started_returning": make_entity_target_state_trigger(
|
||||
DOMAIN, LawnMowerActivity.RETURNING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@ docked: *trigger_common
|
||||
errored: *trigger_common
|
||||
paused_mowing: *trigger_common
|
||||
started_mowing: *trigger_common
|
||||
started_returning: *trigger_common
|
||||
|
||||
@@ -3,20 +3,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from led_ble import LEDBLE
|
||||
from led_ble import BLEAK_EXCEPTIONS, LEDBLE
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
|
||||
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEVICE_TIMEOUT
|
||||
from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator, LEDBLEData
|
||||
from .const import DEVICE_TIMEOUT, UPDATE_SECONDS
|
||||
from .models import LEDBLEConfigEntry, LEDBLEData
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bool:
|
||||
"""Set up LED BLE from a config entry."""
|
||||
@@ -48,9 +53,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bo
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_update() -> None:
|
||||
"""Update the device state."""
|
||||
try:
|
||||
await led_ble.update()
|
||||
except BLEAK_EXCEPTIONS as ex:
|
||||
raise UpdateFailed(str(ex)) from ex
|
||||
|
||||
startup_event = asyncio.Event()
|
||||
cancel_first_update = led_ble.register_callback(lambda *_: startup_event.set())
|
||||
coordinator = LEDBLECoordinator(hass, entry, led_ble)
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=led_ble.name,
|
||||
update_method=_async_update,
|
||||
update_interval=timedelta(seconds=UPDATE_SECONDS),
|
||||
)
|
||||
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""The LED BLE coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from led_ble import BLEAK_EXCEPTIONS, LEDBLE
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import UPDATE_SECONDS
|
||||
|
||||
type LEDBLEConfigEntry = ConfigEntry[LEDBLEData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDBLEData:
|
||||
"""Data for the led ble integration."""
|
||||
|
||||
title: str
|
||||
device: LEDBLE
|
||||
coordinator: LEDBLECoordinator
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LEDBLECoordinator(DataUpdateCoordinator[None]):
|
||||
"""Class to manage fetching LED BLE data."""
|
||||
|
||||
config_entry: LEDBLEConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: LEDBLEConfigEntry,
|
||||
led_ble: LEDBLE,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=led_ble.name,
|
||||
update_interval=timedelta(seconds=UPDATE_SECONDS),
|
||||
)
|
||||
self.led_ble = led_ble
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update the device state."""
|
||||
try:
|
||||
await self.led_ble.update()
|
||||
except BLEAK_EXCEPTIONS as ex:
|
||||
raise UpdateFailed(str(ex)) from ex
|
||||
@@ -19,10 +19,13 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_EFFECT_SPEED
|
||||
from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator
|
||||
from .models import LEDBLEConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -35,7 +38,7 @@ async def async_setup_entry(
|
||||
async_add_entities([LEDBLEEntity(data.coordinator, data.device, entry.title)])
|
||||
|
||||
|
||||
class LEDBLEEntity(CoordinatorEntity[LEDBLECoordinator], LightEntity):
|
||||
class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity):
|
||||
"""Representation of LEDBLE device."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.WHITE}
|
||||
@@ -44,7 +47,7 @@ class LEDBLEEntity(CoordinatorEntity[LEDBLECoordinator], LightEntity):
|
||||
_attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
def __init__(
|
||||
self, coordinator: LEDBLECoordinator, device: LEDBLE, name: str
|
||||
self, coordinator: DataUpdateCoordinator[None], device: LEDBLE, name: str
|
||||
) -> None:
|
||||
"""Initialize an ledble light."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
21
homeassistant/components/led_ble/models.py
Normal file
21
homeassistant/components/led_ble/models.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""The led ble integration models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from led_ble import LEDBLE
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
type LEDBLEConfigEntry = ConfigEntry[LEDBLEData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDBLEData:
|
||||
"""Data for the led ble integration."""
|
||||
|
||||
title: str
|
||||
device: LEDBLE
|
||||
coordinator: DataUpdateCoordinator[None]
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Diagnostics support for Libre Hardware Monitor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, replace
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorData
|
||||
|
||||
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
lhm_data: LibreHardwareMonitorData = config_entry.runtime_data.data
|
||||
|
||||
return {
|
||||
"config_entry_data": {
|
||||
**async_redact_data(dict(config_entry.data), TO_REDACT),
|
||||
},
|
||||
"lhm_data": _as_dict(lhm_data),
|
||||
}
|
||||
|
||||
|
||||
def _as_dict(data: LibreHardwareMonitorData) -> dict[str, Any]:
|
||||
return asdict(
|
||||
replace(
|
||||
data,
|
||||
main_device_ids_and_names=dict(data.main_device_ids_and_names), # type: ignore[arg-type]
|
||||
sensor_data=dict(data.sensor_data), # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["mastodon"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["Mastodon.py==2.1.2"]
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ rules:
|
||||
Web service does not support discovery.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -67,21 +67,12 @@ class MaxCubeClimate(ClimateEntity):
|
||||
"""MAX! Cube ClimateEntity."""
|
||||
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT]
|
||||
_attr_preset_modes = [
|
||||
PRESET_NONE,
|
||||
PRESET_BOOST,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_AWAY,
|
||||
PRESET_ON,
|
||||
]
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(self, handler, device):
|
||||
"""Initialize MAX! Cube ClimateEntity."""
|
||||
@@ -89,7 +80,17 @@ class MaxCubeClimate(ClimateEntity):
|
||||
self._attr_name = f"{room.name} {device.name}"
|
||||
self._cubehandle = handler
|
||||
self._device = device
|
||||
self._attr_should_poll = True
|
||||
self._attr_unique_id = self._device.serial
|
||||
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._attr_preset_modes = [
|
||||
PRESET_NONE,
|
||||
PRESET_BOOST,
|
||||
PRESET_COMFORT,
|
||||
PRESET_ECO,
|
||||
PRESET_AWAY,
|
||||
PRESET_ON,
|
||||
]
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
@@ -105,7 +106,7 @@ class MaxCubeClimate(ClimateEntity):
|
||||
return self._device.max_temperature or MAX_TEMPERATURE
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._device.actual_temperature
|
||||
|
||||
@@ -175,7 +176,7 @@ class MaxCubeClimate(ClimateEntity):
|
||||
return HVACAction.OFF if self.hvac_mode == HVACMode.OFF else HVACAction.IDLE
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
temp = self._device.target_temperature
|
||||
if temp is None or temp < self.min_temp or temp > self.max_temp:
|
||||
|
||||
@@ -617,11 +617,11 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
evaporate_water = 327
|
||||
shabbat_program = 335
|
||||
yom_tov = 336
|
||||
drying = 357, 2028
|
||||
drying = 357
|
||||
heat_crockery = 358
|
||||
prove_dough = 359, 2023
|
||||
prove_dough = 359
|
||||
low_temperature_cooking = 360
|
||||
steam_cooking = 8, 361
|
||||
steam_cooking = 361
|
||||
keeping_warm = 362
|
||||
apple_sponge = 364
|
||||
apple_pie = 365
|
||||
@@ -668,9 +668,9 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
saddle_of_roebuck = 456
|
||||
salmon_fillet = 461
|
||||
potato_cheese_gratin = 464
|
||||
trout = 486, 2224
|
||||
carp = 491, 2233
|
||||
salmon_trout = 492, 2241
|
||||
trout = 486
|
||||
carp = 491
|
||||
salmon_trout = 492
|
||||
springform_tin_15cm = 496
|
||||
springform_tin_20cm = 497
|
||||
springform_tin_25cm = 498
|
||||
@@ -736,15 +736,137 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
pork_belly = 701
|
||||
pikeperch_fillet_with_vegetables = 702
|
||||
steam_bake = 99001
|
||||
|
||||
|
||||
class DishWarmerProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for dish warmers."""
|
||||
|
||||
no_program = 0, -1
|
||||
warm_cups_glasses = 1
|
||||
warm_dishes_plates = 2
|
||||
keep_warm = 3
|
||||
slow_roasting = 4
|
||||
|
||||
|
||||
class RobotVacuumCleanerProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for robot vacuum cleaners."""
|
||||
|
||||
no_program = 0, -1
|
||||
auto = 1
|
||||
spot = 2
|
||||
turbo = 3
|
||||
silent = 4
|
||||
|
||||
|
||||
class CoffeeSystemProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for coffee systems."""
|
||||
|
||||
no_program = 0, -1
|
||||
|
||||
check_appliance = 17004
|
||||
|
||||
# profile 1
|
||||
ristretto = 24000, 24032, 24064, 24096, 24128
|
||||
espresso = 24001, 24033, 24065, 24097, 24129
|
||||
coffee = 24002, 24034, 24066, 24098, 24130
|
||||
long_coffee = 24003, 24035, 24067, 24099, 24131
|
||||
cappuccino = 24004, 24036, 24068, 24100, 24132
|
||||
cappuccino_italiano = 24005, 24037, 24069, 24101, 24133
|
||||
latte_macchiato = 24006, 24038, 24070, 24102, 24134
|
||||
espresso_macchiato = 24007, 24039, 24071, 24135
|
||||
cafe_au_lait = 24008, 24040, 24072, 24104, 24136
|
||||
caffe_latte = 24009, 24041, 24073, 24105, 24137
|
||||
flat_white = 24012, 24044, 24076, 24108, 24140
|
||||
very_hot_water = 24013, 24045, 24077, 24109, 24141
|
||||
hot_water = 24014, 24046, 24078, 24110, 24142
|
||||
hot_milk = 24015, 24047, 24079, 24111, 24143
|
||||
milk_foam = 24016, 24048, 24080, 24112, 24144
|
||||
black_tea = 24017, 24049, 24081, 24113, 24145
|
||||
herbal_tea = 24018, 24050, 24082, 24114, 24146
|
||||
fruit_tea = 24019, 24051, 24083, 24115, 24147
|
||||
green_tea = 24020, 24052, 24084, 24116, 24148
|
||||
white_tea = 24021, 24053, 24085, 24117, 24149
|
||||
japanese_tea = 24022, 29054, 24086, 24118, 24150
|
||||
# special programs
|
||||
coffee_pot = 24400
|
||||
barista_assistant = 24407
|
||||
# machine settings menu
|
||||
appliance_settings = (
|
||||
16016, # display brightness
|
||||
16018, # volume
|
||||
16019, # buttons volume
|
||||
16020, # child lock
|
||||
16021, # water hardness
|
||||
16027, # welcome sound
|
||||
16033, # connection status
|
||||
16035, # remote control
|
||||
16037, # remote update
|
||||
24500, # total dispensed
|
||||
24502, # lights appliance on
|
||||
24503, # lights appliance off
|
||||
24504, # turn off lights after
|
||||
24506, # altitude
|
||||
24513, # performance mode
|
||||
24516, # turn off after
|
||||
24537, # advanced mode
|
||||
24542, # tea timer
|
||||
24549, # total coffee dispensed
|
||||
24550, # total tea dispensed
|
||||
24551, # total ristretto
|
||||
24552, # total cappuccino
|
||||
24553, # total espresso
|
||||
24554, # total coffee
|
||||
24555, # total long coffee
|
||||
24556, # total italian cappuccino
|
||||
24557, # total latte macchiato
|
||||
24558, # total caffe latte
|
||||
24560, # total espresso macchiato
|
||||
24562, # total flat white
|
||||
24563, # total coffee with milk
|
||||
24564, # total black tea
|
||||
24565, # total herbal tea
|
||||
24566, # total fruit tea
|
||||
24567, # total green tea
|
||||
24568, # total white tea
|
||||
24569, # total japanese tea
|
||||
24571, # total milk foam
|
||||
24572, # total hot milk
|
||||
24573, # total hot water
|
||||
24574, # total very hot water
|
||||
24575, # counter to descaling
|
||||
24576, # counter to brewing unit degreasing
|
||||
24800, # maintenance
|
||||
24801, # profiles settings menu
|
||||
24813, # add profile
|
||||
)
|
||||
appliance_rinse = 24750, 24759, 24773, 24787, 24788
|
||||
intermediate_rinsing = 24758
|
||||
automatic_maintenance = 24778
|
||||
descaling = 24751
|
||||
brewing_unit_degrease = 24753
|
||||
milk_pipework_rinse = 24754
|
||||
milk_pipework_clean = 24789
|
||||
|
||||
|
||||
class SteamOvenMicroProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for steam oven micro combo."""
|
||||
|
||||
no_program = 0, -1
|
||||
steam_cooking = 8
|
||||
microwave = 19
|
||||
popcorn = 53
|
||||
quick_mw = 54
|
||||
sous_vide = 72
|
||||
eco_steam_cooking = 75
|
||||
rapid_steam_cooking = 77
|
||||
descale = 326
|
||||
menu_cooking = 330
|
||||
reheating_with_steam = 2018
|
||||
defrosting_with_steam = 2019
|
||||
blanching = 2020
|
||||
bottling = 2021
|
||||
sterilize_crockery = 2022
|
||||
prove_dough = 2023
|
||||
soak = 2027
|
||||
reheating_with_microwave = 2029
|
||||
defrosting_with_microwave = 2030
|
||||
@@ -898,15 +1020,18 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
gilt_head_bream_fillet = 2220
|
||||
codfish_piece = 2221, 2232
|
||||
codfish_fillet = 2222, 2231
|
||||
trout = 2224
|
||||
pike_fillet = 2225
|
||||
pike_piece = 2226
|
||||
halibut_fillet_2_cm = 2227
|
||||
halibut_fillet_3_cm = 2230
|
||||
carp = 2233
|
||||
salmon_fillet_2_cm = 2234
|
||||
salmon_fillet_3_cm = 2235
|
||||
salmon_steak_2_cm = 2238
|
||||
salmon_steak_3_cm = 2239
|
||||
salmon_piece = 2240
|
||||
salmon_trout = 2241
|
||||
iridescent_shark_fillet = 2244
|
||||
red_snapper_fillet_2_cm = 2245
|
||||
red_snapper_fillet_3_cm = 2248
|
||||
@@ -1143,116 +1268,6 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
round_grain_rice_general_rapid_steam_cooking = 3411
|
||||
|
||||
|
||||
class DishWarmerProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for dish warmers."""
|
||||
|
||||
no_program = 0, -1
|
||||
warm_cups_glasses = 1
|
||||
warm_dishes_plates = 2
|
||||
keep_warm = 3
|
||||
slow_roasting = 4
|
||||
|
||||
|
||||
class RobotVacuumCleanerProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for robot vacuum cleaners."""
|
||||
|
||||
no_program = 0, -1
|
||||
auto = 1
|
||||
spot = 2
|
||||
turbo = 3
|
||||
silent = 4
|
||||
|
||||
|
||||
class CoffeeSystemProgramId(MieleEnum, missing_to_none=True):
|
||||
"""Program Id codes for coffee systems."""
|
||||
|
||||
no_program = 0, -1
|
||||
|
||||
check_appliance = 17004
|
||||
|
||||
# profile 1
|
||||
ristretto = 24000, 24032, 24064, 24096, 24128
|
||||
espresso = 24001, 24033, 24065, 24097, 24129
|
||||
coffee = 24002, 24034, 24066, 24098, 24130
|
||||
long_coffee = 24003, 24035, 24067, 24099, 24131
|
||||
cappuccino = 24004, 24036, 24068, 24100, 24132
|
||||
cappuccino_italiano = 24005, 24037, 24069, 24101, 24133
|
||||
latte_macchiato = 24006, 24038, 24070, 24102, 24134
|
||||
espresso_macchiato = 24007, 24039, 24071, 24135
|
||||
cafe_au_lait = 24008, 24040, 24072, 24104, 24136
|
||||
caffe_latte = 24009, 24041, 24073, 24105, 24137
|
||||
flat_white = 24012, 24044, 24076, 24108, 24140
|
||||
very_hot_water = 24013, 24045, 24077, 24109, 24141
|
||||
hot_water = 24014, 24046, 24078, 24110, 24142
|
||||
hot_milk = 24015, 24047, 24079, 24111, 24143
|
||||
milk_foam = 24016, 24048, 24080, 24112, 24144
|
||||
black_tea = 24017, 24049, 24081, 24113, 24145
|
||||
herbal_tea = 24018, 24050, 24082, 24114, 24146
|
||||
fruit_tea = 24019, 24051, 24083, 24115, 24147
|
||||
green_tea = 24020, 24052, 24084, 24116, 24148
|
||||
white_tea = 24021, 24053, 24085, 24117, 24149
|
||||
japanese_tea = 24022, 29054, 24086, 24118, 24150
|
||||
# special programs
|
||||
coffee_pot = 24400
|
||||
barista_assistant = 24407
|
||||
# machine settings menu
|
||||
appliance_settings = (
|
||||
16016, # display brightness
|
||||
16018, # volume
|
||||
16019, # buttons volume
|
||||
16020, # child lock
|
||||
16021, # water hardness
|
||||
16027, # welcome sound
|
||||
16033, # connection status
|
||||
16035, # remote control
|
||||
16037, # remote update
|
||||
24500, # total dispensed
|
||||
24502, # lights appliance on
|
||||
24503, # lights appliance off
|
||||
24504, # turn off lights after
|
||||
24506, # altitude
|
||||
24513, # performance mode
|
||||
24516, # turn off after
|
||||
24537, # advanced mode
|
||||
24542, # tea timer
|
||||
24549, # total coffee dispensed
|
||||
24550, # total tea dispensed
|
||||
24551, # total ristretto
|
||||
24552, # total cappuccino
|
||||
24553, # total espresso
|
||||
24554, # total coffee
|
||||
24555, # total long coffee
|
||||
24556, # total italian cappuccino
|
||||
24557, # total latte macchiato
|
||||
24558, # total caffe latte
|
||||
24560, # total espresso macchiato
|
||||
24562, # total flat white
|
||||
24563, # total coffee with milk
|
||||
24564, # total black tea
|
||||
24565, # total herbal tea
|
||||
24566, # total fruit tea
|
||||
24567, # total green tea
|
||||
24568, # total white tea
|
||||
24569, # total japanese tea
|
||||
24571, # total milk foam
|
||||
24572, # total hot milk
|
||||
24573, # total hot water
|
||||
24574, # total very hot water
|
||||
24575, # counter to descaling
|
||||
24576, # counter to brewing unit degreasing
|
||||
24800, # maintenance
|
||||
24801, # profiles settings menu
|
||||
24813, # add profile
|
||||
)
|
||||
appliance_rinse = 24750, 24759, 24773, 24787, 24788
|
||||
intermediate_rinsing = 24758
|
||||
automatic_maintenance = 24778
|
||||
descaling = 24751
|
||||
brewing_unit_degrease = 24753
|
||||
milk_pipework_rinse = 24754
|
||||
milk_pipework_clean = 24789
|
||||
|
||||
|
||||
PROGRAM_IDS: dict[int, type[MieleEnum]] = {
|
||||
MieleAppliance.WASHING_MACHINE: WashingMachineProgramId,
|
||||
MieleAppliance.TUMBLE_DRYER: TumbleDryerProgramId,
|
||||
@@ -1263,7 +1278,7 @@ PROGRAM_IDS: dict[int, type[MieleEnum]] = {
|
||||
MieleAppliance.STEAM_OVEN_MK2: OvenProgramId,
|
||||
MieleAppliance.STEAM_OVEN: OvenProgramId,
|
||||
MieleAppliance.STEAM_OVEN_COMBI: OvenProgramId,
|
||||
MieleAppliance.STEAM_OVEN_MICRO: OvenProgramId,
|
||||
MieleAppliance.STEAM_OVEN_MICRO: SteamOvenMicroProgramId,
|
||||
MieleAppliance.WASHER_DRYER: WashingMachineProgramId,
|
||||
MieleAppliance.ROBOT_VACUUM_CLEANER: RobotVacuumCleanerProgramId,
|
||||
MieleAppliance.COFFEE_SYSTEM: CoffeeSystemProgramId,
|
||||
|
||||
@@ -474,7 +474,6 @@
|
||||
"drain_spin": "Drain/spin",
|
||||
"drop_cookies_1_tray": "Drop cookies (1 tray)",
|
||||
"drop_cookies_2_trays": "Drop cookies (2 trays)",
|
||||
"drying": "Drying",
|
||||
"duck": "Duck",
|
||||
"dutch_hash": "Dutch hash",
|
||||
"easy_care": "Easy care",
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
"""The Mullvad VPN integration."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from mullvad_api import MullvadAPI
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MullvadCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Mullvad VPN integration."""
|
||||
coordinator = MullvadCoordinator(hass, entry)
|
||||
|
||||
async def async_get_mullvad_api_data():
|
||||
async with asyncio.timeout(10):
|
||||
api = await hass.async_add_executor_job(MullvadAPI)
|
||||
return api.data
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
logging.getLogger(__name__),
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_method=async_get_mullvad_api_data,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN] = coordinator
|
||||
|
||||
@@ -9,10 +9,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MullvadCoordinator
|
||||
|
||||
BINARY_SENSORS = (
|
||||
BinarySensorEntityDescription(
|
||||
@@ -37,14 +39,14 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class MullvadBinarySensor(CoordinatorEntity[MullvadCoordinator], BinarySensorEntity):
|
||||
class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
"""Represents a Mullvad binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MullvadCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_description: BinarySensorEntityDescription,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""The Mullvad VPN coordinator."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mullvad_api import MullvadAPI
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MullvadCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Mullvad VPN data update coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize the Mullvad coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from Mullvad API."""
|
||||
async with asyncio.timeout(10):
|
||||
api = await self.hass.async_add_executor_job(MullvadAPI)
|
||||
return api.data
|
||||
@@ -24,7 +24,7 @@ SUBENTRY_TYPE_ZONE = "zone"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_PORT = 4999
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(seconds=5)
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
|
||||
DEFAULT_INFER_ARMING_STATE = False
|
||||
DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION
|
||||
|
||||
|
||||
@@ -41,9 +41,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up button for Netgear component."""
|
||||
router = entry.runtime_data.router
|
||||
coordinator_tracker = entry.runtime_data.coordinator_tracker
|
||||
async_add_entities(
|
||||
NetgearRouterButtonEntity(coordinator_tracker, entity_description)
|
||||
NetgearRouterButtonEntity(coordinator_tracker, router, entity_description)
|
||||
for entity_description in BUTTONS
|
||||
)
|
||||
|
||||
@@ -56,14 +57,13 @@ class NetgearRouterButtonEntity(NetgearRouterCoordinatorEntity, ButtonEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearTrackerCoordinator,
|
||||
router: NetgearRouter,
|
||||
entity_description: NetgearButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, router)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.router.serial_number}-{entity_description.key}"
|
||||
)
|
||||
self._attr_unique_id = f"{router.serial_number}-{entity_description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers the button press service."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DEVICE_ICONS
|
||||
from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator
|
||||
from .entity import NetgearDeviceEntity
|
||||
from .router import NetgearRouter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +38,9 @@ async def async_setup_entry(
|
||||
if mac in tracked:
|
||||
continue
|
||||
|
||||
new_entities.append(NetgearScannerEntity(coordinator_tracker, device))
|
||||
new_entities.append(
|
||||
NetgearScannerEntity(coordinator_tracker, router, device)
|
||||
)
|
||||
tracked.add(mac)
|
||||
|
||||
async_add_entities(new_entities)
|
||||
@@ -56,10 +59,11 @@ class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearTrackerCoordinator,
|
||||
router: NetgearRouter,
|
||||
device: dict,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator, device)
|
||||
super().__init__(coordinator, router, device)
|
||||
self._hostname = self.get_hostname()
|
||||
self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network")
|
||||
self._attr_name = self._device_name
|
||||
|
||||
@@ -25,11 +25,12 @@ class NetgearDeviceEntity(CoordinatorEntity[NetgearTrackerCoordinator]):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearTrackerCoordinator,
|
||||
router: NetgearRouter,
|
||||
device: dict,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator)
|
||||
self._router = coordinator.router
|
||||
self._router = router
|
||||
self._device = device
|
||||
self._mac = device["mac"]
|
||||
self._device_name = self.get_device_name()
|
||||
@@ -39,7 +40,7 @@ class NetgearDeviceEntity(CoordinatorEntity[NetgearTrackerCoordinator]):
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self._mac)},
|
||||
default_name=self._device_name,
|
||||
default_model=device["device_model"],
|
||||
via_device=(DOMAIN, coordinator.router.unique_id),
|
||||
via_device=(DOMAIN, router.unique_id),
|
||||
)
|
||||
|
||||
def get_device_name(self):
|
||||
@@ -92,10 +93,10 @@ class NetgearRouterCoordinatorEntity[T: NetgearDataCoordinator[Any]](
|
||||
):
|
||||
"""Base class for a Netgear router entity."""
|
||||
|
||||
def __init__(self, coordinator: T) -> None:
|
||||
def __init__(self, coordinator: T, router: NetgearRouter) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
CoordinatorEntity.__init__(self, coordinator)
|
||||
NetgearRouterEntity.__init__(self, coordinator.router)
|
||||
NetgearRouterEntity.__init__(self, router)
|
||||
|
||||
@abstractmethod
|
||||
@callback
|
||||
|
||||
@@ -33,6 +33,7 @@ from .coordinator import (
|
||||
NetgearTrackerCoordinator,
|
||||
)
|
||||
from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity
|
||||
from .router import NetgearRouter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -281,7 +282,7 @@ async def async_setup_entry(
|
||||
coordinator_link = entry.runtime_data.coordinator_link
|
||||
|
||||
async_add_entities(
|
||||
NetgearRouterSensorEntity(coordinator, description)
|
||||
NetgearRouterSensorEntity(coordinator, router, description)
|
||||
for (coordinator, descriptions) in (
|
||||
(coordinator_traffic, SENSOR_TRAFFIC_TYPES),
|
||||
(coordinator_speed, SENSOR_SPEED_TYPES),
|
||||
@@ -310,7 +311,7 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
new_entities.extend(
|
||||
NetgearSensorEntity(coordinator_tracker, device, attribute)
|
||||
NetgearSensorEntity(coordinator_tracker, router, device, attribute)
|
||||
for attribute in sensors
|
||||
)
|
||||
tracked.add(mac)
|
||||
@@ -329,11 +330,12 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearTrackerCoordinator,
|
||||
router: NetgearRouter,
|
||||
device: dict,
|
||||
attribute: str,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator, device)
|
||||
super().__init__(coordinator, router, device)
|
||||
self._attribute = attribute
|
||||
self.entity_description = SENSOR_TYPES[attribute]
|
||||
self._attr_unique_id = f"{self._mac}-{attribute}"
|
||||
@@ -367,12 +369,13 @@ class NetgearRouterSensorEntity(NetgearRouterCoordinatorEntity, RestoreSensor):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearDataCoordinator[dict[str, Any] | None],
|
||||
router: NetgearRouter,
|
||||
entity_description: NetgearSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, router)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.router.serial_number}-{entity_description.key}-{entity_description.index}"
|
||||
self._attr_unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}"
|
||||
|
||||
self._value: StateType | date | datetime | Decimal = None
|
||||
self.async_update_device()
|
||||
|
||||
@@ -126,7 +126,9 @@ async def async_setup_entry(
|
||||
|
||||
new_entities.extend(
|
||||
[
|
||||
NetgearAllowBlock(coordinator_tracker, device, entity_description)
|
||||
NetgearAllowBlock(
|
||||
coordinator_tracker, router, device, entity_description
|
||||
)
|
||||
for entity_description in SWITCH_TYPES
|
||||
]
|
||||
)
|
||||
@@ -148,11 +150,12 @@ class NetgearAllowBlock(NetgearDeviceEntity, SwitchEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearTrackerCoordinator,
|
||||
router: NetgearRouter,
|
||||
device: dict,
|
||||
entity_description: SwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator, device)
|
||||
super().__init__(coordinator, router, device)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self._mac}-{entity_description.key}"
|
||||
self.async_update_device()
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NetgearConfigEntry, NetgearFirmwareCoordinator
|
||||
from .entity import NetgearRouterCoordinatorEntity
|
||||
from .router import NetgearRouter
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,8 +26,9 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up update entities for Netgear component."""
|
||||
router = entry.runtime_data.router
|
||||
coordinator = entry.runtime_data.coordinator_firmware
|
||||
entities = [NetgearUpdateEntity(coordinator)]
|
||||
entities = [NetgearUpdateEntity(coordinator, router)]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -42,10 +44,11 @@ class NetgearUpdateEntity(
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NetgearFirmwareCoordinator,
|
||||
router: NetgearRouter,
|
||||
) -> None:
|
||||
"""Initialize a Netgear device."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.router.serial_number}-update"
|
||||
super().__init__(coordinator, router)
|
||||
self._attr_unique_id = f"{router.serial_number}-update"
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
|
||||
@@ -199,12 +199,12 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
return self._thermostat.is_blower_active()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._zone.get_temperature()
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self._thermostat.get_fan_mode()
|
||||
|
||||
@@ -275,14 +275,14 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
def current_humidity(self):
|
||||
"""Humidity indoors."""
|
||||
if self._has_relative_humidity:
|
||||
return percent_conv(self._thermostat.get_relative_humidity())
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> int | None:
|
||||
def target_temperature(self):
|
||||
"""Temperature we try to reach."""
|
||||
current_mode = self._zone.get_current_mode()
|
||||
|
||||
@@ -293,7 +293,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> int | None:
|
||||
def target_temperature_high(self):
|
||||
"""Highest temperature we are trying to reach."""
|
||||
current_mode = self._zone.get_current_mode()
|
||||
|
||||
@@ -302,7 +302,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
return self._zone.get_cooling_setpoint()
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> int | None:
|
||||
def target_temperature_low(self):
|
||||
"""Lowest temperature we are trying to reach."""
|
||||
current_mode = self._zone.get_current_mode()
|
||||
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nsw_fuel import FuelCheckClient
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from nsw_fuel import FuelCheckClient, FuelCheckError, Station
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DATA_NSW_FUEL_STATION
|
||||
from .coordinator import NSWFuelStationCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "nsw_fuel_station"
|
||||
SCAN_INTERVAL = datetime.timedelta(hours=1)
|
||||
|
||||
CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -20,9 +27,46 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the NSW Fuel Station platform."""
|
||||
client = FuelCheckClient()
|
||||
|
||||
coordinator = NSWFuelStationCoordinator(hass, client)
|
||||
async def async_update_data():
|
||||
return await hass.async_add_executor_job(fetch_station_price_data, client)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=None,
|
||||
name="sensor",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
update_method=async_update_data,
|
||||
)
|
||||
hass.data[DATA_NSW_FUEL_STATION] = coordinator
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationPriceData:
|
||||
"""Data structure for O(1) price and name lookups."""
|
||||
|
||||
stations: dict[int, Station]
|
||||
prices: dict[tuple[int, str], float]
|
||||
|
||||
|
||||
def fetch_station_price_data(client: FuelCheckClient) -> StationPriceData | None:
|
||||
"""Fetch fuel price and station data."""
|
||||
try:
|
||||
raw_price_data = client.get_fuel_prices()
|
||||
# Restructure prices and station details to be indexed by station code
|
||||
# for O(1) lookup
|
||||
return StationPriceData(
|
||||
stations={s.code: s for s in raw_price_data.stations},
|
||||
prices={
|
||||
(p.station_code, p.fuel_type): p.price for p in raw_price_data.prices
|
||||
},
|
||||
)
|
||||
|
||||
except FuelCheckError as exc:
|
||||
raise UpdateFailed(
|
||||
f"Failed to fetch NSW Fuel station price data: {exc}"
|
||||
) from exc
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Coordinator for the NSW Fuel Station integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from nsw_fuel import FuelCheckClient, FuelCheckError, Station
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = datetime.timedelta(hours=1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationPriceData:
|
||||
"""Data structure for O(1) price and name lookups."""
|
||||
|
||||
stations: dict[int, Station]
|
||||
prices: dict[tuple[int, str], float]
|
||||
|
||||
|
||||
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData | None]):
|
||||
"""Class to manage fetching NSW fuel station data."""
|
||||
|
||||
config_entry: None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: FuelCheckClient) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=None,
|
||||
name="sensor",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> StationPriceData | None:
|
||||
"""Fetch data from API."""
|
||||
return await self.hass.async_add_executor_job(
|
||||
_fetch_station_price_data, self.client
|
||||
)
|
||||
|
||||
|
||||
def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData | None:
|
||||
"""Fetch fuel price and station data."""
|
||||
try:
|
||||
raw_price_data = client.get_fuel_prices()
|
||||
# Restructure prices and station details to be indexed by station code
|
||||
# for O(1) lookup
|
||||
return StationPriceData(
|
||||
stations={s.code: s for s in raw_price_data.stations},
|
||||
prices={
|
||||
(p.station_code, p.fuel_type): p.price for p in raw_price_data.prices
|
||||
},
|
||||
)
|
||||
except FuelCheckError as exc:
|
||||
raise UpdateFailed(
|
||||
f"Failed to fetch NSW Fuel station price data: {exc}"
|
||||
) from exc
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user