mirror of
https://github.com/home-assistant/core.git
synced 2026-05-06 16:47:03 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13dd831874 | |||
| 3be5906398 | |||
| cef918d6f8 | |||
| 19aa1b6578 | |||
| b0eb69936e | |||
| b6096a71d1 | |||
| 059d7011ba | |||
| bbe00ef79e | |||
| 7f447abc3a | |||
| 923e099467 | |||
| 26714c6d9f | |||
| 5f1201dbbe | |||
| 52e1d9443c | |||
| 824f5205e9 | |||
| cf8bc55add | |||
| 1e9244f4fc | |||
| be4f4928d5 | |||
| 80f6f8ee31 | |||
| 267d52491a | |||
| ee84d625cd | |||
| 5d091d25d5 | |||
| 97b5f1cf64 | |||
| d89bcd83d9 | |||
| 073b20c4b2 | |||
| 2af9405750 | |||
| 10084c8c0c | |||
| 7e8f5365ce | |||
| 65f9dcd7bf | |||
| 4c8f37fef6 | |||
| d1295fa260 | |||
| 9b2eea920f | |||
| c81c1cbb14 | |||
| 11ee05874a | |||
| 7d7c47b56e | |||
| dc4210595f | |||
| 7430366d9b | |||
| ae3bd54ca7 | |||
| e3ce7fb000 | |||
| 9286b517d3 | |||
| 4d62e4765d | |||
| ea55ef90a6 | |||
| 751765b97b | |||
| 11ed1fe20f | |||
| 9b5166769a | |||
| 70c2a323ce | |||
| 0ec5d6b273 | |||
| b1e8dc2ebb | |||
| e144804d28 | |||
| 8521a49986 | |||
| 3587f9613f | |||
| 2f1dd3a817 | |||
| 2c2e8db19f | |||
| 64a3f91132 | |||
| bd61c893e4 | |||
| 6bb759b887 | |||
| 280b5ef388 | |||
| 416d4e02a0 | |||
| c99f261a2d | |||
| 9c9a058eb0 | |||
| 7b51b929ef | |||
| 74971ebcd1 | |||
| 1f5d80ca44 | |||
| 9075c6a5cb | |||
| ab4162601f | |||
| 38de48ac9d | |||
| 597d9a2ada | |||
| 71494b6c97 | |||
| 57e66baf53 | |||
| 63dfc97346 | |||
| 1b4a7d55c0 | |||
| 8c8a863867 | |||
| 28d65e987c | |||
| d0c0f02311 | |||
| f90e9ceb6c | |||
| 553ba5e7ab | |||
| 6633f16d13 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "sensereo",
|
||||
"name": "Sensereo",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user