Compare commits

..

76 Commits

Author SHA1 Message Date
Joakim Plate 13dd831874 Update gardena ble to 2.8.1 (#169914) 2026-05-06 16:25:37 +02:00
Tom Wilkie 3be5906398 Register Hive Hub MAC address as device connection (#169040)
Signed-off-by: Tom Wilkie <tom.wilkie@gmail.com>
2026-05-06 16:12:59 +02:00
Erik Montnemery cef918d6f8 Remove _get_tracked_value method from EntityConditionBase (#169906) 2026-05-06 14:59:57 +02:00
Jan Bouwhuis 19aa1b6578 Remove advanced options dependency from MQTT integration (#169833) 2026-05-06 14:52:07 +02:00
Daniel Hjelseth Høyer b0eb69936e Bump pyTibber to 0.37.4 (#169907) 2026-05-06 14:47:10 +02:00
Erik Montnemery b6096a71d1 Exclude incompatible humidifier entities from humidifier automations (#169905) 2026-05-06 14:44:30 +02:00
Erik Montnemery 059d7011ba Exclude incompatible water_heater entities from water_heater automations (#169904) 2026-05-06 14:44:19 +02:00
epenet bbe00ef79e De-duplicate code to build Tuya device info (#169899) 2026-05-06 14:29:47 +02:00
Erik Montnemery 7f447abc3a Exclude incompatible climate entities from climate automations (#169903) 2026-05-06 14:18:14 +02:00
Erik Montnemery 923e099467 Unload scripts and conditions created by template entities (#169366) 2026-05-06 14:11:37 +02:00
Erik Montnemery 26714c6d9f Add media_player volume condition (#169897) 2026-05-06 13:15:01 +02:00
Erik Montnemery 5f1201dbbe Exclude incompatible entities from temperature automations (#169901) 2026-05-06 13:10:53 +02:00
Erik Montnemery 52e1d9443c Exclude incompatible entities from humidity automations (#169898) 2026-05-06 13:10:24 +02:00
Manu 824f5205e9 Record notification from legacy notify action in Mobile App (#169749) 2026-05-06 12:57:57 +02:00
Erik Montnemery cf8bc55add Add media_player muted conditions (#169892) 2026-05-06 12:38:05 +02:00
Bram Kragten 1e9244f4fc Update frontend to 20260429.3 (#169893) 2026-05-06 12:19:24 +02:00
Tom Matheussen be4f4928d5 Bump satel_integra to 1.3.1 (#169889) 2026-05-06 11:27:14 +02:00
Erik Montnemery 80f6f8ee31 Improve entity trigger tests (#169881) 2026-05-06 10:48:36 +02:00
Erik Montnemery 267d52491a Add media_player volume triggers (#169885) 2026-05-06 10:48:10 +02:00
Ludovic BOUÉ ee84d625cd Expose SET_SPEED for all fans via PercentSetting in Matter (#169696)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-05-06 10:16:31 +02:00
dependabot[bot] 5d091d25d5 Bump j178/prek-action from 2.0.2 to 2.0.3 (#169882) 2026-05-06 09:50:18 +02:00
Erik Montnemery 97b5f1cf64 Add method _should_include to EntityConditionBase (#169884)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-06 09:49:22 +02:00
Zoltán Farkasdi d89bcd83d9 netatmo: bump pyatmo v9.4.0 (#169735)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-05-06 09:16:22 +02:00
Joost Lekkerkerker 073b20c4b2 Fix Zinvolt select options (#169886) 2026-05-06 09:09:24 +02:00
epenet 2af9405750 Cleanup unused code in Tuya util (#169883) 2026-05-06 08:42:05 +02:00
Erik Montnemery 10084c8c0c Add trigger timer.time_remaining (#169763) 2026-05-05 23:54:49 -04:00
Erik Montnemery 7e8f5365ce Add method _should_include to EntityTriggerBase (#169837) 2026-05-06 00:50:22 +02:00
Erik Montnemery 65f9dcd7bf Improve condition test helper docstrings (#169871) 2026-05-06 00:32:37 +02:00
epenet 4c8f37fef6 Bump tuya-device-handlers to 0.0.19 (#169848) 2026-05-05 22:23:14 +02:00
Erik Montnemery d1295fa260 Validate yaml matches implementation in automation options_supported tests (#169798) 2026-05-05 22:20:28 +02:00
Diogo Gomes 9b2eea920f Add V2C LED lights (#169778)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 22:19:59 +02:00
Petro31 c81c1cbb14 Remove legacy weather template entities (#169734) 2026-05-05 22:18:46 +02:00
Erik Montnemery 11ee05874a Improve trigger test helper docstrings (#169869) 2026-05-05 22:11:08 +02:00
puddly 7d7c47b56e Bump serialx to 1.7.0 (#169867) 2026-05-05 21:06:30 +02:00
epenet dc4210595f Fix flaky test_set_scan_interval_via_platform (#169856)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:49:15 +02:00
Freekers 7430366d9b Enable web search support for gpt-5-nano (#169710) 2026-05-05 20:47:52 +03:00
Crocmagnon ae3bd54ca7 switchbot: remove unwanted future annotations import preventing build on all new PRs (#169863) 2026-05-05 19:40:27 +02:00
Glenn Waters e3ce7fb000 Bump elkm1-lib to 2.2.15 (#169843)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 18:50:17 +02:00
epenet 9286b517d3 Add ruff rule to prevent __future__ annotations (#169852)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 18:42:10 +02:00
elgris 4d62e4765d Add a number entity to set display time offset (in minutes) for Switchbot Meter CO2 devices. (#169603)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 17:45:47 +02:00
Michael Hansen ea55ef90a6 Bump intents to 2026.5.5 (#169855) 2026-05-05 18:22:22 +03:00
epenet 751765b97b Cleanup from __future__ import annotations (#169850) 2026-05-05 16:35:21 +02:00
Denis Shulyaka 11ed1fe20f Return the requested format for OpenAI TTS (#169839)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 10:28:20 -04:00
Joost Lekkerkerker 9b5166769a Add Sensereo matter brand (#169836) 2026-05-05 10:18:01 -04:00
Joost Lekkerkerker 70c2a323ce Add Zunzunbee Zigbee brand (#169838) 2026-05-05 10:17:49 -04:00
Ronald van der Meer 0ec5d6b273 Add API version to Duco diagnostics for support triage (#169802) 2026-05-05 15:48:43 +02:00
Robert Resch b1e8dc2ebb Remove show_advanced_options in Ecovacs and always show all options (#169831) 2026-05-05 15:42:08 +02:00
Artur Pragacz e144804d28 Fix async_unload teardown race in scripts (#169562) 2026-05-05 15:03:37 +02:00
cengelen 8521a49986 Bump growatt server to 2.1.0 (#169495)
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 14:11:50 +02:00
Raj Laud 3587f9613f Bump victron-ble-ha-parser to 0.7.0 (#169736)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 13:57:19 +02:00
Jan Bouwhuis 2f1dd3a817 Deprecate MQTT protocol versions 3.x and migrate to version 5 (#169759)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 13:43:18 +02:00
wollew 2c2e8db19f Remove deprecated reboot service for Velux gateway (#169796) 2026-05-05 11:08:00 +02:00
Erik Montnemery 64a3f91132 Improve template reload (#169480) 2026-05-05 10:16:22 +02:00
dependabot[bot] bd61c893e4 Bump dawidd6/action-download-artifact from 20 to 21 (#169793)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:12:07 +02:00
renovate[bot] 6bb759b887 Update infrared-protocols to 2.1.0 (#169785)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 10:11:52 +02:00
Matthias Alphart 280b5ef388 Update xknxproject to 3.9.0 (#169775) 2026-05-05 10:09:24 +02:00
Erik Montnemery 416d4e02a0 Add trigger media_player.unmuted (#169797) 2026-05-05 09:45:45 +02:00
kw6423 c99f261a2d Restore OwnTracks custom device tracker attributes (#169753)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-05-05 09:44:53 +02:00
Thomas D 9c9a058eb0 Add missing initialization charging power status option to Volvo (#169727) 2026-05-05 09:10:13 +02:00
Nathan Spencer 7b51b929ef Bump pylitterbot to 2025.4.0 (#169652) 2026-05-05 09:05:16 +02:00
Ronald van der Meer 74971ebcd1 Bump python-duco-client to 0.4.0 (#169776) 2026-05-05 08:55:22 +02:00
Åke Strandberg 1f5d80ca44 Add missing code for miele washing machine (#169795) 2026-05-05 08:54:12 +02:00
Erik Montnemery 9075c6a5cb Add trigger media_player.muted (#156736) 2026-05-05 08:22:03 +02:00
Manu ab4162601f Remove YAML import from Duck DNS integration (#169769) 2026-05-05 07:45:40 +02:00
HoffmanEl 38de48ac9d Add data_description to airnow config flow strings (#169783) 2026-05-05 07:43:18 +02:00
Nikolai Rahimi 597d9a2ada Add Mitsubishi Comfort integration (#167472)
Co-authored-by: Nikolai Rahimi <nikolairahimi@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-05 00:16:00 +02:00
optimusbasti 71494b6c97 Bump aioautomower to 2.7.5 (#169758) 2026-05-04 22:27:46 +01:00
A. Gideonse 57e66baf53 Update Indevolt integration quality scale to silver (#167843)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-04 23:05:11 +02:00
Nathan Spencer 63dfc97346 Limit power status binary sensor to non-LR5 devices (#169659) 2026-05-04 22:51:17 +02:00
shbatm 1b4a7d55c0 Add precipitation device class to WeatherFlow Cloud accumulation sensors (#169638)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 22:29:12 +02:00
Matthew Gibson 8c8a863867 Add ptdevices Integration (#156307)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-04 22:15:52 +02:00
Keilin Bickar 28d65e987c bump sense-energy to 0.14.1 (#169761) 2026-05-04 21:22:45 +02:00
Daniel Hjelseth Høyer d0c0f02311 Bump pyTibber to 0.37.3 (#169762) 2026-05-04 21:21:57 +02:00
kernelpanic85 f90e9ceb6c Add Celsius and Fahrenheit to Smartthings UNITS mapping (#169686) 2026-05-04 21:20:04 +02:00
G Johansson 553ba5e7ab Add binary sensor to Nord Pool (#169684) 2026-05-04 21:10:06 +02:00
Erwin Douna 6633f16d13 Add system health to Portainer (#169698) 2026-05-04 21:07:16 +02:00
254 changed files with 9821 additions and 2911 deletions
+2 -2
View File
@@ -108,7 +108,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
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@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
with:
extra-args: --all-files zizmor
+1
View File
@@ -442,6 +442,7 @@ 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
+4
View File
@@ -1092,6 +1092,8 @@ 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
@@ -1378,6 +1380,8 @@ 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
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"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.4.1"]
"requirements": ["serialx==1.7.0"]
}
+7 -1
View File
@@ -17,7 +17,13 @@
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
},
"description": "To generate API key go to {api_key_url}"
"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}."
}
}
},
@@ -899,12 +899,13 @@ 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
self.action_script.async_unload()
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+23 -5
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,12 +59,33 @@ 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),
@@ -88,10 +109,7 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity": ClimateTargetHumidityCondition,
"target_temperature": ClimateTargetTemperatureCondition,
}
+38 -10
View File
@@ -8,14 +8,15 @@ 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,
)
@@ -55,6 +56,13 @@ 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
@@ -75,6 +83,32 @@ 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(
@@ -83,14 +117,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"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_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -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.3.24"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
}
+1 -25
View File
@@ -2,10 +2,6 @@
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
@@ -16,18 +12,7 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_ACCESS_TOKEN): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -35,15 +20,6 @@ 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,7 +16,6 @@ from homeassistant.helpers.selector import (
from .const import DOMAIN
from .helpers import update_duckdns
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
@@ -68,18 +67,6 @@ 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 -35
View File
@@ -1,45 +1,11 @@
"""Issues for Duck DNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.core import HomeAssistant
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,10 +49,6 @@
"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,6 +13,9 @@ 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",
@@ -31,9 +34,15 @@ 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()
@@ -43,10 +52,15 @@ async def async_get_config_entry_diagnostics(
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.10"],
"requirements": ["python-duco-client==0.4.0"],
"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,10 +137,6 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not self.show_advanced_options:
return await self.async_step_auth()
if user_input:
self._mode = user_input[CONF_MODE]
return await self.async_step_auth()
+1 -1
View File
@@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.13"]
"requirements": ["elkm1-lib==2.2.15"]
}
+3 -1
View File
@@ -199,7 +199,9 @@ class ElkSetting(ElkSensor):
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
self._attr_native_value = (
None if self._element.value is None else str(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.0"]
"requirements": ["sense-energy==0.14.1"]
}
@@ -1,7 +1,5 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
import asyncio
from typing import Any
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.2"]
"requirements": ["home-assistant-frontend==20260429.3"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.4.0"]
"requirements": ["gardena-bluetooth==2.8.1"]
}
@@ -596,7 +596,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(settings_data=self.data)
return self.api.sph_read_ac_charge_times(
self.device_id, settings_data=self.data
)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
@@ -609,4 +611,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
return self.api.sph_read_ac_discharge_times(
self.device_id, settings_data=self.data
)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"requirements": ["growattServer==1.9.0"]
"requirements": ["growattServer==2.1.0"]
}
+208 -159
View File
@@ -6,12 +6,20 @@ from datetime import datetime
import logging
import os
import struct
from typing import Any
from typing import Any, cast
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
GreenOptions,
HomeAssistantInfo,
HomeAssistantOptions,
HostInfo,
InstalledAddon,
NetworkInfo,
OSInfo,
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorOptions,
YellowOptions,
)
@@ -20,7 +28,6 @@ from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken
from homeassistant.components import frontend
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.homeassistant.const import DATA_STOP_HANDLER
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
@@ -34,7 +41,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -45,6 +51,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
@@ -67,12 +74,17 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_HASSIO_HOST,
DATA_HASSIO_HTTP_CONFIG,
DATA_HASSIO_SUPERVISOR_USER,
DATA_CORE_INFO,
DATA_HOST_INFO,
DATA_INFO,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_NETWORK_INFO,
DATA_OS_INFO,
DATA_STORE,
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_MAIN_UPDATE_INTERVAL,
MAIN_COORDINATOR,
@@ -176,61 +188,6 @@ def hostname_from_addon_slug(addon_slug: str) -> str:
return addon_slug.replace("_", "-")
@callback
def _check_deprecated_setup(hass: HomeAssistant) -> None:
"""Create issues for deprecated installation types and architectures."""
os_info = get_os_info(hass)
info = get_info(hass)
if os_info is None or info is None:
return
is_haos = info.get("hassos") is not None
board = os_info.get("board")
arch = info.get("arch", "unknown")
unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"}
unsupported_os_on_board = board in {"rpi3", "rpi4"}
if is_haos and (unsupported_board or unsupported_os_on_board):
issue_id = "deprecated_os_"
if unsupported_os_on_board:
issue_id += "aarch64"
elif unsupported_board:
issue_id += "armv7"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_guide": "https://www.home-assistant.io/installation/",
},
)
bit32 = _is_32_bit()
deprecated_architecture = bit32 and not (
unsupported_board or unsupported_os_on_board
)
if not is_haos or deprecated_architecture:
issue_id = "deprecated"
if not is_haos:
issue_id += "_method"
if deprecated_architecture:
issue_id += "_architecture"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_type": "OS" if is_haos else "Supervised",
"arch": arch,
},
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Hass.io component."""
# Check local setup
@@ -244,37 +201,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return False
async_load_websocket_api(hass)
frontend.async_register_built_in_panel(hass, "app")
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
hass.data[DATA_HASSIO_HOST] = host
hass.data[DATA_HASSIO_HTTP_CONFIG] = config.get("http", {})
async_load_websocket_api(hass)
hass.http.register_view(HassIOView(host, websession))
async_setup_services(hass)
async_setup_discovery_view(hass)
async_setup_auth_view(hass)
async_setup_ingress_view(hass)
frontend.async_register_built_in_panel(hass, "app")
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
supervisor_client = get_supervisor_client(hass)
try:
await supervisor_client.supervisor.ping()
except SupervisorError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_connected",
) from err
except SupervisorError:
_LOGGER.warning("Not connected with the supervisor / system too busy!")
# Load the store
config_store = HassioConfig(hass)
@@ -302,52 +240,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
refresh_token = await hass.auth.async_create_refresh_token(user)
config_store.update(hassio_user=user.id)
assert user is not None
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
hass.http.register_view(HassIOView(host, websession))
# Set up coordinators — these can raise ConfigEntryNotReady.
# Register listeners only after all refreshes succeed to avoid accumulation
# across retries.
dev_reg = dr.async_get(hass)
async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken):
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
ssl=CONF_SSL_CERTIFICATE in http_config,
port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT,
refresh_token=refresh_token.token,
)
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
if http_config.get(CONF_SERVER_HOST) is not None:
options = replace(options, watchdog=False)
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
addon_coordinator = HassioAddOnDataUpdateCoordinator(
hass, entry, dev_reg, coordinator.jobs
try:
await supervisor_client.homeassistant.set_options(options)
except SupervisorError as err:
_LOGGER.warning(
"Failed to update Home Assistant options in Supervisor: %s", err
)
update_hass_api_task = hass.async_create_task(
update_hass_api(config.get("http", {}), refresh_token), eager_start=True
)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
stats_coordinator = HassioStatsDataUpdateCoordinator(hass, entry)
await stats_coordinator.async_config_entry_first_refresh()
hass.data[STATS_COORDINATOR] = stats_coordinator
# All coordinators refreshed successfully. Start the issues listener and
# install the stop handler now so they are never left in a partial state
# if a coordinator refresh raises ConfigEntryNotReady.
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
async def _async_stop(hass: HomeAssistant, restart: bool) -> None:
"""Stop or restart home assistant."""
if restart:
await supervisor_client.homeassistant.restart()
else:
await supervisor_client.homeassistant.stop()
# Install a custom handler for the homeassistant.restart / stop services,
# and restore the previous one when this entry unloads.
prev_stop_handler = hass.data.get(DATA_STOP_HANDLER)
async_set_stop_handler(hass, _async_stop)
def _restore_stop_handler() -> None:
if prev_stop_handler is not None:
async_set_stop_handler(hass, prev_stop_handler)
else:
hass.data.pop(DATA_STOP_HANDLER, None)
entry.async_on_unload(_restore_stop_handler)
last_timezone = None
last_country = None
@@ -370,39 +290,103 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SupervisorError as err:
_LOGGER.warning("Failed to update Supervisor options: %s", err)
entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config))
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)
http_config: dict[str, Any] = hass.data.get(DATA_HASSIO_HTTP_CONFIG, {})
push_config_task = hass.async_create_task(push_config(None), eager_start=True)
# Start listening for problems with supervisor and making issues
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
async def update_hass_api(refresh_token: RefreshToken) -> None:
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
ssl=CONF_SSL_CERTIFICATE in http_config,
port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT,
refresh_token=refresh_token.token,
)
# Register services
async_setup_services(hass, supervisor_client)
if http_config.get(CONF_SERVER_HOST) is not None:
options = replace(options, watchdog=False)
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""
supervisor_client = get_supervisor_client(hass)
try:
await supervisor_client.homeassistant.set_options(options)
except SupervisorError as err:
_LOGGER.warning(
"Failed to update Home Assistant options in Supervisor: %s", err
(
root_info,
host_info,
store_info,
homeassistant_info,
supervisor_info,
os_info,
network_info,
addons_list,
) = cast(
tuple[
RootInfo,
HostInfo,
StoreInfo,
HomeAssistantInfo,
SupervisorInfo,
OSInfo,
NetworkInfo,
list[InstalledAddon],
],
await asyncio.gather(
create_eager_task(supervisor_client.info()),
create_eager_task(supervisor_client.host.info()),
create_eager_task(supervisor_client.store.info()),
create_eager_task(supervisor_client.homeassistant.info()),
create_eager_task(supervisor_client.supervisor.info()),
create_eager_task(supervisor_client.os.info()),
create_eager_task(supervisor_client.network.info()),
create_eager_task(supervisor_client.addons.list()),
),
)
await asyncio.gather(
update_hass_api(refresh_token),
push_config(None),
issues.setup(),
async_setup_addon_panel(hass),
except SupervisorError as err:
_LOGGER.warning("Can't read Supervisor data: %s", err)
else:
hass.data[DATA_INFO] = root_info
hass.data[DATA_HOST_INFO] = host_info
hass.data[DATA_STORE] = store_info
hass.data[DATA_CORE_INFO] = homeassistant_info
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info
hass.data[DATA_OS_INFO] = os_info
hass.data[DATA_NETWORK_INFO] = network_info
hass.data[DATA_ADDONS_LIST] = addons_list
# Fetch data
update_info_task = hass.async_create_task(update_info_data(), eager_start=True)
async def _async_stop(hass: HomeAssistant, restart: bool) -> None:
"""Stop or restart home assistant."""
if restart:
await supervisor_client.homeassistant.restart()
else:
await supervisor_client.homeassistant.stop()
# Set a custom handler for the homeassistant.restart and homeassistant.stop services
async_set_stop_handler(hass, _async_stop)
# Init discovery Hass.io feature
async_setup_discovery_view(hass)
# Init auth Hass.io feature
assert user is not None
async_setup_auth_view(hass, user)
# Init ingress Hass.io feature
async_setup_ingress_view(hass, host)
# Init add-on ingress panels
panels_task = hass.async_create_task(
async_setup_addon_panel(hass), eager_start=True
)
# Make sure to await the update_info task before
# _async_setup_hardware_integration is called
# so the hardware integration can be set up
# and does not fallback to calling later
await update_hass_api_task
await panels_task
await update_info_task
await push_config_task
await issues_task
# Setup hardware integration for the detected board type
@callback
def _async_setup_hardware_integration(_: datetime | None = None) -> None:
@@ -428,9 +412,81 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
_async_setup_hardware_integration()
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
dev_reg = dr.async_get(hass)
coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg)
await coordinator.async_config_entry_first_refresh()
hass.data[MAIN_COORDINATOR] = coordinator
addon_coordinator = HassioAddOnDataUpdateCoordinator(
hass, entry, dev_reg, coordinator.jobs
)
await addon_coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = addon_coordinator
stats_coordinator = HassioStatsDataUpdateCoordinator(hass, entry)
await stats_coordinator.async_config_entry_first_refresh()
hass.data[STATS_COORDINATOR] = stats_coordinator
def deprecated_setup_issue() -> None:
_check_deprecated_setup(hass)
os_info = get_os_info(hass)
info = get_info(hass)
if os_info is None or info is None:
return
is_haos = info.get("hassos") is not None
board = os_info.get("board")
arch = info.get("arch", "unknown")
unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"}
unsupported_os_on_board = board in {"rpi3", "rpi4"}
if is_haos and (unsupported_board or unsupported_os_on_board):
issue_id = "deprecated_os_"
if unsupported_os_on_board:
issue_id += "aarch64"
elif unsupported_board:
issue_id += "armv7"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_guide": "https://www.home-assistant.io/installation/",
},
)
bit32 = _is_32_bit()
deprecated_architecture = bit32 and not (
unsupported_board or unsupported_os_on_board
)
if not is_haos or deprecated_architecture:
issue_id = "deprecated"
if not is_haos:
issue_id += "_method"
if deprecated_architecture:
issue_id += "_architecture"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"installation_type": "OS" if is_haos else "Supervised",
"arch": arch,
},
)
listener()
listener = coordinator.async_add_listener(deprecated_setup_issue)
@@ -448,16 +504,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR]
coordinator.unload()
# Pop coordinators and entry-level data
# Pop coordinators
hass.data.pop(MAIN_COORDINATOR, None)
hass.data.pop(ADDONS_COORDINATOR, None)
hass.data.pop(STATS_COORDINATOR, None)
hass.data.pop(DATA_CONFIG_STORE, None)
hass.data.pop(DATA_HASSIO_SUPERVISOR_USER, None)
if (
supervisor_issues := hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None)
) is not None:
supervisor_issues.unload()
return unload_ok
+12 -15
View File
@@ -6,44 +6,41 @@ import logging
import os
from aiohttp import web
from aiohttp.web_exceptions import (
HTTPNotFound,
HTTPServiceUnavailable,
HTTPUnauthorized,
)
from aiohttp.web_exceptions import HTTPNotFound, HTTPUnauthorized
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.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER
from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup_auth_view(hass: HomeAssistant) -> None:
def async_setup_auth_view(hass: HomeAssistant, user: User) -> None:
"""Auth setup."""
hass.http.register_view(HassIOAuth(hass))
hass.http.register_view(HassIOPasswordReset(hass))
hassio_auth = HassIOAuth(hass, user)
hassio_password_reset = HassIOPasswordReset(hass, user)
hass.http.register_view(hassio_auth)
hass.http.register_view(hassio_password_reset)
class HassIOBaseAuth(HomeAssistantView):
"""Hass.io view to handle auth requests."""
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, user: User) -> None:
"""Initialize WebView."""
self.hass = hass
self.user = user
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)
if user is None:
raise HTTPServiceUnavailable
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
@@ -54,7 +51,7 @@ class HassIOBaseAuth(HomeAssistantView):
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != user.id:
if request[KEY_HASS_USER].id != self.user.id:
_LOGGER.error("Invalid auth request from %s", request[KEY_HASS_USER].name)
raise HTTPUnauthorized
+1 -6
View File
@@ -2,7 +2,7 @@
from datetime import timedelta
from enum import StrEnum
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
@@ -20,8 +20,6 @@ if TYPE_CHECKING:
SupervisorInfo,
)
from homeassistant.auth.models import User
from .config import HassioConfig
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
@@ -147,9 +145,6 @@ DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues")
DATA_KEY_MOUNTS = "mounts"
DATA_HASSIO_HTTP_CONFIG: HassKey[dict[str, Any]] = HassKey("hassio_http_config")
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
@@ -780,7 +780,10 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
)
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
self.is_hass_os = False
if info := self.hass.data.get(DATA_INFO):
self.is_hass_os = info.hassos is not None
else:
self.is_hass_os = False
self.supervisor_client = get_supervisor_client(hass)
self.jobs = SupervisorJobs(hass)
self._dispatcher_disconnect = async_dispatcher_connect(
@@ -840,7 +843,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Build clean coordinator data
self.is_hass_os = info.hassos is not None
new_data = HassioMainData(
core=core_info,
supervisor=supervisor_info,
+3 -4
View File
@@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util.async_ import create_eager_task
from .const import DATA_HASSIO_HOST, X_HASS_SOURCE, X_INGRESS_PATH
from .const import X_HASS_SOURCE, X_INGRESS_PATH
from .http import should_compress
_LOGGER = logging.getLogger(__name__)
@@ -50,9 +50,8 @@ DISABLED_TIMEOUT = ClientTimeout(total=None)
@callback
def async_setup_ingress_view(hass: HomeAssistant) -> None:
"""Set up the Hass.io ingress HTTP view."""
host = hass.data[DATA_HASSIO_HOST]
def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None:
"""Auth setup."""
websession = async_get_clientsession(hass)
hassio_ingress = HassIOIngress(host, websession)
+2 -15
View File
@@ -1,7 +1,6 @@
"""Supervisor events monitor."""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
import logging
@@ -181,8 +180,6 @@ class SupervisorIssues:
self._unhealthy_reasons: set[str] = set()
self._issues: dict[UUID, Issue] = {}
self._supervisor_client = get_supervisor_client(hass)
self._disconnect: Callable[[], None] | None = None
self._cancel_update_retry: Callable[[], None] | None = None
@property
def unhealthy_reasons(self) -> set[str]:
@@ -355,32 +352,22 @@ class SupervisorIssues:
"""Create supervisor events listener."""
await self._update()
self._disconnect = async_dispatcher_connect(
async_dispatcher_connect(
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues
)
def unload(self) -> None:
"""Remove supervisor events listener."""
if self._disconnect is not None:
self._disconnect()
self._disconnect = None
if self._cancel_update_retry is not None:
self._cancel_update_retry()
self._cancel_update_retry = None
async def _update(self, _: datetime | None = None) -> None:
"""Update issues from Supervisor resolution center."""
try:
data = await self._supervisor_client.resolution.info()
except SupervisorError as err:
_LOGGER.error("Failed to update supervisor issues: %r", err)
self._cancel_update_retry = async_call_later(
async_call_later(
self._hass,
REQUEST_REFRESH_DELAY,
HassJob(self._update, cancel_on_shutdown=True),
)
return
self._cancel_update_retry = None
self.unhealthy_reasons = set(data.unhealthy)
self.unsupported_reasons = set(data.unsupported)
+3 -3
View File
@@ -50,7 +50,6 @@ from .const import (
SupervisorEntityModel,
)
from .coordinator import HassioMainDataUpdateCoordinator, get_addons_info
from .handler import get_supervisor_client
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
@@ -164,9 +163,10 @@ SCHEMA_MOUNT_RELOAD = vol.Schema(
@callback
def async_setup_services(hass: HomeAssistant) -> None:
def async_setup_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register the Supervisor services."""
supervisor_client = get_supervisor_client(hass)
async_register_app_services(hass, supervisor_client)
async_register_host_services(hass, supervisor_client)
async_register_backup_restore_services(hass, supervisor_client)
@@ -52,9 +52,6 @@
},
"mount_reload_unknown_device_id": {
"message": "Device ID not found"
},
"supervisor_not_connected": {
"message": "Not connected with the supervisor / system too busy"
}
},
"issues": {
@@ -31,7 +31,6 @@ from .const import (
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DOMAIN,
EVENT_SUPERVISOR_EVENT,
WS_ID,
WS_TYPE,
@@ -210,13 +209,9 @@ def websocket_update_config_info(
msg: dict[str, Any],
) -> None:
"""Send the stored backup config."""
if (
not hass.config_entries.async_loaded_entries(DOMAIN)
or (config_store := hass.data.get(DATA_CONFIG_STORE)) is None
):
connection.send_error(msg["id"], "not_loaded", "Supervisor not loaded")
return
connection.send_result(msg["id"], config_store.data.update_config.to_dict())
connection.send_result(
msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict()
)
@callback
@@ -235,14 +230,10 @@ def websocket_update_config_update(
msg: dict[str, Any],
) -> None:
"""Update the stored backup config."""
if (
not hass.config_entries.async_loaded_entries(DOMAIN)
or (config_store := hass.data.get(DATA_CONFIG_STORE)) is None
):
connection.send_error(msg["id"], "not_loaded", "Supervisor not loaded")
return
changes = dict(msg)
changes.pop("id")
changes.pop("type")
config_store.update(update_config=cast(HassioUpdateParametersDict, changes))
hass.data[DATA_CONFIG_STORE].update(
update_config=cast(HassioUpdateParametersDict, changes)
)
connection.send_result(msg["id"])
+11 -5
View File
@@ -44,14 +44,20 @@ 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, 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"],
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"],
)
await hass.config_entries.async_forward_entry_setups(
@@ -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
from homeassistant.core import HomeAssistant, State
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,6 +46,20 @@ 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."""
@@ -79,10 +93,7 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
"is_target_humidity": IsTargetHumidityCondition,
}
+26 -3
View File
@@ -14,9 +14,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -31,8 +31,31 @@ 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": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
"is_value": HumidityCondition,
}
+43 -9
View File
@@ -13,12 +13,13 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
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] = {
@@ -36,13 +37,46 @@ 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": 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="%"
),
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.4"]
"requirements": ["aioautomower==2.7.5"]
}
@@ -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.4.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
}
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["indevolt-api==1.7.1"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==2.0.0"]
"requirements": ["infrared-protocols==2.1.0"]
}
@@ -77,10 +77,9 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
+1 -1
View File
@@ -12,7 +12,7 @@
"quality_scale": "platinum",
"requirements": [
"xknx==3.15.0",
"xknxproject==3.8.2",
"xknxproject==3.9.0",
"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 LitterRobot, LitterRobot4, Robot
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -30,8 +30,11 @@ class RobotBinarySensorEntityDescription(
is_on_fn: Callable[[_WhiskerEntityT], bool]
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check
BINARY_SENSOR_MAP: dict[
type[Robot] | tuple[type[Robot], ...],
tuple[RobotBinarySensorEntityDescription, ...],
] = {
LitterRobot: (
RobotBinarySensorEntityDescription[LitterRobot](
key="sleeping",
translation_key="sleeping",
@@ -56,14 +59,14 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, .
is_on_fn=lambda robot: not robot.is_hopper_removed,
),
),
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot](
(FeederRobot, LitterRobot3, LitterRobot4): (
RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4](
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_status == "AC",
is_on_fn=lambda robot: robot.power_type == "AC",
),
),
}
@@ -16,5 +16,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "platinum",
"requirements": ["pylitterbot==2025.3.2"]
"requirements": ["pylitterbot==2025.4.0"]
}
+8 -2
View File
@@ -251,8 +251,10 @@ 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)
)
@@ -302,8 +304,12 @@ 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.TURN_OFF | FanEntityFeature.TURN_ON
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
@@ -1,11 +1,108 @@
"""Provides conditions for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from datetime import datetime
from typing import Any
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,
@@ -17,18 +114,10 @@ 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,22 +1,51 @@
.condition_common: &condition_common
target:
target: &condition_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
for: &condition_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,5 +1,8 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -14,6 +17,12 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -123,6 +132,9 @@
}
},
"triggers": {
"muted": {
"trigger": "mdi:volume-mute"
},
"paused_playing": {
"trigger": "mdi:pause"
},
@@ -137,6 +149,15 @@
},
"turned_on": {
"trigger": "mdi:power"
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,10 +2,24 @@
"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_for_name": "For at least",
"trigger_threshold_name": "Threshold"
},
"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": {
@@ -65,6 +79,33 @@
}
},
"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": {
@@ -437,6 +478,18 @@
},
"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": {
@@ -496,6 +549,42 @@
}
},
"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,12 +1,125 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
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 . import MediaPlayerState
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, 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,22 +1,62 @@
.trigger_common: &trigger_common
target:
target: &trigger_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
automation_behavior:
mode: trigger
for:
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
+1
View File
@@ -479,6 +479,7 @@ 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
@@ -0,0 +1,95 @@
"""Mitsubishi Comfort integration for Home Assistant."""
import asyncio
import logging
from mitsubishi_comfort import (
DeviceInfo,
IndoorUnit,
KumoStation,
MitsubishiCloudAccount,
)
from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
_LOGGER = logging.getLogger(__name__)
def _make_device(
info: DeviceInfo,
serial: str,
session,
) -> IndoorUnit | KumoStation:
"""Create the appropriate device instance from DeviceInfo."""
cls = IndoorUnit if info.is_indoor_unit else KumoStation
return cls(
name=info.label,
address=info.address,
password_b64=info.password,
crypto_serial_hex=info.crypto_serial,
serial=serial,
connect_timeout=DEFAULT_CONNECT_TIMEOUT,
response_timeout=DEFAULT_RESPONSE_TIMEOUT,
session=session,
)
async def async_setup_entry(
hass: HomeAssistant, entry: MitsubishiComfortConfigEntry
) -> bool:
"""Set up Mitsubishi Comfort from a config entry."""
session = async_get_clientsession(hass)
account = MitsubishiCloudAccount(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
)
try:
await account.login()
devices = await account.discover_devices()
except AuthenticationError as err:
raise ConfigEntryError("Mitsubishi cloud authentication failed") from err
except DeviceConnectionError as err:
raise ConfigEntryNotReady("Cannot reach Mitsubishi cloud") from err
if not devices:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="no_devices",
)
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
for serial, info in devices.items():
if not info.address or not info.password or not info.crypto_serial:
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
continue
device = _make_device(info, serial, session)
coordinators[serial] = MitsubishiComfortCoordinator(
hass, entry, device, info.mac
)
await asyncio.gather(
*(c.async_config_entry_first_refresh() for c in coordinators.values())
)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: MitsubishiComfortConfigEntry
) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await asyncio.gather(
*(c.device.close() for c in entry.runtime_data.values()),
return_exceptions=True,
)
return unload_ok
@@ -0,0 +1,287 @@
"""Climate entity for Mitsubishi Comfort integration."""
from typing import Any
from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
from .entity import MitsubishiComfortEntity
_MODE_TO_HVAC: dict[str, HVACMode] = {
"off": HVACMode.OFF,
"cool": HVACMode.COOL,
"heat": HVACMode.HEAT,
"dry": HVACMode.DRY,
"vent": HVACMode.FAN_ONLY,
"auto": HVACMode.HEAT_COOL,
"autoCool": HVACMode.HEAT_COOL,
"autoHeat": HVACMode.HEAT_COOL,
}
_HVAC_TO_MODE: dict[HVACMode, Mode] = {
HVACMode.OFF: Mode.OFF,
HVACMode.COOL: Mode.COOL,
HVACMode.HEAT: Mode.HEAT,
HVACMode.DRY: Mode.DRY,
HVACMode.FAN_ONLY: Mode.FAN,
HVACMode.HEAT_COOL: Mode.AUTO,
}
_LIB_MODE_TO_HVAC: dict[Mode, HVACMode] = {v: k for k, v in _HVAC_TO_MODE.items()}
_MODE_TO_ACTION: dict[str, HVACAction] = {
"off": HVACAction.OFF,
"cool": HVACAction.COOLING,
"heat": HVACAction.HEATING,
"dry": HVACAction.DRYING,
"vent": HVACAction.FAN,
"auto": HVACAction.IDLE,
"autoCool": HVACAction.COOLING,
"autoHeat": HVACAction.HEATING,
}
_FAN_SPEED_MAP: dict[str, FanSpeed] = {s.value: s for s in FanSpeed}
_VANE_DIR_MAP: dict[str, VaneDirection] = {d.value: d for d in VaneDirection}
_OPT_MODE = "mode"
_OPT_COOL_SETPOINT = "cool_setpoint"
_OPT_HEAT_SETPOINT = "heat_setpoint"
_OPT_FAN_SPEED = "fan_speed"
_OPT_VANE_DIRECTION = "vane_direction"
async def async_setup_entry(
hass: HomeAssistant,
entry: MitsubishiComfortConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Mitsubishi Comfort climate entities."""
coordinators = entry.runtime_data
async_add_entities(
MitsubishiComfortClimate(coordinator)
for coordinator in coordinators.values()
if isinstance(coordinator.device, IndoorUnit)
)
class MitsubishiComfortClimate(MitsubishiComfortEntity, ClimateEntity):
"""Climate entity for a Mitsubishi indoor unit."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = self._device.serial
self._optimistic: dict[str, Any] = {}
def _handle_coordinator_update(self) -> None:
"""Clear optimistic state when real data arrives from device."""
self._optimistic.clear()
super()._handle_coordinator_update()
@property
def _effective_mode(self) -> str | None:
return self._optimistic.get(_OPT_MODE, self._device.status.mode)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
mode = self._effective_mode
return _MODE_TO_HVAC.get(mode) if mode else None
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current HVAC action."""
mode = self._effective_mode
if mode and self._device.status.standby:
return HVACAction.IDLE
return _MODE_TO_ACTION.get(mode) if mode else None
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available HVAC modes."""
return [
_LIB_MODE_TO_HVAC[m]
for m in self._device.supported_modes
if m in _LIB_MODE_TO_HVAC
]
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._device.status.room_temperature
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
return self._device.status.current_humidity
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
mode = self._effective_mode
if mode in ("cool", "autoCool"):
return self._optimistic.get(
_OPT_COOL_SETPOINT, self._device.status.cool_setpoint
)
if mode in ("heat", "autoHeat"):
return self._optimistic.get(
_OPT_HEAT_SETPOINT, self._device.status.heat_setpoint
)
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the upper bound target temperature."""
if self._effective_mode in ("auto", "autoCool", "autoHeat"):
return self._optimistic.get(
_OPT_COOL_SETPOINT, self._device.status.cool_setpoint
)
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lower bound target temperature."""
if self._effective_mode in ("auto", "autoCool", "autoHeat"):
return self._optimistic.get(
_OPT_HEAT_SETPOINT, self._device.status.heat_setpoint
)
return None
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
return self._optimistic.get(_OPT_FAN_SPEED, self._device.status.fan_speed)
@property
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
return [s.value for s in self._device.supported_fan_speeds]
@property
def swing_mode(self) -> str | None:
"""Return the current swing mode."""
return self._optimistic.get(
_OPT_VANE_DIRECTION, self._device.status.vane_direction
)
@property
def swing_modes(self) -> list[str]:
"""Return the list of available swing modes."""
return [d.value for d in self._device.supported_vane_directions]
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
if self._effective_mode in ("heat", "autoHeat"):
if self._device.status.min_heat_setpoint is not None:
return self._device.status.min_heat_setpoint
if self._device.status.min_cool_setpoint is not None:
return self._device.status.min_cool_setpoint
return super().min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
if self._effective_mode in ("heat", "autoHeat"):
if self._device.status.max_heat_setpoint is not None:
return self._device.status.max_heat_setpoint
if self._device.status.max_cool_setpoint is not None:
return self._device.status.max_cool_setpoint
return super().max_temp
@property
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_OFF
)
if Mode.AUTO in self._device.supported_modes:
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
if self._device.supported_vane_directions:
features |= ClimateEntityFeature.SWING_MODE
return features
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
lib_mode = _HVAC_TO_MODE.get(hvac_mode)
if lib_mode is None:
return
result = await self._device.set_mode(lib_mode)
if result.success:
self._optimistic[_OPT_MODE] = result.value
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
mode = self._effective_mode
wrote = False
if ATTR_TARGET_TEMP_HIGH in kwargs:
result = await self._device.set_cool_setpoint(kwargs[ATTR_TARGET_TEMP_HIGH])
if result.success:
self._optimistic[_OPT_COOL_SETPOINT] = result.value
wrote = True
if ATTR_TARGET_TEMP_LOW in kwargs:
result = await self._device.set_heat_setpoint(kwargs[ATTR_TARGET_TEMP_LOW])
if result.success:
self._optimistic[_OPT_HEAT_SETPOINT] = result.value
wrote = True
temp = kwargs.get(ATTR_TEMPERATURE)
if temp is not None:
if mode in ("cool", "autoCool"):
result = await self._device.set_cool_setpoint(temp)
if result.success:
self._optimistic[_OPT_COOL_SETPOINT] = result.value
wrote = True
elif mode in ("heat", "autoHeat"):
result = await self._device.set_heat_setpoint(temp)
if result.success:
self._optimistic[_OPT_HEAT_SETPOINT] = result.value
wrote = True
if wrote:
self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
speed = _FAN_SPEED_MAP.get(fan_mode)
if speed is None:
return
result = await self._device.set_fan_speed(speed)
if result.success:
self._optimistic[_OPT_FAN_SPEED] = result.value
self.async_write_ha_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set the swing mode."""
direction = _VANE_DIR_MAP.get(swing_mode)
if direction is None:
return
result = await self._device.set_vane_direction(direction)
if result.success:
self._optimistic[_OPT_VANE_DIRECTION] = result.value
self.async_write_ha_state()
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.async_set_hvac_mode(HVACMode.OFF)
@@ -0,0 +1,73 @@
"""Config flow for Mitsubishi Comfort integration."""
import logging
from typing import Any
from mitsubishi_comfort import MitsubishiCloudAccount
from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Mitsubishi Comfort."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user setup step."""
errors: dict[str, str] = {}
if user_input is not None:
account = MitsubishiCloudAccount(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
devices: dict = {}
try:
await account.login()
devices = await account.discover_devices()
except AuthenticationError:
errors["base"] = "invalid_auth"
except DeviceConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during setup")
errors["base"] = "unknown"
if not errors:
await self.async_set_unique_id(account.user_id)
self._abort_if_unique_id_configured()
if not devices:
errors["base"] = "no_devices"
else:
return self.async_create_entry(
title=f"Mitsubishi Comfort ({user_input[CONF_USERNAME]})",
data={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
@@ -0,0 +1,12 @@
"""Constants for the Mitsubishi Comfort integration."""
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "mitsubishi_comfort"
PLATFORMS: Final = [Platform.CLIMATE]
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
@@ -0,0 +1,56 @@
"""DataUpdateCoordinator for Mitsubishi Comfort devices."""
import logging
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MitsubishiComfortConfigEntry = ConfigEntry[dict[str, MitsubishiComfortCoordinator]]
class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStation]):
"""Coordinator to poll a single Mitsubishi device."""
def __init__(
self,
hass: HomeAssistant,
entry: MitsubishiComfortConfigEntry,
device: IndoorUnit | KumoStation,
mac: str,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"mitsubishi_comfort_{device.serial}",
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.device = device
self.mac = mac
self.data = device
async def _async_update_data(self) -> IndoorUnit | KumoStation:
"""Poll the device and return it."""
try:
success = await self.device.update_status()
except Exception as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"device_name": self.device.name},
) from err
if not success:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"device_name": self.device.name},
)
return self.device
@@ -0,0 +1,34 @@
"""Base entity for Mitsubishi Comfort integration."""
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MitsubishiComfortCoordinator
class MitsubishiComfortEntity(CoordinatorEntity[MitsubishiComfortCoordinator]):
"""Base class for all Mitsubishi Comfort entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None:
"""Initialize."""
super().__init__(coordinator)
device = coordinator.device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.serial)},
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
name=device.name,
manufacturer="Mitsubishi",
serial_number=device.serial,
sw_version=device.status.firmware_version,
hw_version=device.status.hardware_version,
)
@property
def _device(self) -> IndoorUnit | KumoStation:
"""Return the underlying device from coordinator data."""
return self.coordinator.data
@@ -0,0 +1,11 @@
{
"domain": "mitsubishi_comfort",
"name": "Mitsubishi Comfort",
"codeowners": ["@nikolairahimi"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
}
@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No service actions registered.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
config-entry-unloading: done
log-when-unavailable: done
entity-unavailable: done
action-exceptions:
status: exempt
comment: No service actions registered.
reauthentication-flow: todo
parallel-updates: todo
test-coverage: todo
integration-owner: done
docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: No options flow.
# Gold
entity-translations: todo
entity-device-class: todo
devices: done
entity-category:
status: exempt
comment: Single climate entity per device, no diagnostic entities yet.
entity-disabled-by-default:
status: exempt
comment: Single climate entity per device, enabled by default.
discovery: todo
stale-devices: todo
diagnostics: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
repair-issues: todo
docs-use-cases: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-data-update: done
docs-known-limitations: done
docs-examples: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,36 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No devices were found on this account",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Kumo Cloud account.",
"username": "The email address for your Kumo Cloud account."
}
}
}
},
"exceptions": {
"communication_error": {
"message": "Error communicating with {device_name}"
},
"no_devices": {
"message": "No devices were found in your Mitsubishi Comfort account"
},
"update_failed": {
"message": "{device_name} returned no data"
}
}
}
@@ -82,6 +82,7 @@ ATTR_SENSOR_UOM = "unit_of_measurement"
SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification"
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
+23 -1
View File
@@ -21,9 +21,13 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -46,6 +50,7 @@ from .const import (
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
SIGNAL_RECORD_NOTIFICATION,
)
from .helpers import device_info
from .push_notification import PushChannel
@@ -111,6 +116,21 @@ class MobileAppNotifyEntity(NotifyEntity):
translation_placeholders={"device_name": self._config_entry.title},
)
@callback
def _async_handle_notification(self, webhook_id: str) -> None:
"""Handle notifications triggered externally."""
if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]:
self._async_record_notification()
async def async_added_to_hass(self) -> None:
"""Register callback."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification
)
)
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
@@ -195,6 +215,7 @@ class MobileAppNotificationService(BaseNotificationService):
data,
partial(self._async_send_remote_message_target, entry),
)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
continue
# Test if local push only.
@@ -203,6 +224,7 @@ class MobileAppNotificationService(BaseNotificationService):
continue
await self._async_send_remote_message_target(entry, data)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
if failed_targets:
raise HomeAssistantError(
+29 -1
View File
@@ -11,7 +11,12 @@ import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD
from homeassistant.const import (
CONF_DISCOVERY,
CONF_PLATFORM,
CONF_PROTOCOL,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigValidationError,
@@ -27,6 +32,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
@@ -73,12 +79,14 @@ from .const import (
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_311,
TEMPLATE_ERRORS,
Platform,
)
@@ -424,6 +432,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
mqtt_data: MqttData
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=True,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
# Fetch configuration
+9 -3
View File
@@ -63,7 +63,6 @@ from .const import (
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
DEFAULT_PORT,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
@@ -74,6 +73,7 @@ from .const import (
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5,
PROTOCOL_31,
PROTOCOL_311,
TRANSPORT_WEBSOCKETS,
)
from .models import (
@@ -331,7 +331,10 @@ class MqttClientSetup:
config = self._config
clean_session: bool | None = None
if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31:
# If no protocol setting is set in the config entry data
# we assume the config was migrated from YAML, and the
# protocol version is defaulting to legacy version 3.1.1.
if (protocol := config.get(CONF_PROTOCOL, PROTOCOL_311)) == PROTOCOL_31:
proto = mqtt.MQTTv31
clean_session = True
elif protocol == PROTOCOL_5:
@@ -420,7 +423,10 @@ class MQTT:
self.loop = hass.loop
self.config_entry = config_entry
self.conf = conf
self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5
# If no protocol setting is set in the config entry data
# we assume the config was migrated from YAML, and the
# protocol version is defaulting to legacy version 3.1.1.
self.is_mqttv5 = conf.get(CONF_PROTOCOL, PROTOCOL_311) == PROTOCOL_5
self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict(
set
+7 -5
View File
@@ -4073,6 +4073,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
config: dict[str, Any] = {
CONF_BROKER: addon_discovery_config[CONF_HOST],
CONF_PORT: addon_discovery_config[CONF_PORT],
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
@@ -4301,6 +4302,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
data: dict[str, Any] = self._hassio_discovery.copy()
data[CONF_BROKER] = data.pop(CONF_HOST)
data[CONF_PROTOCOL] = DEFAULT_PROTOCOL
can_connect = await self.hass.async_add_executor_job(
try_connection,
data,
@@ -4312,6 +4314,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
data={
CONF_BROKER: data[CONF_BROKER],
CONF_PORT: data[CONF_PORT],
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: data.get(CONF_USERNAME),
CONF_PASSWORD: data.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
@@ -5178,6 +5181,8 @@ async def async_get_broker_settings( # noqa: C901
) -> bool:
"""Additional validation on broker settings for better error messages."""
if CONF_PROTOCOL not in validated_user_input:
validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL
# Get current certificate settings from config entry
certificate: str | None = (
"auto"
@@ -5366,12 +5371,9 @@ async def async_get_broker_settings( # noqa: C901
description={"suggested_value": current_pass},
)
] = PASSWORD_SELECTOR
# show advanced options checkbox if requested and
# advanced options are enabled
# or when the defaults of advanced options are overridden
# show advanced options checkbox if no defaults
# of the advanced options are overridden
if not advanced_broker_options:
if not flow.show_advanced_options:
return False
fields[
vol.Optional(
ADVANCED_OPTIONS,
+2 -2
View File
@@ -347,14 +347,14 @@ REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT"
PROTOCOL_31 = "3.1"
PROTOCOL_311 = "3.1.1"
PROTOCOL_5 = "5"
SUPPORTED_PROTOCOLS = [PROTOCOL_31, PROTOCOL_311, PROTOCOL_5]
SUPPORTED_PROTOCOLS = [PROTOCOL_5, PROTOCOL_311, PROTOCOL_31]
TRANSPORT_TCP = "tcp"
TRANSPORT_WEBSOCKETS = "websockets"
DEFAULT_PORT = 1883
DEFAULT_KEEPALIVE = 60
DEFAULT_PROTOCOL = PROTOCOL_311
DEFAULT_PROTOCOL = PROTOCOL_5
DEFAULT_TRANSPORT = TRANSPORT_TCP
DEFAULT_BIRTH = {
+63 -8
View File
@@ -6,10 +6,16 @@ import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .config_flow import try_connection
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
URL_MQTT_BROKER_CONFIGURATION = (
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
)
class MQTTDeviceEntryMigration(RepairsFlow):
@@ -50,6 +56,55 @@ class MQTTDeviceEntryMigration(RepairsFlow):
)
class MQTTProtocolV5Migration(RepairsFlow):
"""Handler to migrate to MQTT protocol version 5."""
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.broker = broker
self.protocol = protocol
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
assert entry is not None
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
if await self.hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
return self.async_create_entry(data={})
return self.async_abort(
reason="mqtt_broker_migration_to_v5_failed",
description_placeholders={
"broker": self.broker,
"protocol": self.protocol,
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
},
)
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={"broker": self.broker, "protocol": self.protocol},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -58,13 +113,13 @@ async def async_create_fix_flow(
"""Create flow."""
if TYPE_CHECKING:
assert data is not None
entry_id = data["entry_id"]
subentry_id = data["subentry_id"]
name = data["name"]
if TYPE_CHECKING:
assert isinstance(entry_id, str)
assert isinstance(subentry_id, str)
assert isinstance(name, str)
entry_id: str = data["entry_id"] # type: ignore[assignment]
if issue_id == "protocol_5_migration":
broker: str = data["broker"] # type: ignore[assignment]
protocol: str = data["protocol"] # type: ignore[assignment]
return MQTTProtocolV5Migration(entry_id, broker, protocol)
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
name: str = data["name"] # type: ignore[assignment]
return MQTTDeviceEntryMigration(
entry_id=entry_id,
subentry_id=subentry_id,
@@ -1120,6 +1120,20 @@
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.",
"title": "Invalid config found for MQTT {domain} item"
},
"protocol_5_migration": {
"fix_flow": {
"abort": {
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
},
"step": {
"confirm": {
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
"title": "MQTT protocol change required"
}
}
},
"title": "Deprecated MQTT protocol {protocol} in use"
},
"subentry_migration_discovery": {
"fix_flow": {
"step": {
+2 -3
View File
@@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -55,9 +56,7 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity):
},
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{self.device_type}-preferred_position"
)
self._attr_unique_id = f"{self.device.entity_id}-{device_type_to_str(self.device_type)}-preferred_position"
@callback
def async_update_callback(self) -> None:
+4 -1
View File
@@ -42,6 +42,7 @@ from .const import (
)
from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -102,7 +103,9 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
Camera.__init__(self)
super().__init__(netatmo_device)
self._attr_unique_id = f"{netatmo_device.device.entity_id}-{self.device_type}"
self._attr_unique_id = (
f"{netatmo_device.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._light_state = None
self._publishers.extend(
+4 -1
View File
@@ -54,6 +54,7 @@ from .const import (
)
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom
from .entity import NetatmoRoomEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -219,7 +220,9 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
if self.device_type is NA_THERM:
self._attr_hvac_modes.append(HVACMode.OFF)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
async def async_added_to_hass(self) -> None:
"""Entity created."""
+4 -1
View File
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -70,7 +71,9 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity):
},
]
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
+4 -1
View File
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -62,7 +63,9 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity):
]
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
@@ -3,6 +3,16 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from pyatmo.modules.device_types import DeviceType as NetatmoDeviceType
def device_type_to_str(device_type: NetatmoDeviceType) -> str:
"""Convert a device type to a string.
Used to generate backwards compatible unique ids.
"""
return f"{type(device_type).__name__}.{device_type}"
@dataclass
class NetatmoArea:
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"requirements": ["pyatmo==9.2.3"]
"requirements": ["pyatmo==9.4.0"]
}
+4 -1
View File
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -58,7 +59,9 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity):
},
]
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_is_on = self.device.on
@callback
@@ -0,0 +1,81 @@
"""Binary sensor platform for Nord Pool integration."""
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.sensor import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NordPoolConfigEntry
from .const import CONF_AREAS
from .coordinator import NordPoolDataUpdateCoordinator
from .entity import NordpoolBaseEntity
PARALLEL_UPDATES = 0
def get_tomorrow_price_available(
entity: NordpoolPriceBinarySensor,
) -> bool:
"""Return tomorrow price availability."""
data = entity.coordinator.get_data_tomorrow()
return bool(data and data.entries and entity.area in data.entries[0].entry)
@dataclass(frozen=True, kw_only=True)
class NordpoolBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Nord Pool binary sensor entity."""
value_fn: Callable[[NordpoolPriceBinarySensor], bool | None]
BINARY_SENSOR_TYPES: tuple[NordpoolBinarySensorEntityDescription, ...] = (
NordpoolBinarySensorEntityDescription(
key="tomorrow_price_available",
translation_key="tomorrow_price_available",
value_fn=get_tomorrow_price_available,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NordPoolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Nord Pool binary sensor platform."""
coordinator = entry.runtime_data
areas = coordinator.config_entry.data[CONF_AREAS]
async_add_entities(
NordpoolPriceBinarySensor(coordinator, description, area)
for description in BINARY_SENSOR_TYPES
for area in areas
)
class NordpoolPriceBinarySensor(NordpoolBaseEntity, BinarySensorEntity):
"""Representation of a Nord Pool binary sensor."""
entity_description: NordpoolBinarySensorEntityDescription
def __init__(
self,
coordinator: NordPoolDataUpdateCoordinator,
entity_description: NordpoolBinarySensorEntityDescription,
area: str,
) -> None:
"""Initiate Nord Pool binary sensor."""
super().__init__(coordinator, entity_description, area)
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self)
+1 -1
View File
@@ -8,7 +8,7 @@ LOGGER = logging.getLogger(__package__)
DEFAULT_SCAN_INTERVAL = 60
DOMAIN = "nordpool"
PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
DEFAULT_NAME = "Nord Pool"
CONF_AREAS = "areas"
@@ -164,3 +164,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
"""Return the current day data."""
current_day = dt_util.now().date()
return self.data.entries[current_day]
def get_data_tomorrow(self) -> DeliveryPeriodData | None:
"""Return tomorrow's day data if available."""
tomorrow = dt_util.now().date() + timedelta(days=1)
return self.data.entries.get(tomorrow)
@@ -32,6 +32,11 @@
}
},
"entity": {
"binary_sensor": {
"tomorrow_price_available": {
"name": "Tomorrow price available"
}
},
"sensor": {
"block_average": {
"name": "{block} average"
@@ -72,7 +72,6 @@ UNSUPPORTED_MODELS: list[str] = [
]
UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [
"gpt-5-nano",
"gpt-3.5",
"gpt-4-turbo",
"gpt-4.1-nano",
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from openai import OpenAIError
from propcache.api import cached_property
@@ -164,14 +164,15 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
client = self.entry.runtime_data
response_format = options[ATTR_PREFERRED_FORMAT]
if response_format not in self._supported_formats:
# common aliases
if response_format == "ogg":
response_format = "opus"
elif response_format == "raw":
response_format = "pcm"
else:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
if response_format in ("ogg", "oga"):
codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus"
elif response_format == "raw":
response_format = codec = "pcm"
elif response_format not in self._supported_formats:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
codec = response_format
else:
codec = response_format
try:
async with client.audio.speech.with_streaming_response.create(
@@ -180,7 +181,7 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
input=message,
instructions=str(options.get(CONF_PROMPT)),
speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED),
response_format=response_format,
response_format=codec,
) as response:
response_data = bytearray()
async for chunk in response.iter_bytes():
@@ -1,7 +1,5 @@
"""Services for the Overkiz integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.cover import (
+10 -2
View File
@@ -1,4 +1,12 @@
"""Constants for OwnTracks."""
DOMAIN = "owntracks"
ATTR_UPDATE_TIMESTAMP = "update_timestamp"
from typing import Final
DOMAIN: Final = "owntracks"
ATTR_ADDRESS: Final = "address"
ATTR_BATTERY_STATUS: Final = "battery_status"
ATTR_COURSE: Final = "course"
ATTR_TID: Final = "tid"
ATTR_UPDATE_TIMESTAMP: Final = "update_timestamp"
ATTR_VELOCITY: Final = "velocity"
@@ -21,8 +21,26 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .const import (
ATTR_ADDRESS,
ATTR_BATTERY_STATUS,
ATTR_COURSE,
ATTR_TID,
ATTR_UPDATE_TIMESTAMP,
ATTR_VELOCITY,
DOMAIN,
)
_RESTORED_OWNTRACKS_ATTRIBUTES: tuple[str, ...] = (
ATTR_ADDRESS,
ATTR_BATTERY_STATUS,
ATTR_COURSE,
ATTR_TID,
ATTR_UPDATE_TIMESTAMP,
ATTR_VELOCITY,
)
async def async_setup_entry(
@@ -141,12 +159,19 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity):
return
attr = state.attributes
attributes = {
key: attr[key] for key in _RESTORED_OWNTRACKS_ATTRIBUTES if key in attr
}
if isinstance(update_timestamp := attributes.get(ATTR_UPDATE_TIMESTAMP), str):
attributes[ATTR_UPDATE_TIMESTAMP] = dt_util.parse_datetime(update_timestamp)
self._data = {
"host_name": state.name,
"gps": (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)),
"gps_accuracy": attr.get(ATTR_GPS_ACCURACY),
"battery": attr.get(ATTR_BATTERY_LEVEL),
"source_type": attr.get(ATTR_SOURCE_TYPE),
"attributes": attributes,
}
@callback
+13 -6
View File
@@ -11,7 +11,14 @@ from homeassistant.components.device_tracker import SourceType
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME
from homeassistant.util import decorator, dt as dt_util, slugify
from .const import ATTR_UPDATE_TIMESTAMP
from .const import (
ATTR_ADDRESS,
ATTR_BATTERY_STATUS,
ATTR_COURSE,
ATTR_TID,
ATTR_UPDATE_TIMESTAMP,
ATTR_VELOCITY,
)
from .helper import supports_encryption
_LOGGER = logging.getLogger(__name__)
@@ -72,15 +79,15 @@ def _parse_see_args(message, subscribe_topic):
if "batt" in message:
kwargs["battery"] = message["batt"]
if "vel" in message:
kwargs["attributes"]["velocity"] = message["vel"]
kwargs["attributes"][ATTR_VELOCITY] = message["vel"]
if "tid" in message:
kwargs["attributes"]["tid"] = message["tid"]
kwargs["attributes"][ATTR_TID] = message["tid"]
if "addr" in message:
kwargs["attributes"]["address"] = message["addr"]
kwargs["attributes"][ATTR_ADDRESS] = message["addr"]
if "cog" in message:
kwargs["attributes"]["course"] = message["cog"]
kwargs["attributes"][ATTR_COURSE] = message["cog"]
if "bs" in message:
kwargs["attributes"]["battery_status"] = message["bs"]
kwargs["attributes"][ATTR_BATTERY_STATUS] = message["bs"]
if "t" in message:
if message["t"] in ("c", "u"):
kwargs["source_type"] = SourceType.GPS
@@ -236,5 +236,10 @@
},
"name": "Prune unused images"
}
},
"system_health": {
"info": {
"can_reach_server": "Reach Portainer server"
}
}
}
@@ -0,0 +1,28 @@
"""Provide info to system health."""
from typing import Any
from homeassistant.components import system_health
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
return {
"can_reach_server": system_health.async_check_can_reach_url(
hass, f"{config_entry.data[CONF_URL].rstrip('/')}/api/system/status"
),
}
@@ -0,0 +1,46 @@
"""The PTDevices integration."""
from aioptdevices.configuration import Configuration
from aioptdevices.interface import Interface
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_URL
from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator
_PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
async def async_setup_entry(
hass: HomeAssistant, config_entry: PTDevicesConfigEntry
) -> bool:
"""Set up PTDevices from a config entry."""
auth_token: str = config_entry.data[CONF_API_TOKEN]
session = async_get_clientsession(hass)
ptdevices_interface = Interface(
Configuration(
auth_token=auth_token,
device_id="*", # Retrieve data for all devices in account
url=DEFAULT_URL,
session=session,
)
)
config_entry.runtime_data = coordinator = PTDevicesCoordinator(
hass,
config_entry,
ptdevices_interface,
)
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(config_entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PTDevicesConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
@@ -0,0 +1,118 @@
"""Config flow for PTDevices integration."""
import logging
from typing import Any
import aioptdevices
from aioptdevices.configuration import Configuration
from aioptdevices.interface import Interface
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_URL, DOMAIN
_LOGGER = logging.getLogger(__name__)
_CONF_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
ptdevices_interface = Interface(
Configuration(
auth_token=data[CONF_API_TOKEN],
device_id="*", # Retrieve data for all devices in account
url=DEFAULT_URL,
session=session,
)
)
# Test Connection
try:
response = await ptdevices_interface.get_data()
except aioptdevices.PTDevicesRequestError as err:
raise CannotConnect from err
except aioptdevices.PTDevicesUnauthorizedError as err:
raise InvalidAuth from err
body = response["body"]
# Ensure the first device exists
first_device = next(iter(body.values()), None)
if first_device is None:
raise NoDevicesFound
user_name = first_device.get("user_name")
user_id = first_device.get("user_id")
title: str = str(user_name)
unique_id: str = str(user_id)
# Return title to be used for hub name
return (title, unique_id)
class PTDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PTDevices."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
# Test connection when user data is available
if user_input is not None:
# Test connection
try:
title, unique_id = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_access_token"
except NoDevicesFound:
errors["base"] = "no_devices_found"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Connection Successful
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=title, data=user_input)
# Show setup form
return self.async_show_form(
step_id="user", data_schema=_CONF_SCHEMA, errors=errors
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class NoDevicesFound(HomeAssistantError):
"""No devices were found in the account."""
@@ -0,0 +1,4 @@
"""Constants for the PTDevices integration."""
DOMAIN = "ptdevices"
DEFAULT_URL = "https://api.ptdevices.com/token/v1"
@@ -0,0 +1,88 @@
"""Coordinator for PTDevices integration."""
from datetime import timedelta
import logging
from typing import Final
import aioptdevices
from aioptdevices.interface import Interface, PTDevicesResponseData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import (
REQUEST_REFRESH_DEFAULT_IMMEDIATE,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
REFRESH_COOLDOWN: Final = 30
UPDATE_INTERVAL = timedelta(seconds=60)
type PTDevicesConfigEntry = ConfigEntry[PTDevicesCoordinator]
class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]):
"""Class for interacting with PTDevices get_data."""
config_entry: PTDevicesConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: PTDevicesConfigEntry,
ptdevices_interface: Interface,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
request_refresh_debouncer=Debouncer(
hass,
_LOGGER,
immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE,
cooldown=REFRESH_COOLDOWN,
),
)
self.interface = ptdevices_interface
async def _async_update_data(self) -> PTDevicesResponseData:
try:
data = await self.interface.get_data()
except aioptdevices.PTDevicesRequestError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except aioptdevices.PTDevicesUnauthorizedError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_access_token",
translation_placeholders={"error": repr(err)},
) from err
# Purge stale devices
device_reg = dr.async_get(self.hass)
identifiers = {
(DOMAIN, f"{device_data['user_id']}_{device_id}")
for device_id, device_data in data["body"].items()
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing stale device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)
return data["body"]
@@ -0,0 +1,49 @@
"""PTDevices integration."""
from typing import Any
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PTDevicesCoordinator
class PTDevicesEntity(CoordinatorEntity[PTDevicesCoordinator]):
"""Defines a base PTDevices entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: PTDevicesCoordinator,
sensor_key: str,
device_id: str,
) -> None:
"""Initialize."""
super().__init__(coordinator=coordinator)
self._sensor_key = sensor_key
self._device_id = device_id
self._user_id = coordinator.data[self._device_id]["user_id"]
self._attr_unique_id = f"{self._user_id}_{device_id}_{sensor_key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._user_id}_{self._device_id}")},
connections={(CONNECTION_NETWORK_MAC, self._device_id)},
configuration_url=f"https://www.ptdevices.com/device/level/{self.device['id']}",
manufacturer="ParemTech Inc.",
model=self.device["device_type"],
sw_version=str(self.device["version"]),
name=self.device["title"],
)
@property
def device(self) -> dict[str, Any]:
"""Return the device data."""
return self.coordinator.data[self._device_id]
@property
def available(self) -> bool:
"""Return if the device is available."""
return super().available and self._device_id in self.coordinator.data
@@ -0,0 +1,30 @@
{
"entity": {
"sensor": {
"battery_voltage": {
"default": "mdi:battery"
},
"depth_level": {
"default": "mdi:water"
},
"percent_level": {
"default": "mdi:water-percent"
},
"probe_temperature": {
"default": "mdi:thermometer"
},
"status": {
"default": "mdi:information-outline"
},
"tx_signal": {
"default": "mdi:wifi"
},
"volume_level": {
"default": "mdi:water"
},
"wifi_signal": {
"default": "mdi:wifi"
}
}
}
}
@@ -0,0 +1,12 @@
{
"domain": "ptdevices",
"name": "PTDevices",
"codeowners": ["@ParemTech-Inc", "@frogman85978"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ptdevices",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioptdevices"],
"quality_scale": "bronze",
"requirements": ["aioptdevices==2026.03.2"]
}

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