mirror of
https://github.com/home-assistant/core.git
synced 2026-05-07 10:26:51 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac932ddca7 | |||
| d306ec3e3f |
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "marantz",
|
||||
"name": "Marantz",
|
||||
"integrations": ["marantz", "marantz_infrared"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "sensereo",
|
||||
"name": "Sensereo",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", [])}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user