Compare commits

..

14 Commits

Author SHA1 Message Date
Ludovic BOUÉ 72ababf5c2 Modifies the async_set_temperature method to execute logic synchronously in the event loop, to avoid thread safety violations with legacy synchronous implementations. 2025-11-22 21:04:02 +00:00
Ludovic BOUÉ e100d8c53e Improve target temperature management by executing the adjustment method in an executor to avoid blocking the event loop. 2025-11-22 20:40:10 +00:00
Ludovic BOUÉ a678bed3b2 Execute the temperature definition directly in the event loop to ensure thread safety. 2025-11-22 18:18:50 +00:00
Ludovic BOUÉ ff8a4463ab Add unit_of_measurement for temperature fields 2025-11-22 18:50:15 +01:00
Ludovic BOUÉ 4d1aa15ddf Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-22 18:48:59 +01:00
Ludovic BOUÉ d91e7f060c Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-22 18:48:37 +01:00
Ludovic BOUÉ 20c99ecf04 Added support for the target temperature range in the temperature control service 2025-11-22 17:03:36 +00:00
Ludovic BOUÉ 6a711ee423 Reorder the name and description fields 2025-11-22 16:57:48 +00:00
Ludovic BOUÉ fc9e131297 Fix JSON formatting in strings.json 2025-11-22 17:43:32 +01:00
Ludovic BOUÉ abafd20a3a Merge branch 'dev' into gj-20250705-02 2025-11-22 17:41:15 +01:00
G Johansson d5c244395c Fix reproduce state 2025-08-03 10:31:23 +00:00
G Johansson eeaa7dfb4d Add to demo 2025-08-03 10:31:23 +00:00
G Johansson 6c64615e0f Add tests 2025-08-03 10:31:23 +00:00
G Johansson 7379de033f Add target temp range to water_heater 2025-08-03 10:31:23 +00:00
2791 changed files with 35249 additions and 195784 deletions
-1
View File
@@ -13,7 +13,6 @@ core: &core
# Our base platforms, that are used by other integrations
base_platforms: &base_platforms
- homeassistant/components/ai_task/**
- homeassistant/components/air_quality/**
- homeassistant/components/alarm_control_panel/**
- homeassistant/components/assist_satellite/**
+1
View File
@@ -27,6 +27,7 @@
"charliermarsh.ruff",
"ms-python.pylint",
"ms-python.vscode-pylance",
"visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml",
"esbenp.prettier-vscode",
"GitHub.vscode-pull-request-github",
-3
View File
@@ -51,9 +51,6 @@ rules:
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+
+101 -161
View File
@@ -14,9 +14,6 @@ env:
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2025.12.0"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
init:
@@ -24,16 +21,18 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
outputs:
architectures: ${{ steps.info.outputs.architectures }}
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
architectures: ${{ env.ARCHITECTURES }}
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -70,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: translations
path: translations.tar.gz
@@ -80,7 +79,7 @@ jobs:
name: Build ${{ matrix.arch }} base core image
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
@@ -89,18 +88,13 @@ jobs:
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -111,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -122,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -169,7 +163,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations
@@ -190,60 +184,16 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- &install_cosign
name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build variables
id: vars
shell: bash
run: |
echo "base_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ env.BASE_IMAGE_VERSION }}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${{ steps.vars.outputs.base_image }}"
- name: Verify cache image signature
id: cache
continue-on-error: true
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${{ steps.vars.outputs.cache_image }}"
# home-assistant/builder doesn't support sha pinning
- name: Build base image
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: home-assistant/builder@2025.09.0
with:
context: .
file: ./Dockerfile
platforms: ${{ steps.vars.outputs.platform }}
push: true
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
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
run: |
cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
args: |
$BUILD_ARGS \
--${{ matrix.arch }} \
--cosign \
--target /data \
--generic ${{ needs.init.outputs.version }}
build_machine:
name: Build ${{ matrix.machine }} machine core image
@@ -273,7 +223,7 @@ jobs:
- green
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set build additional args
run: |
@@ -295,7 +245,7 @@ jobs:
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@2025.11.0
uses: home-assistant/builder@2025.09.0
with:
args: |
$BUILD_ARGS \
@@ -311,7 +261,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -354,7 +304,13 @@ jobs:
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- *install_cosign
- name: Checkout the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.2.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -364,104 +320,88 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify architecture image signatures
- name: Build Meta Image
shell: bash
run: |
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do
echo "Verifying ${arch} image signature..."
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
done
echo "✓ All images verified successfully"
export DOCKER_CLI_EXPERIMENTAL=enabled
# Generate all Docker tags based on version string
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# Examples:
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
tags: |
type=raw,value=${{ needs.init.outputs.version }},priority=9999
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
function create_manifest() {
local tag_l=${1}
local tag_r=${2}
local registry=${{ matrix.registry }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
docker manifest create "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \
"${registry}/aarch64-homeassistant:${tag_r}"
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
run: |
# Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do
if docker buildx imagetools create \
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
done
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \
--os linux --arch amd64
- name: Create and push multi-arch manifests
shell: bash
run: |
# Build list of architecture images dynamically
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.version }}")
done
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/aarch64-homeassistant:${tag_r}" \
--os linux --arch arm64 --variant=v8
# Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${{ steps.meta.outputs.tags }}"
for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}")
done
docker manifest push --purge "${registry}/home-assistant:${tag_l}"
cosign sign --yes "${registry}/home-assistant:${tag_l}"
}
# Create manifest with ALL tags in a single operation (much faster!)
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
function validate_image() {
local image=${1}
if ! cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp https://github.com/home-assistant/core/.* "${image}"; then
echo "Invalid signature!"
exit 1
fi
}
# Sign each tag separately (signing requires individual tag names)
echo "Signing all tags..."
for tag in "${TAGS[@]}"; do
echo "Signing ${tag}"
cosign sign --yes "${tag}"
done
function push_dockerhub() {
local image=${1}
local tag=${2}
echo "All manifests created and signed successfully"
docker tag "ghcr.io/home-assistant/${image}:${tag}" "docker.io/homeassistant/${image}:${tag}"
docker push "docker.io/homeassistant/${image}:${tag}"
cosign sign --yes "docker.io/homeassistant/${image}:${tag}"
}
# Pull images from github container registry and verify signature
docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then
# Upload images to dockerhub
push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}"
fi
# Create version tag
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
# Create general tags
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
create_manifest "dev" "${{ needs.init.outputs.version }}"
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
create_manifest "beta" "${{ needs.init.outputs.version }}"
create_manifest "rc" "${{ needs.init.outputs.version }}"
else
create_manifest "stable" "${{ needs.init.outputs.version }}"
create_manifest "latest" "${{ needs.init.outputs.version }}"
create_manifest "beta" "${{ needs.init.outputs.version }}"
create_manifest "rc" "${{ needs.init.outputs.version }}"
# Create series version tag (e.g. 2021.6)
v="${{ needs.init.outputs.version }}"
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
fi
build_python:
name: Build PyPi package
@@ -474,15 +414,15 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations
@@ -519,7 +459,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -551,7 +491,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
+12 -12
View File
@@ -40,9 +40,9 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.2"
DEFAULT_PYTHON: "3.13.11"
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
HA_SHORT_VERSION: "2025.12"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -99,7 +99,7 @@ jobs:
steps:
- &checkout
name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: |
@@ -257,13 +257,13 @@ jobs:
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: &actions-setup-python actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: &key-pre-commit-venv >-
@@ -304,7 +304,7 @@ jobs:
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-restore actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
fail-on-cache-miss: true
@@ -511,7 +511,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -534,7 +534,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -864,7 +864,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: pytest_buckets
- &compile-english-translations
@@ -1188,7 +1188,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with:
fail_ci_if_error: true
flags: full-suite
@@ -1313,7 +1313,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+3 -3
View File
@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
category: "/language:python"
@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
with:
model: openai/gpt-4o
system-prompt: |
@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
with:
model: openai/gpt-4o-mini
system-prompt: |
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
+15 -8
View File
@@ -28,14 +28,16 @@ jobs:
name: Initialize wheels builder
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
outputs:
architectures: ${{ steps.info.outputs.architectures }}
steps:
- &checkout
name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -48,6 +50,10 @@ jobs:
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -r requirements.txt
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master
- name: Create requirements_diff file
run: |
if [[ ${{ github.event_name }} =~ (schedule|workflow_dispatch) ]]; then
@@ -74,7 +80,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: env_file
path: ./.env_file
@@ -108,10 +114,9 @@ jobs:
fail-fast: false
matrix: &matrix-build
abi: ["cp313", "cp314"]
arch: ["amd64", "aarch64"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- arch: amd64
os: ubuntu-latest
- os: ubuntu-latest
- arch: aarch64
os: ubuntu-24.04-arm
steps:
@@ -119,7 +124,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: env_file
@@ -135,8 +140,9 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: &home-assistant-wheels home-assistant/wheels@2025.10.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -177,6 +183,7 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: *home-assistant-wheels
with:
-1
View File
@@ -92,7 +92,6 @@ pip-selfcheck.json
venv
.venv
Pipfile*
uv.lock
share/*
/Scripts/
+1 -1
View File
@@ -94,7 +94,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(script/hassfest/(metadata|docker)\.py|homeassistant/const\.py$|pyproject\.toml)$
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
- id: hassfest-mypy-config
name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config
+1 -2
View File
@@ -120,6 +120,7 @@ homeassistant.components.blueprint.*
homeassistant.components.bluesound.*
homeassistant.components.bluetooth.*
homeassistant.components.bluetooth_adapters.*
homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
homeassistant.components.bosch_alarm.*
@@ -187,7 +188,6 @@ homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
homeassistant.components.energenie_power_sockets.*
homeassistant.components.energy.*
homeassistant.components.energyid.*
homeassistant.components.energyzero.*
homeassistant.components.enigma2.*
homeassistant.components.enphase_envoy.*
@@ -567,7 +567,6 @@ homeassistant.components.wake_word.*
homeassistant.components.wallbox.*
homeassistant.components.waqi.*
homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.*
homeassistant.components.weather.*
homeassistant.components.webhook.*
Generated
+14 -53
View File
@@ -69,12 +69,8 @@ build.json @home-assistant/supervisor
/tests/components/airly/ @bieniu
/homeassistant/components/airnow/ @asymworks
/tests/components/airnow/ @asymworks
/homeassistant/components/airobot/ @mettolen
/tests/components/airobot/ @mettolen
/homeassistant/components/airos/ @CoMPaTech
/tests/components/airos/ @CoMPaTech
/homeassistant/components/airpatrol/ @antondalgren
/tests/components/airpatrol/ @antondalgren
/homeassistant/components/airq/ @Sibgatulin @dl2080
/tests/components/airq/ @Sibgatulin @dl2080
/homeassistant/components/airthings/ @danielhiversen @LaStrada
@@ -123,8 +119,6 @@ build.json @home-assistant/supervisor
/tests/components/androidtv/ @JeffLIrion @ollo69
/homeassistant/components/androidtv_remote/ @tronikos @Drafteed
/tests/components/androidtv_remote/ @tronikos @Drafteed
/homeassistant/components/anglian_water/ @pantherale0
/tests/components/anglian_water/ @pantherale0
/homeassistant/components/anova/ @Lash-L
/tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex
@@ -187,8 +181,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/automation/ @home-assistant/core
/tests/components/automation/ @home-assistant/core
/homeassistant/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
/homeassistant/components/awair/ @ahayworth @danielsjf
/tests/components/awair/ @ahayworth @danielsjf
/homeassistant/components/aws_s3/ @tomasbedrich
/tests/components/aws_s3/ @tomasbedrich
/homeassistant/components/axis/ @Kane610
@@ -220,8 +214,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/homeassistant/components/bluemaestro/ @bdraco
@@ -308,8 +302,8 @@ build.json @home-assistant/supervisor
/tests/components/config/ @home-assistant/core
/homeassistant/components/configurator/ @home-assistant/core
/tests/components/configurator/ @home-assistant/core
/homeassistant/components/control4/ @lawtancool @davidrecordon
/tests/components/control4/ @lawtancool @davidrecordon
/homeassistant/components/control4/ @lawtancool
/tests/components/control4/ @lawtancool
/homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/cookidoo/ @miaucl
@@ -395,8 +389,6 @@ build.json @home-assistant/supervisor
/tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duke_energy/ @hunterjm
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd
@@ -420,8 +412,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt
/homeassistant/components/egauge/ @neggert
/tests/components/egauge/ @neggert
/homeassistant/components/eheimdigital/ @autinerd
/tests/components/eheimdigital/ @autinerd
/homeassistant/components/ekeybionyx/ @richardpolzer
@@ -456,15 +446,13 @@ build.json @home-assistant/supervisor
/tests/components/energenie_power_sockets/ @gnumpi
/homeassistant/components/energy/ @home-assistant/core
/tests/components/energy/ @home-assistant/core
/homeassistant/components/energyid/ @JrtPec @Molier
/tests/components/energyid/ @JrtPec @Molier
/homeassistant/components/energyzero/ @klaasnicolaas
/tests/components/energyzero/ @klaasnicolaas
/homeassistant/components/enigma2/ @autinerd
/tests/components/enigma2/ @autinerd
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
/homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99
@@ -480,8 +468,6 @@ build.json @home-assistant/supervisor
/tests/components/escea/ @lazdavila
/homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco
/tests/components/esphome/ @jesserockz @kbx81 @bdraco
/homeassistant/components/essent/ @jaapp
/tests/components/essent/ @jaapp
/homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99
/homeassistant/components/event/ @home-assistant/core
@@ -516,8 +502,6 @@ build.json @home-assistant/supervisor
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP
/tests/components/firmata/ @DaAwesomeP
/homeassistant/components/fish_audio/ @noambav
/tests/components/fish_audio/ @noambav
/homeassistant/components/fitbit/ @allenporter
/tests/components/fitbit/ @allenporter
/homeassistant/components/fivem/ @Sander0542
@@ -532,8 +516,6 @@ build.json @home-assistant/supervisor
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -547,8 +529,6 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
@@ -579,8 +559,6 @@ build.json @home-assistant/supervisor
/tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
/homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core
@@ -609,8 +587,6 @@ build.json @home-assistant/supervisor
/tests/components/goodwe/ @mletenay @starkillerOG
/homeassistant/components/google/ @allenporter
/tests/components/google/ @allenporter
/homeassistant/components/google_air_quality/ @Thomas55555
/tests/components/google_air_quality/ @Thomas55555
/homeassistant/components/google_assistant/ @home-assistant/cloud
/tests/components/google_assistant/ @home-assistant/cloud
/homeassistant/components/google_assistant_sdk/ @tronikos
@@ -661,8 +637,6 @@ build.json @home-assistant/supervisor
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
/tests/components/hassio/ @home-assistant/supervisor
/homeassistant/components/hdfury/ @glenndehaan
/tests/components/hdfury/ @glenndehaan
/homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran
@@ -670,8 +644,7 @@ build.json @home-assistant/supervisor
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64 @ptarjan
/tests/components/hikvision/ @mezz64 @ptarjan
/homeassistant/components/hikvision/ @mezz64
/homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead
@@ -723,8 +696,6 @@ build.json @home-assistant/supervisor
/tests/components/huawei_lte/ @scop @fphammerle
/homeassistant/components/hue/ @marcelveldt
/tests/components/hue/ @marcelveldt
/homeassistant/components/hue_ble/ @flip-dots
/tests/components/hue_ble/ @flip-dots
/homeassistant/components/huisbaasje/ @dennisschroer
/tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
@@ -800,8 +771,6 @@ build.json @home-assistant/supervisor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
@@ -1172,8 +1141,6 @@ build.json @home-assistant/supervisor
/tests/components/open_router/ @joostlek
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w @firstof9
/tests/components/openevse/ @c00w @firstof9
/homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen
@@ -1205,8 +1172,8 @@ build.json @home-assistant/supervisor
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1373,8 +1340,8 @@ build.json @home-assistant/supervisor
/tests/components/ring/ @sdb9696
/homeassistant/components/risco/ @OnFreund
/tests/components/risco/ @OnFreund
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @Lash-L @allenporter
@@ -1703,8 +1670,8 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_train/ @gjohansson-ST
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
/tests/components/transmission/ @engrbm87 @JPHutchins
/homeassistant/components/trend/ @jpbede
/tests/components/trend/ @jpbede
/homeassistant/components/triggercmd/ @rvmey
@@ -1780,7 +1747,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel
/tests/components/vivotek/ @HarlemSquirrel
/homeassistant/components/vizio/ @raman325
/tests/components/vizio/ @raman325
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
@@ -1805,12 +1771,9 @@ build.json @home-assistant/supervisor
/tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger
@@ -1823,8 +1786,6 @@ build.json @home-assistant/supervisor
/tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner
/homeassistant/components/web_rtc/ @home-assistant/core
/tests/components/web_rtc/ @home-assistant/core
/homeassistant/components/webdav/ @jpbede
/tests/components/webdav/ @jpbede
/homeassistant/components/webhook/ @home-assistant/core
Generated
+15 -16
View File
@@ -4,33 +4,32 @@
ARG BUILD_FROM
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 \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
ARG QEMU_CPU
# Home Assistant S6-Overlay
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.9.17
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.9.6
WORKDIR /usr/src
+11 -8
View File
@@ -35,22 +35,25 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
USER vscode
COPY .python-version ./
RUN uv python install
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN --mount=type=bind,source=.python-version,target=.python-version \
uv python install \
&& uv venv $VIRTUAL_ENV
RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
&& uv pip install -e ~/hass-release/
# Install Python dependencies from requirements
RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
--mount=type=bind,source=homeassistant/package_constraints.txt,target=homeassistant/package_constraints.txt \
--mount=type=bind,source=requirements_test.txt,target=requirements_test.txt \
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
uv pip install -r requirements.txt -r requirements_test.txt
COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
RUN uv pip install -r requirements.txt
COPY requirements_test.txt requirements_test_pre_commit.txt ./
RUN uv pip install -r requirements_test.txt
WORKDIR /workspaces
+16
View File
@@ -0,0 +1,16 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*
labels:
io.hass.type: core
org.opencontainers.image.title: Home Assistant
org.opencontainers.image.description: Open-source home automation platform running on Python 3
org.opencontainers.image.source: https://github.com/home-assistant/core
org.opencontainers.image.authors: The Home Assistant Authors
org.opencontainers.image.url: https://www.home-assistant.io/
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
org.opencontainers.image.licenses: Apache-2.0
-2
View File
@@ -402,8 +402,6 @@ class AuthManager:
if user.is_owner:
raise ValueError("Unable to deactivate the owner")
await self._store.async_deactivate_user(user)
for refresh_token in list(user.refresh_tokens.values()):
self.async_remove_refresh_token(refresh_token)
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
"""Remove credentials."""
-2
View File
@@ -7,7 +7,6 @@ from typing import Any, Final
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_CORE_CONFIG_UPDATE,
EVENT_LABS_UPDATED,
EVENT_LOVELACE_UPDATED,
EVENT_PANELS_UPDATED,
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
@@ -46,7 +45,6 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED,
EVENT_LABEL_REGISTRY_UPDATED,
EVENT_LABS_UPDATED,
EVENT_CATEGORY_REGISTRY_UPDATED,
EVENT_FLOOR_REGISTRY_UPDATED,
}
+6 -9
View File
@@ -624,16 +624,13 @@ async def async_enable_logging(
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
def rename_old_file() -> None:
"""Rename old log file in executor."""
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path
@@ -1003,7 +1000,7 @@ class _WatchPendingSetups:
# We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done
# once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up
_LOGGER.warning(
"Waiting for integrations to complete setup: %s",
"Waiting on integrations to complete setup: %s",
self._setup_started,
)
-1
View File
@@ -2,7 +2,6 @@
"domain": "google",
"name": "Google",
"integrations": [
"google_air_quality",
"google_assistant",
"google_assistant_sdk",
"google_cloud",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "philips",
"name": "Philips",
"integrations": ["dynalite", "hue", "hue_ble", "philips_js"]
"integrations": ["dynalite", "hue", "philips_js"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "raspberry_pi",
"name": "Raspberry Pi",
"integrations": ["raspberry_pi", "rpi_power", "remote_rpi_gpio"]
"integrations": ["raspberry_pi", "rpi_camera", "rpi_power", "remote_rpi_gpio"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==5.0.0"]
"requirements": ["accuweather==4.2.2"]
}
@@ -9,16 +9,15 @@ from actron_neo_api import (
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER, DOMAIN
from .const import _LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
ActronAirSystemCoordinator,
)
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
PLATFORM = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -30,13 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronAirAPIError as err:
raise ConfigEntryNotReady from err
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
@@ -50,10 +48,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators=system_coordinators,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)
+52 -12
View File
@@ -15,10 +15,12 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
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 .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity
PARALLEL_UPDATES = 0
@@ -54,7 +56,8 @@ async def async_setup_entry(
for coordinator in system_coordinators.values():
status = coordinator.data
entities.append(ActronSystemClimate(coordinator))
name = status.ac_system.system_name
entities.append(ActronSystemClimate(coordinator, name))
entities.extend(
ActronZoneClimate(coordinator, zone)
@@ -65,9 +68,10 @@ async def async_setup_entry(
async_add_entities(entities)
class ActronAirClimateEntity(ClimateEntity):
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
"""Base class for Actron Air climate entities."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -79,17 +83,43 @@ class ActronAirClimateEntity(ClimateEntity):
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
class ActronSystemClimate(BaseClimateEntity):
"""Representation of the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._attr_unique_id = self._serial_number
super().__init__(coordinator, name)
serial_number = coordinator.serial_number
self._attr_unique_id = serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=self._status.ac_system.system_name,
manufacturer="Actron Air",
model_id=self._status.ac_system.master_wc_model,
sw_version=self._status.ac_system.master_wc_firmware_version,
serial_number=serial_number,
)
@property
def min_temp(self) -> float:
@@ -118,7 +148,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.base_fan_mode
fan_mode = self._status.user_aircon_settings.fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property
@@ -138,7 +168,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -152,7 +182,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
class ActronZoneClimate(BaseClimateEntity):
"""Representation of a zone within the Actron Air system."""
_attr_supported_features = (
@@ -167,8 +197,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
zone: ActronAirZone,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, zone)
self._attr_unique_id: str = self._zone_identifier
super().__init__(coordinator, zone.title)
serial_number = coordinator.serial_number
self._zone_id: int = zone.zone_id
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, serial_number),
)
@property
def min_temp(self) -> float:
@@ -216,4 +256,4 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
await self._zone.set_temperature(temperature=kwargs["temperature"])
@@ -1,12 +1,11 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from collections.abc import Mapping
from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
@@ -96,16 +95,8 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
@@ -123,21 +114,6 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
del self.login_task
return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -5,24 +5,16 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN
from .const import _LOGGER
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@@ -37,6 +29,9 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
@@ -64,20 +59,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
async def _async_update_data(self) -> ActronAirStatus:
"""Fetch updates and merge incremental changes into the full state."""
try:
await self.api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": repr(err)},
) from err
await self.api.update_status()
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status
@@ -1,63 +0,0 @@
"""Base entity classes for Actron Air integration."""
from actron_neo_api import ActronAirZone
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
"""Base class for Actron Air entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return not self.coordinator.is_device_stale()
class ActronAirAcEntity(ActronAirEntity):
"""Base class for Actron Air entities."""
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._serial_number)},
name=coordinator.data.ac_system.system_name,
manufacturer="Actron Air",
model_id=coordinator.data.ac_system.master_wc_model,
sw_version=coordinator.data.ac_system.master_wc_firmware_version,
serial_number=self._serial_number,
)
class ActronAirZoneEntity(ActronAirEntity):
"""Base class for Actron Air zone entities."""
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirZone,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._zone_id: int = zone.zone_id
self._zone_identifier = f"{self._serial_number}_zone_{zone.zone_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._zone_identifier)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, self._serial_number),
)
@@ -1,30 +0,0 @@
{
"entity": {
"switch": {
"away_mode": {
"default": "mdi:home-export-outline",
"state": {
"off": "mdi:home-import-outline"
}
},
"continuous_fan": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
},
"quiet_mode": {
"default": "mdi:volume-low",
"state": {
"off": "mdi:volume-high"
}
},
"turbo_mode": {
"default": "mdi:fan-plus",
"state": {
"off": "mdi:fan"
}
}
}
}
}
@@ -10,8 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/actron_air",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.4.1"]
"requirements": ["actron-neo-api==0.1.87"]
}
@@ -36,7 +36,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: todo
# Gold
@@ -2,12 +2,10 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start authentication flow",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
"oauth2_error": "Failed to start OAuth2 flow"
},
"error": {
"oauth2_error": "Failed to start authentication flow. Please try again later."
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
@@ -18,42 +16,14 @@
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error"
},
"reauth_confirm": {
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"timeout": {
"data": {},
"description": "The authentication process timed out. Please try again.",
"title": "Authentication timeout"
"description": "The authorization process timed out. Please try again.",
"title": "Authorization timeout"
},
"user": {
"title": "Actron Air Authentication"
"title": "Actron Air OAuth2 Authorization"
}
}
},
"entity": {
"switch": {
"away_mode": {
"name": "Away mode"
},
"continuous_fan": {
"name": "Continuous fan"
},
"quiet_mode": {
"name": "Quiet mode"
},
"turbo_mode": {
"name": "Turbo mode"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}
}
}
@@ -1,102 +0,0 @@
"""Switch platform for Actron Air integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Actron Air switch entities."""
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
ActronAirSwitchEntityDescription(
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air switch entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirSwitch(coordinator, description)
for coordinator in system_coordinators.values()
for description in SWITCHES
if description.is_supported_fn(coordinator)
)
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Actron Air switch."""
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling",
"loggers": ["adax", "adax_local"],
"requirements": ["adax==0.4.0", "Adax-local==0.3.0"]
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
}
@@ -4,7 +4,6 @@
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/advantage_air",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["advantage_air"],
"requirements": ["advantage-air==0.4.4"]
@@ -4,7 +4,6 @@
"codeowners": ["@Noltari"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aemet",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.6.4"]
@@ -4,7 +4,6 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aftership",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["pyaftership==21.11.0"]
}
@@ -4,7 +4,6 @@
"codeowners": ["@ispysoftware"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/agent_dvr",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["agent"],
"requirements": ["agent-py==0.0.24"]
+4 -4
View File
@@ -101,8 +101,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
_validate_structure_fields,
),
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector(
{"accept": ["*/*"], "multiple": True}
vol.Optional(ATTR_ATTACHMENTS): vol.All(
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
),
}
),
@@ -118,8 +118,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string,
vol.Optional(ATTR_ATTACHMENTS): selector.MediaSelector(
{"accept": ["*/*"], "multiple": True}
vol.Optional(ATTR_ATTACHMENTS): vol.All(
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
),
}
),
@@ -4,7 +4,6 @@
"codeowners": ["@asymworks"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airnow",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
"requirements": ["pyairnow==1.3.1"]
@@ -1,34 +0,0 @@
"""The Airobot integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
"""Set up Airobot from a config entry."""
coordinator = AirobotDataUpdateCoordinator(hass, entry)
# Fetch initial data so we have data when entities subscribe
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: AirobotConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,89 +0,0 @@
"""Button platform for Airobot integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pyairobotrest.exceptions import (
AirobotConnectionError,
AirobotError,
AirobotTimeoutError,
)
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotButtonEntityDescription(ButtonEntityDescription):
"""Describes Airobot button entity."""
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
AirobotButtonEntityDescription(
key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot button entities."""
coordinator = entry.runtime_data
async_add_entities(
AirobotButton(coordinator, description) for description in BUTTON_TYPES
)
class AirobotButton(AirobotEntity, ButtonEntity):
"""Representation of an Airobot button."""
entity_description: AirobotButtonEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotButtonEntityDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
except (AirobotConnectionError, AirobotTimeoutError):
# Connection errors during reboot are expected as device restarts
pass
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
translation_placeholders={"button": self.entity_description.key},
) from err
-168
View File
@@ -1,168 +0,0 @@
"""Climate platform for Airobot thermostat."""
from __future__ import annotations
from typing import Any
from pyairobotrest.const import (
MODE_AWAY,
MODE_HOME,
SETPOINT_TEMP_MAX,
SETPOINT_TEMP_MIN,
)
from pyairobotrest.exceptions import AirobotError
from pyairobotrest.models import ThermostatSettings, ThermostatStatus
from homeassistant.components.climate import (
PRESET_AWAY,
PRESET_BOOST,
PRESET_HOME,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .entity import AirobotEntity
PARALLEL_UPDATES = 1
_PRESET_MODE_2_MODE = {
PRESET_AWAY: MODE_AWAY,
PRESET_HOME: MODE_HOME,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot climate platform."""
coordinator = entry.runtime_data
async_add_entities([AirobotClimate(coordinator)])
class AirobotClimate(AirobotEntity, ClimateEntity):
"""Representation of an Airobot thermostat."""
_attr_name = None
_attr_translation_key = "thermostat"
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [HVACMode.HEAT]
_attr_preset_modes = [PRESET_HOME, PRESET_AWAY, PRESET_BOOST]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_min_temp = SETPOINT_TEMP_MIN
_attr_max_temp = SETPOINT_TEMP_MAX
@property
def _status(self) -> ThermostatStatus:
"""Get status from coordinator data."""
return self.coordinator.data.status
@property
def _settings(self) -> ThermostatSettings:
"""Get settings from coordinator data."""
return self.coordinator.data.settings
@property
def current_temperature(self) -> float | None:
"""Return the current temperature.
If floor temperature is available, thermostat is set up for floor heating.
"""
if self._status.temp_floor is not None:
return self._status.temp_floor
return self._status.temp_air
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
return self._status.hum_air
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
if self._settings.is_home_mode:
return self._settings.setpoint_temp
return self._settings.setpoint_temp_away
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
if self._status.is_heating:
return HVACMode.HEAT
return HVACMode.OFF
@property
def hvac_action(self) -> HVACAction:
"""Return current HVAC action."""
if self._status.is_heating:
return HVACAction.HEATING
return HVACAction.IDLE
@property
def preset_mode(self) -> str | None:
"""Return current preset mode."""
if self._settings.setting_flags.boost_enabled:
return PRESET_BOOST
if self._settings.is_home_mode:
return PRESET_HOME
return PRESET_AWAY
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs[ATTR_TEMPERATURE]
try:
if self._settings.is_home_mode:
await self.coordinator.client.set_home_temperature(float(temperature))
else:
await self.coordinator.client.set_away_temperature(float(temperature))
except AirobotError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
translation_placeholders={"temperature": str(temperature)},
) from err
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode.
This thermostat only supports HEAT mode. The climate platform validates
that only supported modes are passed, so this method is a no-op.
"""
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
try:
if preset_mode == PRESET_BOOST:
# Enable boost mode
if not self._settings.setting_flags.boost_enabled:
await self.coordinator.client.set_boost_mode(True)
else:
# Disable boost mode if it's enabled
if self._settings.setting_flags.boost_enabled:
await self.coordinator.client.set_boost_mode(False)
# Set the mode (HOME or AWAY)
await self.coordinator.client.set_mode(_PRESET_MODE_2_MODE[preset_mode])
except AirobotError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_preset_mode_failed",
translation_placeholders={"preset_mode": preset_mode},
) from err
await self.coordinator.async_request_refresh()
@@ -1,270 +0,0 @@
"""Config flow for the Airobot integration."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any
from pyairobotrest import AirobotClient
from pyairobotrest.exceptions import (
AirobotAuthError,
AirobotConnectionError,
AirobotError,
AirobotTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow as BaseConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
@dataclass
class DeviceInfo:
"""Device information."""
title: str
device_id: str
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> DeviceInfo:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
client = AirobotClient(
host=data[CONF_HOST],
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
session=session,
)
try:
# Try to fetch data to validate connection and authentication
status = await client.get_statuses()
settings = await client.get_settings()
except AirobotAuthError as err:
raise InvalidAuth from err
except (AirobotConnectionError, AirobotTimeoutError, AirobotError) as err:
raise CannotConnect from err
# Use device name or device ID as title
title = settings.device_name or status.device_id
return DeviceInfo(title=title, device_id=status.device_id)
class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airobot."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_host: str | None = None
self._discovered_mac: str | None = None
self._discovered_device_id: str | None = None
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
# Store the discovered IP address and MAC
self._discovered_host = discovery_info.ip
self._discovered_mac = discovery_info.macaddress
# Extract device_id from hostname (format: airobot-thermostat-t01xxxxxx)
hostname = discovery_info.hostname.lower()
device_id = hostname.replace("airobot-thermostat-", "").upper()
self._discovered_device_id = device_id
# Set unique_id to device_id for duplicate detection
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
# Show the confirmation form
return await self.async_step_dhcp_confirm()
async def async_step_dhcp_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle DHCP discovery confirmation - ask for credentials only."""
errors: dict[str, str] = {}
if user_input is not None:
# Combine discovered host and device_id with user-provided password
data = {
CONF_HOST: self._discovered_host,
CONF_USERNAME: self._discovered_device_id,
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
info = await validate_input(self.hass, data)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Store MAC address in config entry data
if self._discovered_mac:
data[CONF_MAC] = self._discovered_mac
return self.async_create_entry(title=info.title, data=data)
# Only ask for password since we already have the device_id from discovery
return self.async_show_form(
step_id="dhcp_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={
"host": self._discovered_host or "",
"device_id": self._discovered_device_id or "",
},
errors=errors,
)
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:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Use device ID as unique ID to prevent duplicates
await self.async_set_unique_id(info.device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info.title, data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Verify the device ID matches the existing config entry
await self.async_set_unique_id(info.device_id)
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=user_input,
title=info.title,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, reconfigure_entry.data
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
# Combine existing data with new password
data = {
CONF_HOST: reauth_entry.data[CONF_HOST],
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
await validate_input(self.hass, data)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={
"username": reauth_entry.data[CONF_USERNAME],
"host": reauth_entry.data[CONF_HOST],
},
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
@@ -1,5 +0,0 @@
"""Constants for the Airobot integration."""
from typing import Final
DOMAIN: Final = "airobot"
@@ -1,68 +0,0 @@
"""Coordinator for the Airobot integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from pyairobotrest import AirobotClient
from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .models import AirobotData
_LOGGER = logging.getLogger(__name__)
# Update interval - thermostat measures air every 30 seconds
UPDATE_INTERVAL = timedelta(seconds=30)
type AirobotConfigEntry = ConfigEntry[AirobotDataUpdateCoordinator]
class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
"""Class to manage fetching Airobot data."""
config_entry: AirobotConfigEntry
def __init__(self, hass: HomeAssistant, entry: AirobotConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
session = async_get_clientsession(hass)
self.client = AirobotClient(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
async def _async_update_data(self) -> AirobotData:
"""Fetch data from API endpoint."""
try:
status = await self.client.get_statuses()
settings = await self.client.get_settings()
except AirobotAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from err
except AirobotConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from err
return AirobotData(status=status, settings=settings)
@@ -1,38 +0,0 @@
"""Diagnostics support for Airobot."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry
TO_REDACT_CONFIG = [CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirobotConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
# Build device capabilities info
device_capabilities = None
if coordinator.data:
device_capabilities = {
"has_floor_sensor": coordinator.data.status.has_floor_sensor,
"has_co2_sensor": coordinator.data.status.has_co2_sensor,
"hw_version": coordinator.data.status.hw_version,
"fw_version": coordinator.data.status.fw_version,
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT_CONFIG),
"device_capabilities": device_capabilities,
"status": asdict(coordinator.data.status) if coordinator.data else None,
"settings": asdict(coordinator.data.settings) if coordinator.data else None,
}
@@ -1,42 +0,0 @@
"""Base entity for Airobot integration."""
from __future__ import annotations
from homeassistant.const import CONF_MAC
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
"""Base class for Airobot entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
status = coordinator.data.status
settings = coordinator.data.settings
self._attr_unique_id = status.device_id
connections = set()
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
connections.add((CONNECTION_NETWORK_MAC, mac))
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, status.device_id)},
connections=connections,
name=settings.device_name or status.device_id,
manufacturer="Airobot",
model="Thermostat",
model_id="TE1",
sw_version=str(status.fw_version),
hw_version=str(status.hw_version),
)
@@ -1,9 +0,0 @@
{
"entity": {
"number": {
"hysteresis_band": {
"default": "mdi:delta"
}
}
}
}
@@ -1,17 +0,0 @@
{
"domain": "airobot",
"name": "Airobot",
"codeowners": ["@mettolen"],
"config_flow": true,
"dhcp": [
{
"hostname": "airobot-thermostat-*"
}
],
"documentation": "https://www.home-assistant.io/integrations/airobot",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "gold",
"requirements": ["pyairobotrest==0.2.0"]
}
@@ -1,15 +0,0 @@
"""Models for the Airobot integration."""
from __future__ import annotations
from dataclasses import dataclass
from pyairobotrest.models import ThermostatSettings, ThermostatStatus
@dataclass
class AirobotData:
"""Data from the Airobot coordinator."""
status: ThermostatStatus
settings: ThermostatSettings
@@ -1,99 +0,0 @@
"""Number platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pyairobotrest.const import HYSTERESIS_BAND_MAX, HYSTERESIS_BAND_MIN
from pyairobotrest.exceptions import AirobotError
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotNumberEntityDescription(NumberEntityDescription):
"""Describes Airobot number entity."""
value_fn: Callable[[AirobotDataUpdateCoordinator], float]
set_value_fn: Callable[[AirobotDataUpdateCoordinator, float], Awaitable[None]]
NUMBERS: tuple[AirobotNumberEntityDescription, ...] = (
AirobotNumberEntityDescription(
key="hysteresis_band",
translation_key="hysteresis_band",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=HYSTERESIS_BAND_MIN / 10.0,
native_max_value=HYSTERESIS_BAND_MAX / 10.0,
native_step=0.1,
value_fn=lambda coordinator: coordinator.data.settings.hysteresis_band,
set_value_fn=lambda coordinator, value: coordinator.client.set_hysteresis_band(
value
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot number platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotNumber(coordinator, description) for description in NUMBERS
)
class AirobotNumber(AirobotEntity, NumberEntity):
"""Representation of an Airobot number entity."""
entity_description: AirobotNumberEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> float:
"""Return the current value."""
return self.entity_description.value_fn(self.coordinator)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
try:
await self.entity_description.set_value_fn(self.coordinator, value)
except AirobotError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_value_failed",
translation_placeholders={"error": str(err)},
) from err
else:
await self.coordinator.async_request_refresh()
@@ -1,72 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register 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: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not use event subscriptions.
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: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Single device integration, no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: This integration doesn't have any cases where raising an issue is needed.
stale-devices:
status: exempt
comment: Single device integration, no stale device handling needed.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
-150
View File
@@ -1,150 +0,0 @@
"""Sensor platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from pyairobotrest.models import ThermostatStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from . import AirobotConfigEntry
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotSensorEntityDescription(SensorEntityDescription):
"""Describes Airobot sensor entity."""
value_fn: Callable[[ThermostatStatus], StateType | datetime]
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
uptime_to_stable_datetime = ignore_variance(
lambda value: utcnow().replace(microsecond=0) - timedelta(seconds=value),
timedelta(minutes=2),
)
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
AirobotSensorEntityDescription(
key="air_temperature",
translation_key="air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_air,
),
AirobotSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.hum_air,
),
AirobotSensorEntityDescription(
key="floor_temperature",
translation_key="floor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temp_floor,
supported_fn=lambda status: status.has_floor_sensor,
),
AirobotSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.co2,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="air_quality_index",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.aqi,
supported_fn=lambda status: status.has_co2_sensor,
),
AirobotSensorEntityDescription(
key="heating_uptime",
translation_key="heating_uptime",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.heating_uptime,
entity_registry_enabled_default=False,
),
AirobotSensorEntityDescription(
key="errors",
translation_key="errors",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.errors,
),
AirobotSensorEntityDescription(
key="device_uptime",
translation_key="device_uptime",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: uptime_to_stable_datetime(status.device_uptime),
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotSensor(coordinator, description)
for description in SENSOR_TYPES
if description.supported_fn(coordinator.data.status)
)
class AirobotSensor(AirobotEntity, SensorEntity):
"""Representation of an Airobot sensor."""
entity_description: AirobotSensorEntityDescription
def __init__(
self,
coordinator,
description: AirobotSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data.status)
@@ -1,105 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "Device ID does not match the existing configuration. Please use the correct device credentials."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"dhcp_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
},
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
},
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "Device ID"
},
"data_description": {
"host": "[%key:component::airobot::config::step::user::data_description::host%]",
"password": "[%key:component::airobot::config::step::user::data_description::password%]",
"username": "[%key:component::airobot::config::step::user::data_description::username%]"
},
"description": "Update your Airobot thermostat connection details. Note: The Device ID must remain the same as the original configuration."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "Device ID"
},
"data_description": {
"host": "The hostname or IP address of your Airobot thermostat.",
"password": "The thermostat password.",
"username": "The thermostat Device ID (e.g., T01XXXXXX)."
},
"description": "Enter your Airobot thermostat connection details. Find the Device ID and password in the thermostat settings menu under Connectivity → Mobile app."
}
}
},
"entity": {
"number": {
"hysteresis_band": {
"name": "Hysteresis band"
}
},
"sensor": {
"air_temperature": {
"name": "Air temperature"
},
"device_uptime": {
"name": "Device uptime"
},
"errors": {
"name": "Error count"
},
"floor_temperature": {
"name": "Floor temperature"
},
"heating_uptime": {
"name": "Heating uptime"
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."
},
"button_press_failed": {
"message": "Failed to press {button} button."
},
"connection_failed": {
"message": "Failed to communicate with device."
},
"set_preset_mode_failed": {
"message": "Failed to set preset mode to {preset_mode}."
},
"set_temperature_failed": {
"message": "Failed to set temperature to {temperature}."
},
"set_value_failed": {
"message": "Failed to set value: {error}"
}
}
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["airos==0.6.1"]
"requirements": ["airos==0.6.0"]
}
@@ -1,24 +0,0 @@
"""The AirPatrol integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import AirPatrolConfigEntry, AirPatrolDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: AirPatrolConfigEntry) -> bool:
"""Set up AirPatrol from a config entry."""
coordinator = AirPatrolDataUpdateCoordinator(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: AirPatrolConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,198 +0,0 @@
"""Climate platform for AirPatrol integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
SWING_OFF,
SWING_ON,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
AP_TO_HA_HVAC_MODES = {
"heat": HVACMode.HEAT,
"cool": HVACMode.COOL,
"off": HVACMode.OFF,
}
HA_TO_AP_HVAC_MODES = {value: key for key, value in AP_TO_HA_HVAC_MODES.items()}
AP_TO_HA_FAN_MODES = {
"min": FAN_LOW,
"max": FAN_HIGH,
"auto": FAN_AUTO,
}
HA_TO_AP_FAN_MODES = {value: key for key, value in AP_TO_HA_FAN_MODES.items()}
AP_TO_HA_SWING_MODES = {
"on": SWING_ON,
"off": SWING_OFF,
}
HA_TO_AP_SWING_MODES = {value: key for key, value in AP_TO_HA_SWING_MODES.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol climate entities."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolClimate(coordinator, unit_id)
for unit_id, unit in units.items()
if "climate" in unit
)
class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
"""AirPatrol climate entity."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF]
_attr_fan_modes = [FAN_LOW, FAN_HIGH, FAN_AUTO]
_attr_swing_modes = [SWING_ON, SWING_OFF]
_attr_min_temp = 16.0
_attr_max_temp = 30.0
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator, unit_id)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
@property
def params(self) -> dict[str, Any]:
"""Return the current parameters for the climate entity."""
return self.climate_data.get("ParametersData") or {}
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
if humidity := self.climate_data.get("RoomHumidity"):
return float(humidity)
return None
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if temp := self.climate_data.get("RoomTemp"):
return float(temp)
return None
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
if temp := self.params.get("PumpTemp"):
return float(temp)
return None
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
pump_power = self.params.get("PumpPower")
pump_mode = self.params.get("PumpMode")
if pump_power and pump_power == "on" and pump_mode:
return AP_TO_HA_HVAC_MODES.get(pump_mode)
return HVACMode.OFF
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_speed = self.params.get("FanSpeed")
if fan_speed:
return AP_TO_HA_FAN_MODES.get(fan_speed)
return None
@property
def swing_mode(self) -> str | None:
"""Return the current swing mode."""
swing = self.params.get("Swing")
if swing:
return AP_TO_HA_SWING_MODES.get(swing)
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
params = self.params.copy()
if ATTR_TEMPERATURE in kwargs:
temp = kwargs[ATTR_TEMPERATURE]
params["PumpTemp"] = f"{temp:.3f}"
await self._async_set_params(params)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
params = self.params.copy()
if hvac_mode == HVACMode.OFF:
params["PumpPower"] = "off"
else:
params["PumpPower"] = "on"
params["PumpMode"] = HA_TO_AP_HVAC_MODES.get(hvac_mode)
await self._async_set_params(params)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
params = self.params.copy()
params["FanSpeed"] = HA_TO_AP_FAN_MODES.get(fan_mode)
await self._async_set_params(params)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing mode."""
params = self.params.copy()
params["Swing"] = HA_TO_AP_SWING_MODES.get(swing_mode)
await self._async_set_params(params)
async def async_turn_on(self) -> None:
"""Turn the entity on."""
params = self.params.copy()
if mode := AP_TO_HA_HVAC_MODES.get(params["PumpMode"]):
await self.async_set_hvac_mode(mode)
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.async_set_hvac_mode(HVACMode.OFF)
async def _async_set_params(self, params: dict[str, Any]) -> None:
"""Set the unit to dry mode."""
new_climate_data = self.climate_data.copy()
new_climate_data["ParametersData"] = params
await self.coordinator.api.set_unit_climate_data(
self._unit_id, new_climate_data
)
await self.coordinator.async_request_refresh()
@@ -1,111 +0,0 @@
"""Config flow for the AirPatrol integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
async def validate_api(
hass: HomeAssistant, user_input: dict[str, str]
) -> tuple[str | None, str | None, dict[str, str]]:
"""Validate the API connection."""
errors: dict[str, str] = {}
session = async_get_clientsession(hass)
access_token = None
unique_id = None
try:
api = await AirPatrolAPI.authenticate(
session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except AirPatrolAuthenticationError:
errors["base"] = "invalid_auth"
except AirPatrolError:
errors["base"] = "cannot_connect"
else:
access_token = api.get_access_token()
unique_id = api.get_unique_id()
return (access_token, unique_id, errors)
class AirPatrolConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for AirPatrol."""
VERSION = 1
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:
access_token, unique_id, errors = await validate_api(self.hass, user_input)
if access_token and unique_id:
user_input[CONF_ACCESS_TOKEN] = access_token
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication with new credentials."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
if user_input:
access_token, unique_id, errors = await validate_api(self.hass, user_input)
if access_token and unique_id:
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_mismatch()
user_input[CONF_ACCESS_TOKEN] = access_token
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors
)
@@ -1,16 +0,0 @@
"""Constants for the AirPatrol integration."""
from datetime import timedelta
import logging
from airpatrol.api import AirPatrolAuthenticationError, AirPatrolError
from homeassistant.const import Platform
DOMAIN = "airpatrol"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=1)
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
@@ -1,100 +0,0 @@
"""Data update coordinator for AirPatrol."""
from __future__ import annotations
from typing import Any
from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
type AirPatrolConfigEntry = ConfigEntry[AirPatrolDataUpdateCoordinator]
class AirPatrolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Class to manage fetching AirPatrol data."""
config_entry: AirPatrolConfigEntry
api: AirPatrolAPI
def __init__(self, hass: HomeAssistant, config_entry: AirPatrolConfigEntry) -> None:
"""Initialize."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN.capitalize()} {config_entry.title}",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
async def _async_setup(self) -> None:
try:
await self._setup_client()
except AirPatrolError as api_err:
raise UpdateFailed(
f"Error communicating with AirPatrol API: {api_err}"
) from api_err
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Update unit data from AirPatrol API."""
return {unit_data["unit_id"]: unit_data for unit_data in await self._get_data()}
async def _get_data(self, retry: bool = False) -> list[dict[str, Any]]:
"""Fetch data from API."""
try:
return await self.api.get_data()
except AirPatrolAuthenticationError as auth_err:
if retry:
raise ConfigEntryAuthFailed(
"Authentication with AirPatrol failed"
) from auth_err
await self._update_token()
return await self._get_data(retry=True)
except AirPatrolError as err:
raise UpdateFailed(
f"Error communicating with AirPatrol API: {err}"
) from err
async def _update_token(self) -> None:
"""Refresh the AirPatrol API client and update the access token."""
session = async_get_clientsession(self.hass)
try:
self.api = await AirPatrolAPI.authenticate(
session,
self.config_entry.data[CONF_EMAIL],
self.config_entry.data[CONF_PASSWORD],
)
except AirPatrolAuthenticationError as auth_err:
raise ConfigEntryAuthFailed(
"Authentication with AirPatrol failed"
) from auth_err
self.hass.config_entries.async_update_entry(
self.config_entry,
data={
**self.config_entry.data,
CONF_ACCESS_TOKEN: self.api.get_access_token(),
},
)
async def _setup_client(self) -> None:
"""Set up the AirPatrol API client from stored access_token."""
session = async_get_clientsession(self.hass)
api = AirPatrolAPI(
session,
self.config_entry.data[CONF_ACCESS_TOKEN],
self.config_entry.unique_id,
)
try:
await api.get_data()
except AirPatrolAuthenticationError:
await self._update_token()
self.api = api
@@ -1,54 +0,0 @@
"""Base entity for AirPatrol integration."""
from __future__ import annotations
from typing import Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AirPatrolDataUpdateCoordinator
class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
"""Base entity for AirPatrol devices."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
) -> None:
"""Initialize the AirPatrol entity."""
super().__init__(coordinator)
self._unit_id = unit_id
device = coordinator.data[unit_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unit_id)},
name=device["name"],
manufacturer=device["manufacturer"],
model=device["model"],
serial_number=device["hwid"],
)
@property
def device_data(self) -> dict[str, Any]:
"""Return the device data."""
return self.coordinator.data[self._unit_id]
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data for this unit."""
return self.device_data["climate"]
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self._unit_id in self.coordinator.data
and "climate" in self.device_data
and self.climate_data is not None
)
@@ -1,11 +0,0 @@
{
"domain": "airpatrol",
"name": "AirPatrol",
"codeowners": ["@antondalgren"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airpatrol",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["airpatrol==0.1.0"]
}
@@ -1,65 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide custom actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities doesn't 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: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
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: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -1,89 +0,0 @@
"""Sensors for AirPatrol integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirPatrolSensorEntityDescription(SensorEntityDescription):
"""Describes AirPatrol sensor entity."""
data_field: str
SENSOR_DESCRIPTIONS = (
AirPatrolSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
data_field="RoomTemp",
),
AirPatrolSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
data_field="RoomHumidity",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol sensors."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolSensor(coordinator, unit_id, description)
for unit_id, unit in units.items()
for description in SENSOR_DESCRIPTIONS
if "climate" in unit and unit["climate"] is not None
)
class AirPatrolSensor(AirPatrolEntity, SensorEntity):
"""AirPatrol sensor entity."""
entity_description: AirPatrolSensorEntityDescription
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
description: AirPatrolSensorEntityDescription,
) -> None:
"""Initialize AirPatrol sensor."""
super().__init__(coordinator, unit_id)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}"
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if value := self.climate_data.get(self.entity_description.data_field):
return float(value)
return None
@@ -1,38 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Login credentials do not match the configured account"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "[%key:component::airpatrol::config::step::user::data_description::email%]",
"password": "[%key:component::airpatrol::config::step::user::data_description::password%]"
},
"description": "Reauthenticate with AirPatrol"
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Your AirPatrol email address",
"password": "Your AirPatrol password"
},
"description": "Connect to AirPatrol"
}
}
}
}
@@ -17,7 +17,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/airthings",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["airthings"],
"requirements": ["airthings-cloud==0.2.0"]
@@ -27,7 +27,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airthings-ble==1.2.0"]
}
@@ -4,7 +4,6 @@
"codeowners": ["@samsinnamon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airtouch4",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["airtouch4pyapi"],
"requirements": ["airtouch4pyapi==1.0.5"]
@@ -4,7 +4,6 @@
"codeowners": ["@danzel"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
"requirements": ["airtouch5py==0.3.0"]
@@ -9,8 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/airzone",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.4"]
"requirements": ["aioairzone==1.0.2"]
}
@@ -4,7 +4,6 @@
"codeowners": ["@Noltari"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.7.2"]
@@ -36,28 +36,5 @@
"alarm_trigger": {
"service": "mdi:bell-ring"
}
},
"triggers": {
"armed": {
"trigger": "mdi:shield"
},
"armed_away": {
"trigger": "mdi:shield-lock"
},
"armed_home": {
"trigger": "mdi:shield-home"
},
"armed_night": {
"trigger": "mdi:shield-moon"
},
"armed_vacation": {
"trigger": "mdi:shield-airplane"
},
"disarmed": {
"trigger": "mdi:shield-off"
},
"triggered": {
"trigger": "mdi:bell-ring"
}
}
}
@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -75,15 +71,6 @@
"message": "Arming requires a code but none was given for {entity_id}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"alarm_arm_away": {
"description": "Arms the alarm in the away mode.",
@@ -156,77 +143,5 @@
"name": "Trigger"
}
},
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm armed"
},
"armed_away": {
"description": "Triggers after one or more alarms become armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm armed away"
},
"armed_home": {
"description": "Triggers after one or more alarms become armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm armed home"
},
"armed_night": {
"description": "Triggers after one or more alarms become armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm armed night"
},
"armed_vacation": {
"description": "Triggers after one or more alarms become armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm armed vacation"
},
"disarmed": {
"description": "Triggers after one or more alarms become disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm disarmed"
},
"triggered": {
"description": "Triggers after one or more alarms become triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "Alarm triggered"
}
}
"title": "Alarm control panel"
}
@@ -1,103 +0,0 @@
"""Provides triggers for alarm control panels."""
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
Trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if supports_feature(self._hass, entity_id, self._required_features)
}
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain = domain
_to_states = {to_state}
_required_features = required_features
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_entity_transition_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
},
to_states={
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
},
),
"armed_away": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelEntityFeature.ARM_AWAY,
),
"armed_home": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelEntityFeature.ARM_HOME,
),
"armed_night": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelEntityFeature.ARM_NIGHT,
),
"armed_vacation": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.DISARMED
),
"triggered": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for alarm control panels."""
return TRIGGERS
@@ -1,53 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: alarm_control_panel
fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
armed: *trigger_common
armed_away:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
armed_home:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
armed_night:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
armed_vacation:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
disarmed: *trigger_common
triggered: *trigger_common
@@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
data[CONF_PASSWORD],
)
return await api.login.login_mode_interactive(data[CONF_CODE])
return await api.login_mode_interactive(data[CONF_CODE])
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -21,7 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 300
SCAN_INTERVAL = 30
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
@@ -45,7 +45,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=SCAN_INTERVAL, immediate=False
hass, _LOGGER, cooldown=30, immediate=False
),
)
self.api = AmazonEchoApi(
@@ -59,7 +59,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
await self.api.login.login_mode_stored_data()
await self.api.login_mode_stored_data()
data = await self.api.get_devices_data()
except CannotConnect as err:
raise UpdateFailed(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==11.0.2"]
"requirements": ["aioamazondevices==8.0.1"]
}
@@ -4,7 +4,6 @@
"codeowners": ["@madpilot"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["amberelectric"],
"requirements": ["amberelectric==2.0.12"]
+4 -25
View File
@@ -7,6 +7,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -29,36 +30,14 @@ __all__ = [
"async_devices_payload",
]
CONF_SNAPSHOTS_URL = "snapshots_url"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_SNAPSHOTS_URL): vol.Any(str, None),
}
)
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
"""Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {})
# For now we want to enable device analytics only if the url option
# is explicitly listed in YAML.
if CONF_SNAPSHOTS_URL in analytics_config:
disable_snapshots = False
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
disable_snapshots = True
snapshots_url = None
analytics = Analytics(hass, snapshots_url, disable_snapshots)
analytics = Analytics(hass)
# Load stored data
await analytics.load()
+40 -53
View File
@@ -59,6 +59,9 @@ from homeassistant.loader import (
from homeassistant.setup import async_get_loaded_integrations
from .const import (
ANALYTICS_ENDPOINT_URL,
ANALYTICS_ENDPOINT_URL_DEV,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
ATTR_ADDON_COUNT,
ATTR_ADDONS,
ATTR_ARCH,
@@ -88,14 +91,10 @@ from .const import (
ATTR_USER_COUNT,
ATTR_UUID,
ATTR_VERSION,
BASIC_ENDPOINT_URL,
BASIC_ENDPOINT_URL_DEV,
DOMAIN,
INTERVAL,
LOGGER,
PREFERENCE_SCHEMA,
SNAPSHOT_DEFAULT_URL,
SNAPSHOT_URL_PATH,
SNAPSHOT_VERSION,
STORAGE_KEY,
STORAGE_VERSION,
@@ -237,18 +236,10 @@ class AnalyticsData:
class Analytics:
"""Analytics helper class for the analytics integration."""
def __init__(
self,
hass: HomeAssistant,
snapshots_url: str | None = None,
disable_snapshots: bool = False,
) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the Analytics class."""
self._hass: HomeAssistant = hass
self._snapshots_url = snapshots_url
self._disable_snapshots = disable_snapshots
self._session = async_get_clientsession(hass)
self.hass: HomeAssistant = hass
self.session = async_get_clientsession(hass)
self._data = AnalyticsData(False, {})
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
self._basic_scheduled: CALLBACK_TYPE | None = None
@@ -258,15 +249,13 @@ class Analytics:
def preferences(self) -> dict:
"""Return the current active preferences."""
preferences = self._data.preferences
result = {
return {
ATTR_BASE: preferences.get(ATTR_BASE, False),
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
}
if not self._disable_snapshots:
result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False)
return result
@property
def onboarded(self) -> bool:
@@ -283,13 +272,13 @@ class Analytics:
"""Return the endpoint that will receive the payload."""
if RELEASE_CHANNEL is ReleaseChannel.DEV:
# dev installations will contact the dev analytics environment
return BASIC_ENDPOINT_URL_DEV
return BASIC_ENDPOINT_URL
return ANALYTICS_ENDPOINT_URL_DEV
return ANALYTICS_ENDPOINT_URL
@property
def supervisor(self) -> bool:
"""Return bool if a supervisor is present."""
return is_hassio(self._hass)
return is_hassio(self.hass)
async def load(self) -> None:
"""Load preferences."""
@@ -299,7 +288,7 @@ class Analytics:
if (
self.supervisor
and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None
and (supervisor_info := hassio.get_supervisor_info(self.hass)) is not None
):
if not self.onboarded:
# User have not configured analytics, get this setting from the supervisor
@@ -326,7 +315,7 @@ class Analytics:
if self.supervisor:
await hassio.async_update_diagnostics(
self._hass, self.preferences.get(ATTR_DIAGNOSTICS, False)
self.hass, self.preferences.get(ATTR_DIAGNOSTICS, False)
)
async def send_analytics(self, _: datetime | None = None) -> None:
@@ -334,7 +323,7 @@ class Analytics:
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
return
hass = self._hass
hass = self.hass
supervisor_info = None
operating_system_info: dict[str, Any] = {}
@@ -474,7 +463,7 @@ class Analytics:
try:
async with timeout(30):
response = await self._session.post(self.endpoint_basic, json=payload)
response = await self.session.post(self.endpoint_basic, json=payload)
if response.status == 200:
LOGGER.info(
(
@@ -490,9 +479,11 @@ class Analytics:
self.endpoint_basic,
)
except TimeoutError:
LOGGER.error("Timeout sending analytics to %s", BASIC_ENDPOINT_URL)
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
except aiohttp.ClientError as err:
LOGGER.error("Error sending analytics to %s: %r", BASIC_ENDPOINT_URL, err)
LOGGER.error(
"Error sending analytics to %s: %r", ANALYTICS_ENDPOINT_URL, err
)
@callback
def _async_should_report_integration(
@@ -516,7 +507,7 @@ class Analytics:
if not integration.config_flow:
return False
entries = self._hass.config_entries.async_entries(integration.domain)
entries = self.hass.config_entries.async_entries(integration.domain)
# Filter out ignored and disabled entries
return any(
@@ -530,7 +521,7 @@ class Analytics:
if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False):
return
payload = await _async_snapshot_payload(self._hass)
payload = await _async_snapshot_payload(self.hass)
headers = {
"Content-Type": "application/json",
@@ -541,16 +532,11 @@ class Analytics:
self._data.submission_identifier
)
url = (
self._snapshots_url
if self._snapshots_url is not None
else SNAPSHOT_DEFAULT_URL
)
url += SNAPSHOT_URL_PATH
try:
async with timeout(30):
response = await self._session.post(url, json=payload, headers=headers)
response = await self.session.post(
ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers
)
if response.status == 200: # OK
response_data = await response.json()
@@ -576,7 +562,7 @@ class Analytics:
# Clear the invalid identifier and retry on next cycle
LOGGER.warning(
"Invalid submission identifier to %s, clearing: %s",
url,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
error_message,
)
self._data.submission_identifier = None
@@ -585,7 +571,7 @@ class Analytics:
LOGGER.warning(
"Malformed snapshot analytics submission (%s) to %s: %s",
error_kind,
url,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
error_message,
)
@@ -593,7 +579,7 @@ class Analytics:
response_text = await response.text()
LOGGER.warning(
"Snapshot analytics service %s unavailable: %s",
url,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
response_text,
)
@@ -601,18 +587,18 @@ class Analytics:
LOGGER.warning(
"Unexpected status code %s when submitting snapshot analytics to %s",
response.status,
url,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
)
except TimeoutError:
LOGGER.error(
"Timeout sending snapshot analytics to %s",
url,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
)
except aiohttp.ClientError as err:
LOGGER.error(
"Error sending snapshot analytics to %s: %r",
url,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
err,
)
@@ -636,7 +622,7 @@ class Analytics:
elif self._basic_scheduled is None:
# Wait 15 min after started for basic analytics
self._basic_scheduled = async_call_later(
self._hass,
self.hass,
900,
HassJob(
self._async_schedule_basic,
@@ -645,7 +631,10 @@ class Analytics:
),
)
if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots:
if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in (
ReleaseChannel.DEV,
ReleaseChannel.NIGHTLY,
):
LOGGER.debug("Snapshot analytics not scheduled")
if self._snapshot_scheduled:
self._snapshot_scheduled()
@@ -653,11 +642,9 @@ class Analytics:
elif self._snapshot_scheduled is None:
snapshot_submission_time = self._data.snapshot_submission_time
interval_seconds = INTERVAL.total_seconds()
if snapshot_submission_time is None:
# Randomize the submission time within the 24 hours
snapshot_submission_time = random.uniform(0, interval_seconds)
snapshot_submission_time = random.uniform(0, 86400)
self._data.snapshot_submission_time = snapshot_submission_time
await self._save()
LOGGER.debug(
@@ -667,10 +654,10 @@ class Analytics:
# Calculate delay until next submission
current_time = time.time()
delay = (snapshot_submission_time - current_time) % interval_seconds
delay = (snapshot_submission_time - current_time) % 86400
self._snapshot_scheduled = async_call_later(
self._hass,
self.hass,
delay,
HassJob(
self._async_schedule_snapshots,
@@ -685,7 +672,7 @@ class Analytics:
# Send basic analytics every day
self._basic_scheduled = async_track_time_interval(
self._hass,
self.hass,
self.send_analytics,
INTERVAL,
name="basic analytics daily",
@@ -698,7 +685,7 @@ class Analytics:
# Send snapshot analytics every day
self._snapshot_scheduled = async_track_time_interval(
self._hass,
self.hass,
self.send_snapshot,
INTERVAL,
name="snapshot analytics daily",
+4 -6
View File
@@ -5,17 +5,15 @@ import logging
import voluptuous as vol
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
SNAPSHOT_VERSION = "1"
ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}"
DOMAIN = "analytics"
INTERVAL = timedelta(days=1)
STORAGE_KEY = "core.analytics"
STORAGE_VERSION = 1
BASIC_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
BASIC_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
SNAPSHOT_VERSION = 1
SNAPSHOT_DEFAULT_URL = "https://device-database.eco-dev-aws.openhomefoundation.com"
SNAPSHOT_URL_PATH = f"/api/v1/snapshot/{SNAPSHOT_VERSION}"
LOGGER: logging.Logger = logging.getLogger(__package__)
@@ -4,7 +4,6 @@
"codeowners": ["@engrbm87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pydroid-ipcam==3.0.0"]
}
@@ -1,73 +0,0 @@
"""The Anglian Water integration."""
from __future__ import annotations
from aiohttp import CookieJar
from pyanglianwater import AnglianWater
from pyanglianwater.auth import MSOB2CAuth
from pyanglianwater.exceptions import (
ExpiredAccessTokenError,
SelfAssertedError,
SmartMeterUnavailableError,
)
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_PASSWORD,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: AnglianWaterConfigEntry
) -> bool:
"""Set up Anglian Water from a config entry."""
auth = MSOB2CAuth(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=async_create_clientsession(
hass,
cookie_jar=CookieJar(quote_cookie=False),
),
refresh_token=entry.data[CONF_ACCESS_TOKEN],
)
try:
await auth.send_refresh_request()
except (ExpiredAccessTokenError, SelfAssertedError) as err:
raise ConfigEntryAuthFailed from err
_aw = AnglianWater(authenticator=auth)
try:
await _aw.validate_smart_meter(entry.data[CONF_ACCOUNT_NUMBER])
except SmartMeterUnavailableError as err:
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="smart_meter_unavailable"
) from err
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_ACCESS_TOKEN: auth.refresh_token}
)
entry.runtime_data = coordinator = AnglianWaterUpdateCoordinator(
hass=hass, api=_aw, config_entry=entry
)
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: AnglianWaterConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
@@ -1,171 +0,0 @@
"""Config flow for the Anglian Water integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from aiohttp import CookieJar
from pyanglianwater import AnglianWater
from pyanglianwater.auth import MSOB2CAuth
from pyanglianwater.exceptions import (
InvalidAccountIdError,
SelfAssertedError,
SmartMeterUnavailableError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): selector.TextSelector(),
vol.Required(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}
)
async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
"""Validate the provided credentials."""
try:
await auth.send_login_request()
except SelfAssertedError:
return "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
return "unknown"
return auth
def humanize_account_data(account: dict) -> str:
"""Convert an account data into a human-readable format."""
if account["address"]["company_name"] != "":
return f"{account['account_number']} - {account['address']['company_name']}"
if account["address"]["building_name"] != "":
return f"{account['account_number']} - {account['address']['building_name']}"
return f"{account['account_number']} - {account['address']['postcode']}"
async def get_accounts(auth: MSOB2CAuth) -> list[selector.SelectOptionDict]:
"""Retrieve the list of accounts associated with the authenticated user."""
_aw = AnglianWater(authenticator=auth)
accounts = await _aw.api.get_associated_accounts()
return [
selector.SelectOptionDict(
value=str(account["account_number"]),
label=humanize_account_data(account),
)
for account in accounts["result"]["active"]
]
async def validate_account(auth: MSOB2CAuth, account_number: str) -> str | MSOB2CAuth:
"""Validate the provided account number."""
_aw = AnglianWater(authenticator=auth)
try:
await _aw.validate_smart_meter(account_number)
except (InvalidAccountIdError, SmartMeterUnavailableError):
return "smart_meter_unavailable"
return auth
class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anglian Water."""
def __init__(self) -> None:
"""Initialize the config flow."""
self.authenticator: MSOB2CAuth | None = None
self.accounts: list[selector.SelectOptionDict] = []
self.user_input: dict[str, Any] | None = None
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:
self.authenticator = MSOB2CAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_create_clientsession(
self.hass,
cookie_jar=CookieJar(quote_cookie=False),
),
)
validation_response = await validate_credentials(self.authenticator)
if isinstance(validation_response, str):
errors["base"] = validation_response
else:
self.accounts = await get_accounts(self.authenticator)
if len(self.accounts) > 1:
self.user_input = user_input
return await self.async_step_select_account()
account_number = self.accounts[0]["value"]
self.user_input = user_input
return await self.async_step_complete(
{
CONF_ACCOUNT_NUMBER: account_number,
}
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_select_account(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the account selection step."""
errors = {}
if user_input is not None:
if TYPE_CHECKING:
assert self.authenticator
validation_result = await validate_account(
self.authenticator,
user_input[CONF_ACCOUNT_NUMBER],
)
if isinstance(validation_result, str):
errors["base"] = validation_result
else:
return await self.async_step_complete(user_input)
return self.async_show_form(
step_id="select_account",
data_schema=vol.Schema(
{
vol.Required(CONF_ACCOUNT_NUMBER): selector.SelectSelector(
selector.SelectSelectorConfig(
options=self.accounts,
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_complete(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle the final configuration step."""
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert self.authenticator
assert self.user_input
config_entry_data = {
**self.user_input,
CONF_ACCOUNT_NUMBER: user_input[CONF_ACCOUNT_NUMBER],
CONF_ACCESS_TOKEN: self.authenticator.refresh_token,
}
return self.async_create_entry(
title=user_input[CONF_ACCOUNT_NUMBER],
data=config_entry_data,
)
@@ -1,4 +0,0 @@
"""Constants for the Anglian Water integration."""
DOMAIN = "anglian_water"
CONF_ACCOUNT_NUMBER = "account_number"
@@ -1,165 +0,0 @@
"""Anglian Water data coordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyanglianwater import AnglianWater
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import VolumeConverter
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(minutes=60)
class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
"""Anglian Water data update coordinator."""
config_entry: AnglianWaterConfigEntry
def __init__(
self,
hass: HomeAssistant,
api: AnglianWater,
config_entry: AnglianWaterConfigEntry,
) -> None:
"""Initialize update coordinator."""
super().__init__(
hass=hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=config_entry,
)
self.api = api
async def _async_update_data(self) -> None:
"""Update data from Anglian Water's API."""
try:
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self._insert_statistics()
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
raise UpdateFailed from err
async def _insert_statistics(self) -> None:
"""Insert statistics for water meters into Home Assistant."""
for meter in self.api.meters.values():
id_prefix = (
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
)
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
name_prefix = (
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
f"{meter.serial_number}"
)
usage_metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Usage",
source=DOMAIN,
statistic_id=usage_statistic_id,
unit_class=VolumeConverter.UNIT_CLASS,
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
)
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
)
if not last_stat:
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping update",
meter.readings[0]["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
end,
{
usage_statistic_id,
},
"hour",
None,
{"sum"},
)
if stats:
break
if end:
_LOGGER.debug(
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
usage_statistics = []
for read in meter.readings:
parsed_read_at = dt_util.parse_datetime(read["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping reading",
read["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])
usage_statistics.append(
StatisticData(
start=start,
state=usage_state,
sum=usage_sum,
)
)
_LOGGER.debug(
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
)
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)
@@ -1,48 +0,0 @@
"""Anglian Water entity."""
from __future__ import annotations
import logging
from pyanglianwater.meter import SmartMeter
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AnglianWaterUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class AnglianWaterEntity(CoordinatorEntity[AnglianWaterUpdateCoordinator]):
"""Defines a Anglian Water entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AnglianWaterUpdateCoordinator,
smart_meter: SmartMeter,
key: str,
) -> None:
"""Initialize Anglian Water entity."""
super().__init__(coordinator)
self.smart_meter = smart_meter
self._attr_unique_id = f"{smart_meter.serial_number}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, smart_meter.serial_number)},
name=smart_meter.serial_number,
manufacturer="Anglian Water",
serial_number=smart_meter.serial_number,
)
async def async_added_to_hass(self) -> None:
"""When entity is loaded."""
self.coordinator.api.updated_data_callbacks.append(self.async_write_ha_state)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self) -> None:
"""When will be removed from HASS."""
self.coordinator.api.updated_data_callbacks.remove(self.async_write_ha_state)
await super().async_will_remove_from_hass()
@@ -1,13 +0,0 @@
{
"domain": "anglian_water",
"name": "Anglian Water",
"after_dependencies": ["recorder"],
"codeowners": ["@pantherale0"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.0"]
}
@@ -1,83 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No custom actions are defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
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:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
Unable to discover meters.
discovery:
status: exempt
comment: |
Unable to discover meters.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
No entities are disabled by default.
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: |
Entities do not require different icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
Read-only integration and no repairs are possible.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -1,117 +0,0 @@
"""Anglian Water sensor platform."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from pyanglianwater.meter import SmartMeter
from homeassistant.components.sensor import (
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
from .entity import AnglianWaterEntity
PARALLEL_UPDATES = 0
class AnglianWaterSensor(StrEnum):
"""Store keys for Anglian Water sensors."""
YESTERDAY_CONSUMPTION = "yesterday_consumption"
YESTERDAY_WATER_COST = "yesterday_water_cost"
YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost"
LATEST_READING = "latest_reading"
@dataclass(frozen=True, kw_only=True)
class AnglianWaterSensorEntityDescription(SensorEntityDescription):
"""Describes AnglianWater sensor entity."""
value_fn: Callable[[SmartMeter], float]
ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
AnglianWaterSensorEntityDescription(
key=AnglianWaterSensor.YESTERDAY_CONSUMPTION,
native_unit_of_measurement=UnitOfVolume.LITERS,
device_class=SensorDeviceClass.WATER,
value_fn=lambda entity: entity.get_yesterday_consumption,
state_class=SensorStateClass.TOTAL,
translation_key=AnglianWaterSensor.YESTERDAY_CONSUMPTION,
entity_category=EntityCategory.DIAGNOSTIC,
),
AnglianWaterSensorEntityDescription(
key=AnglianWaterSensor.LATEST_READING,
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.WATER,
value_fn=lambda entity: entity.latest_read,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key=AnglianWaterSensor.LATEST_READING,
entity_category=EntityCategory.DIAGNOSTIC,
),
AnglianWaterSensorEntityDescription(
key=AnglianWaterSensor.YESTERDAY_WATER_COST,
native_unit_of_measurement="GBP",
device_class=SensorDeviceClass.MONETARY,
value_fn=lambda entity: entity.yesterday_water_cost,
translation_key=AnglianWaterSensor.YESTERDAY_WATER_COST,
entity_category=EntityCategory.DIAGNOSTIC,
),
AnglianWaterSensorEntityDescription(
key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
native_unit_of_measurement="GBP",
device_class=SensorDeviceClass.MONETARY,
value_fn=lambda entity: entity.yesterday_sewerage_cost,
translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AnglianWaterConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
async_add_devices(
AnglianWaterSensorEntity(
coordinator=entry.runtime_data,
description=entity_description,
smart_meter=smart_meter,
)
for entity_description in ENTITY_DESCRIPTIONS
for smart_meter in entry.runtime_data.api.meters.values()
)
class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
"""Defines a Anglian Water sensor."""
entity_description: AnglianWaterSensorEntityDescription
def __init__(
self,
coordinator: AnglianWaterUpdateCoordinator,
smart_meter: SmartMeter,
description: AnglianWaterSensorEntityDescription,
) -> None:
"""Initialize Anglian Water sensor."""
super().__init__(coordinator, smart_meter, description.key)
self.entity_description = description
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.smart_meter)
@@ -1,62 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"smart_meter_unavailable": "This account does not have any smart meters associated with it. If this is unexpected, enter your Billing Account Number found at the top of your latest bill.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"select_account": {
"data": {
"account_number": "Billing account number"
},
"data_description": {
"account_number": "Select the billing account you wish to use."
},
"description": "Multiple active billing accounts were found with your credentials. Please select the account you wish to use. If this is unexpected, contact Anglian Water to confirm your active accounts."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your password",
"username": "Username or email used to log in to the Anglian Water website."
},
"description": "Enter your Anglian Water account credentials to connect to Home Assistant."
}
}
},
"entity": {
"sensor": {
"latest_reading": {
"name": "Latest reading"
},
"yesterday_consumption": {
"name": "Yesterday's usage"
},
"yesterday_sewerage_cost": {
"name": "Yesterday's sewerage cost"
},
"yesterday_water_cost": {
"name": "Yesterday's water cost"
}
}
},
"exceptions": {
"auth_expired": {
"message": "Authentication token expired"
},
"service_unavailable": {
"message": "Anglian Water services are currently unavailable for maintenance."
},
"smart_meter_unavailable": {
"message": "This account no longer has a smart meter associated with it."
}
}
}
@@ -4,7 +4,6 @@
"codeowners": ["@Lash-L"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anova",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.17.0"]
@@ -4,7 +4,6 @@
"codeowners": ["@hyralex"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anthemav",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["anthemav"],
"requirements": ["anthemav==1.4.1"]
+15 -2
View File
@@ -17,7 +17,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
from .const import (
CONF_CHAT_MODEL,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
)
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -37,7 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
)
try:
await client.models.list(timeout=10.0)
# Use model from first conversation subentry for validation
subentries = list(entry.subentries.values())
if subentries:
model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
else:
model_id = RECOMMENDED_CHAT_MODEL
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
LOGGER.debug("Anthropic model: %s", model.display_name)
except anthropic.AuthenticationError as err:
LOGGER.error("Invalid API key: %s", err)
return False
@@ -6,7 +6,7 @@ from functools import partial
import json
import logging
import re
from typing import Any, cast
from typing import Any
import anthropic
import voluptuous as vol
@@ -54,11 +54,17 @@ from .const import (
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
NON_THINKING_MODELS,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
RECOMMENDED_WEB_SEARCH,
RECOMMENDED_WEB_SEARCH_MAX_USES,
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
WEB_SEARCH_UNSUPPORTED_MODELS,
)
@@ -70,13 +76,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
DEFAULT_CONVERSATION_OPTIONS = {
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
DEFAULT_AI_TASK_OPTIONS = {
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}
@@ -130,13 +136,13 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
subentries=[
{
"subentry_type": "conversation",
"data": DEFAULT_CONVERSATION_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task_data",
"data": DEFAULT_AI_TASK_OPTIONS,
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
@@ -174,9 +180,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""Add a subentry."""
if self._subentry_type == "ai_task_data":
self.options = DEFAULT_AI_TASK_OPTIONS.copy()
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
self.options = DEFAULT_CONVERSATION_OPTIONS.copy()
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_init()
async def async_step_reconfigure(
@@ -277,7 +283,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
step_schema: VolDictType = {
vol.Optional(
CONF_CHAT_MODEL,
default=DEFAULT[CONF_CHAT_MODEL],
default=RECOMMENDED_CHAT_MODEL,
): SelectSelector(
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
@@ -285,11 +291,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
),
vol.Optional(
CONF_MAX_TOKENS,
default=DEFAULT[CONF_MAX_TOKENS],
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_TEMPERATURE,
default=DEFAULT[CONF_TEMPERATURE],
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
}
@@ -319,14 +325,12 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith(tuple(NON_THINKING_MODELS)):
step_schema[
vol.Optional(
CONF_THINKING_BUDGET, default=DEFAULT[CONF_THINKING_BUDGET]
)
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
] = vol.All(
NumberSelector(
NumberSelectorConfig(
min=0,
max=self.options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
)
),
vol.Coerce(int),
@@ -339,15 +343,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
{
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
default=RECOMMENDED_WEB_SEARCH,
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
default=RECOMMENDED_WEB_SEARCH_MAX_USES,
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
default=RECOMMENDED_WEB_SEARCH_USER_LOCATION,
): bool,
}
)
@@ -365,10 +369,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
user_input = {}
if user_input is not None:
if user_input.get(CONF_WEB_SEARCH, DEFAULT[CONF_WEB_SEARCH]) and not errors:
if user_input.get(CONF_WEB_SEARCH, RECOMMENDED_WEB_SEARCH) and not errors:
if user_input.get(
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
CONF_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION
):
user_input.update(await self._get_location_data())
@@ -421,8 +424,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
@@ -455,7 +456,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
}
)
response = await client.messages.create(
model=cast(str, DEFAULT[CONF_CHAT_MODEL]),
model=RECOMMENDED_CHAT_MODEL,
messages=[
{
"role": "user",
@@ -470,7 +471,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"content": "{", # hints the model to skip any preamble
},
],
max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]),
max_tokens=RECOMMENDED_MAX_TOKENS,
)
_LOGGER.debug("Model response: %s", response.content)
location_data = location_schema(

Some files were not shown because too many files have changed in this diff Show More