Compare commits

..

2 Commits

Author SHA1 Message Date
Paulus Schoutsen ac932ddca7 Add buttons platform to Marantz IR Remote (PM6006)
Adds the three commands not exposed by the media player as button
entities: Speaker A/B, Source Direct, and Loudness. Each press alternates
the RC-5 toggle bit shared with the media player.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:24:52 -04:00
Paulus Schoutsen d306ec3e3f Add Marantz IR Remote integration for the PM6006
Adds a new integration that proxies Marantz amplifier IR codes through
an existing infrared transmitter entity (e.g. via ESPHome). Initial
release ships a media player covering power, volume, mute, and source
selection (CD, Coax, Network, Optical, Phono, Recorder, Tuner).

Codes are sourced from the infrared-protocols library; analog inputs
use standard RC-5, while Optical / Coax / Network use captured Marantz
"Pre-Code" raw 38 kHz timings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:23:07 -04:00
433 changed files with 5031 additions and 15820 deletions
+3 -3
View File
@@ -108,7 +108,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -119,7 +119,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -323,7 +323,7 @@ jobs:
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish to ${{ matrix.registry }}
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
+10 -5
View File
@@ -281,7 +281,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@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
with:
extra-args: --all-files zizmor
@@ -372,13 +372,14 @@ jobs:
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
HASH_FILES: ${{ hashFiles('requirements.txt', 'requirements_all.txt', 'requirements_test.txt', 'homeassistant/package_constraints.txt') }}
HASH_FILES: ${{ hashFiles('requirements.txt', 'requirements_all.txt', 'requirements_test.txt') }}
run: |
partial_key="${RUNNER_OS}-${RUNNER_ARCH}-${PYTHON_VERSION}-uv-"
echo "partial_key=${partial_key}" >> $GITHUB_OUTPUT
echo "full_key=${partial_key}${HASH_FILES}" >> $GITHUB_OUTPUT
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
id: cache-uv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.UV_CACHE_DIR }}
@@ -473,13 +474,17 @@ jobs:
run: |
./script/check_dirty
- name: Prune uv cache
if: steps.cache-venv.outputs.cache-hit != 'true'
if: |
steps.cache-uv.outputs.cache-hit != 'true'
&& (
success()
|| (always() && steps.create-venv.outcome == 'success'))
id: prune-uv-cache
run: |
. venv/bin/activate
uv cache prune --ci
- name: Save uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
if: steps.prune-uv-cache.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.UV_CACHE_DIR }}
+1 -1
View File
@@ -354,6 +354,7 @@ homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.marantz_infrared.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
@@ -442,7 +443,6 @@ homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
homeassistant.components.proximity.*
homeassistant.components.prusalink.*
homeassistant.components.ptdevices.*
homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
Generated
+6 -8
View File
@@ -851,8 +851,8 @@ CLAUDE.md @home-assistant/core
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1 @ssyrell
/tests/components/insteon/ @teharris1 @ssyrell
/homeassistant/components/insteon/ @teharris1
/tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
@@ -1036,6 +1036,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/marantz_infrared/ @home-assistant/core
/tests/components/marantz_infrared/ @home-assistant/core
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah
@@ -1092,8 +1094,6 @@ CLAUDE.md @home-assistant/core
/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/mitsubishi_comfort/ @nikolairahimi
/tests/components/mitsubishi_comfort/ @nikolairahimi
/homeassistant/components/moat/ @bdraco
/tests/components/moat/ @bdraco
/homeassistant/components/mobile_app/ @home-assistant/core
@@ -1380,8 +1380,6 @@ CLAUDE.md @home-assistant/core
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978
/tests/components/ptdevices/ @ParemTech-Inc @frogman85978
/homeassistant/components/pterodactyl/ @elmurato
/tests/components/pterodactyl/ @elmurato
/homeassistant/components/pure_energie/ @klaasnicolaas
@@ -1495,8 +1493,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "marantz",
"name": "Marantz",
"integrations": ["marantz", "marantz_infrared"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.1"]
"requirements": ["serialx==1.4.1"]
}
@@ -6,12 +6,7 @@ from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
@@ -110,14 +105,6 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
# Check if this is a reconfigure flow
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data.email,
@@ -151,20 +138,6 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm")
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration request."""
return await self.async_step_reconfigure_confirm()
async def async_step_reconfigure_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reconfiguration dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reconfigure_confirm")
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -60,7 +60,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not have any known issues that require repair.
@@ -4,8 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start authentication flow",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_account": "You must authenticate with the same Actron Air account that was originally configured."
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
},
"error": {
"oauth2_error": "Failed to start authentication flow. Please try again later."
@@ -23,10 +22,6 @@
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"reconfigure_confirm": {
"description": "Reconfigure your Actron Air account. You will be prompted to log in again. Note: you must use the same account that was originally configured.",
"title": "Reconfigure Actron Air"
},
"timeout": {
"data": {},
"description": "The authentication process timed out. Please try again.",
+1 -7
View File
@@ -17,13 +17,7 @@
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
},
"data_description": {
"api_key": "To generate an API key, go to {api_key_url}.",
"latitude": "The latitude of your location.",
"longitude": "The longitude of your location.",
"radius": "The radius in miles around your location to search for reporting stations."
},
"description": "To generate an API key, go to {api_key_url}."
"description": "To generate API key go to {api_key_url}"
}
}
},
@@ -899,13 +899,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+4 -8
View File
@@ -1,6 +1,6 @@
"""BleBox sensor entities."""
from datetime import datetime, timedelta
from datetime import datetime
import blebox_uniapi.sensor
@@ -30,9 +30,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
SCAN_INTERVAL = timedelta(seconds=5)
SENSOR_TYPES = (
SensorEntityDescription(
key="pm1",
@@ -56,9 +53,9 @@ SENSOR_TYPES = (
),
SensorEntityDescription(
key="powerConsumption",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
icon="mdi:lightning-bolt",
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key="humidity",
@@ -153,7 +150,6 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
@property
def last_reset(self) -> datetime | None:
"""Return the time when the sensor was last reset, if implemented."""
if self.state_class != SensorStateClass.TOTAL:
return None
native_implementation = getattr(self._feature, "last_reset", None)
return native_implementation or super().last_reset
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.1.2"]
"requirements": ["bring-api==1.1.1"]
}
+23 -3
View File
@@ -1,16 +1,36 @@
"""Provides triggers for buttons."""
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(StatelessEntityTriggerBase):
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
@@ -14,6 +14,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["pycasperglow==1.2.0"]
}
@@ -45,12 +45,12 @@ rules:
comment: No network discovery.
discovery: done
docs-data-update: done
docs-examples: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Each config entry represents a single device.
+5 -23
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,33 +59,12 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -109,7 +88,10 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": ClimateTargetHumidityCondition,
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}
+10 -38
View File
@@ -8,15 +8,14 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -56,13 +55,6 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -83,32 +75,6 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -117,8 +83,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -3,10 +3,7 @@
import asyncio
from datetime import datetime, timedelta
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorEntity,
)
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import (
CONF_COMMAND,
CONF_NAME,
@@ -28,7 +25,6 @@ from homeassistant.util import dt as dt_util
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .sensor import CommandSensorData
from .utils import create_platform_yaml_not_supported_issue
DEFAULT_NAME = "Binary Command Sensor"
DEFAULT_PAYLOAD_ON = "ON"
@@ -45,7 +41,6 @@ async def async_setup_platform(
) -> None:
"""Set up the Command line Binary Sensor."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, BINARY_SENSOR_DOMAIN)
return
binary_sensor_config = discovery_info
@@ -4,7 +4,7 @@ import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverEntity
from homeassistant.components.cover import CoverEntity
from homeassistant.const import (
CONF_COMMAND_CLOSE,
CONF_COMMAND_OPEN,
@@ -26,11 +26,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .utils import (
async_call_shell_with_timeout,
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
)
from .utils import async_call_shell_with_timeout, async_check_output_or_log
SCAN_INTERVAL = timedelta(seconds=15)
@@ -43,7 +39,6 @@ async def async_setup_platform(
) -> None:
"""Set up cover controlled by shell commands."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, COVER_DOMAIN)
return
covers = []
@@ -4,29 +4,25 @@ import logging
import subprocess
from typing import Any
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
BaseNotificationService,
)
from homeassistant.components.notify import BaseNotificationService
from homeassistant.const import CONF_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .utils import create_platform_yaml_not_supported_issue, render_template_args
from .utils import render_template_args
_LOGGER = logging.getLogger(__name__)
async def async_get_service(
def get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> CommandLineNotificationService | None:
"""Get the Command Line notification service."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, NOTIFY_DOMAIN)
return None
notify_config = discovery_info
@@ -8,7 +8,6 @@ from typing import Any
from jsonpath import jsonpath
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_COMMAND,
CONF_NAME,
@@ -33,11 +32,7 @@ from .const import (
LOGGER,
TRIGGER_ENTITY_OPTIONS,
)
from .utils import (
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
render_template_args,
)
from .utils import async_check_output_or_log, render_template_args
DEFAULT_NAME = "Command Sensor"
@@ -52,7 +47,6 @@ async def async_setup_platform(
) -> None:
"""Set up the Command Sensor."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, SENSOR_DOMAIN)
return
sensor_config = discovery_info
@@ -1,10 +1,4 @@
{
"issues": {
"platform_yaml_not_supported": {
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
"title": "Platform YAML is not supported in Command Line"
}
},
"services": {
"reload": {
"description": "Reloads command line configuration from the YAML-configuration.",
@@ -4,11 +4,7 @@ import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
ENTITY_ID_FORMAT,
SwitchEntity,
)
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import (
CONF_COMMAND_OFF,
CONF_COMMAND_ON,
@@ -29,11 +25,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .utils import (
async_call_shell_with_timeout,
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
)
from .utils import async_call_shell_with_timeout, async_check_output_or_log
SCAN_INTERVAL = timedelta(seconds=30)
@@ -46,7 +38,6 @@ async def async_setup_platform(
) -> None:
"""Find and return switches controlled by shell commands."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, SWITCH_DOMAIN)
return
switches = []
+1 -18
View File
@@ -4,10 +4,9 @@ import asyncio
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from .const import DOMAIN, LOGGER
from .const import LOGGER
_EXEC_FAILED_CODE = 127
@@ -92,19 +91,3 @@ def render_template_args(hass: HomeAssistant, command: str) -> str | None:
LOGGER.debug("Running command: %s", command)
return command
def create_platform_yaml_not_supported_issue(
hass: HomeAssistant, platform_domain: str
) -> None:
"""Create an issue when platform yaml is used."""
async_create_issue(
hass,
DOMAIN,
f"{platform_domain}_platform_yaml_not_supported",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="platform_yaml_not_supported",
translation_placeholders={"platform": platform_domain},
learn_more_url="https://www.home-assistant.io/integrations/command_line/",
)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
}
+5 -18
View File
@@ -9,18 +9,11 @@
"step": {
"user": {
"data": {
"hostname": "Hostname",
"port": "IPv4 port",
"port_ipv6": "IPv6 port",
"resolver": "IPv4 resolver",
"resolver_ipv6": "IPv6 resolver"
},
"data_description": {
"hostname": "The hostname for which to perform the DNS query.",
"port": "Port used for the IPv4 lookup.",
"port_ipv6": "Port used for the IPv6 lookup.",
"resolver": "Resolver used for the IPv4 lookup.",
"resolver_ipv6": "Resolver used for the IPv6 lookup."
"hostname": "The hostname for which to perform the DNS query",
"port": "Port for IPV4 lookup",
"port_ipv6": "Port for IPV6 lookup",
"resolver": "Resolver for IPV4 lookup",
"resolver_ipv6": "Resolver for IPV6 lookup"
}
}
}
@@ -57,12 +50,6 @@
"port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]"
},
"data_description": {
"port": "[%key:component::dnsip::config::step::user::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]"
}
}
}
+21 -4
View File
@@ -6,22 +6,39 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(StatelessEntityTriggerBase):
class DoorbellRangTrigger(EntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the entity is available and the event type is ring."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
"rang": DoorbellRangTrigger,
+25 -1
View File
@@ -2,6 +2,10 @@
import logging
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -12,7 +16,18 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_ACCESS_TOKEN): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -20,6 +35,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_setup_services(hass)
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
@@ -16,6 +16,7 @@ from homeassistant.helpers.selector import (
from .const import DOMAIN
from .helpers import update_duckdns
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
@@ -67,6 +68,18 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"url": "https://www.duckdns.org/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
+35 -1
View File
@@ -1,11 +1,45 @@
"""Issues for Duck DNS integration."""
from homeassistant.core import HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.6.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Duck DNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": "/config/integrations/dashboard/add?domain=duckdns"
},
)
def action_called_without_config_entry(hass: HomeAssistant) -> None:
"""Deprecate the use of action without config entry."""
@@ -49,6 +49,10 @@
"deprecated_call_without_config_entry": {
"description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.",
"title": "Detected deprecated use of action without config entry"
},
"deprecated_yaml_import_issue_error": {
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Duck DNS YAML configuration import failed"
}
},
"services": {
@@ -13,9 +13,6 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -34,15 +31,9 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
@@ -52,15 +43,10 @@ async def async_get_config_entry_diagnostics(
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.4.0"],
"requirements": ["python-duco-client==0.3.9"],
"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][]].*",
@@ -137,6 +137,10 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not self.show_advanced_options:
return await self.async_step_auth()
if user_input:
self._mode = user_input[CONF_MODE]
return await self.async_step_auth()
+1 -1
View File
@@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.15"]
"requirements": ["elkm1-lib==2.2.13"]
}
+1 -3
View File
@@ -199,9 +199,7 @@ class ElkSetting(ElkSensor):
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = (
None if self._element.value is None else str(self._element.value)
)
self._attr_native_value = self._element.value
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.14.1"]
"requirements": ["sense-energy==0.14.0"]
}
+17 -6
View File
@@ -2,13 +2,13 @@
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
StatelessEntityTriggerBase,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
@@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EventReceivedTrigger(StatelessEntityTriggerBase):
class EventReceivedTrigger(EntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
@@ -39,10 +39,21 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type matches one of the configured types."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
-5
View File
@@ -29,11 +29,6 @@ class FlussButton(FlussEntity, ButtonEntity):
_attr_name = None
@property
def available(self) -> bool:
"""Return True only when the device is online."""
return super().available and self.device["internetConnected"]
async def async_press(self) -> None:
"""Handle the button press."""
try:
+2 -1
View File
@@ -5,4 +5,5 @@ import logging
DOMAIN = "fluss"
LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(minutes=30)
UPDATE_INTERVAL = 60 # seconds
UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL)
+5 -25
View File
@@ -1,6 +1,5 @@
"""DataUpdateCoordinator for Fluss+ integration."""
import asyncio
from typing import Any
from fluss_api import (
@@ -16,12 +15,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import LOGGER, UPDATE_INTERVAL
from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages fetching Fluss device data on a schedule."""
def __init__(
@@ -34,19 +33,11 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
LOGGER,
name=f"Fluss+ ({slugify(api_key[:8])})",
config_entry=config_entry,
update_interval=UPDATE_INTERVAL,
update_interval=UPDATE_INTERVAL_TIMEDELTA,
)
async def _async_get_connectivity(self, device_id: str) -> bool:
"""Return connectivity for a device; False if the status call fails."""
try:
status = await self.api.async_get_device_status(device_id)
except FlussApiClientError:
return False
return status["status"]["internetConnected"]
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch Fluss+ devices and merge per-device connectivity status."""
"""Fetch data from the Fluss API and return as a dictionary keyed by deviceId."""
try:
devices = await self.api.async_get_devices()
except FlussApiClientAuthenticationError as err:
@@ -54,15 +45,4 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
except FlussApiClientError as err:
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
device_list = [
device
for device in devices["devices"]
if device["userPermissions"]["canUseWiFi"]
]
connectivity = await asyncio.gather(
*(self._async_get_connectivity(d["deviceId"]) for d in device_list)
)
return {
device["deviceId"]: {**device, "internetConnected": connected}
for device, connected in zip(device_list, connectivity, strict=False)
}
return {device["deviceId"]: device for device in devices.get("devices", [])}
+1 -2
View File
@@ -5,7 +5,6 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from fritzconnection.core.exceptions import FritzConnectionException
from fritzconnection.lib.fritzstatus import FritzStatus
from requests.exceptions import RequestException
@@ -144,7 +143,7 @@ def _is_suitable_cpu_temperature(status: FritzStatus) -> bool:
"""Return whether the CPU temperature sensor is suitable."""
try:
cpu_temp = status.get_cpu_temperatures()[0]
except RequestException, IndexError, FritzConnectionException:
except RequestException, IndexError:
_LOGGER.debug("CPU temperature not supported by the device")
return False
if cpu_temp == 0:
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.3"]
"requirements": ["home-assistant-frontend==20260429.1"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.8.1"]
"requirements": ["gardena-bluetooth==2.4.0"]
}
@@ -39,9 +39,6 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import GoodweConfigEntry, GoodweUpdateCoordinator
# Coordinator handles all data updates, so parallel updates are not needed
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
# Sensor name of battery SoC
@@ -596,9 +596,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(
self.device_id, settings_data=self.data
)
return self.api.sph_read_ac_charge_times(settings_data=self.data)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
@@ -611,6 +609,4 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(
self.device_id, settings_data=self.data
)
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"requirements": ["growattServer==2.1.0"]
"requirements": ["growattServer==1.9.0"]
}
+8 -13
View File
@@ -12,7 +12,6 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -42,18 +41,14 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
@@ -1,9 +1,8 @@
"""Binary sensor platform for Hass.io addons."""
from collections.abc import Callable
from dataclasses import dataclass
import itertools
from aiohasupervisor.models import AddonState
from aiohasupervisor.models.mounts import MountState
from homeassistant.components.binary_sensor import (
@@ -15,46 +14,41 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR
from .const import (
ADDONS_COORDINATOR,
ATTR_STARTED,
ATTR_STATE,
DATA_KEY_ADDONS,
DATA_KEY_MOUNTS,
MAIN_COORDINATOR,
)
from .entity import HassioAddonEntity, HassioMountEntity
@dataclass(frozen=True, kw_only=True)
class HassioAddonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hass.io add-on binary sensor entity description."""
@dataclass(frozen=True)
class HassioBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hassio binary sensor entity description."""
value_fn: Callable[[HassioAddonBinarySensor], bool]
@dataclass(frozen=True, kw_only=True)
class HassioMountBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hass.io mount binary sensor entity description."""
value_fn: Callable[[HassioMountBinarySensor], bool]
target: str | None = None
ADDON_ENTITY_DESCRIPTIONS = (
HassioAddonBinarySensorEntityDescription(
HassioBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
entity_registry_enabled_default=False,
key="state",
key=ATTR_STATE,
translation_key="state",
value_fn=lambda entity: (
entity.coordinator.data.addons[entity.addon_slug].addon.state
== AddonState.STARTED
),
target=ATTR_STARTED,
),
)
MOUNT_ENTITY_DESCRIPTIONS = (
HassioMountBinarySensorEntityDescription(
HassioBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
key="state",
key=ATTR_STATE,
translation_key="mount",
value_fn=lambda entity: (
entity.coordinator.data.mounts[entity.mount_name].state == MountState.ACTIVE
),
target=MountState.ACTIVE.value,
),
)
@@ -69,46 +63,57 @@ async def async_setup_entry(
coordinator = hass.data[MAIN_COORDINATOR]
async_add_entities(
[
*[
itertools.chain(
[
HassioAddonBinarySensor(
addon=addon,
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data.addons.values()
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
],
*[
[
HassioMountBinarySensor(
mount=mount,
coordinator=coordinator,
entity_description=entity_description,
)
for mount in coordinator.data.mounts.values()
for mount in coordinator.data[DATA_KEY_MOUNTS].values()
for entity_description in MOUNT_ENTITY_DESCRIPTIONS
],
]
)
)
class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity):
"""Binary sensor for Hass.io add-ons."""
entity_description: HassioAddonBinarySensorEntityDescription
entity_description: HassioBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self)
value = self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][
self.entity_description.key
]
if self.entity_description.target is None:
return value
return value == self.entity_description.target
class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity):
"""Binary sensor for Hass.io mount."""
entity_description: HassioMountBinarySensorEntityDescription
entity_description: HassioBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self)
value = getattr(
self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name],
self.entity_description.key,
)
if self.entity_description.target is None:
return value
return value == self.entity_description.target
+2 -8
View File
@@ -8,11 +8,9 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from aiohasupervisor.models import (
AddonsStats,
HomeAssistantInfo,
HostInfo,
InstalledAddon,
InstalledAddonComplete,
NetworkInfo,
OSInfo,
RootInfo,
@@ -114,12 +112,8 @@ DATA_OS_INFO: HassKey[OSInfo] = HassKey("hassio_os_info")
DATA_NETWORK_INFO: HassKey[NetworkInfo] = HassKey("hassio_network_info")
DATA_SUPERVISOR_INFO: HassKey[SupervisorInfo] = HassKey("hassio_supervisor_info")
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO: HassKey[dict[str, InstalledAddonComplete | None]] = HassKey(
"hassio_addons_info"
)
DATA_ADDONS_STATS: HassKey[dict[str, AddonsStats | None]] = HassKey(
"hassio_addons_stats"
)
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list")
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
+110 -224
View File
@@ -3,20 +3,17 @@
import asyncio
from collections import defaultdict
from collections.abc import Awaitable
from dataclasses import dataclass
from copy import deepcopy
import logging
from typing import TYPE_CHECKING, Any, cast
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import (
AddonsStats,
AddonState,
CIFSMountResponse,
HomeAssistantInfo,
HomeAssistantStats,
HostInfo,
InstalledAddon,
InstalledAddonComplete,
NetworkInfo,
NFSMountResponse,
OSInfo,
@@ -24,11 +21,10 @@ from aiohasupervisor.models import (
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorStats,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MANUFACTURER
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
@@ -38,10 +34,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ATTR_ADDONS,
ATTR_AUTO_UPDATE,
ATTR_DATA,
ATTR_REPOSITORIES,
ATTR_REPOSITORY,
ATTR_SLUG,
ATTR_STARTUP,
ATTR_UPDATE_KEY,
ATTR_URL,
ATTR_VERSION,
ATTR_WS_EVENT,
CONTAINER_STATS,
CORE_CONTAINER,
@@ -52,6 +53,12 @@ from .const import (
DATA_CORE_STATS,
DATA_HOST_INFO,
DATA_INFO,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_NETWORK_INFO,
DATA_OS_INFO,
@@ -79,106 +86,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
@dataclass
class HassioMainData:
"""Data class for HassioMainDataUpdateCoordinator."""
core: HomeAssistantInfo
supervisor: SupervisorInfo
host: HostInfo
mounts: dict[str, CIFSMountResponse | NFSMountResponse]
os: OSInfo | None
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"core": self.core.to_dict(),
"supervisor": self.supervisor.to_dict(),
"host": self.host.to_dict(),
"mounts": {name: mount.to_dict() for name, mount in self.mounts.items()},
"os": self.os.to_dict() if self.os is not None else None,
}
@dataclass
class AddonData:
"""Data for a single installed addon."""
addon: InstalledAddon
auto_update: bool
repository: str
@dataclass
class HassioAddonData:
"""Data class for HassioAddOnDataUpdateCoordinator."""
addons: dict[str, AddonData]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"addons": {
slug: {
"addon": addon_data.addon.to_dict(),
"auto_update": addon_data.auto_update,
"repository": addon_data.repository,
}
for slug, addon_data in self.addons.items()
},
}
@dataclass
class HassioStatsData:
"""Data class for HassioStatsDataUpdateCoordinator."""
core: HomeAssistantStats | None
supervisor: SupervisorStats | None
addons: dict[str, AddonsStats | None]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"core": self.core.to_dict() if self.core is not None else None,
"supervisor": (
self.supervisor.to_dict() if self.supervisor is not None else None
),
"addons": {
slug: stats.to_dict() if stats is not None else None
for slug, stats in self.addons.items()
},
}
def _installed_addon_from_complete(info: InstalledAddonComplete) -> InstalledAddon:
"""Build an InstalledAddon from an InstalledAddonComplete object.
InstalledAddonComplete contains a superset of InstalledAddon fields.
This helper extracts only the fields needed for InstalledAddon so fresh
data from an addon_info call can be stored in AddonData.addon.
"""
return InstalledAddon(
advanced=info.advanced,
available=info.available,
build=info.build,
description=info.description,
homeassistant=info.homeassistant,
icon=info.icon,
logo=info.logo,
name=info.name,
repository=info.repository,
slug=info.slug,
stage=info.stage,
update_available=info.update_available,
url=info.url,
version_latest=info.version_latest,
version=info.version,
detached=info.detached,
state=info.state,
)
@callback
def get_info(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return generic information from Supervisor.
@@ -244,25 +151,7 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | N
Async friendly.
"""
addons_info: dict[str, InstalledAddonComplete | None] | None = hass.data.get(
DATA_ADDONS_INFO
)
if addons_info is None:
return None
# Converting these fields for compatibility as that is what was returned here.
# We'll leave it this way as long as these component APIs continue to return
# dictionaries. If/when we switch to using the aiohasupervisor models for everything
# internally and externally that will be dropped.
return {
slug: dict(
hassio_api=info.supervisor_api,
hassio_role=info.supervisor_role,
**info.to_dict(),
)
if info is not None
else None
for slug, info in addons_info.items()
}
return hass.data.get(DATA_ADDONS_INFO)
@callback
@@ -281,11 +170,7 @@ def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]:
Async friendly.
"""
addons_stats: dict[str, AddonsStats | None] = hass.data.get(DATA_ADDONS_STATS) or {}
return {
slug: stats.to_dict() if stats is not None else None
for slug, stats in addons_stats.items()
}
return hass.data.get(DATA_ADDONS_STATS) or {}
@callback
@@ -294,8 +179,7 @@ def get_core_stats(hass: HomeAssistant) -> dict[str, Any]:
Async friendly.
"""
stats = hass.data.get(DATA_CORE_STATS)
return stats.to_dict() if stats is not None else {}
return hass.data.get(DATA_CORE_STATS) or {}
@callback
@@ -304,8 +188,7 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]:
Async friendly.
"""
stats = hass.data.get(DATA_SUPERVISOR_STATS)
return stats.to_dict() if stats is not None else {}
return hass.data.get(DATA_SUPERVISOR_STATS) or {}
@callback
@@ -339,20 +222,19 @@ def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
@callback
def async_register_addons_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[AddonData]
entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[dict[str, Any]]
) -> None:
"""Register addons in the device registry."""
for addon_data in addons:
addon = addon_data.addon
for addon in addons:
params = DeviceInfo(
identifiers={(DOMAIN, addon.slug)},
identifiers={(DOMAIN, addon[ATTR_SLUG])},
model=SupervisorEntityModel.ADDON,
sw_version=addon.version,
name=addon.name,
sw_version=addon[ATTR_VERSION],
name=addon[ATTR_NAME],
entry_type=dr.DeviceEntryType.SERVICE,
configuration_url=f"homeassistant://hassio/addon/{addon.slug}",
configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}",
)
if manufacturer := addon_data.repository or addon.url:
if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL):
params[ATTR_MANUFACTURER] = manufacturer
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@@ -378,14 +260,14 @@ def async_register_mounts_in_dev_reg(
@callback
def async_register_os_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, os_info: OSInfo
entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
) -> None:
"""Register OS in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "OS")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.OS,
sw_version=os_info.version,
sw_version=os_dict[ATTR_VERSION],
name="Home Assistant Operating System",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -412,14 +294,14 @@ def async_register_host_in_dev_reg(
def async_register_core_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
core_info: HomeAssistantInfo,
core_dict: dict[str, Any],
) -> None:
"""Register core in the device registry."""
"""Register OS in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "core")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.CORE,
sw_version=core_info.version,
sw_version=core_dict[ATTR_VERSION],
name="Home Assistant Core",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -430,14 +312,14 @@ def async_register_core_in_dev_reg(
def async_register_supervisor_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
supervisor_info: SupervisorInfo,
supervisor_dict: dict[str, Any],
) -> None:
"""Register supervisor in the device registry."""
"""Register OS in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "supervisor")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.SUPERVISOR,
sw_version=supervisor_info.version,
sw_version=supervisor_dict[ATTR_VERSION],
name="Home Assistant Supervisor",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -454,7 +336,7 @@ def async_remove_devices_from_dev_reg(
dev_reg.async_remove_device(dev.id)
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to retrieve Hass.io container stats."""
config_entry: ConfigEntry
@@ -476,18 +358,18 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
lambda: defaultdict(set)
)
async def _async_update_data(self) -> HassioStatsData:
async def _async_update_data(self) -> dict[str, Any]:
"""Update stats data via library."""
try:
await self._fetch_stats()
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
return HassioStatsData(
core=self.hass.data.get(DATA_CORE_STATS),
supervisor=self.hass.data.get(DATA_SUPERVISOR_STATS),
addons=self.hass.data.get(DATA_ADDONS_STATS) or {},
)
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = get_core_stats(self.hass)
new_data[DATA_KEY_SUPERVISOR] = get_supervisor_stats(self.hass)
new_data[DATA_KEY_ADDONS] = get_addons_stats(self.hass)
return new_data
async def _fetch_stats(self) -> None:
"""Fetch container stats for subscribed entities."""
@@ -505,7 +387,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
if updates:
api_results: list[ResponseData] = await asyncio.gather(*updates.values())
for key, result in zip(updates, api_results, strict=True):
data[key] = result
data[key] = result.to_dict()
# Fetch addon stats
addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or []
@@ -515,9 +397,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
}
addons_stats: dict[str, AddonsStats | None] = data.setdefault(
DATA_ADDONS_STATS, {}
)
addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {})
# Clean up cache for stopped/removed addons
for slug in addons_stats.keys() - started_addons:
@@ -535,14 +415,14 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
)
addons_stats.update(addon_stats_results)
async def _update_addon_stats(self, slug: str) -> tuple[str, AddonsStats | None]:
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Update single addon stats."""
try:
stats = await self.supervisor_client.addons.addon_stats(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch stats for %s: %s", slug, err)
return (slug, None)
return (slug, stats)
return (slug, stats.to_dict())
@callback
def async_enable_container_updates(
@@ -565,7 +445,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
return _remove
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to retrieve Hass.io Add-on status."""
config_entry: ConfigEntry
@@ -596,7 +476,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
self.supervisor_client = get_supervisor_client(hass)
self.jobs = jobs
async def _async_update_data(self) -> HassioAddonData:
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
@@ -607,7 +487,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
# Fetch addon info for all addons on first update, or only
# for addons with subscribed entities on subsequent updates.
addon_info_results: dict[str, InstalledAddonComplete | None] = dict(
addon_info_results = dict(
await asyncio.gather(
*[
self._update_addon_info(slug)
@@ -623,35 +503,39 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
self.hass.data[DATA_ADDONS_LIST] = installed_addons
# Update addon info cache in hass.data
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
addon_info_cache: dict[str, Any] = self.hass.data.setdefault(
DATA_ADDONS_INFO, {}
)
for slug in addon_info_cache.keys() - all_addons:
del addon_info_cache[slug]
addon_info_cache.update(addon_info_results)
# Build repository name lookup from store data
store = self.hass.data.get(DATA_STORE)
repositories: dict[str, str] = (
{repo.slug: repo.name for repo in store.repositories} if store else {}
)
# Build clean coordinator data
new_addons: dict[str, AddonData] = {}
for addon in installed_addons:
addon_info = addon_info_cache.get(addon.slug)
auto_update = addon_info.auto_update if addon_info is not None else False
repo_slug = addon.repository
repository = repositories.get(repo_slug, repo_slug)
new_addons[addon.slug] = AddonData(
addon=addon,
auto_update=auto_update,
repository=repository,
)
new_data = HassioAddonData(addons=new_addons)
store = self.hass.data.get(DATA_STORE)
if store:
repositories = {repo.slug: repo.name for repo in store.repositories}
else:
repositories = {}
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
new_data: dict[str, Any] = {}
new_data[DATA_KEY_ADDONS] = {
(slug := addon[ATTR_SLUG]): {
**addon,
ATTR_AUTO_UPDATE: (addon_info_cache.get(slug) or {}).get(
ATTR_AUTO_UPDATE, False
),
ATTR_REPOSITORY: repositories.get(
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
),
}
for addon in addons_list_dicts
}
# If this is the initial refresh, register all addons
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, list(new_data.addons.values())
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
)
# Remove add-ons that are no longer installed from device registry
@@ -662,16 +546,19 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
)
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data.addons):
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# If there are new add-ons, we should reload the config entry so we can
# create new devices and entities. We can return the new data because
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
if self.data and (set(new_data.addons) - set(self.data.addons)):
if self.data and (
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
@@ -682,16 +569,18 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
except SupervisorNotFoundError:
return None
async def _update_addon_info(
self, slug: str
) -> tuple[str, InstalledAddonComplete | None]:
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Return the info for an addon."""
try:
info = await self.supervisor_client.addons.addon_info(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch info for %s: %s", slug, err)
return (slug, None)
return (slug, info)
# Translate to legacy hassio names for compatibility
info_dict = info.to_dict()
info_dict["hassio_api"] = info_dict.pop("supervisor_api")
info_dict["hassio_role"] = info_dict.pop("supervisor_role")
return (slug, info_dict)
@callback
def async_enable_addon_info_updates(
@@ -738,26 +627,16 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
"""Force refresh of addon info data for a specific addon."""
try:
slug, info = await self._update_addon_info(addon_slug)
if info is not None and DATA_KEY_ADDONS in self.data:
if slug in self.data[DATA_KEY_ADDONS]:
data = deepcopy(self.data)
data[DATA_KEY_ADDONS][slug].update(info)
self.async_set_updated_data(data)
except SupervisorError as err:
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
return
if info is not None and self.data and slug in self.data.addons:
updated = AddonData(
addon=_installed_addon_from_complete(info),
auto_update=info.auto_update,
repository=self.data.addons[slug].repository,
)
self.async_set_updated_data(
HassioAddonData(addons={**self.data.addons, slug: updated})
)
# Update addon info cache in hass.data
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
addon_info_cache[slug] = info
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to retrieve Hass.io status."""
config_entry: ConfigEntry
@@ -800,7 +679,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
):
self.config_entry.async_create_task(self.hass, self.async_request_refresh())
async def _async_update_data(self) -> HassioMainData:
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
@@ -843,13 +722,13 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Build clean coordinator data
new_data = HassioMainData(
core=core_info,
supervisor=supervisor_info,
host=host_info,
mounts={mount.name: mount for mount in mounts_info.mounts},
os=os_info if self.is_hass_os else None,
)
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = core_info.to_dict()
new_data[DATA_KEY_SUPERVISOR] = supervisor_info.to_dict()
new_data[DATA_KEY_HOST] = host_info.to_dict()
new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts}
if self.is_hass_os:
new_data[DATA_KEY_OS] = os_info.to_dict()
# Update hass.data for legacy accessor functions
self.hass.data[DATA_INFO] = info
@@ -863,15 +742,19 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
# If this is the initial refresh, register all main components
if is_first_update:
async_register_mounts_in_dev_reg(
self.entry_id, self.dev_reg, list(new_data.mounts.values())
self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values()
)
async_register_core_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
)
async_register_core_in_dev_reg(self.entry_id, self.dev_reg, new_data.core)
async_register_supervisor_in_dev_reg(
self.entry_id, self.dev_reg, new_data.supervisor
self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR]
)
async_register_host_in_dev_reg(self.entry_id, self.dev_reg)
if self.is_hass_os:
async_register_os_in_dev_reg(self.entry_id, self.dev_reg, os_info)
async_register_os_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_OS]
)
# Remove mounts that no longer exists from device registry
supervisor_mount_devices = {
@@ -881,7 +764,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
)
if device.model == SupervisorEntityModel.MOUNT
}
if stale_mounts := supervisor_mount_devices - set(new_data.mounts):
if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]):
async_remove_devices_from_dev_reg(
self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts}
)
@@ -893,12 +776,15 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
self.dev_reg.async_remove_device(dev.id)
# If there are new mounts, we should reload the config entry so we can
# create new devices and entities. We can return the new data because
# create new devices and entities. We can return an empty dict because
# coordinator will be recreated.
if self.data and (set(new_data.mounts) - set(self.data.mounts)):
if self.data and (
set(new_data[DATA_KEY_MOUNTS]) - set(self.data.get(DATA_KEY_MOUNTS, {}))
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
@@ -56,8 +56,8 @@ async def async_get_config_entry_diagnostics(
devices.append({"device": asdict(device), "entities": entities})
return {
"coordinator_data": coordinator.data.to_dict(),
"addons_coordinator_data": addons_coordinator.data.to_dict(),
"stats_coordinator_data": stats_coordinator.data.to_dict(),
"coordinator_data": coordinator.data,
"addons_coordinator_data": addons_coordinator.data,
"stats_coordinator_data": stats_coordinator.data,
"devices": devices,
}
+74 -54
View File
@@ -1,20 +1,27 @@
"""Base for Hass.io entities."""
from collections.abc import Callable
from typing import Any
from aiohasupervisor.models import CIFSMountResponse, HostInfo, NFSMountResponse, OSInfo
from aiohasupervisor.models.base import ContainerStats
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONTAINER_STATS, DOMAIN
from .const import (
ATTR_SLUG,
CONTAINER_STATS,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DOMAIN,
)
from .coordinator import (
AddonData,
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsData,
HassioStatsDataUpdateCoordinator,
)
@@ -30,7 +37,7 @@ class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
entity_description: EntityDescription,
*,
container_id: str,
stats_fn: Callable[[HassioStatsData], ContainerStats | None],
data_key: str,
device_id: str,
unique_id_prefix: str,
) -> None:
@@ -38,25 +45,27 @@ class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
super().__init__(coordinator)
self.entity_description = entity_description
self._container_id = container_id
self._stats_fn = stats_fn
self._data_key = data_key
self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
@property
def _stats(self) -> ContainerStats | None:
"""Return the stats object for this entity's container."""
return self._stats_fn(self.coordinator.data)
@property
def stats(self) -> ContainerStats:
"""Return the stats object, asserting it is available."""
assert self._stats is not None
return self._stats
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._stats is not None
if self._data_key == DATA_KEY_ADDONS:
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in (
self.coordinator.data[DATA_KEY_ADDONS].get(self._container_id) or {}
)
)
return (
super().available
and self._data_key in self.coordinator.data
and self.entity_description.key in self.coordinator.data[self._data_key]
)
async def async_added_to_hass(self) -> None:
"""Subscribe to stats updates."""
@@ -83,31 +92,24 @@ class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]):
self,
coordinator: HassioAddOnDataUpdateCoordinator,
entity_description: EntityDescription,
addon: AddonData,
addon: dict[str, Any],
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._addon_slug = addon.addon.slug
self._attr_unique_id = f"{addon.addon.slug}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon.addon.slug)})
@property
def addon_slug(self) -> str:
"""Return the add-on slug."""
return self._addon_slug
@property
def addon_data(self) -> AddonData:
"""Return the add-on data, asserting it is available."""
data = self.coordinator.data
assert self._addon_slug in data.addons
return data.addons[self._addon_slug]
self._addon_slug = addon[ATTR_SLUG]
self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon[ATTR_SLUG])})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._addon_slug in self.coordinator.data.addons
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
)
async def async_added_to_hass(self) -> None:
"""Subscribe to addon info updates."""
@@ -138,13 +140,11 @@ class HassioOSEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.data.os is not None
@property
def os(self) -> OSInfo:
"""Return the OS info object, asserting it is available."""
assert self.coordinator.data.os is not None
return self.coordinator.data.os
return (
super().available
and DATA_KEY_OS in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_OS]
)
class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@@ -164,10 +164,13 @@ class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")})
@property
def host(self) -> HostInfo:
"""Return the host info, asserting it is available."""
assert self.coordinator.data.host is not None
return self.coordinator.data.host
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_HOST in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST]
)
class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@@ -186,6 +189,16 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator])
self._attr_unique_id = f"home_assistant_supervisor_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "supervisor")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_SUPERVISOR in self.coordinator.data
and self.entity_description.key
in self.coordinator.data[DATA_KEY_SUPERVISOR]
)
class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
"""Base Entity for Core."""
@@ -203,6 +216,15 @@ class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
self._attr_unique_id = f"home_assistant_core_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "core")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_CORE in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE]
)
class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
"""Base Entity for Mount."""
@@ -226,12 +248,10 @@ class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
)
self._mount = mount
@property
def mount_name(self) -> str:
"""Return the mount name."""
return self._mount.name
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.mount_name in self.coordinator.data.mounts
return (
super().available
and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS]
)
+44 -106
View File
@@ -1,10 +1,5 @@
"""Sensor platform for Hass.io addons."""
from collections.abc import Callable
from dataclasses import dataclass
from aiohasupervisor.models.base import ContainerStats
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -20,12 +15,19 @@ from .const import (
ADDONS_COORDINATOR,
ATTR_CPU_PERCENT,
ATTR_MEMORY_PERCENT,
ATTR_SLUG,
ATTR_VERSION,
ATTR_VERSION_LATEST,
CORE_CONTAINER,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
STATS_COORDINATOR,
SUPERVISOR_CONTAINER,
)
from .coordinator import HassioStatsData
from .entity import (
HassioAddonEntity,
HassioHostEntity,
@@ -33,125 +35,74 @@ from .entity import (
HassioStatsEntity,
)
@dataclass(frozen=True, kw_only=True)
class HassioAddonSensorEntityDescription(SensorEntityDescription):
"""Hass.io add-on sensor entity description."""
value_fn: Callable[[HassioAddonSensor], str | None]
@dataclass(frozen=True, kw_only=True)
class HassioStatsSensorEntityDescription(SensorEntityDescription):
"""Hass.io stats sensor entity description."""
value_fn: Callable[[HassioStatsSensor], float]
@dataclass(frozen=True, kw_only=True)
class HassioOSSensorEntityDescription(SensorEntityDescription):
"""Hass.io OS sensor entity description."""
value_fn: Callable[[HassioOSSensor], str | None]
@dataclass(frozen=True, kw_only=True)
class HassioHostSensorEntityDescription(SensorEntityDescription):
"""Hass.io host sensor entity description."""
value_fn: Callable[[HostSensor], str | float | None]
ADDON_ENTITY_DESCRIPTIONS = (
HassioAddonSensorEntityDescription(
COMMON_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
entity_registry_enabled_default=False,
key="version",
key=ATTR_VERSION,
translation_key="version",
value_fn=lambda entity: entity.addon_data.addon.version,
),
HassioAddonSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key="version_latest",
key=ATTR_VERSION_LATEST,
translation_key="version_latest",
value_fn=lambda entity: entity.addon_data.addon.version_latest,
),
)
STATS_ENTITY_DESCRIPTIONS = (
HassioStatsSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_CPU_PERCENT,
translation_key="cpu_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.stats.cpu_percent,
),
HassioStatsSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_MEMORY_PERCENT,
translation_key="memory_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.stats.memory_percent,
),
)
OS_ENTITY_DESCRIPTIONS = (
HassioOSSensorEntityDescription(
entity_registry_enabled_default=False,
key="version",
translation_key="version",
value_fn=lambda entity: entity.os.version,
),
HassioOSSensorEntityDescription(
entity_registry_enabled_default=False,
key="version_latest",
translation_key="version_latest",
value_fn=lambda entity: entity.os.version_latest,
),
)
OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS
HOST_ENTITY_DESCRIPTIONS = (
HassioHostSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key="agent_version",
translation_key="agent_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.agent_version,
),
HassioHostSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key="apparmor_version",
translation_key="apparmor_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.apparmor_version,
),
HassioHostSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_total",
translation_key="disk_total",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_total,
),
HassioHostSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_used",
translation_key="disk_used",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_used,
),
HassioHostSensorEntityDescription(
SensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_free",
translation_key="disk_free",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_free,
),
)
@@ -175,32 +126,21 @@ async def async_setup_entry(
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data.addons.values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in COMMON_ENTITY_DESCRIPTIONS
)
# Add-on stats sensors (cpu_percent, memory_percent)
def stats_fn_factory(
addon_slug: str,
) -> Callable[[HassioStatsData], ContainerStats | None]:
"""Return a stats_fn for the given add-on slug."""
def stats_fn(data: HassioStatsData) -> ContainerStats | None:
"""Return the stats for the given add-on."""
return data.addons.get(addon_slug)
return stats_fn
entities.extend(
HassioStatsSensor(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=addon.addon.slug,
stats_fn=stats_fn_factory(addon.addon.slug),
device_id=addon.addon.slug,
unique_id_prefix=addon.addon.slug,
container_id=addon[ATTR_SLUG],
data_key=DATA_KEY_ADDONS,
device_id=addon[ATTR_SLUG],
unique_id_prefix=addon[ATTR_SLUG],
)
for addon in addons_coordinator.data.addons.values()
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in STATS_ENTITY_DESCRIPTIONS
)
@@ -210,7 +150,7 @@ async def async_setup_entry(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=CORE_CONTAINER,
stats_fn=lambda data: data.core,
data_key=DATA_KEY_CORE,
device_id="core",
unique_id_prefix="home_assistant_core",
)
@@ -223,7 +163,7 @@ async def async_setup_entry(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=SUPERVISOR_CONTAINER,
stats_fn=lambda data: data.supervisor,
data_key=DATA_KEY_SUPERVISOR,
device_id="supervisor",
unique_id_prefix="home_assistant_supervisor",
)
@@ -255,42 +195,40 @@ async def async_setup_entry(
class HassioAddonSensor(HassioAddonEntity, SensorEntity):
"""Sensor to track a Hass.io add-on attribute."""
entity_description: HassioAddonSensorEntityDescription
@property
def native_value(self) -> str | None:
def native_value(self) -> str:
"""Return native value of entity."""
return self.entity_description.value_fn(self)
return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][
self.entity_description.key
]
class HassioStatsSensor(HassioStatsEntity, SensorEntity):
"""Sensor to track container stats."""
entity_description: HassioStatsSensorEntityDescription
@property
def native_value(self) -> float:
def native_value(self) -> str:
"""Return native value of entity."""
return self.entity_description.value_fn(self)
if self._data_key == DATA_KEY_ADDONS:
return self.coordinator.data[DATA_KEY_ADDONS][self._container_id][
self.entity_description.key
]
return self.coordinator.data[self._data_key][self.entity_description.key]
class HassioOSSensor(HassioOSEntity, SensorEntity):
"""Sensor to track a Hass.io OS attribute."""
entity_description: HassioOSSensorEntityDescription
@property
def native_value(self) -> str | None:
def native_value(self) -> str:
"""Return native value of entity."""
return self.entity_description.value_fn(self)
return self.coordinator.data[DATA_KEY_OS][self.entity_description.key]
class HostSensor(HassioHostEntity, SensorEntity):
"""Sensor to track a host attribute."""
entity_description: HassioHostSensorEntityDescription
@property
def native_value(self) -> str | float | None:
def native_value(self) -> str:
"""Return native value of entity."""
return self.entity_description.value_fn(self)
return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key]
+10 -10
View File
@@ -4,15 +4,15 @@ import logging
from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import AddonState
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
from .entity import HassioAddonEntity
from .handler import get_supervisor_client
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
ENTITY_DESCRIPTION = SwitchEntityDescription(
key="state",
key=ATTR_STATE,
name=None,
icon="mdi:puzzle",
entity_registry_enabled_default=False,
@@ -41,7 +41,7 @@ async def async_setup_entry(
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in coordinator.data.addons.values()
for addon in coordinator.data[DATA_KEY_ADDONS].values()
)
@@ -49,19 +49,19 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
"""Switch for Hass.io add-ons."""
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return true if the add-on is on."""
return (
self.coordinator.data.addons[self._addon_slug].addon.state
== AddonState.STARTED
)
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
state = addon_data.get(self.entity_description.key)
return state == ATTR_STARTED
@property
def entity_picture(self) -> str | None:
"""Return the icon of the add-on if any."""
if not self.available:
return None
if self.coordinator.data.addons[self._addon_slug].addon.icon:
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
if addon_data.get(ATTR_ICON):
return f"/api/hassio/addons/{self._addon_slug}/icon"
return None
+32 -24
View File
@@ -13,12 +13,22 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR
from .coordinator import AddonData
from .const import (
ADDONS_COORDINATOR,
ATTR_AUTO_UPDATE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
)
from .entity import (
HassioAddonEntity,
HassioCoreEntity,
@@ -68,7 +78,7 @@ async def async_setup_entry(
coordinator=addons_coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in addons_coordinator.data.addons.values()
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
)
async_add_entities(entities)
@@ -98,29 +108,29 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
_version_before_update: str | None = None
@property
def _addon_data(self) -> AddonData:
def _addon_data(self) -> dict:
"""Return the add-on data."""
return self.coordinator.data.addons[self._addon_slug]
return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug]
@property
def auto_update(self) -> bool:
"""Return true if auto-update is enabled for the add-on."""
return self._addon_data.auto_update
return self._addon_data[ATTR_AUTO_UPDATE]
@property
def title(self) -> str | None:
"""Return the title of the update."""
return self._addon_data.addon.name
return self._addon_data[ATTR_NAME]
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self._addon_data.addon.version_latest
return self._addon_data[ATTR_VERSION_LATEST]
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
return self._addon_data.addon.version
return self._addon_data[ATTR_VERSION]
@property
def in_progress(self) -> bool | None:
@@ -134,7 +144,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Return the icon of the add-on if any."""
if not self.available:
return None
if self._addon_data.addon.icon:
if self._addon_data[ATTR_ICON]:
return f"/api/hassio/addons/{self._addon_slug}/icon"
return None
@@ -226,16 +236,14 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
_attr_title = "Home Assistant Operating System"
@property
def latest_version(self) -> str | None:
def latest_version(self) -> str:
"""Return the latest version."""
assert self.coordinator.data.os is not None
return self.coordinator.data.os.version_latest
return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST]
@property
def installed_version(self) -> str | None:
def installed_version(self) -> str:
"""Return the installed version."""
assert self.coordinator.data.os is not None
return self.coordinator.data.os.version
return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION]
@property
def entity_picture(self) -> str | None:
@@ -285,19 +293,19 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
return self._attr_in_progress
@property
def latest_version(self) -> str | None:
def latest_version(self) -> str:
"""Return the latest version."""
return self.coordinator.data.supervisor.version_latest
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST]
@property
def installed_version(self) -> str:
"""Return the installed version."""
return self.coordinator.data.supervisor.version
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION]
@property
def auto_update(self) -> bool:
"""Return true if auto-update is enabled for supervisor."""
return self.coordinator.data.supervisor.auto_update
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_AUTO_UPDATE]
@property
def release_url(self) -> str | None:
@@ -381,14 +389,14 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
_attr_title = "Home Assistant Core"
@property
def latest_version(self) -> str | None:
def latest_version(self) -> str:
"""Return the latest version."""
return self.coordinator.data.core.version_latest
return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST]
@property
def installed_version(self) -> str | None:
def installed_version(self) -> str:
"""Return the installed version."""
return self.coordinator.data.core.version
return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION]
@property
def entity_picture(self) -> str | None:
+5 -11
View File
@@ -44,20 +44,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
except HiveReauthRequired as err:
raise ConfigEntryAuthFailed from err
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, hub_data["device_id"])},
connections=connections,
name=hub_data["hiveName"],
model=hub_data["deviceData"]["model"],
sw_version=hub_data["deviceData"]["version"],
manufacturer=hub_data["deviceData"]["manufacturer"],
identifiers={(DOMAIN, devices["parent"][0]["device_id"])},
name=devices["parent"][0]["hiveName"],
model=devices["parent"][0]["deviceData"]["model"],
sw_version=devices["parent"][0]["deviceData"]["version"],
manufacturer=devices["parent"][0]["deviceData"]["manufacturer"],
)
await hass.config_entries.async_forward_entry_setups(
+3 -17
View File
@@ -117,22 +117,9 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
_LOGGER.debug("2FA successful")
if self.source == SOURCE_REAUTH:
try:
device_registered = await self.hive_auth.is_device_registered()
except HiveApiError as err:
_LOGGER.debug(
"Failed to check whether the Hive device is registered during reauthentication: %s",
err,
)
errors["base"] = "no_internet_available"
else:
if device_registered:
return await self.async_setup_hive_entry()
self.device_registration = True
return await self.async_step_configuration()
else:
self.device_registration = True
return await self.async_step_configuration()
return await self.async_setup_hive_entry()
self.device_registration = True
return await self.async_step_configuration()
schema = vol.Schema({vol.Required(CONF_CODE): str})
return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors)
@@ -184,7 +171,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Re Authenticate a user."""
self.data = dict(entry_data)
data = {
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.96", "babel==2.15.0"]
"requirements": ["holidays==0.95", "babel==2.15.0"]
}
@@ -60,7 +60,7 @@
"include_exclude_mode": "Inclusion mode",
"mode": "HomeKit mode"
},
"description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.",
"description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.",
"title": "Select mode and domains."
},
"yaml": {
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.10.0"]
"requirements": ["homematicip==2.9.0"]
}
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,20 +46,6 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -93,7 +79,10 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": IsTargetHumidityCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
}
+3 -26
View File
@@ -14,9 +14,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -31,31 +31,8 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": HumidityCondition,
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
}
+9 -43
View File
@@ -13,13 +13,12 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -37,46 +36,13 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.5"]
"requirements": ["aioautomower==2.7.4"]
}
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
}
+19 -10
View File
@@ -76,12 +76,14 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
# The default for new entries is to not include text and headers
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
)
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
{
@@ -91,15 +93,18 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Optional(
CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS
): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
)
OPTIONS_SCHEMA_ADVANCED = {
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
async def validate_input(
hass: HomeAssistant, user_input: dict[str, Any]
@@ -146,6 +151,8 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
schema = CONFIG_SCHEMA
if self.show_advanced_options:
schema = schema.extend(CONFIG_SCHEMA_ADVANCED)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=schema)
@@ -243,6 +250,8 @@ class ImapOptionsFlow(OptionsFlow):
return self.async_create_entry(data={})
schema = OPTIONS_SCHEMA
if self.show_advanced_options:
schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Button platform for Indevolt integration."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Final
from indevolt_api import IndevoltRealtimeAction
@@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0
class IndevoltButtonEntityDescription(ButtonEntityDescription):
"""Custom entity description class for Indevolt button entities."""
generation: tuple[int, ...] = (1, 2)
generation: list[int] = field(default_factory=lambda: [1, 2])
BUTTONS: Final = (
@@ -10,6 +10,7 @@ from indevolt_api import (
IndevoltConfig,
IndevoltEnergyMode,
IndevoltRealtimeAction,
TimeOutException,
)
from homeassistant.config_entries import ConfigEntry
@@ -77,8 +78,10 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Fetch device info once on boot."""
try:
config_data = await self.api.get_config()
except (ClientError, OSError) as err:
raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err
except TimeOutException as err:
raise ConfigEntryNotReady(
f"Device config retrieval timed out: {err}"
) from err
# Cache device information
device_data = config_data.get("device", {})
@@ -91,16 +94,16 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
return await self.api.fetch_data(sensor_keys)
except (ClientError, OSError) as err:
raise UpdateFailed(f"Device update failed: {err}") from err
except TimeOutException as err:
raise UpdateFailed(f"Device update timed out: {err}") from err
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
"""Push/write data values to given key on the device."""
try:
return await self.api.set_data(sensor_key, value)
except TimeoutError as err:
except TimeOutException as err:
raise DeviceTimeoutError(f"Device push timed out: {err}") from err
except (ClientError, OSError) as err:
except (ClientError, ConnectionError, OSError) as err:
raise DeviceConnectionError(f"Device push failed: {err}") from err
async def async_switch_energy_mode(
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["indevolt-api==1.7.1"]
"quality_scale": "bronze",
"requirements": ["indevolt-api==1.6.5"]
}
+6 -6
View File
@@ -1,6 +1,6 @@
"""Number platform for Indevolt integration."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Final
from indevolt_api import IndevoltConfig
@@ -27,15 +27,15 @@ PARALLEL_UPDATES = 0
class IndevoltNumberEntityDescription(NumberEntityDescription):
"""Custom entity description class for Indevolt number entities."""
generation: list[int] = field(default_factory=lambda: [1, 2])
read_key: str
write_key: str
generation: tuple[int, ...] = (1, 2)
NUMBERS: Final = (
IndevoltNumberEntityDescription(
key="discharge_limit",
generation=(2,),
generation=[2],
translation_key="discharge_limit",
read_key=IndevoltConfig.READ_DISCHARGE_LIMIT,
write_key=IndevoltConfig.WRITE_DISCHARGE_LIMIT,
@@ -46,7 +46,7 @@ NUMBERS: Final = (
),
IndevoltNumberEntityDescription(
key="max_ac_output_power",
generation=(2,),
generation=[2],
translation_key="max_ac_output_power",
read_key=IndevoltConfig.READ_MAX_AC_OUTPUT_POWER,
write_key=IndevoltConfig.WRITE_MAX_AC_OUTPUT_POWER,
@@ -58,7 +58,7 @@ NUMBERS: Final = (
),
IndevoltNumberEntityDescription(
key="inverter_input_limit",
generation=(2,),
generation=[2],
translation_key="inverter_input_limit",
read_key=IndevoltConfig.READ_INVERTER_INPUT_LIMIT,
write_key=IndevoltConfig.WRITE_INVERTER_INPUT_LIMIT,
@@ -70,7 +70,7 @@ NUMBERS: Final = (
),
IndevoltNumberEntityDescription(
key="feedin_power_limit",
generation=(2,),
generation=[2],
translation_key="feedin_power_limit",
read_key=IndevoltConfig.READ_FEEDIN_POWER_LIMIT,
write_key=IndevoltConfig.WRITE_FEEDIN_POWER_LIMIT,
+1 -1
View File
@@ -25,7 +25,7 @@ class IndevoltSelectEntityDescription(SelectEntityDescription):
write_key: str
value_to_option: dict[IndevoltEnergyMode, str]
unavailable_values: list[IndevoltEnergyMode] = field(default_factory=list)
generation: tuple[int, ...] = (1, 2)
generation: list[int] = field(default_factory=lambda: [1, 2])
SELECTS: Final = (
+5 -5
View File
@@ -1,6 +1,6 @@
"""Switch platform for Indevolt integration."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Final
from indevolt_api import IndevoltConfig
@@ -29,14 +29,14 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription):
write_key: str
read_on_value: int = 1
read_off_value: int = 0
generation: tuple[int, ...] = (1, 2)
generation: list[int] = field(default_factory=lambda: [1, 2])
SWITCHES: Final = (
IndevoltSwitchEntityDescription(
key="grid_charging",
translation_key="grid_charging",
generation=(2,),
generation=[2],
read_key=IndevoltConfig.READ_GRID_CHARGING,
write_key=IndevoltConfig.WRITE_GRID_CHARGING,
read_on_value=1001,
@@ -46,7 +46,7 @@ SWITCHES: Final = (
IndevoltSwitchEntityDescription(
key="light",
translation_key="light",
generation=(2,),
generation=[2],
read_key=IndevoltConfig.READ_LIGHT,
write_key=IndevoltConfig.WRITE_LIGHT,
device_class=SwitchDeviceClass.SWITCH,
@@ -54,7 +54,7 @@ SWITCHES: Final = (
IndevoltSwitchEntityDescription(
key="bypass",
translation_key="bypass",
generation=(2,),
generation=[2],
read_key=IndevoltConfig.READ_BYPASS,
write_key=IndevoltConfig.WRITE_BYPASS,
device_class=SwitchDeviceClass.SWITCH,
@@ -2,7 +2,7 @@
"domain": "insteon",
"name": "Insteon",
"after_dependencies": ["panel_custom"],
"codeowners": ["@teharris1", "@ssyrell"],
"codeowners": ["@teharris1"],
"config_flow": true,
"dependencies": ["http", "usb", "websocket_api"],
"dhcp": [
@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.6.2"
"insteon-frontend-home-assistant==0.6.1"
],
"single_config_entry": true,
"usb": [
@@ -77,9 +77,10 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
+6 -12
View File
@@ -2,7 +2,6 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import StrEnum
from pynecil import LiveDataResponse, OperatingMode, PowerSource
@@ -24,7 +23,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from . import IronOSConfigEntry
from .const import OHM
@@ -58,7 +56,7 @@ class PinecilSensor(StrEnum):
class IronOSSensorEntityDescription(SensorEntityDescription):
"""IronOS sensor entity descriptions."""
value_fn: Callable[[LiveDataResponse, bool], StateType | datetime]
value_fn: Callable[[LiveDataResponse, bool], StateType]
PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
@@ -118,14 +116,10 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
IronOSSensorEntityDescription(
key=PinecilSensor.UPTIME,
translation_key=PinecilSensor.UPTIME,
device_class=SensorDeviceClass.UPTIME,
value_fn=(
lambda data, _: (
(dt_util.utcnow() - timedelta(seconds=data.uptime))
if data.uptime is not None
else None
)
),
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data, _: data.uptime,
entity_category=EntityCategory.DIAGNOSTIC,
),
IronOSSensorEntityDescription(
@@ -206,7 +200,7 @@ class IronOSSensorEntity(IronOSBaseEntity, SensorEntity):
coordinator: IronOSLiveDataCoordinator
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return sensor state."""
return self.entity_description.value_fn(
self.coordinator.data, self.coordinator.has_tip
@@ -24,7 +24,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyisy"],
"requirements": ["pyisy==3.5.1"],
"requirements": ["pyisy==3.4.1"],
"ssdp": [
{
"deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1",
+1 -1
View File
@@ -12,7 +12,7 @@
"quality_scale": "platinum",
"requirements": [
"xknx==3.15.0",
"xknxproject==3.9.0",
"xknxproject==3.8.2",
"knx-frontend==2026.4.30.60856"
],
"single_config_entry": true
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
from pylitterbot import LitterRobot, LitterRobot4, Robot
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -30,11 +30,8 @@ class RobotBinarySensorEntityDescription(
is_on_fn: Callable[[_WhiskerEntityT], bool]
BINARY_SENSOR_MAP: dict[
type[Robot] | tuple[type[Robot], ...],
tuple[RobotBinarySensorEntityDescription, ...],
] = {
LitterRobot: (
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[LitterRobot](
key="sleeping",
translation_key="sleeping",
@@ -59,14 +56,14 @@ BINARY_SENSOR_MAP: dict[
is_on_fn=lambda robot: not robot.is_hopper_removed,
),
),
(FeederRobot, LitterRobot3, LitterRobot4): (
RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4](
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot](
key="power_status",
translation_key="power_status",
device_class=BinarySensorDeviceClass.PLUG,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
is_on_fn=lambda robot: robot.power_type == "AC",
is_on_fn=lambda robot: robot.power_status == "AC",
),
),
}
@@ -16,5 +16,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "platinum",
"requirements": ["pylitterbot==2025.4.0"]
"requirements": ["pylitterbot==2025.3.2"]
}
@@ -0,0 +1,37 @@
"""Marantz IR Remote integration for Home Assistant."""
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
@dataclass
class MarantzIrRuntimeData:
"""Runtime data for a Marantz IR config entry.
The RC-5 toggle bit must alternate between distinct key presses so
the receiver can distinguish a new press from a held-down repeat.
The toggle is tracked at the device level (one value per config
entry) so all entities — buttons and the media player — share it.
"""
toggle: int = 0
type MarantzIrConfigEntry = ConfigEntry[MarantzIrRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: MarantzIrConfigEntry) -> bool:
"""Set up Marantz IR from a config entry."""
entry.runtime_data = MarantzIrRuntimeData()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MarantzIrConfigEntry) -> bool:
"""Unload a Marantz IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,80 @@
"""Button platform for Marantz IR integration.
Only commands that aren't already exposed by the media player live here:
speaker A/B, source-direct toggle, and loudness toggle.
"""
from dataclasses import dataclass
from infrared_protocols.codes.marantz.pm6006 import MarantzPM6006Code
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MarantzIrConfigEntry
from .const import CONF_INFRARED_ENTITY_ID, CONF_MODEL, MarantzModel
from .entity import MarantzIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class MarantzIrButtonEntityDescription(ButtonEntityDescription):
"""Describes Marantz IR button entity."""
command_code: MarantzPM6006Code
PM6006_BUTTON_DESCRIPTIONS: tuple[MarantzIrButtonEntityDescription, ...] = (
MarantzIrButtonEntityDescription(
key="speaker_ab",
translation_key="speaker_ab",
command_code=MarantzPM6006Code.SPEAKER_AB,
),
MarantzIrButtonEntityDescription(
key="source_direct",
translation_key="source_direct",
command_code=MarantzPM6006Code.SOURCE_DIRECT,
),
MarantzIrButtonEntityDescription(
key="loudness",
translation_key="loudness",
command_code=MarantzPM6006Code.LOUDNESS,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MarantzIrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Marantz IR buttons from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
model = entry.data[CONF_MODEL]
if model == MarantzModel.PM6006:
async_add_entities(
MarantzIrButton(entry, infrared_entity_id, description)
for description in PM6006_BUTTON_DESCRIPTIONS
)
class MarantzIrButton(MarantzIrEntity, ButtonEntity):
"""Marantz IR button entity."""
entity_description: MarantzIrButtonEntityDescription
def __init__(
self,
entry: MarantzIrConfigEntry,
infrared_entity_id: str,
description: MarantzIrButtonEntityDescription,
) -> None:
"""Initialize Marantz IR button."""
super().__init__(entry, infrared_entity_id, unique_id_suffix=description.key)
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_command(self.entity_description.command_code)
@@ -0,0 +1,77 @@
"""Config flow for Marantz IR integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_INFRARED_ENTITY_ID, CONF_MODEL, DOMAIN, MarantzModel
MODEL_NAMES: dict[MarantzModel, str] = {
MarantzModel.PM6006: "PM6006",
}
class MarantzIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Marantz IR."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
emitter_entity_ids = async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
entity_id = user_input[CONF_INFRARED_ENTITY_ID]
model = user_input[CONF_MODEL]
await self.async_set_unique_id(f"marantz_ir_{model}_{entity_id}")
self._abort_if_unique_id_configured()
ent_reg = er.async_get(self.hass)
entry = ent_reg.async_get(entity_id)
entity_name = (
entry.name or entry.original_name or entity_id if entry else entity_id
)
model_name = MODEL_NAMES[MarantzModel(model)]
title = f"Marantz {model_name} via {entity_name}"
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=[model.value for model in MarantzModel],
translation_key=CONF_MODEL,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entity_ids,
)
),
}
),
)
@@ -0,0 +1,13 @@
"""Constants for the Marantz IR integration."""
from enum import StrEnum
DOMAIN = "marantz_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_MODEL = "model"
class MarantzModel(StrEnum):
"""Supported Marantz models."""
PM6006 = "pm6006"
@@ -0,0 +1,97 @@
"""Common entity for Marantz IR integration."""
import logging
from types import ModuleType
from infrared_protocols.codes.marantz import pm6006
from homeassistant.components.infrared import async_send_command
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_state_change_event
from . import MarantzIrConfigEntry
from .const import CONF_MODEL, DOMAIN, MarantzModel
_LOGGER = logging.getLogger(__name__)
# Each supported model points at the library module that exposes its codes
# and the ``MODEL_ID`` / ``MODEL_NAME`` constants used for the device
# registry entry.
_MODEL_MODULES: dict[MarantzModel, ModuleType] = {
MarantzModel.PM6006: pm6006,
}
class MarantzIrEntity(Entity):
"""Marantz IR base entity."""
_attr_has_entity_name = True
def __init__(
self,
entry: MarantzIrConfigEntry,
infrared_entity_id: str,
unique_id_suffix: str,
) -> None:
"""Initialize Marantz IR entity."""
self._infrared_entity_id = infrared_entity_id
self._runtime_data = entry.runtime_data
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
model_module = _MODEL_MODULES[MarantzModel(entry.data[CONF_MODEL])]
self._make_command = model_module.make_command
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"Marantz {model_module.MODEL_NAME}",
manufacturer="Marantz",
model=model_module.MODEL_ID,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
ir_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
if ir_available != self.available:
_LOGGER.info(
"Infrared entity %s used by %s is %s",
self._infrared_entity_id,
self.entity_id,
"available" if ir_available else "unavailable",
)
self._attr_available = ir_available
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def _send_command(self, code: pm6006.MarantzPM6006Code) -> None:
"""Send an IR command using the Marantz protocol.
Flips the RC-5 toggle bit before each frame so the receiver
treats consecutive presses as new presses, not as a held repeat.
"""
self._runtime_data.toggle ^= 1
await async_send_command(
self.hass,
self._infrared_entity_id,
self._make_command(code, toggle=self._runtime_data.toggle),
context=self._context,
)
@@ -0,0 +1,11 @@
{
"domain": "marantz_infrared",
"name": "Marantz Infrared",
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/marantz_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "silver"
}
@@ -0,0 +1,100 @@
"""Media player platform for Marantz IR integration."""
from infrared_protocols.codes.marantz.pm6006 import MarantzPM6006Code
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MarantzIrConfigEntry
from .const import CONF_INFRARED_ENTITY_ID, CONF_MODEL, MarantzModel
from .entity import MarantzIrEntity
PARALLEL_UPDATES = 1
# The Optical button on the amp toggles between the two optical inputs and
# the receiver remembers which one was last used, so we cannot deterministically
# pick between Optical 1 and Optical 2 over IR. We expose a single Optical
# entry that just sends the toggle and let the user press again to switch.
SOURCE_TO_CODE: dict[str, MarantzPM6006Code] = {
"CD": MarantzPM6006Code.SOURCE_CD,
"Coax": MarantzPM6006Code.SOURCE_COAX,
"Network": MarantzPM6006Code.SOURCE_NETWORK,
"Optical": MarantzPM6006Code.SOURCE_OPTICAL,
"Phono": MarantzPM6006Code.SOURCE_PHONO,
"Recorder": MarantzPM6006Code.SOURCE_CDR,
"Tuner": MarantzPM6006Code.SOURCE_TUNER,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: MarantzIrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Marantz IR media player from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
model = entry.data[CONF_MODEL]
if model == MarantzModel.PM6006:
async_add_entities([MarantzIrAmplifierMediaPlayer(entry, infrared_entity_id)])
class MarantzIrAmplifierMediaPlayer(MarantzIrEntity, MediaPlayerEntity):
"""Marantz IR amplifier media player entity."""
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_source_list = list(SOURCE_TO_CODE)
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(self, entry: MarantzIrConfigEntry, infrared_entity_id: str) -> None:
"""Initialize Marantz IR amplifier media player."""
super().__init__(entry, infrared_entity_id, unique_id_suffix="media_player")
self._attr_state = MediaPlayerState.ON
async def async_turn_on(self) -> None:
"""Send the power toggle and assume the amplifier is now on.
Marantz integrated amplifiers expose only a single POWER toggle
over IR — there are no discrete on/off codes — so turn-on and
turn-off send the same frame and rely on assumed_state.
"""
await self._send_command(MarantzPM6006Code.POWER)
self._attr_state = MediaPlayerState.ON
self.async_write_ha_state()
async def async_turn_off(self) -> None:
"""Send the power toggle and assume the amplifier is now off."""
await self._send_command(MarantzPM6006Code.POWER)
self._attr_state = MediaPlayerState.OFF
self.async_write_ha_state()
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_command(MarantzPM6006Code.VOLUME_UP)
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_command(MarantzPM6006Code.VOLUME_DOWN)
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_command(MarantzPM6006Code.MUTE)
async def async_select_source(self, source: str) -> None:
"""Select an input source."""
await self._send_command(SOURCE_TO_CODE[source])
self._attr_source = source
self.async_write_ha_state()
@@ -0,0 +1,110 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration 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 provide additional 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: done
test-before-configure: done
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
No entities should be disabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations:
status: exempt
comment: |
This integration does not use custom icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration has no external dependencies.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: done
@@ -0,0 +1,42 @@
{
"config": {
"abort": {
"already_configured": "This Marantz device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared transmitter",
"model": "Model"
},
"data_description": {
"infrared_entity_id": "The infrared transmitter entity to use for sending commands.",
"model": "The Marantz model to control."
},
"description": "Select the Marantz model and the infrared transmitter entity to use for controlling your Marantz device.",
"title": "Set up Marantz IR Remote"
}
}
},
"entity": {
"button": {
"loudness": {
"name": "Loudness"
},
"source_direct": {
"name": "Source direct"
},
"speaker_ab": {
"name": "Speaker A/B"
}
}
},
"selector": {
"model": {
"options": {
"pm6006": "PM6006"
}
}
}
}
+2 -8
View File
@@ -251,10 +251,8 @@ class MatterFan(MatterEntity, FanEntity):
return
self._feature_map = feature_map
self._attr_supported_features = FanEntityFeature(0)
# Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
@@ -304,12 +302,8 @@ class MatterFan(MatterEntity, FanEntity):
if feature_map & FanControlFeature.kAirflowDirection:
self._attr_supported_features |= FanEntityFeature.DIRECTION
# PercentSetting is always a mandatory attribute of the FanControl cluster,
# so percentage-based speed control is always available.
self._attr_supported_features |= (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
@@ -1,108 +1,11 @@
"""Provides conditions for media players."""
from datetime import datetime
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityConditionBase,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
from .const import DOMAIN, MediaPlayerState
class _MediaPlayerMutedConditionBase(EntityConditionBase):
"""Base class for media player is_muted/is_unmuted conditions."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _state_valid_since(self, state: State) -> datetime:
"""Anchor `for:` durations to `last_updated` for the muted attribute.
Needed because the domain spec does not reflect that the condition
reads from the muted and volume attributes.
"""
return state.last_updated
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Skip entities without volume attributes from the all/count check."""
return super()._should_include(state) and self._has_volume_attributes(state)
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the entity state matches the targeted muted state."""
if not self._has_volume_attributes(entity_state):
return False
return self._is_muted(entity_state) is self._target_muted
class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is muted."""
_target_muted = True
class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is not muted."""
_target_muted = False
class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Condition for media player volume level with 0.0-1.0 to percentage conversion."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)}
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the volume value converted from 0.0-1.0 to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return float(raw) * 100.0
except TypeError, ValueError:
return None
def _should_include(self, state: State) -> bool:
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_muted": MediaPlayerIsMutedCondition,
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -114,10 +17,18 @@ CONDITIONS: dict[str, type[Condition]] = {
MediaPlayerState.PLAYING,
},
),
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED),
"is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING),
"is_unmuted": MediaPlayerIsUnmutedCondition,
"is_volume": MediaPlayerIsVolumeCondition,
}
@@ -1,51 +1,22 @@
.condition_common: &condition_common
target: &condition_media_player_target
target:
entity:
domain: media_player
fields:
behavior: &condition_behavior
behavior:
required: true
default: any
selector:
automation_behavior:
mode: condition
for: &condition_for
for:
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_muted: *condition_common
is_off: *condition_common
is_on: *condition_common
is_not_playing: *condition_common
is_paused: *condition_common
is_playing: *condition_common
is_unmuted: *condition_common
is_volume:
target: *condition_media_player_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: is
number: *volume_threshold_number
@@ -1,8 +1,5 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -17,12 +14,6 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -132,9 +123,6 @@
}
},
"triggers": {
"muted": {
"trigger": "mdi:volume-mute"
},
"paused_playing": {
"trigger": "mdi:pause"
},
@@ -149,15 +137,6 @@
},
"turned_on": {
"trigger": "mdi:power"
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,24 +2,10 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold"
"trigger_for_name": "For at least"
},
"conditions": {
"is_muted": {
"description": "Tests if one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is muted"
},
"is_not_playing": {
"description": "Tests if one or more media players are not playing.",
"fields": {
@@ -79,33 +65,6 @@
}
},
"name": "Media player is playing"
},
"is_unmuted": {
"description": "Tests if one or more media players are not muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is not muted"
},
"is_volume": {
"description": "Tests the volume of one or more media players.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::condition_threshold_name%]"
}
},
"name": "Volume"
}
},
"device_automation": {
@@ -478,18 +437,6 @@
},
"title": "Media player",
"triggers": {
"muted": {
"description": "Triggers after one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player muted"
},
"paused_playing": {
"description": "Triggers after one or more media players pause playing.",
"fields": {
@@ -549,42 +496,6 @@
}
},
"name": "Media player turned on"
},
"unmuted": {
"description": "Triggers after one or more media players are unmuted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player unmuted"
},
"volume_changed": {
"description": "Triggers after the volume of one or more media players changes.",
"fields": {
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume changed"
},
"volume_crossed_threshold": {
"description": "Triggers after the volume of one or more media players crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume crossed threshold"
}
}
}
@@ -1,125 +1,12 @@
"""Provides triggers for media players."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
Trigger,
make_entity_transition_trigger,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
from . import MediaPlayerState
from .const import DOMAIN
VOLUME_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
}
class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
"""Base class for media player muted/unmuted triggers."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without volume attributes cannot be muted, so they are
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return super()._should_include(state) and self._has_volume_attributes(state)
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if not self._has_volume_attributes(to_state):
return False
return self.is_muted(from_state) != self.is_muted(to_state)
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state."""
if not self._has_volume_attributes(state):
return False
return self.is_muted(state) is self._target_muted
class MediaPlayerMutedTrigger(_MediaPlayerMutedStateTriggerBase):
"""Class for media player muted triggers."""
_target_muted = True
class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase):
"""Class for media player unmuted triggers."""
_target_muted = False
class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for volume triggers."""
_domain_specs = VOLUME_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked volume as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert 0.0-1.0 range to percentage (0-100)
return value * 100.0
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without a volume level cannot have their volume tracked,
so they are excluded - otherwise an "all" check would never pass
when there are media players without volume support.
"""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin):
"""Trigger for media player volume changes."""
class VolumeCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin
):
"""Trigger for media player volume crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": VolumeChangedTrigger,
"volume_crossed_threshold": VolumeCrossedThresholdTrigger,
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -1,62 +1,22 @@
.trigger_common: &trigger_common
target: &trigger_media_player_target
target:
entity:
domain: media_player
fields:
behavior: &trigger_behavior
behavior:
required: true
default: any
selector:
automation_behavior:
mode: trigger
for: &trigger_for
for:
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
-1
View File
@@ -479,7 +479,6 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
down_filled_items = 129
cottons_eco = 133
quick_power_wash = 146, 10031
quick_intense = 177
eco_40_60 = 190, 10007
bed_linen = 10047
easy_care = 10016

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