Compare commits

..

14 Commits

Author SHA1 Message Date
Paulus Schoutsen a0c7b03855 Bump rf-protocols to 2.0.0
2.0.0 renames load_codes to get_codes. Update the import and call
site in light.py accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 07:22:51 -04:00
Paulus Schoutsen 76cac3354c Bump rf-protocols to 1.0.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:14:39 -04:00
Paulus Schoutsen 7f21f7eb20 Add radio_frequency demo platform to kitchen_sink
Mirror the infrared demo platform so the kitchen_sink integration also
exposes a sample radio frequency transmitter, useful for developing
against the new entity platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:54:35 -04:00
Paulus Schoutsen 9e432b3dae Bump rf-protocols to 1.0.0 and load codes from disk
The 1.0.0 release replaces the per-device command classes with a
filesystem-backed loader. Load the device codes once at module level in
light.py and resolve commands lazily through an executor when needed.

The conftest mock command also follows the new list[int] timings
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:39:15 -04:00
Paulus Schoutsen 4d940b8804 Remove __init__ method from Honeywell light component
Removed the __init__ method from the light class.
2026-04-17 23:38:51 -04:00
Paulus Schoutsen 4c4eabfab6 Adopt availability tracking from lg_infrared 2026-04-17 23:38:51 -04:00
Paulus Schoutsen 345b87a80e Add honeywell_string_lights integration
Introduce a new Honeywell String Lights integration that drives the
lights over the radio_frequency entity platform. The OOK turn on/off
commands are provided by the rf-protocols library.

Bump the rf-protocols requirement to 0.1.0 across radio_frequency and
honeywell_string_lights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:38:51 -04:00
Paulus Schoutsen e921400af8 Adopt rf-protocols 1.0.0 get_raw_timings signature
In rf-protocols 1.0.0, RadioFrequencyCommand.get_raw_timings() returns
a flat list[int] of signed alternating microseconds directly, so the
ESPHome platform can pass it straight through without unpacking Timing
objects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:37:53 -04:00
Paulus Schoutsen 8b942efff7 Remove None support from ESPHome supported_frequency_ranges
Always return frequency ranges from device info, matching the
updated RadioFrequencyTransmitterEntity contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:37:53 -04:00
Paulus Schoutsen fccc84cb50 Add radio_frequency platform to ESPHome
Implement the new radio_frequency platform on ESPHome, mirroring
the infrared platform pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:37:53 -04:00
Paulus Schoutsen ac2c691513 Bump rf-protocols to 1.0.0
The new major release replaces the Timing dataclass with a raw list of
signed alternating microseconds. Update the mock command fixture to
match the new get_raw_timings signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:37:50 -04:00
Paulus Schoutsen 5349ce496e Update homeassistant/components/radio_frequency/__init__.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 23:37:50 -04:00
Paulus Schoutsen 1d25c0df0a Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 23:37:50 -04:00
Paulus Schoutsen de3aaac14f Add radio_frequency entity platform
Add a new radio_frequency entity domain that acts as an abstraction
layer between RF transceiver hardware and device-specific integrations,
following the same pattern as the infrared entity platform.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:37:50 -04:00
405 changed files with 4023 additions and 49935 deletions
@@ -1,6 +1,7 @@
---
name: github-pr-reviewer
description: Reviews GitHub pull requests and provides feedback comments. This is the top skill to use for reviewing Pull Requests from GitHub.
description: Reviews GitHub pull requests and provides feedback comments.
disallowedTools: Write, Edit
---
# Review GitHub Pull Request
-2
View File
@@ -12,8 +12,6 @@ description: Everything you need to know to build, test and review Home Assistan
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
+1
View File
@@ -36,6 +36,7 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**
-3
View File
@@ -32,9 +32,6 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
# Skills
-11
View File
@@ -6,7 +6,6 @@
"pep621",
"pip_requirements",
"pre-commit",
"regex",
"homeassistant-manifest"
],
@@ -27,16 +26,6 @@
]
},
"regexManagers": [
{
"description": "Update ruff required-version in pyproject.toml",
"managerFilePatterns": ["/^pyproject\\.toml$/"],
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
}
],
"minimumReleaseAge": "7 days",
"prConcurrentLimit": 10,
"prHourlyLimit": 2,
+24 -24
View File
@@ -282,7 +282,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -303,7 +303,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
with:
extra-args: --all-files zizmor
@@ -366,7 +366,7 @@ jobs:
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
key: >-
@@ -374,7 +374,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -386,7 +386,7 @@ jobs:
env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
@@ -432,7 +432,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -486,7 +486,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -517,7 +517,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -554,7 +554,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -645,7 +645,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -696,7 +696,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -749,7 +749,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -806,7 +806,7 @@ jobs:
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -814,7 +814,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: .mypy_cache
key: >-
@@ -856,7 +856,7 @@ jobs:
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -889,7 +889,7 @@ jobs:
check-latest: true
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -932,7 +932,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -966,7 +966,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1084,7 +1084,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1119,7 +1119,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1242,7 +1242,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1279,7 +1279,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
@@ -1425,7 +1425,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.APT_CACHE_DIR }}
@@ -1459,7 +1459,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
fail-on-cache-miss: true
+2 -2
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10
rev: v0.15.1
hooks:
- id: ruff-check
args:
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.0
rev: v1.23.1
hooks:
- id: zizmor
args:
-3
View File
@@ -46,7 +46,6 @@ homeassistant.components.accuweather.*
homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
homeassistant.components.actiontec.*
homeassistant.components.actron_air.*
homeassistant.components.adax.*
homeassistant.components.adguard.*
homeassistant.components.aftership.*
@@ -179,7 +178,6 @@ homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
homeassistant.components.duco.*
homeassistant.components.dunehd.*
homeassistant.components.duotecno.*
homeassistant.components.easyenergy.*
@@ -224,7 +222,6 @@ homeassistant.components.fronius.*
homeassistant.components.frontend.*
homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fumis.*
homeassistant.components.fyta.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
-3
View File
@@ -22,6 +22,3 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
Generated
+6 -4
View File
@@ -592,8 +592,6 @@ CLAUDE.md @home-assistant/core
/tests/components/fujitsu_fglair/ @crevetor
/homeassistant/components/fully_kiosk/ @cgarwood
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/fumis/ @frenck
/tests/components/fumis/ @frenck
/homeassistant/components/fyta/ @dontinelli
/tests/components/fyta/ @dontinelli
/homeassistant/components/garage_door/ @home-assistant/core
@@ -756,6 +754,8 @@ CLAUDE.md @home-assistant/core
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
@@ -1411,6 +1411,8 @@ CLAUDE.md @home-assistant/core
/tests/components/radarr/ @tkdrob
/homeassistant/components/radio_browser/ @frenck
/tests/components/radio_browser/ @frenck
/homeassistant/components/radio_frequency/ @home-assistant/core
/tests/components/radio_frequency/ @home-assistant/core
/homeassistant/components/radiotherm/ @vinnyfuria
/tests/components/radiotherm/ @vinnyfuria
/homeassistant/components/rainbird/ @konikvranik @allenporter
@@ -1991,8 +1993,8 @@ CLAUDE.md @home-assistant/core
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @tr4nt0r
/tests/components/xbox/ @tr4nt0r
/homeassistant/components/xbox/ @hunterjm @tr4nt0r
/tests/components/xbox/ @hunterjm @tr4nt0r
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79
Generated
-1
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
-1
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "honeywell",
"name": "Honeywell",
"integrations": ["lyric", "evohome", "honeywell"]
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.2.2"]
}
+5 -16
View File
@@ -15,10 +15,8 @@ from homeassistant.components.climate import (
)
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 .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
@@ -141,24 +139,20 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@actron_air_command
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[fan_mode]
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@actron_air_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR[hvac_mode]
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temperature_missing",
)
await self._status.user_aircon_settings.set_temperature(temperature=temperature)
temp = kwargs.get(ATTR_TEMPERATURE)
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@@ -227,9 +221,4 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temperature_missing",
)
await self._zone.set_temperature(temperature=temperature)
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
@@ -23,7 +23,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self._user_code: str = ""
self._verification_uri: str = ""
self._expires_minutes: str = "30"
self.login_task: asyncio.Task[None] | None = None
self.login_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -94,7 +94,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Error getting user info: %s", err)
return self.async_abort(reason="oauth2_error")
unique_id = user_data.sub
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
@@ -107,7 +107,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data.email,
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
)
@@ -78,14 +78,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
translation_placeholders={"error": repr(err)},
) from err
status = self.api.state_manager.get_status(self.serial_number)
if status is None:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": "Status not available"},
)
self.status = status
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status
@@ -24,7 +24,7 @@ def actron_air_command[_EntityT: ActronAirEntity, **_P](
"""
@wraps(func)
async def wrapper(self: _EntityT, /, *args: _P.args, **kwargs: _P.kwargs) -> None:
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap API calls with exception handling."""
try:
await func(self, *args, **kwargs)
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.3"]
"requirements": ["actron-neo-api==0.5.0"]
}
@@ -69,4 +69,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done
strict-typing: todo
@@ -58,9 +58,6 @@
"setup_connection_error": {
"message": "Failed to connect to the Actron Air API"
},
"temperature_missing": {
"message": "Provide a temperature value when adjusting the climate entity."
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.4.3"]
"requirements": ["aioamazondevices==13.4.1"]
}
+10 -17
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from anthropic.resources.messages.messages import DEPRECATED_MODELS
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
@@ -15,7 +13,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
from .const import (
CONF_CHAT_MODEL,
DEFAULT_CONVERSATION_NAME,
DEPRECATED_MODELS,
DOMAIN,
LOGGER,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
@@ -40,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
entry.async_on_unload(entry.add_update_listener(async_update_options))
for subentry in entry.subentries.values():
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model in DEPRECATED_MODELS:
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith(
tuple(DEPRECATED_MODELS)
):
ir.async_create_issue(
hass,
DOMAIN,
@@ -230,19 +236,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
)
hass.config_entries.async_update_entry(entry, minor_version=3)
if entry.version == 2 and entry.minor_version == 3:
# Remove Temperature parameter
CONF_TEMPERATURE = "temperature"
for subentry in entry.subentries.values():
data = subentry.data.copy()
if CONF_TEMPERATURE not in data:
continue
data.pop(CONF_TEMPERATURE, None)
hass.config_entries.async_update_subentry(entry, subentry, data=data)
hass.config_entries.async_update_entry(entry, minor_version=4)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
@@ -50,6 +50,7 @@ from .const import (
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
@@ -65,6 +66,8 @@ from .const import (
DEFAULT_CONVERSATION_NAME,
DOMAIN,
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
TOOL_SEARCH_UNSUPPORTED_MODELS,
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
@@ -108,7 +111,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
VERSION = 2
MINOR_VERSION = 4
MINOR_VERSION = 3
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -323,6 +326,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
): SelectSelector(
SelectSelectorConfig(options=self._get_model_list(), custom_value=True)
),
vol.Optional(
CONF_TEMPERATURE,
default=DEFAULT[CONF_TEMPERATURE],
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_PROMPT_CACHING,
default=DEFAULT[CONF_PROMPT_CACHING],
@@ -391,10 +398,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
model = self.options[CONF_CHAT_MODEL]
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.supported
and not self.model_info.capabilities.thinking.types.adaptive.supported
if not model.startswith(tuple(NON_THINKING_MODELS)) and model.startswith(
tuple(NON_ADAPTIVE_THINKING_MODELS)
):
step_schema[
vol.Optional(
@@ -413,23 +418,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
else:
self.options.pop(CONF_THINKING_BUDGET, None)
if (
self.model_info.capabilities
and (effort_capability := self.model_info.capabilities.effort).supported
):
effort_options: list[str] = []
if self.model_info.capabilities.thinking.types.adaptive.supported:
effort_options.append("none")
if effort_capability.low.supported:
effort_options.append("low")
if effort_capability.medium.supported:
effort_options.append("medium")
if effort_capability.high.supported:
effort_options.append("high")
if effort_capability.xhigh and effort_capability.xhigh.supported:
effort_options.append("xhigh")
if effort_capability.max.supported:
effort_options.append("max")
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
step_schema[
vol.Optional(
CONF_THINKING_EFFORT,
@@ -437,7 +426,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
)
] = SelectSelector(
SelectSelectorConfig(
options=effort_options,
options=["none", "low", "medium", "high", "max"],
translation_key=CONF_THINKING_EFFORT,
mode=SelectSelectorMode.DROPDOWN,
)
@@ -15,6 +15,7 @@ CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT_CACHING = "prompt_caching"
CONF_TEMPERATURE = "temperature"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
@@ -42,6 +43,7 @@ DEFAULT = {
CONF_CODE_EXECUTION: False,
CONF_MAX_TOKENS: 3000,
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
@@ -50,6 +52,31 @@ DEFAULT = {
CONF_WEB_SEARCH_MAX_USES: 5,
}
NON_THINKING_MODELS = [
"claude-3-haiku",
]
NON_ADAPTIVE_THINKING_MODELS = [
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
@@ -58,7 +85,21 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
TOOL_SEARCH_UNSUPPORTED_MODELS = [
"claude-3",
"claude-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]
+24 -52
View File
@@ -30,7 +30,6 @@ from anthropic.types import (
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
ModelInfo,
OutputConfigParam,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
@@ -98,6 +97,7 @@ from .const import (
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT_CACHING,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
@@ -112,6 +112,10 @@ from .const import (
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
PromptCaching,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
@@ -753,43 +757,33 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
):
model_args["cache_control"] = {"type": "ephemeral"}
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.adaptive.supported
):
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
if thinking_effort != "none":
model_args["thinking"] = ThinkingConfigAdaptiveParam(
type="adaptive", display="summarized"
)
model_args["thinking"] = ThinkingConfigAdaptiveParam(type="adaptive")
model_args["output_config"] = OutputConfigParam(effort=thinking_effort)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
)
else:
thinking_budget = options.get(
CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET]
)
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.enabled.supported
not model.startswith(tuple(NON_THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
):
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", display="summarized", budget_tokens=thinking_budget
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
if (
self.model_info.capabilities
and self.model_info.capabilities.effort.supported
):
model_args["output_config"] = OutputConfigParam(
effort=options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
model_args["temperature"] = options.get(
CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE]
)
tools: list[ToolUnionParam] = []
@@ -801,11 +795,9 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
if options.get(CONF_CODE_EXECUTION):
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_WEB_SEARCH)
):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
@@ -814,11 +806,9 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
if options.get(CONF_WEB_SEARCH):
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_CODE_EXECUTION)
):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
@@ -856,17 +846,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
]
last_message["content"].extend( # type: ignore[union-attr]
await async_prepare_files_for_prompt(
self.hass,
self.model_info,
[(a.path, a.mime_type) for a in last_content.attachments],
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
)
)
if structure and structure_name:
if (
self.model_info.capabilities
and self.model_info.capabilities.structured_outputs.supported
):
if not model.startswith(tuple(UNSUPPORTED_STRUCTURED_OUTPUT_MODELS)):
# Native structured output for those models who support it.
structure_name = None
model_args.setdefault("output_config", OutputConfigParam())[
@@ -1007,7 +992,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
async def async_prepare_files_for_prompt(
hass: HomeAssistant, model_info: ModelInfo, files: list[tuple[Path, str | None]]
hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
"""Append files to a prompt.
@@ -1028,26 +1013,13 @@ async def async_prepare_files_for_prompt(
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if (
not mime_type
or not mime_type.startswith(("image/", "application/pdf"))
or not model_info.capabilities
or (
mime_type.startswith("image/")
and not model_info.capabilities.image_input.supported
)
or (
mime_type.startswith("application/pdf")
and not model_info.capabilities.pdf_input.supported
)
):
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="wrong_file_type",
translation_placeholders={
"file_path": file_path.as_posix(),
"mime_type": mime_type or "unknown",
"model": model_info.display_name,
},
)
if mime_type == "image/jpg":
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.96.0"]
"requirements": ["anthropic==0.92.0"]
}
@@ -6,7 +6,6 @@ from collections.abc import Iterator
from typing import TYPE_CHECKING
import anthropic
from anthropic.resources.messages.messages import DEPRECATED_MODELS
import voluptuous as vol
from homeassistant import data_entry_flow
@@ -20,7 +19,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
from .const import CONF_CHAT_MODEL, DOMAIN
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
from .coordinator import model_alias
if TYPE_CHECKING:
@@ -64,7 +63,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
model_list = [
model_option
for model_option in await self.get_model_list(client)
if model_option["value"] not in DEPRECATED_MODELS
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
self._model_list_cache[entry.entry_id] = model_list
@@ -106,7 +105,6 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
"model": model,
"subentry_name": subentry.title,
"subentry_type": self._format_subentry_type(subentry.subentry_type),
"retirement_date": DEPRECATED_MODELS[model],
},
)
@@ -133,7 +131,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
continue
for subentry in entry.subentries.values():
model = subentry.data.get(CONF_CHAT_MODEL)
if model and model in DEPRECATED_MODELS:
if model and model.startswith(tuple(DEPRECATED_MODELS)):
yield entry.entry_id, subentry.subentry_id
async def _async_next_target(
@@ -160,7 +158,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
continue
model = subentry.data.get(CONF_CHAT_MODEL)
if not model or model not in DEPRECATED_MODELS:
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
continue
self._current_entry_id = entry_id
@@ -205,7 +205,7 @@
"message": "`{file_path}` does not exist."
},
"wrong_file_type": {
"message": "The {model} model does not support {mime_type} file types (for `{file_path}`)."
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
}
},
"issues": {
@@ -219,7 +219,7 @@
"data_description": {
"chat_model": "Select the new model to use."
},
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated and will reach end-of-life on {retirement_date}. Select a supported model to continue.",
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.",
"title": "Update model"
}
}
@@ -241,8 +241,7 @@
"low": "[%key:common::state::low%]",
"max": "Max",
"medium": "[%key:common::state::medium%]",
"none": "None",
"xhigh": "X-High"
"none": "None"
}
}
}
@@ -34,7 +34,7 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
def get_serial_number_from_jid(jid: str) -> str:
"""Get serial number from Beolink JID."""
return jid.split(".")[2].split("@", maxsplit=1)[0]
return jid.split(".")[2].split("@")[0]
async def get_remotes(client: MozartClient) -> list[PairedRemote]:
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==4.0.4",
"habluetooth==6.1.0"
"habluetooth==6.0.0"
]
}
@@ -7,11 +7,9 @@ from typing import Final
from aiohttp import CookieJar
from pybravia import BraviaClient
from homeassistant.components import ssdp
from homeassistant.const import CONF_HOST, CONF_MAC, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import CONF_USE_SSL
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
@@ -48,19 +46,6 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def async_ssdp_callback(
discovery_info: SsdpServiceInfo, change: ssdp.SsdpChange
) -> None:
await coordinator.async_request_refresh()
config_entry.async_on_unload(
await ssdp.async_register_callback(
hass,
async_ssdp_callback,
{"nt": "urn:schemas-upnp-org:device:MediaRenderer:1", "_host": host},
)
)
return True
@@ -173,9 +173,6 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
power_status = await self.client.get_power_status()
self.is_on = power_status == "active"
self.skipped_updates = 0
self.update_interval = (
timedelta(seconds=120) if power_status == "standby" else SCAN_INTERVAL
)
if not self.system_info:
self.system_info = await self.client.get_system_info()
@@ -6,7 +6,6 @@ DOMAIN = "broadlink"
DOMAINS_AND_TYPES = {
Platform.CLIMATE: {"HYS"},
Platform.INFRARED: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.LIGHT: {"LB1", "LB2"},
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.SELECT: {"HYS"},
@@ -45,6 +44,3 @@ DEVICE_TYPES = set.union(*DOMAINS_AND_TYPES.values())
DEFAULT_PORT = 80
DEFAULT_TIMEOUT = 5
# Broadlink IR packet format - repeat count byte offset
IR_PACKET_REPEAT_INDEX = 1
@@ -1,184 +0,0 @@
"""Infrared platform for Broadlink remotes."""
from __future__ import annotations
from typing import TYPE_CHECKING
from broadlink.exceptions import BroadlinkException
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
import infrared_protocols
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, IR_PACKET_REPEAT_INDEX
from .entity import BroadlinkEntity
if TYPE_CHECKING:
from .device import BroadlinkDevice
PARALLEL_UPDATES = 1
class BroadlinkIRCommand(InfraredCommand):
"""Raw IR command with optional Broadlink hardware repeat count.
This class lets you send raw timing data through a Broadlink infrared
entity. The repeat_count maps directly to the Broadlink packet repeat
byte: the device will re-transmit the entire IR burst that many
additional times after the first transmission.
Use this when you have existing Broadlink-encoded IR data (e.g. from
IR code databases like SmartIR) and want to use it with the new
infrared platform.
Protocol-aware commands (infrared_protocols.NECCommand, LgTVCommand,
etc.) manage repeats *inside* get_raw_timings() and should use the
default repeat=0. Only BroadlinkIRCommand should set hardware repeat.
Example: Migrating IR code database base64 codes to the infrared platform:
import base64
from broadlink.remote import data_to_pulses
from homeassistant.components.broadlink.infrared import BroadlinkIRCommand
from homeassistant.components.broadlink.const import IR_PACKET_REPEAT_INDEX
# Decode base64 IR code (e.g. from IR code database)
packet_data = base64.b64decode(b64_code)
repeat_count = packet_data[IR_PACKET_REPEAT_INDEX]
# Parse Broadlink packet to microsecond timings
pulses = data_to_pulses(packet_data)
timings = list(zip(pulses[::2], pulses[1::2]))
if len(pulses) % 2:
timings.append((pulses[-1], 0))
# Create command
cmd = BroadlinkIRCommand(timings, repeat_count=repeat_count)
await infrared.async_send_command(hass, entity_id, cmd)
"""
# Standard IR carrier frequency. Broadlink hardware handles the carrier
# internally, so this value is informational only.
MODULATION = 38000
def __init__(
self,
timings: list[tuple[int, int]],
repeat_count: int = 0,
) -> None:
"""Initialize with timing pairs and optional repeat count.
Args:
timings: List of (mark_us, space_us) pairs in microseconds.
repeat_count: Broadlink hardware repeat count (0 = send once).
Must be 0255 (the hardware repeat byte is a single unsigned byte).
Raises:
ValueError: If repeat_count is outside 0255 range.
"""
if not 0 <= repeat_count <= 255:
raise ValueError(f"repeat_count must be 0255, got {repeat_count}")
super().__init__(modulation=self.MODULATION, repeat_count=repeat_count)
self._timings = [
infrared_protocols.Timing(high_us=high, low_us=low) for high, low in timings
]
def get_raw_timings(self) -> list[infrared_protocols.Timing]:
"""Return timing pairs for transmission."""
return self._timings
def timings_to_broadlink_packet(
timings: list[tuple[int, int]],
repeat: int = 0,
) -> bytes:
"""Convert raw timing pairs (high_us, low_us) to a Broadlink IR packet.
Args:
timings: List of (mark_us, space_us) pairs in microseconds.
repeat: Number of extra repeats (0 = send once).
Returns:
Binary packet ready for Broadlink send_data().
"""
if not 0 <= repeat <= 255:
raise ValueError(f"repeat must be 0255, got {repeat}")
# Flatten (mark, space) pairs into a pulse list, omitting any zero-length spaces
pulses: list[int] = []
for high_us, low_us in timings:
pulses.append(high_us)
if low_us:
pulses.append(low_us)
# Use broadlink library's encoder (tick=32.84 µs)
packet = bytearray(_bl_pulses_to_data(pulses))
packet[IR_PACKET_REPEAT_INDEX] = repeat
return bytes(packet)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Broadlink infrared entity."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkInfraredEntity(device)])
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
"""Broadlink infrared transmitter entity."""
_attr_has_entity_name = True
_attr_translation_key = "infrared"
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
super().__init__(device)
self._attr_unique_id = f"{device.unique_id}-infrared"
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command via the Broadlink device.
Handles two types of repeat behavior:
1. Protocol-aware commands (NECCommand, etc.): These encode repeats
(like NEC repeat codes) inside their get_raw_timings() data. The
Broadlink packet is sent with repeat=0.
2. BroadlinkIRCommand: Carries Broadlink hardware repeat count,
which tells the device to re-transmit the entire burst N times.
This is used for protocols/commands that need multiple full frame
transmissions (e.g. legacy SmartIR data).
Using isinstance check ensures protocol-level repeats (already in
timing data) don't get conflated with hardware repeats.
"""
timings = [
(timing.high_us, timing.low_us) for timing in command.get_raw_timings()
]
# Only BroadlinkIRCommand uses Broadlink hardware repeat. Protocol-aware
# commands (NECCommand, etc.) encode repeats inside get_raw_timings()
# and must use hardware repeat=0 to avoid double-repeating.
if isinstance(command, BroadlinkIRCommand):
repeat = command.repeat_count
else:
repeat = 0
packet = timings_to_broadlink_packet(timings, repeat=repeat)
try:
await self._device.async_request(self._device.api.send_data, packet)
except (BroadlinkException, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_command_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -3,7 +3,6 @@
"name": "Broadlink",
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
"config_flow": true,
"dependencies": ["infrared"],
"dhcp": [
{
"registered_devices": true
@@ -49,11 +49,6 @@
}
},
"entity": {
"infrared": {
"infrared": {
"name": "IR transmitter"
}
},
"select": {
"day_of_week": {
"name": "Day of week",
@@ -82,10 +77,5 @@
"name": "Total consumption"
}
}
},
"exceptions": {
"send_command_failed": {
"message": "Failed to send IR command: {error}"
}
}
}
@@ -738,7 +738,10 @@ class CalendarEntity(Entity):
listener(None)
return
event_list: list[JsonValueType] = [event.as_dict() for event in events]
event_list: list[JsonValueType] = [
dataclasses.asdict(event, dict_factory=_list_events_dict_factory)
for event in events
]
listener(event_list)
async def async_get_events(
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/camera",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==1.8.3"]
"requirements": ["PyTurboJPEG==2.2.0"]
}
@@ -50,9 +50,7 @@ ATTR_UID = "uid"
ATTR_LATITUDE = "latitude"
ATTR_LONGITUDE = "longitude"
ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_FREE_EBIKES = "free_ebikes"
ATTR_TIMESTAMP = "timestamp"
EXTRA_EBIKES = "ebikes"
CONF_NETWORK = "network"
CONF_STATIONS_LIST = "stations"
@@ -240,6 +238,5 @@ class CityBikesStation(SensorEntity):
ATTR_LATITUDE: station.latitude,
ATTR_LONGITUDE: station.longitude,
ATTR_EMPTY_SLOTS: station.empty_slots,
ATTR_FREE_EBIKES: station.extra.get(EXTRA_EBIKES),
ATTR_TIMESTAMP: station.timestamp,
}
@@ -6,7 +6,6 @@ import asyncio
from collections.abc import Callable, Coroutine, Sequence
from datetime import datetime, timedelta
import hashlib
import logging
from types import ModuleType
from typing import Any, Final, Protocol, final
@@ -83,8 +82,6 @@ from .const import (
SourceType,
)
_LOGGER = logging.getLogger(__name__)
SERVICE_SEE: Final = "see"
SOURCE_TYPES = [cls.value for cls in SourceType]
@@ -131,8 +128,6 @@ SERVICE_SEE_PAYLOAD_SCHEMA: Final[vol.Schema] = vol.Schema(
YAML_DEVICES: Final = "known_devices.yaml"
EVENT_NEW_DEVICE: Final = "device_tracker_new_device"
DATA_LEGACY_TRACKERS: Final = "device_tracker.legacy_trackers"
class SeeCallback(Protocol):
"""Protocol type for DeviceTracker.see callback."""
@@ -248,19 +243,8 @@ async def _async_setup_integration(
tracker = await get_tracker(hass, config)
tracker_future.set_result(tracker)
warned_called_see = False
async def async_see_service(call: ServiceCall) -> None:
"""Service to see a device."""
nonlocal warned_called_see
if not warned_called_see:
_LOGGER.warning(
"The %s.%s action is deprecated and will be removed in "
"Home Assistant Core 2027.5",
DOMAIN,
SERVICE_SEE,
)
warned_called_see = True
# Temp workaround for iOS, introduced in 0.65
data = dict(call.data)
data.pop("hostname", None)
@@ -343,18 +327,6 @@ class DeviceTrackerPlatform:
try:
scanner = None
setup: bool | None = None
legacy_trackers = hass.data.setdefault(DATA_LEGACY_TRACKERS, set())
if full_name not in legacy_trackers:
legacy_trackers.add(full_name)
_LOGGER.warning(
"The legacy device tracker platform %s is being set up; legacy "
"device trackers are deprecated and will be removed in Home "
"Assistant Core 2027.5, please migrate to an integration which "
"uses a modern config entry based device tracker",
full_name,
)
if hasattr(self.platform, "async_get_scanner"):
scanner = await self.platform.async_get_scanner(
hass, {DOMAIN: self.config}
+1 -74
View File
@@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -32,78 +31,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
_host: str
_box_name: str
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
try:
box_name, mac = await self._validate_input(discovery_info.host)
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
_LOGGER.exception("Unexpected error discovering Duco box via zeroconf")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self._host = discovery_info.host
self._box_name = box_name
self.context["title_placeholders"] = {"name": box_name}
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._box_name,
data={CONF_HOST: self._host},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self._box_name},
)
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:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
_LOGGER.exception("Unexpected error connecting to Duco box")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
title=box_name,
data_updates={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_SCHEMA, reconfigure_entry.data
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -119,7 +46,7 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected error connecting to Duco box")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(mac), raise_on_progress=False)
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
return self.async_create_entry(
+1 -11
View File
@@ -2,9 +2,7 @@
from __future__ import annotations
import logging
from duco.exceptions import DucoError, DucoRateLimitError
from duco.exceptions import DucoError
from duco.models import Node, NodeType, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
@@ -17,8 +15,6 @@ from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
# Permanent speed states ordered low → high.
@@ -122,12 +118,6 @@ class DucoVentilationFanEntity(DucoEntity, FanEntity):
await self.coordinator.client.async_set_ventilation_state(
self._node_id, state
)
except DucoRateLimitError as err:
_LOGGER.warning("Duco write rate limit exceeded for node %s", self._node_id)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="rate_limit_exceeded",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
+2 -8
View File
@@ -7,12 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "silver",
"requirements": ["python-duco-client==0.3.2"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
"type": "_http._tcp.local."
}
]
"quality_scale": "bronze",
"requirements": ["python-duco-client==0.3.1"]
}
@@ -46,8 +46,18 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
discovery-update-info:
status: todo
comment: >-
DHCP host updating to be implemented in a follow-up PR.
The device hostname follows the pattern duco_<last 6 chars of MAC>
(e.g. duco_061293), which can be used for DHCP hostname matching.
discovery:
status: todo
comment: >-
Device can be discovered via DHCP. The hostname follows the pattern
duco_<last 6 chars of MAC> (e.g. duco_061293). To be implemented
in a follow-up PR together with discovery-update-info.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
@@ -66,14 +76,8 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: >-
The integration has no actionable repair scenarios. Connection failures are
handled by the coordinator (unavailable entities) and resolve automatically.
There are no credentials to expire and no versioned API to become
incompatible with.
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: >-
@@ -82,4 +86,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
strict-typing: todo
+1 -20
View File
@@ -1,29 +1,13 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
"unknown": "[%key:common::config_flow::error::unknown%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
"description": "Do you want to set up {name}?"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::duco::config::step::user::data_description::host%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -86,9 +70,6 @@
},
"failed_to_set_state": {
"message": "Failed to set ventilation state: {error}"
},
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
}
}
}
@@ -35,6 +35,7 @@ from aioesphomeapi import (
MediaPlayerInfo,
MediaPlayerSupportedFormat,
NumberInfo,
RadioFrequencyInfo,
SelectInfo,
SensorInfo,
SensorState,
@@ -88,6 +89,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
NumberInfo: Platform.NUMBER,
@@ -0,0 +1,75 @@
"""Radio Frequency platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import (
EntityState,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
ModulationType.OOK: RadioFrequencyModulation.OOK,
}
class EsphomeRadioFrequencyEntity(
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
):
"""ESPHome radio frequency entity using native API."""
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges from device info."""
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self.async_write_ha_state()
@convert_api_error_ha_error
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command."""
timings = command.get_raw_timings()
_LOGGER.debug("Sending RF command: %s", timings)
self._client.radio_frequency_transmit_raw_timings(
self._static_info.key,
frequency=command.frequency,
timings=timings,
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
repeat_count=command.repeat_count + 1,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=RadioFrequencyInfo,
entity_type=EsphomeRadioFrequencyEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities & RadioFrequencyCapability.TRANSMITTER
),
)
+31 -32
View File
@@ -9,11 +9,10 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import EVOHOME_DATA
from .coordinator import EvoDataUpdateCoordinator
from .entity import is_valid_zone, unique_zone_id
from .entity import EvoEntity, is_valid_zone
async def async_setup_platform(
@@ -41,22 +40,32 @@ async def async_setup_platform(
async_add_entities(entities)
for entity in entities:
await entity.update_attrs()
class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEntity):
"""Base for Evohome's Button entities."""
class EvoResetButtonBase(EvoEntity, ButtonEntity):
"""Button entity for system reset."""
_attr_entity_category = EntityCategory.CONFIG
_evo_device: evo.ControlSystem | evo.HotWater | evo.Zone
_evo_state_attr_names = ()
def __init__(
self,
coordinator: EvoDataUpdateCoordinator,
evo_device: evo.ControlSystem | evo.HotWater | evo.Zone,
) -> None:
"""Initialize an Evohome reset button entity."""
super().__init__(coordinator, context=evo_device.id)
self._evo_device = evo_device
"""Initialize the system reset button."""
super().__init__(coordinator, evo_device)
# zones can be renamed, so set name in their property method
if isinstance(evo_device, evo.ControlSystem):
self._attr_name = f"Reset {evo_device.location.name}"
elif not isinstance(evo_device, evo.Zone):
self._attr_name = f"Reset {evo_device.name}"
self._attr_unique_id = f"{evo_device.id}_reset"
async def async_press(self) -> None:
"""Reset the Evohome entity to its base operating mode."""
@@ -66,41 +75,28 @@ class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEnti
class EvoResetSystemButton(EvoResetButtonBase):
"""Button entity for system reset."""
_attr_translation_key = "reset_system_mode"
_evo_device: evo.ControlSystem
def __init__(
self,
coordinator: EvoDataUpdateCoordinator,
evo_device: evo.ControlSystem,
) -> None:
"""Initialize the system reset button."""
super().__init__(coordinator, evo_device)
self._attr_unique_id = f"{evo_device.id}_reset"
self._attr_name = f"Reset {evo_device.location.name}"
_evo_id_attr = "system_id"
class EvoResetDhwButton(EvoResetButtonBase):
"""Button entity for DHW override reset."""
_attr_translation_key = "clear_dhw_override"
_evo_device: evo.HotWater
def __init__(
self,
coordinator: EvoDataUpdateCoordinator,
evo_device: evo.HotWater,
) -> None:
"""Initialize the DHW reset button."""
super().__init__(coordinator, evo_device)
self._attr_unique_id = f"{evo_device.id}_reset"
self._attr_name = f"Reset {evo_device.name}"
_evo_id_attr = "dhw_id"
class EvoResetZoneButton(EvoResetButtonBase):
"""Button entity for zone override reset."""
_attr_translation_key = "clear_zone_override"
_evo_device: evo.Zone
_evo_id_attr = "zone_id"
def __init__(
self,
@@ -109,9 +105,12 @@ class EvoResetZoneButton(EvoResetButtonBase):
) -> None:
"""Initialize the zone reset button."""
super().__init__(coordinator, evo_device)
self._attr_unique_id = f"{unique_zone_id(evo_device)}_reset"
if evo_device.id == evo_device.tcs.id:
# this system does not have a distinct ID for the zone
self._attr_unique_id = f"{evo_device.id}z_reset"
@property
def name(self) -> str:
"""Return the name, dynamically following any zone rename."""
"""Return the name of the evohome entity."""
return f"Reset {self._evo_device.name}"
+8 -2
View File
@@ -49,7 +49,7 @@ from .const import (
EvoService,
)
from .coordinator import EvoDataUpdateCoordinator
from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id
from .entity import EvoChild, EvoEntity, is_valid_zone
from .helpers import async_create_deprecation_issue_once
_LOGGER = logging.getLogger(__name__)
@@ -170,8 +170,13 @@ class EvoZone(EvoChild, EvoClimateEntity):
"""Initialize an evohome-compatible heating zone."""
super().__init__(coordinator, evo_device)
self._evo_id = evo_device.id
self._attr_unique_id = unique_zone_id(evo_device)
if evo_device.id == evo_device.tcs.id:
# this system does not have a distinct ID for the zone
self._attr_unique_id = f"{evo_device.id}z"
else:
self._attr_unique_id = evo_device.id
if coordinator.client_v1:
self._attr_precision = PRECISION_TENTHS
@@ -347,6 +352,7 @@ class EvoController(EvoClimateEntity):
"""Initialize an evohome-compatible controller."""
super().__init__(coordinator, evo_device)
self._evo_id = evo_device.id
self._attr_unique_id = evo_device.id
self._attr_name = evo_device.location.name
+5 -13
View File
@@ -28,19 +28,12 @@ def is_valid_zone(zone: evo.Zone) -> bool:
)
def unique_zone_id(evo_device: evo.Zone) -> str:
"""Return a unique identifier for a zone-based entity.
Some systems assign the zone the same ID as its parent TCS; in that case
we append 'z' so the zone entity doesn't collide with the controller entity.
"""
if evo_device.id == evo_device.tcs.id:
return f"{evo_device.id}z"
return evo_device.id
class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
"""Base for Evohome's Climate & WaterHeater entities."""
"""Base for any evohome-compatible entity (controller, DHW, zone).
This includes the controller, (1 to 12) heating zones and (optionally) a
DHW controller.
"""
_evo_device: evo.ControlSystem | evo.HotWater | evo.Zone
_evo_id_attr: str
@@ -93,7 +86,6 @@ class EvoChild(EvoEntity):
"""Initialize an evohome-compatible child entity (DHW, zone)."""
super().__init__(coordinator, evo_device)
self._evo_id = evo_device.id
self._evo_tcs = evo_device.tcs
self._schedule: list[DayOfWeekDhwT] | None = None
@@ -70,6 +70,7 @@ async def async_setup_platform(
class EvoDHW(EvoChild, WaterHeaterEntity):
"""Base for any evohome-compatible DHW controller."""
_attr_name = "DHW controller"
_attr_operation_list = list(HA_STATE_TO_EVO)
_attr_supported_features = (
WaterHeaterEntityFeature.AWAY_MODE
@@ -88,6 +89,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
"""Initialize an evohome-compatible DHW controller."""
super().__init__(coordinator, evo_device)
self._evo_id = evo_device.id
self._attr_unique_id = evo_device.id
self._attr_name = evo_device.name # is static
@@ -19,7 +19,7 @@
"data_description": {
"api_key": "The new API access token for authenticating with Firefly III"
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Remote access and tokens**. Create a new personal access token and copy it (it will only display once)."
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
},
"reconfigure": {
"data": {
@@ -46,7 +46,7 @@
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "Verify the SSL certificate of the Firefly III instance"
},
"description": "You can create an API key in the Firefly III UI. Go to **Options > Remote access and tokens**. Create a new personal access token and copy it (it will only display once)."
"description": "You can create an API key in the Firefly III UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
}
}
},
@@ -198,7 +198,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
def is_matching(self, other_flow: Self) -> bool:
"""Return True if other_flow is matching this flow."""
return other_flow._host == self._host
return other_flow._host == self._host # noqa: SLF001
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -148,7 +148,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
def is_matching(self, other_flow: Self) -> bool:
"""Return True if other_flow is matching this flow."""
return other_flow._host == self._host
return other_flow._host == self._host # noqa: SLF001
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -26,7 +26,6 @@ from homeassistant.components.media_player import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import FrontierSiliconConfigEntry
from .browse_media import browse_node, browse_top_level
@@ -119,8 +118,7 @@ class AFSAPIDevice(MediaPlayerEntity):
features |= MediaPlayerEntityFeature.REPEAT_SET
if self.__play_caps & PlayCaps.SHUFFLE:
features |= MediaPlayerEntityFeature.SHUFFLE_SET
if self.__play_caps & PlayCaps.SEEK:
features |= MediaPlayerEntityFeature.SEEK
if self._supports_sound_mode:
features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
@@ -225,21 +223,6 @@ class AFSAPIDevice(MediaPlayerEntity):
self._attr_is_volume_muted = await afsapi.get_mute()
self._attr_media_image_url = await afsapi.get_play_graphic()
if self.__play_caps and self.__play_caps & PlayCaps.SEEK:
position_ms = await afsapi.get_play_position()
duration_ms = await afsapi.get_play_duration()
self._attr_media_position = (
position_ms // 1000 if position_ms is not None else None
)
self._attr_media_duration = (
duration_ms // 1000 if duration_ms is not None else None
)
self._attr_media_position_updated_at = dt_util.utcnow()
else:
self._attr_media_position = None
self._attr_media_duration = None
self._attr_media_position_updated_at = None
if self._supports_sound_mode:
try:
eq_preset = await afsapi.get_eq_preset()
@@ -264,9 +247,6 @@ class AFSAPIDevice(MediaPlayerEntity):
self._attr_is_volume_muted = None
self._attr_media_image_url = None
self._attr_sound_mode = None
self._attr_media_position = None
self._attr_media_duration = None
self._attr_media_position_updated_at = None
self._attr_volume_level = None
@@ -354,10 +334,6 @@ class AFSAPIDevice(MediaPlayerEntity):
"""Set shuffle mode."""
await self.fs_device.set_play_shuffle(shuffle)
async def async_media_seek(self, position: float) -> None:
"""Seek to a position in seconds."""
await self.fs_device.set_play_position(int(position * 1000))
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
@@ -1,26 +0,0 @@
"""Support for Fumis pellet stoves."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool:
"""Set up Fumis from a config entry."""
coordinator = FumisDataUpdateCoordinator(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: FumisConfigEntry) -> bool:
"""Unload Fumis config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-128
View File
@@ -1,128 +0,0 @@
"""Support for Fumis climate entities."""
from __future__ import annotations
from typing import Any
from fumis import StoveStatus
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
from .entity import FumisEntity
from .helpers import fumis_exception_handler
PARALLEL_UPDATES = 1
STOVE_STATUS_TO_HVAC_ACTION: dict[StoveStatus, HVACAction | None] = {
StoveStatus.OFF: HVACAction.OFF,
StoveStatus.COLD_START_OFF: HVACAction.OFF,
StoveStatus.WOOD_BURNING_OFF: HVACAction.OFF,
StoveStatus.PRE_HEATING: HVACAction.PREHEATING,
StoveStatus.IGNITION: HVACAction.PREHEATING,
StoveStatus.PRE_COMBUSTION: HVACAction.PREHEATING,
StoveStatus.COLD_START: HVACAction.PREHEATING,
StoveStatus.COMBUSTION: HVACAction.HEATING,
StoveStatus.ECO: HVACAction.HEATING,
StoveStatus.HYBRID_INIT: HVACAction.HEATING,
StoveStatus.HYBRID_START: HVACAction.HEATING,
StoveStatus.WOOD_START: HVACAction.HEATING,
StoveStatus.WOOD_COMBUSTION: HVACAction.HEATING,
StoveStatus.COOLING: HVACAction.IDLE,
StoveStatus.UNKNOWN: None,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: FumisConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fumis climate entity based on a config entry."""
async_add_entities([FumisClimateEntity(entry.runtime_data)])
class FumisClimateEntity(FumisEntity, ClimateEntity):
"""Defines a Fumis climate entity."""
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_max_temp = 35.0
_attr_min_temp = 10.0
_attr_name = None
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_target_temperature_step = 0.5
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, coordinator: FumisDataUpdateCoordinator) -> None:
"""Initialize the Fumis climate entity."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.config_entry.unique_id
@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC mode."""
if self.coordinator.data.controller.on:
return HVACMode.HEAT
return HVACMode.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current HVAC action."""
return STOVE_STATUS_TO_HVAC_ACTION[
self.coordinator.data.controller.stove_status
]
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if (temp := self.coordinator.data.controller.main_temperature) is None:
return None
return temp.actual
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
if (temp := self.coordinator.data.controller.main_temperature) is None:
return None
return temp.setpoint
@fumis_exception_handler
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
if hvac_mode == HVACMode.HEAT:
await self.coordinator.client.turn_on()
else:
await self.coordinator.client.turn_off()
await self.coordinator.async_request_refresh()
@fumis_exception_handler
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.client.set_target_temperature(temperature)
await self.coordinator.async_request_refresh()
@fumis_exception_handler
async def async_turn_on(self) -> None:
"""Turn on the stove."""
await self.coordinator.client.turn_on()
await self.coordinator.async_request_refresh()
@fumis_exception_handler
async def async_turn_off(self) -> None:
"""Turn off the stove."""
await self.coordinator.client.turn_off()
await self.coordinator.async_request_refresh()
@@ -1,82 +0,0 @@
"""Config flow to configure the Fumis integration."""
from __future__ import annotations
from typing import Any
from fumis import (
Fumis,
FumisAuthenticationError,
FumisConnectionError,
FumisStoveOfflineError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_MAC, CONF_PIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN, LOGGER
class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Fumis config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
mac = user_input[CONF_MAC].replace(":", "").replace("-", "").upper()
fumis = Fumis(
mac=mac,
password=user_input[CONF_PIN],
session=async_get_clientsession(self.hass),
)
try:
info = await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(mac), raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info.controller.model_name or "Fumis",
data={
CONF_MAC: mac,
CONF_PIN: user_input[CONF_PIN],
},
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_MAC): TextSelector(
TextSelectorConfig(autocomplete="off")
),
vol.Required(CONF_PIN): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
user_input,
),
errors=errors,
)
-11
View File
@@ -1,11 +0,0 @@
"""Constants for the Fumis integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Final
DOMAIN: Final = "fumis"
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(seconds=30)
@@ -1,70 +0,0 @@
"""DataUpdateCoordinator for Fumis."""
from __future__ import annotations
from fumis import (
Fumis,
FumisAuthenticationError,
FumisConnectionError,
FumisError,
FumisInfo,
FumisStoveOfflineError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
type FumisConfigEntry = ConfigEntry[FumisDataUpdateCoordinator]
class FumisDataUpdateCoordinator(DataUpdateCoordinator[FumisInfo]):
"""Class to manage fetching Fumis data."""
config_entry: FumisConfigEntry
def __init__(self, hass: HomeAssistant, entry: FumisConfigEntry) -> None:
"""Initialize the coordinator."""
self.client = Fumis(
mac=entry.data[CONF_MAC],
password=entry.data[CONF_PIN],
session=async_get_clientsession(hass),
)
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=f"{DOMAIN}_{entry.unique_id}",
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> FumisInfo:
"""Fetch data from the Fumis API."""
try:
return await self.client.update_info()
except FumisAuthenticationError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from err
except FumisStoveOfflineError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="stove_offline",
) from err
except FumisConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err
except FumisError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(err)},
) from err
-35
View File
@@ -1,35 +0,0 @@
"""Base entity for the Fumis integration."""
from __future__ import annotations
from homeassistant.const import CONF_MAC
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FumisDataUpdateCoordinator
class FumisEntity(CoordinatorEntity[FumisDataUpdateCoordinator]):
"""Defines a Fumis entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: FumisDataUpdateCoordinator) -> None:
"""Initialize a Fumis entity."""
super().__init__(coordinator=coordinator)
info = coordinator.data
mac = format_mac(coordinator.config_entry.data[CONF_MAC])
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=info.controller.manufacturer or "Fumis",
model=info.controller.model_name,
name=info.controller.model_name or "Pellet stove",
sw_version=str(info.controller.version),
hw_version=str(info.unit.version),
)
-63
View File
@@ -1,63 +0,0 @@
"""Helpers for Fumis."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from fumis import (
FumisAuthenticationError,
FumisConnectionError,
FumisError,
FumisStoveOfflineError,
)
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import FumisEntity
def fumis_exception_handler[_FumisEntityT: FumisEntity, **_P](
func: Callable[Concatenate[_FumisEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_FumisEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Fumis calls to handle exceptions.
A decorator that wraps the passed in function, catches Fumis errors.
"""
async def handler(self: _FumisEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
self.coordinator.async_update_listeners()
except FumisAuthenticationError as error:
self.hass.config_entries.async_schedule_reload(
self.coordinator.config_entry.entry_id
)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from error
except FumisStoveOfflineError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stove_offline",
) from error
except FumisConnectionError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except FumisError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(error)},
) from error
return handler
@@ -1,12 +0,0 @@
{
"domain": "fumis",
"name": "Fumis",
"codeowners": ["@frenck"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fumis",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["fumis"],
"quality_scale": "bronze",
"requirements": ["fumis==0.2.1"]
}
@@ -1,78 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This 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: This integration does not have any custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery:
status: todo
comment: DHCP discovery can be added.
discovery-update-info:
status: todo
comment: DHCP discovery based update can be added.
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: This integration connects to a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not raise any repairable issues.
stale-devices:
status: exempt
comment: This integration connects to a single device.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -1,40 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"device_offline": "Your stove's Fumis WiRCU Wi-Fi module is not connected to the internet. Make sure the module has power and is connected to your Wi-Fi network.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"mac": "MAC address",
"pin": "PIN code"
},
"data_description": {
"mac": "The MAC address is a unique code of letters and numbers that identifies your stove. You can find it on the label of the Fumis WiRCU Wi-Fi module connected to your stove.",
"pin": "You can find the PIN code on the label of the Fumis WiRCU Wi-Fi module connected to your stove."
},
"description": "Integrate your Fumis-based pellet stove with Home Assistant to monitor and control it. You can see your stove's temperature, heating status, and adjust the target temperature right from your dashboard or use it in your automations. This way, you can make sure your home is always nice, warm, and comfortable."
}
}
},
"exceptions": {
"authentication_error": {
"message": "Authentication with the Fumis online service failed. Check your MAC address and PIN code."
},
"communication_error": {
"message": "An error occurred while communicating with the Fumis online service: {error}"
},
"stove_offline": {
"message": "Your stove's Fumis WiRCU Wi-Fi module is not connected to the internet."
},
"unknown_error": {
"message": "An unexpected error occurred while communicating with the Fumis online service: {error}"
}
}
}
@@ -133,7 +133,7 @@ DESCRIPTIONS = (
key=FlowStatistics.overall.unique_id,
translation_key="flow_statistics_overall",
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.WATER,
device_class=SensorDeviceClass.VOLUME,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.LITERS,
char=FlowStatistics.overall,
@@ -141,7 +141,6 @@ DESCRIPTIONS = (
GardenaBluetoothSensorEntityDescription(
key=FlowStatistics.current.unique_id,
translation_key="flow_statistics_current",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
@@ -151,7 +150,7 @@ DESCRIPTIONS = (
key=FlowStatistics.resettable.unique_id,
translation_key="flow_statistics_resettable",
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.WATER,
device_class=SensorDeviceClass.VOLUME,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.LITERS,
char=FlowStatistics.resettable,
@@ -167,7 +166,6 @@ DESCRIPTIONS = (
GardenaBluetoothSensorEntityDescription(
key=Spray.current_distance.unique_id,
translation_key="spray_current_distance",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
char=Spray.current_distance,
@@ -75,7 +75,7 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN):
def is_matching(self, other_flow: Self) -> bool:
"""Return True if other_flow is matching this flow."""
return other_flow._ip_address == self._ip_address
return other_flow._ip_address == self._ip_address # noqa: SLF001
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -256,13 +256,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Invalid response format during login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
if not login_response.get("success"):
if login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
return self._async_show_password_form({"base": ERROR_INVALID_AUTH})
_LOGGER.debug(
"Growatt login failed: %s", login_response.get("msg", "Unknown error")
)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
return self._async_show_password_form({"base": ERROR_INVALID_AUTH})
self.user_id = login_response["user"]["id"]
self.data = user_input
+32 -3
View File
@@ -25,15 +25,17 @@ from aiohasupervisor.models import (
SupervisorOptions,
YellowOptions,
)
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken
from homeassistant.components import frontend
from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
StaticPathConfig,
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
@@ -154,7 +156,12 @@ _LOGGER = logging.getLogger(__name__)
# wait for the import of the platforms
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
CONF_FRONTEND_REPO = "development_repo"
CONFIG_SCHEMA = vol.Schema(
{vol.Optional(DOMAIN): vol.Schema({vol.Optional(CONF_FRONTEND_REPO): cv.isdir})},
extra=vol.ALLOW_EXTRA,
)
DEPRECATION_URL = (
@@ -191,7 +198,7 @@ def hostname_from_addon_slug(addon_slug: str) -> str:
return addon_slug.replace("_", "-")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Set up the Hass.io component."""
# Check local setup
for env in ("SUPERVISOR", "SUPERVISOR_TOKEN"):
@@ -243,8 +250,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
refresh_token = await hass.auth.async_create_refresh_token(user)
config_store.update(hassio_user=user.id)
# This overrides the normal API call that would be forwarded
development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO)
if development_repo is not None:
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/api/hassio/app",
os.path.join(development_repo, "hassio/build"),
False,
)
]
)
hass.http.register_view(HassIOView(host, websession))
await panel_custom.async_register_panel(
hass,
frontend_url_path="hassio",
webcomponent_name="hassio-main",
js_url="/api/hassio/app/entrypoint.js",
embed_iframe=True,
require_admin=True,
)
async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken):
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
+15 -4
View File
@@ -14,6 +14,7 @@ from aiohttp import web
from aiohttp.client import ClientTimeout
from aiohttp.hdrs import (
AUTHORIZATION,
CACHE_CONTROL,
CONTENT_ENCODING,
CONTENT_LENGTH,
CONTENT_TYPE,
@@ -80,13 +81,20 @@ PATHS_ADMIN = re.compile(
r")$"
)
# Unauthenticated requests come in for add-on images
# Unauthenticated requests come in for Supervisor panel + add-on images
PATHS_NO_AUTH = re.compile(
r"^(?:"
r"|app/.*"
r"|(store/)?addons/[^/]+/(logo|icon)"
r")$"
)
NO_STORE = re.compile(
r"^(?:"
r"|app/entrypoint.js"
r")$"
)
# Follow logs should not be compressed, to be able to get streamed by frontend
NO_COMPRESS = re.compile(
r"^(?:"
@@ -210,7 +218,7 @@ class HassIOView(HomeAssistantView):
# Stream response
response = web.StreamResponse(
status=client.status, headers=_response_header(client)
status=client.status, headers=_response_header(client, path)
)
response.content_type = client.content_type
@@ -235,13 +243,16 @@ class HassIOView(HomeAssistantView):
post = _handle
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
"""Create response header."""
return {
headers = {
name: value
for name, value in response.headers.items()
if name not in RESPONSE_HEADERS_FILTER
}
if NO_STORE.match(path):
headers[CACHE_CONTROL] = "no-store, max-age=0"
return headers
def _get_timeout(path: str) -> ClientTimeout:
-1
View File
@@ -81,7 +81,6 @@ MODEL_INPUTS = {
"XLR 2",
"Analog 1",
"Analog 2",
"Analog 3",
"BNC",
"Coaxial",
"Optical 1",
+1 -1
View File
@@ -10,5 +10,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.9"]
"requirements": ["pyhive-integration==1.0.8"]
}
@@ -452,16 +452,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
"arch": arch,
},
)
if not info["docker"] and not info["virtualenv"]:
ir.async_create_issue(
hass,
DOMAIN,
"unsupported_local_deps",
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="unsupported_local_deps",
)
# Delay deprecation check to make sure installation method is determined correctly
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_check_deprecation)
@@ -106,12 +106,12 @@
"title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]"
},
"deprecated_method": {
"description": "This system is using the {installation_type} installation type, which has been unsupported since Home Assistant 2025.12. To continue receiving updates and support, migrate to a supported installation method.",
"title": "Unsupported installation method"
"description": "This system is using the {installation_type} installation type, which has been deprecated and will become unsupported following the release of Home Assistant 2025.12. While you can continue using your current setup after that point, we strongly recommend migrating to a supported installation method.",
"title": "Deprecation notice: Installation method"
},
"deprecated_method_architecture": {
"description": "This system is using the {installation_type} installation type, and 32-bit hardware (`{arch}`), both of which have been unsupported since Home Assistant 2025.12. To continue receiving updates and support, migrate to supported hardware and use a supported installation method.",
"title": "Unsupported installation method and architecture"
"description": "This system is using the {installation_type} installation type, and 32-bit hardware (`{arch}`), both of which have been deprecated and will no longer be supported after the release of Home Assistant 2025.12.",
"title": "Deprecation notice"
},
"deprecated_os_aarch64": {
"description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. To continue using Home Assistant on this hardware, you will need to install a 64-bit operating system. Please refer to our [installation guide]({installation_guide}).",
@@ -203,10 +203,6 @@
}
},
"title": "Storage corruption detected for {storage_key}"
},
"unsupported_local_deps": {
"description": "This system is running Home Assistant outside a virtual environment or a Docker container. This is not supported and will not work after the release of Home Assistant 2026.11.",
"title": "Deprecation notice: Installation method"
}
},
"services": {
@@ -7,7 +7,8 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==1.1.0",
"serialx==1.2.2",
"universal-silabs-flasher==1.0.3",
"ha-silabs-firmware-client==0.3.0"
]
}
@@ -0,0 +1,20 @@
"""The Honeywell String Lights integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Honeywell String Lights from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,61 @@
"""Config flow for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Any
from rf_protocols import RadioFrequencyCommand
import voluptuous as vol
from homeassistant.components.radio_frequency import async_get_transmitters
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .const import CONF_TRANSMITTER, DOMAIN
from .light import COMMANDS
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Honeywell String Lights."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job(
COMMANDS.load_command, "turn_on"
)
try:
transmitters = async_get_transmitters(
self.hass, sample_command.frequency, sample_command.modulation
)
except HomeAssistantError:
return self.async_abort(reason="no_transmitters")
if not transmitters:
return self.async_abort(reason="no_compatible_transmitters")
if user_input is not None:
registry = er.async_get(self.hass)
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
assert entity_entry is not None
await self.async_set_unique_id(entity_entry.id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Honeywell String Lights",
data={CONF_TRANSMITTER: entity_entry.id},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
selector.EntitySelectorConfig(include_entities=transmitters),
),
}
),
)
@@ -0,0 +1,9 @@
"""Constants for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Final
DOMAIN: Final = "honeywell_string_lights"
CONF_TRANSMITTER: Final = "transmitter"
@@ -0,0 +1,77 @@
"""Common entity for Honeywell String Lights integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_TRANSMITTER, DOMAIN
_LOGGER = logging.getLogger(__name__)
class HoneywellStringLightsEntity(Entity):
"""Honeywell String Lights base entity."""
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the entity."""
self._transmitter = entry.data[CONF_TRANSMITTER]
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Honeywell",
model="String Lights",
name=entry.title,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to transmitter entity state changes."""
await super().async_added_to_hass()
transmitter_entity_id = er.async_validate_entity_id(
er.async_get(self.hass), self._transmitter
)
@callback
def _async_transmitter_state_changed(
event: Event[EventStateChangedData],
) -> None:
"""Handle transmitter entity state changes."""
new_state = event.data["new_state"]
transmitter_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
if transmitter_available != self.available:
_LOGGER.info(
"Transmitter %s used by %s is %s",
transmitter_entity_id,
self.entity_id,
"available" if transmitter_available else "unavailable",
)
self._attr_available = transmitter_available
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass,
[transmitter_entity_id],
_async_transmitter_state_changed,
)
)
# Set initial availability based on current transmitter entity state
transmitter_state = self.hass.states.get(transmitter_entity_id)
self._attr_available = (
transmitter_state is not None
and transmitter_state.state != STATE_UNAVAILABLE
)
@@ -0,0 +1,64 @@
"""Light platform for Honeywell String Lights."""
from __future__ import annotations
from typing import Any
from rf_protocols import get_codes
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .entity import HoneywellStringLightsEntity
PARALLEL_UPDATES = 1
COMMANDS = get_codes("honeywell/string_lights")
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell String Lights light platform."""
async_add_entities([HoneywellStringLight(config_entry)])
class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity):
"""Representation of a Honeywell String Lights set controlled via RF."""
_attr_assumed_state = True
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_is_on = False
_attr_name = None
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Restore last known state."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
self._attr_is_on = last_state.state == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
await self._async_send_command("turn_on")
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self._async_send_command("turn_off")
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await self.hass.async_add_executor_job(COMMANDS.load_command, name)
await async_send_command(self.hass, self._transmitter, command)
@@ -0,0 +1,12 @@
{
"domain": "honeywell_string_lights",
"name": "Honeywell String Lights",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["radio_frequency"],
"documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze",
"requirements": ["rf-protocols==2.0.0"]
}
@@ -0,0 +1,124 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not register custom service actions.
appropriate-polling:
status: exempt
comment: |
This integration transmits RF commands and does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not register custom service actions.
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:
status: exempt
comment: |
This integration does not use runtime data.
test-before-configure:
status: exempt
comment: |
RF transmission is a one-way broadcast with no device to contact.
test-before-setup:
status: exempt
comment: |
RF transmission is a one-way broadcast with no device to contact.
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no options.
docs-installation-parameters: todo
entity-unavailable:
status: exempt
comment: |
RF transmission is a one-way broadcast; the light uses assumed state.
integration-owner: done
log-when-unavailable:
status: exempt
comment: |
RF transmission is a one-way broadcast; the light uses assumed state.
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not authenticate.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
RF devices cannot be discovered.
docs-data-update:
status: exempt
comment: |
RF transmission is one-way; there is no data update.
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry represents a single static device.
entity-category:
status: exempt
comment: |
The single entity represents the primary device function.
entity-device-class:
status: exempt
comment: |
Light entities do not have device classes.
entity-disabled-by-default:
status: exempt
comment: |
The single entity represents the primary device function.
entity-translations:
status: exempt
comment: |
The entity uses the device name.
exception-translations: todo
icon-translations:
status: exempt
comment: |
Light uses the default icon for its state.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No known repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry represents a single static device.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
This integration does not use a web session.
strict-typing: todo
@@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.",
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first."
},
"step": {
"user": {
"data": {
"transmitter": "Radio frequency transmitter"
},
"data_description": {
"transmitter": "The radio frequency transmitter used to control the Honeywell String Lights."
}
}
}
}
}
@@ -11,8 +11,7 @@ from automower_ble.protocol import ResponseResult
from bleak import BleakError
from bleak_retry_connector import get_device
from gardena_bluetooth.const import ScanService
from gardena_bluetooth.parse import ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.parse import ManufacturerData, ProductType
import voluptuous as vol
from homeassistant.components import bluetooth
@@ -38,6 +37,43 @@ USER_SCHEMA = vol.Schema(
REAUTH_SCHEMA = BLUETOOTH_SCHEMA
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
LOGGER.debug(
"Unsupported device, missing service %s: %s", ScanService, discovery_info
)
return False
if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)):
LOGGER.debug(
"Unsupported device, missing manufacturer data %s: %s",
ManufacturerData.company,
discovery_info,
)
return False
manufacturer_data = ManufacturerData.decode(data)
product_type = ProductType.from_manufacturer_data(manufacturer_data)
# Some mowers only expose the serial number in the manufacturer data
# and not the product type, so we allow None here as well.
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
return False
if not manufacturer_data.pairable:
LOGGER.error(
"The mower does not appear to be pairable. "
"Ensure the mower is in pairing mode before continuing. "
"If the mower isn't pariable you will receive authentication "
"errors and be unable to connect"
)
LOGGER.debug("Supported device: %s", manufacturer_data)
return True
def _pin_valid(pin: str) -> bool:
"""Check if the pin is valid."""
try:
@@ -55,32 +91,6 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
address: str | None = None
mower_name: str = ""
pin: str | None = None
pairable: bool | None = None
async def _is_supported(self, discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
LOGGER.debug(
"Unsupported device, missing service %s: %s",
ScanService,
discovery_info,
)
return False
manufacturer_data = (
await async_get_manufacturer_data({discovery_info.address})
)[discovery_info.address]
if manufacturer_data.product_type != ProductType.MOWER:
LOGGER.debug(
"Unsupported device: %s (%s)", manufacturer_data, discovery_info
)
return False
self.pairable = manufacturer_data.pairable
LOGGER.debug("Supported device: %s", manufacturer_data)
return True
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
@@ -88,7 +98,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the bluetooth discovery step."""
LOGGER.debug("Discovered device: %s", discovery_info)
if not await self._is_supported(discovery_info):
if not _is_supported(discovery_info):
return self.async_abort(reason="no_devices_found")
self.context["title_placeholders"] = {
@@ -112,13 +122,6 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_pin"
else:
self.pin = user_input[CONF_PIN]
if self.pairable is False:
LOGGER.warning(
"The mower does not appear to be pairable. "
"Ensure the mower is in pairing mode before continuing. "
"If the mower isn't pairable you will receive authentication "
"errors and be unable to connect"
)
return await self.check_mower(user_input)
return self.async_show_form(
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2026.4.0"]
"requirements": ["pydrawise==2026.3.0"]
}
@@ -4,9 +4,6 @@
"hydrological_alert": {
"default": "mdi:alert-octagon-outline"
},
"ice_phenomena": {
"default": "mdi:snowflake"
},
"water_flow": {
"default": "mdi:waves-arrow-right"
},
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.1.1"]
"requirements": ["imgw_pib==2.1.0"]
}
+1 -14
View File
@@ -16,12 +16,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
UnitOfLength,
UnitOfTemperature,
UnitOfVolumeFlowRate,
)
from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -65,14 +60,6 @@ SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = (
value=lambda data: data.hydrological_alert.value,
attrs=gen_alert_attributes,
),
ImgwPibSensorEntityDescription(
key="ice_phenomena",
translation_key="ice_phenomena",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.ice_phenomena.value,
suggested_display_precision=0,
),
ImgwPibSensorEntityDescription(
key="water_flow",
translation_key="water_flow",
@@ -59,9 +59,6 @@
}
}
},
"ice_phenomena": {
"name": "Ice phenomena"
},
"water_flow": {
"name": "Water flow"
},
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated",
"loggers": ["hdate"],
"requirements": ["hdate[astral]==1.2.1"],
"requirements": ["hdate[astral]==1.1.2"],
"single_config_entry": true
}
@@ -62,6 +62,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
Platform.RADIO_FREQUENCY,
Platform.SENSOR,
Platform.SWITCH,
Platform.WEATHER,
@@ -0,0 +1,67 @@
"""Demo platform that offers a fake radio frequency entity."""
from __future__ import annotations
from rf_protocols import RadioFrequencyCommand
from homeassistant.components import persistent_notification
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo radio frequency platform."""
async_add_entities(
[
DemoRadioFrequency(
unique_id="rf_transmitter",
device_name="RF Blaster",
entity_name="Radio Frequency Transmitter",
),
]
)
class DemoRadioFrequency(RadioFrequencyTransmitterEntity):
"""Representation of a demo radio frequency entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
"""Initialize the demo radio frequency entity."""
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges."""
return [(300_000_000, 928_000_000)]
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command."""
persistent_notification.async_create(
self.hass,
str(command.get_raw_timings()),
title="Radio Frequency Command",
)
+1 -1
View File
@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.8.2",
"knx-frontend==2026.4.19.175239"
"knx-frontend==2026.3.28.223133"
],
"single_config_entry": true
}
+5 -10
View File
@@ -154,10 +154,6 @@
}
},
"config_panel": {
"common": {
"group_address": "Group address",
"group_addresses": "Group addresses"
},
"dashboard": {
"connection_flow": {
"description": "Reconfigure KNX connection or import a new KNX keyring file",
@@ -964,7 +960,6 @@
"description": "Minimum time between consecutive sends. This can be used to prevent high traffic on the KNX bus when values change very frequently. Only the most recent value during the cooldown period is sent.",
"label": "Cooldown"
},
"copy_info": "Copying options of {entity_name} ({entity_id}).",
"default": {
"description": "The value to send if the entity state is `unavailable` or `unknown`, or if the attribute is not set. If `default` is omitted, nothing is sent in these cases, but the last known value remains available for read requests.",
"label": "Default value"
@@ -974,7 +969,7 @@
"label": "Entity"
},
"ga": {
"label": "[%key:component::knx::config_panel::common::group_address%]"
"label": "Group address"
},
"periodic_send": {
"description": "Time interval to automatically resend the current value to the KNX bus, even if it hasnt changed.",
@@ -1218,7 +1213,7 @@
"fields": {
"address": {
"description": "Group address(es) that shall be added or removed. Lists are allowed.",
"name": "[%key:component::knx::config_panel::common::group_address%]"
"name": "[%key:component::knx::services::send::fields::address::name%]"
},
"remove": {
"description": "Whether the group address(es) will be removed.",
@@ -1236,7 +1231,7 @@
"fields": {
"address": {
"description": "Group address state or attribute updates will be sent to. GroupValueRead requests will be answered. Per address only one exposure can be registered.",
"name": "[%key:component::knx::config_panel::common::group_address%]"
"name": "[%key:component::knx::services::send::fields::address::name%]"
},
"attribute": {
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set, the state will be sent. Eg. for a light the state is either “on” or “off” - with attribute you can expose its “brightness”.",
@@ -1266,7 +1261,7 @@
"fields": {
"address": {
"description": "Group address(es) to send read request to. Lists will read multiple group addresses.",
"name": "[%key:component::knx::config_panel::common::group_address%]"
"name": "[%key:component::knx::services::send::fields::address::name%]"
}
},
"name": "Read from KNX bus"
@@ -1280,7 +1275,7 @@
"fields": {
"address": {
"description": "Group address(es) to write to. Lists will send to multiple group addresses successively.",
"name": "[%key:component::knx::config_panel::common::group_address%]"
"name": "Group address"
},
"payload": {
"description": "Payload to send to the bus. Integers are treated as DPT 1/2/3 payloads. For DPTs > 6 bits send a list. Each value represents 1 octet (0-255). Pad with 0 to DPT byte length.",
@@ -70,7 +70,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None):
media_content_id=search_id,
media_content_type=search_type,
title=title,
can_play=bool(search_type in PLAYABLE_MEDIA_TYPES and search_id),
can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id,
can_expand=True,
children=children,
thumbnail=thumbnail,
@@ -57,11 +57,11 @@ class LgIrTvMediaPlayer(LgIrEntity, MediaPlayerEntity):
async def async_turn_on(self) -> None:
"""Turn on the TV."""
await self._send_command(LGTVCode.POWER_ON)
await self._send_command(LGTVCode.POWER)
async def async_turn_off(self) -> None:
"""Turn off the TV."""
await self._send_command(LGTVCode.POWER_OFF)
await self._send_command(LGTVCode.POWER)
async def async_volume_up(self) -> None:
"""Send volume up command."""
@@ -197,12 +197,6 @@ def _parse_event(event: dict[str, Any]) -> Event:
and value.tzinfo is not None
):
event[key] = dt_util.as_local(value).replace(tzinfo=None)
# UNTIL in the rrule must be floating (timezone-naive) to match the
# floating dtstart used by the ical library. Strip tzinfo from UNTIL
# if present, converting to local time first.
if (rrule_obj := event.get(EVENT_RRULE)) and isinstance(rrule_obj, Recur):
if isinstance(rrule_obj.until, datetime) and rrule_obj.until.tzinfo is not None:
rrule_obj.until = dt_util.as_local(rrule_obj.until).replace(tzinfo=None)
try:
return Event(**event)
+7 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Collection, Mapping
from collections.abc import Callable, Mapping
from typing import Any
from homeassistant.components.sensor import ATTR_STATE_CLASS, NON_NUMERIC_DEVICE_CLASSES
@@ -75,12 +75,12 @@ def _async_config_entries_for_ids(
def async_determine_event_types(
hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
) -> set[EventType[Any] | str]:
) -> tuple[EventType[Any] | str, ...]:
"""Reduce the event types based on the entity ids and device ids."""
logbook_config: LogbookConfig = hass.data[DOMAIN]
external_events = logbook_config.external_events
if not entity_ids and not device_ids:
return {*BUILT_IN_EVENTS, *external_events}
return (*BUILT_IN_EVENTS, *external_events)
interested_domains: set[str] = set()
for entry_id in _async_config_entries_for_ids(hass, entity_ids, device_ids):
@@ -93,16 +93,16 @@ def async_determine_event_types(
# to add them since we have historically included
# them when matching only on entities
#
interested_event_types: set[EventType[Any] | str] = {
intrested_event_types: set[EventType[Any] | str] = {
external_event
for external_event, domain_call in external_events.items()
if domain_call[0] in interested_domains
} | AUTOMATION_EVENTS
if entity_ids:
# We also allow entity_ids to be recorded via manual logbook entries.
interested_event_types.add(EVENT_LOGBOOK_ENTRY)
intrested_event_types.add(EVENT_LOGBOOK_ENTRY)
return interested_event_types
return tuple(intrested_event_types)
@callback
@@ -187,7 +187,7 @@ def async_subscribe_events(
hass: HomeAssistant,
subscriptions: list[CALLBACK_TYPE],
target: Callable[[Event[Any]], None],
event_types: Collection[EventType[Any] | str],
event_types: tuple[EventType[Any] | str, ...],
entities_filter: Callable[[str], bool] | None,
entity_ids: list[str] | None,
device_ids: list[str] | None,
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Collection, Generator, Sequence
from collections.abc import Callable, Generator, Sequence
from dataclasses import dataclass, field
from datetime import datetime as dt
import logging
@@ -126,7 +126,7 @@ class EventProcessor:
def __init__(
self,
hass: HomeAssistant,
event_types: Collection[EventType[Any] | str],
event_types: tuple[EventType[Any] | str, ...],
entity_ids: list[str] | None = None,
device_ids: list[str] | None = None,
context_id: str | None = None,

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