Compare commits

..

3 Commits

Author SHA1 Message Date
Mike Degatano
04860f666f Add jobs info mock to switch tests 2025-09-30 18:30:01 +00:00
Mike Degatano
8d009184a1 Add tests 2025-09-30 18:30:01 +00:00
Mike Degatano
7cf29ef136 Add progress reporting for addon/core updates 2025-09-30 18:30:00 +00:00
375 changed files with 9393 additions and 21429 deletions

View File

@@ -203,7 +203,6 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.*
homeassistant.components.filesize.*
homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
@@ -326,7 +325,6 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*

6
CODEOWNERS generated
View File

@@ -492,8 +492,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP
@@ -910,8 +908,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -957,8 +953,6 @@ build.json @home-assistant/supervisor
/tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch
/homeassistant/components/meteoclimatic/ @adrianmo
/tests/components/meteoclimatic/ @adrianmo

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@@ -616,44 +616,34 @@ async def async_enable_logging(
),
)
logger = logging.getLogger()
logger.setLevel(logging.INFO if verbose else logging.WARNING)
# Log errors to a file if we have write access to file or config dir
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
else:
err_log_path = os.path.abspath(log_file)
if err_log_path:
err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK)
):
err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days
)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK)
):
err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days
)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger.addHandler(err_handler)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
# Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
logger = logging.getLogger()
logger.addHandler(err_handler)
logger.setLevel(logging.INFO if verbose else logging.WARNING)
# Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass)

View File

@@ -1,5 +0,0 @@
{
"domain": "eltako",
"name": "Eltako",
"iot_standards": ["matter"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "konnected",
"name": "Konnected",
"integrations": ["konnected", "konnected_esphome"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "level",
"name": "Level",
"iot_standards": ["matter"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.5.4"]
"requirements": ["airos==0.5.3"]
}

View File

@@ -23,10 +23,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
URL_API_INTEGRATION = {
"url": "https://dashboard.airthings.com/integrations/api-integration"
}
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airthings."""
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=URL_API_INTEGRATION,
description_placeholders={
"url": (
"https://dashboard.airthings.com/integrations/api-integration"
),
},
)
errors = {}
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Airthings", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders=URL_API_INTEGRATION,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -4,9 +4,9 @@
"user": {
"data": {
"id": "ID",
"secret": "Secret"
},
"description": "Log in at {url} to find your credentials"
"secret": "Secret",
"description": "Login at {url} to find your credentials"
}
}
},
"error": {

View File

@@ -171,7 +171,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="no_devices_found")
titles = {
address: get_name(discovery.device)
address: discovery.device.name
for (address, discovery) in self._discovered_devices.items()
}
return self.async_show_form(

View File

@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling",
"requirements": ["airthings-ble==1.1.1"]
"requirements": ["airthings-ble==0.9.2"]
}

View File

@@ -114,8 +114,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
),
}
PARALLEL_UPDATES = 0
@callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:

View File

@@ -6,9 +6,6 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"address": "The Airthings devices discovered via Bluetooth."
}
},
"bluetooth_confirm": {

View File

@@ -2,14 +2,17 @@
from airtouch4pyapi import AirTouch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator
from .coordinator import AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry."""
@@ -19,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs()
if not info:
raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch)
coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -2,34 +2,26 @@
import logging
from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data."""
def __init__(
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
) -> None:
def __init__(self, hass, airtouch):
"""Initialize global Airtouch data updater."""
self.airtouch = airtouch
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)

View File

@@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return detailed information about entities and devices."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
@@ -513,8 +513,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
integration_configs: dict[str, AnalyticsModifications] = {}
removed_devices: set[str] = set()
# Get device list
for device_entry in dev_reg.devices.values():
if not device_entry.primary_config_entry:
@@ -527,10 +525,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
if config_entry is None:
continue
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
removed_devices.add(device_entry.id)
continue
integration_domain = config_entry.domain
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
@@ -620,15 +614,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
device_config = integration_config.devices.get(device_id, device_config)
if device_config.remove:
removed_devices.add(device_id)
continue
device_entry = dev_reg.devices[device_id]
device_id_mapping[device_id] = (integration_domain, len(devices_info))
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
devices_info.append(
{
"entities": [],
"entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version,
@@ -637,7 +631,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"model_id": device_entry.model_id,
"sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id,
"entities": [],
}
)
@@ -676,7 +669,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
entity_entry = ent_reg.entities[entity_id]
entity_state = hass.states.get(entity_id)
entity_state = hass.states.get(entity_entry.entity_id)
entity_info = {
# LIMITATION: `assumed_state` can be overridden by users;
@@ -697,19 +690,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"unit_of_measurement": entity_entry.unit_of_measurement,
}
if (device_id_ := entity_entry.device_id) is not None:
if device_id_ in removed_devices:
# The device was removed, so we remove the entity too
continue
if (
new_device_id := device_id_mapping.get(device_id_)
) is not None and (new_device_id[0] == integration_domain):
device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info)
continue
entities_info.append(entity_info)
if (
((device_id_ := entity_entry.device_id) is not None)
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
and (new_device_id[0] == integration_domain)
):
device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info)
else:
entities_info.append(entity_info)
return {
"version": "home-assistant:1",

View File

@@ -2,7 +2,9 @@
from __future__ import annotations
from typing import Any
from typing import Any, TypeVar
T = TypeVar("T", dict[str, Any], list[Any], None)
TRANSLATION_MAP = {
"wan_rx": "sensor_rx_bytes",
@@ -34,7 +36,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T:
def translate_to_legacy(raw: T) -> T:
"""Translate raw data to legacy format for dicts and lists."""
if raw is None:

View File

@@ -26,6 +26,9 @@ async def async_setup_entry(
if CONF_HOST in config_entry.data:
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
config_entry.async_on_unload(
config_entry.add_update_listener(_async_update_listener)
)
else:
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
@@ -33,11 +36,6 @@ async def async_setup_entry(
config_entry.runtime_data = coordinator
if CONF_HOST in config_entry.data:
config_entry.async_on_unload(
config_entry.add_update_listener(_async_update_listener)
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@@ -272,13 +272,6 @@ async def async_setup_entry(
observations: list[ConfigType] = [
dict(subentry.data) for subentry in config_entry.subentries.values()
]
for observation in observations:
if observation[CONF_PLATFORM] == CONF_TEMPLATE:
observation[CONF_VALUE_TEMPLATE] = Template(
observation[CONF_VALUE_TEMPLATE], hass
)
prior: float = config[CONF_PRIOR]
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)

View File

@@ -51,6 +51,12 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval
@@ -112,6 +118,12 @@ ATTR_FILENAME: Final = "filename"
ATTR_MEDIA_PLAYER: Final = "media_player"
ATTR_FORMAT: Final = "format"
# These constants are deprecated as of Home Assistant 2024.10
# Please use the StreamType enum instead.
_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10")
_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10")
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10")
class CameraEntityFeature(IntFlag):
"""Supported features of the camera entity."""
@@ -1105,3 +1117,11 @@ async def async_handle_record_service(
duration=service_call.data[CONF_DURATION],
lookback=service_call.data[CONF_LOOKBACK],
)
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.10.1"]
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.1"]
"requirements": ["pycync==0.4.0"]
}

View File

@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
self._attr_translation_key = "button"
self._attr_translation_placeholders = {"key": str(key)}
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the binary sensor state."""
if (
message[0] == self._remote_control_property.element_uid

View File

@@ -48,6 +48,7 @@ class DevoloDeviceEntity(Entity):
)
self.subscriber: Subscriber | None = None
self.sync_callback = self._sync
self._value: float
@@ -68,7 +69,7 @@ class DevoloDeviceEntity(Entity):
self._device_instance.uid, self.subscriber
)
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the state."""
if message[0] == self._attr_unique_id:
self._value = message[1]

View File

@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
"""
return f"{self._attr_unique_id}_{self._sensor_type}"
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the consumption sensor state."""
if message[0] == self._attr_unique_id:
self._value = getattr(

View File

@@ -13,3 +13,8 @@ class Subscriber:
"""Initiate the subscriber."""
self.name = name
self.callback = callback
def update(self, message: str) -> None:
"""Trigger hass to update the device."""
_LOGGER.debug('%s got message "%s"', self.name, message)
self.callback(message)

View File

@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
"""Switch off the device."""
self._binary_switch_property.set(state=False)
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the binary switch state and consumption."""
if message[0].startswith("devolo.BinarySwitch"):
self._attr_is_on = self._device_instance.binary_switch_property[

View File

@@ -176,7 +176,7 @@
"description": "Sets the participating sensors for a climate program.",
"fields": {
"preset_mode": {
"name": "Climate program",
"name": "Climate Name",
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
},
"device_ids": {
@@ -188,7 +188,7 @@
},
"exceptions": {
"invalid_preset": {
"message": "Invalid climate program, available options are: {options}"
"message": "Invalid climate name, available options are: {options}"
},
"invalid_sensor": {
"message": "Invalid sensor for thermostat, available options are: {options}"

View File

@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push",
"requirements": ["aioecowitt==2025.9.2"]
"requirements": ["aioecowitt==2025.9.1"]
}

View File

@@ -1,11 +0,0 @@
"""Analytics platform."""
from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
from homeassistant.core import HomeAssistant
async def async_modify_analytics(
hass: HomeAssistant, analytics_input: AnalyticsInput
) -> AnalyticsModifications:
"""Modify the analytics."""
return AnalyticsModifications(remove=True)

View File

@@ -1,27 +0,0 @@
"""The Firefly III integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Set up Firefly III from a config entry."""
coordinator = FireflyDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -1,140 +0,0 @@
"""Config flow for the Firefly III integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_API_KEY): str,
}
)
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
"""Validate the user input allows us to connect."""
try:
client = Firefly(
api_url=data[CONF_URL],
api_key=data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await client.get_about()
except FireflyAuthenticationError:
raise InvalidAuth from None
except FireflyConnectionError as err:
raise CannotConnect from err
except FireflyTimeoutError as err:
raise FireflyClientTimeout from err
return True
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Firefly III."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
await _validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_URL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth when Firefly III API authentication fails."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth: ask for a new API key and validate."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
await _validate_input(
self.hass,
data={
**reauth_entry.data,
CONF_API_KEY: user_input[CONF_API_KEY],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class FireflyClientTimeout(HomeAssistantError):
"""Error to indicate a timeout occurred."""

View File

@@ -1,6 +0,0 @@
"""Constants for the Firefly III integration."""
DOMAIN = "firefly_iii"
MANUFACTURER = "Firefly III"
NAME = "Firefly III"

View File

@@ -1,137 +0,0 @@
"""Data Update Coordinator for Firefly III integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from aiohttp import CookieJar
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
from pyfirefly.models import Account, Bill, Budget, Category, Currency
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
@dataclass
class FireflyCoordinatorData:
"""Data structure for Firefly III coordinator data."""
accounts: list[Account]
categories: list[Category]
category_details: list[Category]
budgets: list[Budget]
bills: list[Bill]
primary_currency: Currency
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
"""Coordinator to manage data updates for Firefly III integration."""
config_entry: FireflyConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.firefly = Firefly(
api_url=self.config_entry.data[CONF_URL],
api_key=self.config_entry.data[CONF_API_KEY],
session=async_create_clientsession(
self.hass,
self.config_entry.data[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.firefly.get_about()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> FireflyCoordinatorData:
"""Fetch data from Firefly III API."""
now = datetime.now()
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
try:
accounts = await self.firefly.get_accounts()
categories = await self.firefly.get_categories()
category_details = [
await self.firefly.get_category(
category_id=int(category.id), start=start_date, end=end_date
)
for category in categories
]
primary_currency = await self.firefly.get_currency_primary()
budgets = await self.firefly.get_budgets()
bills = await self.firefly.get_bills()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
return FireflyCoordinatorData(
accounts=accounts,
categories=categories,
category_details=category_details,
budgets=budgets,
bills=bills,
primary_currency=primary_currency,
)

View File

@@ -1,40 +0,0 @@
"""Base entity for Firefly III integration."""
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import FireflyDataUpdateCoordinator
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
"""Base class for Firefly III entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize a Firefly entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
identifiers={
(
DOMAIN,
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
)
},
)

View File

@@ -1,18 +0,0 @@
{
"entity": {
"sensor": {
"account_type": {
"default": "mdi:bank",
"state": {
"expense": "mdi:cash-minus",
"revenue": "mdi:cash-plus",
"asset": "mdi:account-cash",
"liability": "mdi:hand-coin"
}
},
"category": {
"default": "mdi:label"
}
}
}
}

View File

@@ -1,10 +0,0 @@
{
"domain": "firefly_iii",
"name": "Firefly III",
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.6"]
}

View File

@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
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
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
No explicit parallel updates are defined.
reauthentication-flow:
status: todo
comment: |
No reauthentication flow is defined. It will be done in a next iteration.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -1,133 +0,0 @@
"""Sensor platform for Firefly III integration."""
from __future__ import annotations
from pyfirefly.models import Account, Category
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
from .entity import FireflyBaseEntity
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="account_type",
translation_key="account",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="category",
translation_key="category",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FireflyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firefly III sensor platform."""
coordinator = entry.runtime_data
entities: list[SensorEntity] = [
FireflyAccountEntity(
coordinator=coordinator,
entity_description=description,
account=account,
)
for account in coordinator.data.accounts
for description in ACCOUNT_SENSORS
]
entities.extend(
FireflyCategoryEntity(
coordinator=coordinator,
entity_description=description,
category=category,
)
for category in coordinator.data.category_details
for description in CATEGORY_SENSORS
)
async_add_entities(entities)
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III account."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
account: Account,
) -> None:
"""Initialize Firefly account entity."""
super().__init__(coordinator, entity_description)
self._account = account
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
self._attr_name = account.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
# Account type state doesn't go well with the icons.json. Need to fix it.
if account.attributes.type == "expense":
self._attr_icon = "mdi:cash-minus"
elif account.attributes.type == "asset":
self._attr_icon = "mdi:account-cash"
elif account.attributes.type == "revenue":
self._attr_icon = "mdi:cash-plus"
elif account.attributes.type == "liability":
self._attr_icon = "mdi:hand-coin"
else:
self._attr_icon = "mdi:bank"
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
return self._account.attributes.current_balance
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III category."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
category: Category,
) -> None:
"""Initialize Firefly category entity."""
super().__init__(coordinator, entity_description)
self._category = category
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
self._attr_name = category.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
spent_items = self._category.attributes.spent or []
earned_items = self._category.attributes.earned or []
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
if spent == 0 and earned == 0:
return None
return spent + earned

View File

@@ -1,49 +0,0 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "The API key for authenticating with Firefly",
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
},
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The new API access token for authenticating with Firefly III"
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "An error occurred while trying to connect to the Firefly instance: {error}"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
}
}
}

View File

@@ -452,10 +452,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.app.router.register_resource(IndexView(repo_path, hass))
async_register_built_in_panel(hass, "light")
async_register_built_in_panel(hass, "security")
async_register_built_in_panel(hass, "climate")
async_register_built_in_panel(hass, "profile")
async_register_built_in_panel(

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251001.0"]
"requirements": ["home-assistant-frontend==20250926.0"]
}

View File

@@ -620,13 +620,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
thinking_config: ThinkingConfig | None = None
if model.startswith("models/gemini-2.5") and not model.endswith(
("tts", "image", "image-preview")
):
thinking_config = ThinkingConfig(include_thoughts=True)
return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
@@ -659,7 +652,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
),
),
],
thinking_config=thinking_config,
thinking_config=ThinkingConfig(include_thoughts=True),
)

View File

@@ -1,18 +1,14 @@
"""The Growatt server PV inverter sensor integration."""
from collections.abc import Mapping
import logging
import growattServer
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.exceptions import ConfigEntryError
from .const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
DEFAULT_URL,
@@ -23,110 +19,36 @@ from .const import (
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
_LOGGER = logging.getLogger(__name__)
def get_device_list_classic(
def get_device_list(
api: growattServer.GrowattApi, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Retrieve the device list for the selected plant."""
plant_id = config[CONF_PLANT_ID]
# Log in to api and fetch first plant if no plant id is defined.
try:
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
# DEBUG: Log the actual response structure
except Exception as ex:
_LOGGER.error("DEBUG - Login response: %s", login_response)
raise ConfigEntryError(
f"Error communicating with Growatt API during login: {ex}"
) from ex
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
_LOGGER.debug("Growatt login failed: %s", msg)
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
raise ConfigEntryError("Username, Password or URL may be incorrect!")
user_id = login_response["user"]["id"]
if plant_id == DEFAULT_PLANT_ID:
try:
plant_info = api.plant_list(user_id)
except Exception as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during plant list: {ex}"
) from ex
if not plant_info or "data" not in plant_info or not plant_info["data"]:
raise ConfigEntryError("No plants found for this account.")
plant_info = api.plant_list(user_id)
plant_id = plant_info["data"][0]["plantId"]
# Get a list of devices for specified plant to add sensors for.
try:
devices = api.device_list(plant_id)
except Exception as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
) from ex
devices = api.device_list(plant_id)
return devices, plant_id
def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Device list logic for Open API V1.
Note: Plant selection (including auto-selection if only one plant exists)
is handled in the config flow before this function is called. This function
only fetches devices for the already-selected plant_id.
"""
plant_id = config[CONF_PLANT_ID]
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
raise ConfigEntryError(
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
supported_devices = [
{
"deviceSn": device.get("device_sn", ""),
"deviceType": "min",
}
for device in devices
if device.get("type") == 7
]
for device in devices:
if device.get("type") != 7:
_LOGGER.warning(
"Device %s with type %s not supported in Open API V1, skipping",
device.get("device_sn", ""),
device.get("type"),
)
return supported_devices, plant_id
def get_device_list(
api, config: Mapping[str, str], api_version: str
) -> tuple[list[dict[str, str]], str]:
"""Dispatch to correct device list logic based on API version."""
if api_version == "v1":
return get_device_list_v1(api, config)
if api_version == "classic":
return get_device_list_classic(api, config)
raise ConfigEntryError(f"Unknown API version: {api_version}")
async def async_setup_entry(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool:
"""Set up Growatt from a config entry."""
config = config_entry.data
username = config[CONF_USERNAME]
url = config.get(CONF_URL, DEFAULT_URL)
# If the URL has been deprecated then change to the default instead
@@ -136,24 +58,11 @@ async def async_setup_entry(
new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data)
# Determine API version
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1"
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
api_version = "classic"
username = config[CONF_USERNAME]
api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=username
)
api.server_url = url
else:
raise ConfigEntryError("Unknown authentication type in config entry.")
# Initialise the library with the username & a random id each time it is started
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
api.server_url = url
devices, plant_id = await hass.async_add_executor_job(
get_device_list, api, config, api_version
)
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
@@ -166,7 +75,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
if device["deviceType"] in ["inverter", "tlx", "storage", "mix"]
}
# Perform the first refresh for the total coordinator

View File

@@ -1,38 +1,22 @@
"""Config flow for growatt server integration."""
import logging
from typing import Any
import growattServer
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import callback
from .const import (
ABORT_NO_PLANTS,
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_URL,
DOMAIN,
ERROR_CANNOT_CONNECT,
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS,
)
_LOGGER = logging.getLogger(__name__)
class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class."""
@@ -43,98 +27,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialise growatt server flow."""
self.user_id: str | None = None
self.user_id = None
self.data: dict[str, Any] = {}
self.auth_type: str | None = None
self.plants: list[dict[str, Any]] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
return self.async_show_menu(
step_id="user",
menu_options=["password_auth", "token_auth"],
)
async def async_step_password_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle username/password authentication."""
if user_input is None:
return self._async_show_password_form()
self.auth_type = AUTH_PASSWORD
# Traditional username/password authentication
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
try:
login_response = await self.hass.async_add_executor_job(
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt API login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error("Invalid response format during login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
return self._async_show_password_form({"base": ERROR_INVALID_AUTH})
self.user_id = login_response["user"]["id"]
self.data = user_input
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
async def async_step_token_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle API token authentication."""
if user_input is None:
return self._async_show_token_form()
self.auth_type = AUTH_API_TOKEN
# Using token authentication
token = user_input[CONF_TOKEN]
self.api = growattServer.OpenApiV1(token=token)
# Verify token by fetching plant list
try:
plant_response = await self.hass.async_add_executor_job(self.api.plant_list)
self.plants = plant_response.get("plants", [])
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt V1 API plant list: %s", ex)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
except growattServer.GrowattV1ApiError as e:
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
getattr(e, "error_code", None),
)
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex
)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
self.data = user_input
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@callback
def _async_show_password_form(
self, errors: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the username/password form to the user."""
def _async_show_user_form(self, errors=None):
"""Show the form to the user."""
data_schema = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
@@ -144,87 +42,58 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="password_auth", data_schema=data_schema, errors=errors
step_id="user", data_schema=data_schema, errors=errors
)
@callback
def _async_show_token_form(
self, errors: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the API token form to the user."""
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
}
"""Handle the start of the config flow."""
if not user_input:
return self._async_show_user_form()
# Initialise the library with the username & a random id each time it is started
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
login_response = await self.hass.async_add_executor_job(
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
return self.async_show_form(
step_id="token_auth",
data_schema=data_schema,
errors=errors,
)
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
return self._async_show_user_form({"base": "invalid_auth"})
self.user_id = login_response["user"]["id"]
self.data = user_input
return await self.async_step_plant()
async def async_step_plant(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle adding a "plant" to Home Assistant."""
if self.auth_type == AUTH_API_TOKEN:
# Using V1 API with token
if not self.plants:
return self.async_abort(reason=ABORT_NO_PLANTS)
plant_info = await self.hass.async_add_executor_job(
self.api.plant_list, self.user_id
)
# Create dictionary of plant_id -> name
plant_dict = {
str(plant["plant_id"]): plant.get("name", "Unknown Plant")
for plant in self.plants
}
if not plant_info["data"]:
return self.async_abort(reason="no_plants")
if user_input is None and len(plant_dict) > 1:
data_schema = vol.Schema(
{vol.Required(CONF_PLANT_ID): vol.In(plant_dict)}
)
return self.async_show_form(step_id="plant", data_schema=data_schema)
plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]}
if user_input is None:
# Single plant => mark it as selected
user_input = {CONF_PLANT_ID: list(plant_dict.keys())[0]}
if user_input is None and len(plant_info["data"]) > 1:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
user_input[CONF_NAME] = plant_dict[user_input[CONF_PLANT_ID]]
return self.async_show_form(step_id="plant", data_schema=data_schema)
else:
# Traditional API
try:
plant_info = await self.hass.async_add_executor_job(
self.api.plant_list, self.user_id
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt API plant list: %s", ex)
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
# Access plant_info["data"] - validate response structure
if not isinstance(plant_info, dict) or "data" not in plant_info:
_LOGGER.error(
"Invalid response format during plant list: missing 'data' key"
)
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
plant_data = plant_info["data"]
if not plant_data:
return self.async_abort(reason=ABORT_NO_PLANTS)
plants = {plant["plantId"]: plant["plantName"] for plant in plant_data}
if user_input is None and len(plant_data) > 1:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
return self.async_show_form(step_id="plant", data_schema=data_schema)
if user_input is None:
# single plant => mark it as selected
user_input = {CONF_PLANT_ID: plant_data[0]["plantId"]}
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
if user_input is None:
# single plant => mark it as selected
user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]}
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
await self.async_set_unique_id(user_input[CONF_PLANT_ID])
self._abort_if_unique_id_configured()
self.data.update(user_input)

View File

@@ -4,16 +4,6 @@ from homeassistant.const import Platform
CONF_PLANT_ID = "plant_id"
# API key support
CONF_API_KEY = "api_key"
# Auth types for config flow
AUTH_PASSWORD = "password"
AUTH_API_TOKEN = "api_token"
CONF_AUTH_TYPE = "auth_type"
DEFAULT_AUTH_TYPE = AUTH_PASSWORD
DEFAULT_PLANT_ID = "0"
DEFAULT_NAME = "Growatt"
@@ -39,10 +29,3 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.SENSOR]
LOGIN_INVALID_AUTH_CODE = "502"
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
ERROR_INVALID_AUTH = "invalid_auth"
# Config flow abort reasons
ABORT_NO_PLANTS = "no_plants"

View File

@@ -1,7 +1,5 @@
"""Coordinator module for managing Growatt data fetching."""
from __future__ import annotations
import datetime
import json
import logging
@@ -40,30 +38,22 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
plant_id: str,
) -> None:
"""Initialize the coordinator."""
self.api_version = (
"v1" if config_entry.data.get("auth_type") == "api_token" else "classic"
self.username = config_entry.data[CONF_USERNAME]
self.password = config_entry.data[CONF_PASSWORD]
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=self.username
)
# Set server URL
self.api.server_url = self.url
self.device_id = device_id
self.device_type = device_type
self.plant_id = plant_id
self.previous_values: dict[str, Any] = {}
if self.api_version == "v1":
self.username = None
self.password = None
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.token = config_entry.data["token"]
self.api = growattServer.OpenApiV1(token=self.token)
elif self.api_version == "classic":
self.username = config_entry.data.get(CONF_USERNAME)
self.password = config_entry.data[CONF_PASSWORD]
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=self.username
)
self.api.server_url = self.url
else:
raise ValueError(f"Unknown API version: {self.api_version}")
# Initialize previous_values to store historical data
self.previous_values: dict[str, Any] = {}
super().__init__(
hass,
@@ -77,54 +67,21 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Update data via library synchronously."""
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
# login only required for classic API
if self.api_version == "classic":
self.api.login(self.username, self.password)
# Login in to the Growatt server
self.api.login(self.username, self.password)
if self.device_type == "total":
if self.api_version == "v1":
# The V1 Plant APIs do not provide the same information as the classic plant_info() API
# More specifically:
# 1. There is no monetary information to be found, so today and lifetime money is not available
# 2. There is no nominal power, this is provided by inverter min_energy()
# This means, for the total coordinator we can only fetch and map the following:
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
total_info = self.api.plant_energy_overview(self.plant_id)
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
else:
# Classic API: use plant_info as before
total_info = self.api.plant_info(self.device_id)
del total_info["deviceList"]
plant_money_text, currency = total_info["plantMoneyText"].split("/")
total_info["plantMoneyText"] = plant_money_text
total_info["currency"] = currency
_LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info)
total_info = self.api.plant_info(self.device_id)
del total_info["deviceList"]
plant_money_text, currency = total_info["plantMoneyText"].split("/")
total_info["plantMoneyText"] = plant_money_text
total_info["currency"] = currency
self.data = total_info
elif self.device_type == "inverter":
self.data = self.api.inverter_detail(self.device_id)
elif self.device_type == "min":
# Open API V1: min device
try:
min_details = self.api.min_detail(self.device_id)
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
_LOGGER.error(
"Error fetching min device data for %s: %s", self.device_id, err
)
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}
self.data = min_info
_LOGGER.debug("min_info for device %s: %r", self.device_id, min_info)
elif self.device_type == "tlx":
tlx_info = self.api.tlx_detail(self.device_id)
self.data = tlx_info["data"]
_LOGGER.debug("tlx_info for device %s: %r", self.device_id, tlx_info)
elif self.device_type == "storage":
storage_info_detail = self.api.storage_params(self.device_id)
storage_energy_overview = self.api.storage_energy_overview(
@@ -188,7 +145,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self.data.get("currency")
def get_data(
self, entity_description: GrowattSensorEntityDescription
self, entity_description: "GrowattSensorEntityDescription"
) -> str | int | float | None:
"""Get the data."""
variable = entity_description.api_key

View File

@@ -51,7 +51,7 @@ async def async_setup_entry(
sensor_descriptions: list = []
if device_coordinator.device_type == "inverter":
sensor_descriptions = list(INVERTER_SENSOR_TYPES)
elif device_coordinator.device_type in ("tlx", "min"):
elif device_coordinator.device_type == "tlx":
sensor_descriptions = list(TLX_SENSOR_TYPES)
elif device_coordinator.device_type == "storage":
sensor_descriptions = list(STORAGE_SENSOR_TYPES)

View File

@@ -2,42 +2,26 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account"
},
"error": {
"invalid_auth": "Authentication failed. Please check your credentials and try again.",
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again."
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"title": "Choose authentication method",
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
"menu_options": {
"password_auth": "Username & Password",
"token_auth": "API Token (MIN/TLX only)"
}
},
"password_auth": {
"title": "Enter your Growatt login credentials",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"url": "[%key:common::config_flow::data::url%]"
}
},
"token_auth": {
"title": "Enter your API token",
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"data": {
"token": "API Token"
}
},
"plant": {
"data": {
"plant_id": "Plant"
},
"title": "Select your plant"
},
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"url": "[%key:common::config_flow::data::url%]"
},
"title": "Enter your Growatt information"
}
}
},

View File

@@ -4,14 +4,9 @@ from uuid import UUID
from habiticalib import Habitica
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -32,7 +27,6 @@ PLATFORMS = [
Platform.BUTTON,
Platform.CALENDAR,
Platform.IMAGE,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
@@ -52,7 +46,6 @@ async def async_setup_entry(
"""Set up habitica from a config entry."""
party_added_by_this_entry: UUID | None = None
device_reg = dr.async_get(hass)
entity_registry = er.async_get(hass)
session = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
@@ -103,15 +96,6 @@ async def async_setup_entry(
device.id, remove_config_entry_id=config_entry.entry_id
)
notify_entities = [
entry.entity_id
for entry in entity_registry.entities.values()
if entry.domain == NOTIFY_DOMAIN
and entry.config_entry_id == config_entry.entry_id
]
for entity_id in notify_entities:
entity_registry.async_remove(entity_id)
hass.config_entries.async_schedule_reload(config_entry.entry_id)
coordinator.async_add_listener(_party_update_listener)

View File

@@ -121,4 +121,4 @@ class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""If the binary sensor is on."""
return self.coordinator.data.party.quest.active
return self.coordinator.data.quest.active

View File

@@ -9,7 +9,6 @@ from datetime import timedelta
from io import BytesIO
import logging
from typing import Any
from uuid import UUID
from aiohttp import ClientError
from habiticalib import (
@@ -49,14 +48,6 @@ class HabiticaData:
tasks: list[TaskData]
@dataclass
class HabiticaPartyData:
"""Habitica party data."""
party: GroupData
members: dict[UUID, UserData]
type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
@@ -201,19 +192,11 @@ class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]):
return png.getvalue()
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]):
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]):
"""Habitica Party Coordinator."""
_update_interval = timedelta(minutes=15)
async def _update_data(self) -> HabiticaPartyData:
async def _update_data(self) -> GroupData:
"""Fetch the latest party data."""
return HabiticaPartyData(
party=(await self.habitica.get_group()).data,
members={
member.id: member
for member in (await self.habitica.get_group_members()).data
if member.id
},
)
return (await self.habitica.get_group()).data

View File

@@ -68,14 +68,14 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]):
super().__init__(coordinator)
if TYPE_CHECKING:
assert config_entry.unique_id
unique_id = f"{config_entry.unique_id}_{coordinator.data.party.id!s}"
unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}"
self.entity_description = entity_description
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
model=NAME,
name=coordinator.data.party.summary,
name=coordinator.data.summary,
identifiers={(DOMAIN, unique_id)},
via_device=(DOMAIN, config_entry.unique_id),
)

View File

@@ -174,9 +174,6 @@
},
"collected_items": {
"default": "mdi:sack"
},
"last_checkin": {
"default": "mdi:login-variant"
}
},
"switch": {
@@ -197,11 +194,6 @@
"quest_running": {
"default": "mdi:script-text-play"
}
},
"notify": {
"party_chat": {
"default": "mdi:forum"
}
}
},
"services": {

View File

@@ -128,7 +128,7 @@ class HabiticaPartyImage(HabiticaPartyBase, ImageEntity):
"""Return URL of image."""
return (
f"{ASSETS_URL}quest_{key}.png"
if (key := self.coordinator.data.party.quest.key)
if (key := self.coordinator.data.quest.key)
else None
)

View File

@@ -1,202 +0,0 @@
"""Notify platform for the Habitica integration."""
from __future__ import annotations
from abc import abstractmethod
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from aiohttp import ClientError
from habiticalib import (
GroupData,
HabiticaException,
NotAuthorizedError,
NotFoundError,
TooManyRequestsError,
UserData,
)
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
NotifyEntity,
NotifyEntityDescription,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HABITICA_KEY
from .const import DOMAIN
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
PARALLEL_UPDATES = 10
class HabiticaNotify(StrEnum):
"""Habitica Notifier."""
PARTY_CHAT = "party_chat"
PRIVATE_MESSAGE = "private_message"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the notify entity platform."""
members_added: set[UUID] = set()
entity_registry = er.async_get(hass)
coordinator = config_entry.runtime_data
if party := coordinator.data.user.party.id:
party_coordinator = hass.data[HABITICA_KEY][party]
async_add_entities(
[HabiticaPartyChatNotifyEntity(coordinator, party_coordinator.data.party)]
)
@callback
def add_entities() -> None:
nonlocal members_added
new_members = set(party_coordinator.data.members.keys()) - members_added
if TYPE_CHECKING:
assert coordinator.data.user.id
new_members.discard(coordinator.data.user.id)
if new_members:
async_add_entities(
HabiticaPrivateMessageNotifyEntity(
coordinator, party_coordinator.data.members[member]
)
for member in new_members
)
members_added |= new_members
delete_members = members_added - set(party_coordinator.data.members.keys())
for member in delete_members:
if entity_id := entity_registry.async_get_entity_id(
NOTIFY_DOMAIN,
DOMAIN,
f"{coordinator.config_entry.unique_id}_{member!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
):
entity_registry.async_remove(entity_id)
members_added.discard(member)
party_coordinator.async_add_listener(add_entities)
add_entities()
class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
"""Habitica base notify entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
) -> None:
"""Initialize a Habitica entity."""
super().__init__(coordinator, self.entity_description)
@abstractmethod
async def _send_message(self, message: str) -> None:
"""Send a Habitica message."""
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message."""
try:
await self._send_message(message)
except NotAuthorizedError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_forbidden",
translation_placeholders={
**self.translation_placeholders,
"reason": e.error.message,
},
) from e
except NotFoundError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_not_found",
translation_placeholders={
**self.translation_placeholders,
"reason": e.error.message,
},
) from e
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
translation_placeholders={"retry_after": str(e.retry_after)},
) from e
except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": e.error.message},
) from e
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e)},
) from e
class HabiticaPartyChatNotifyEntity(HabiticaBaseNotifyEntity):
"""Representation of a Habitica party chat notify entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
party: GroupData,
) -> None:
"""Initialize a Habitica entity."""
self._attr_translation_placeholders = {CONF_NAME: party.name}
self.entity_description = NotifyEntityDescription(
key=HabiticaNotify.PARTY_CHAT,
translation_key=HabiticaNotify.PARTY_CHAT,
)
self.party = party
super().__init__(coordinator)
async def _send_message(self, message: str) -> None:
"""Send a Habitica party chat message."""
await self.coordinator.habitica.send_group_message(
message=message,
group_id=self.party.id,
)
class HabiticaPrivateMessageNotifyEntity(HabiticaBaseNotifyEntity):
"""Representation of a Habitica private message notify entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
member: UserData,
) -> None:
"""Initialize a Habitica entity."""
self._attr_translation_placeholders = {CONF_NAME: member.profile.name or ""}
self.entity_description = NotifyEntityDescription(
key=f"{member.id!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
translation_key=HabiticaNotify.PRIVATE_MESSAGE,
)
self.member = member
super().__init__(coordinator)
async def _send_message(self, message: str) -> None:
"""Send a Habitica private message."""
if TYPE_CHECKING:
assert self.member.id
await self.coordinator.habitica.send_private_message(
message=message,
to_user_id=self.member.id,
)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
import logging
from typing import Any
@@ -54,7 +53,7 @@ PARALLEL_UPDATES = 1
class HabiticaSensorEntityDescription(SensorEntityDescription):
"""Habitica Sensor Description."""
value_fn: Callable[[UserData, ContentData], StateType | datetime]
value_fn: Callable[[UserData, ContentData], StateType]
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
None
)
@@ -115,7 +114,6 @@ class HabiticaSensorEntity(StrEnum):
COLLECTED_ITEMS = "collected_items"
BOSS_RAGE = "boss_rage"
BOSS_RAGE_LIMIT = "boss_rage_limit"
LAST_CHECKIN = "last_checkin"
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
@@ -286,16 +284,6 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
value_fn=pending_quest_items,
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.LAST_CHECKIN,
translation_key=HabiticaSensorEntity.LAST_CHECKIN,
value_fn=(
lambda user, _: dt_util.as_local(last)
if (last := user.auth.timestamps.loggedin)
else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
)
@@ -411,7 +399,7 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
entity_description: HabiticaSensorEntityDescription
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return the state of the device."""
return self.entity_description.value_fn(
@@ -454,12 +442,10 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
entity_description: HabiticaPartySensorEntityDescription
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return the state of the device."""
return self.entity_description.value_fn(
self.coordinator.data.party, self.content
)
return self.entity_description.value_fn(self.coordinator.data, self.content)
@property
def entity_picture(self) -> str | None:
@@ -467,9 +453,7 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
pic = self.entity_description.entity_picture
entity_picture = (
pic
if isinstance(pic, str) or pic is None
else pic(self.coordinator.data.party)
pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data)
)
return (
@@ -484,5 +468,5 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return entity specific state attributes."""
if func := self.entity_description.attributes_fn:
return func(self.coordinator.data.party, self.content)
return func(self.coordinator.data, self.content)
return None

View File

@@ -264,14 +264,6 @@
"name": "[%key:component::habitica::common::quest_name%]"
}
},
"notify": {
"party_chat": {
"name": "Party chat"
},
"private_message": {
"name": "Private message: {name}"
}
},
"sensor": {
"display_name": {
"name": "Display name",
@@ -290,9 +282,6 @@
}
}
},
"last_checkin": {
"name": "Last check-in"
},
"health": {
"name": "Health",
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
@@ -583,12 +572,6 @@
},
"frequency_not_monthly": {
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
},
"send_message_forbidden": {
"message": "You are not allowed to send messages to {name}. ({reason})"
},
"send_message_not_found": {
"message": "Unable to send message, {name} not found. ({reason})"
}
},
"issues": {

View File

@@ -68,6 +68,7 @@ EVENT_HEALTH_CHANGED = "health_changed"
EVENT_SUPPORTED_CHANGED = "supported_changed"
EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"
EVENT_JOB = "job"
UPDATE_KEY_SUPERVISOR = "supervisor"

View File

@@ -56,6 +56,7 @@ from .const import (
SupervisorEntityModel,
)
from .handler import HassioAPIError, get_supervisor_client
from .jobs import SupervisorJobs
if TYPE_CHECKING:
from .issues import SupervisorIssues
@@ -311,6 +312,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
lambda: defaultdict(set)
)
self.supervisor_client = get_supervisor_client(hass)
self.jobs = SupervisorJobs(hass)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
@@ -485,6 +487,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
)
)
# Refresh jobs data
await self.jobs.refresh_data(first_update)
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
"""Update single addon stats."""
try:

View File

@@ -0,0 +1,157 @@
"""Track Supervisor job data and allow subscription to updates."""
from collections.abc import Callable
from dataclasses import dataclass, replace
from functools import partial
from typing import Any
from uuid import UUID
from aiohasupervisor.models import Job
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
callback,
is_callback_check_partial,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
ATTR_DATA,
ATTR_UPDATE_KEY,
ATTR_WS_EVENT,
EVENT_JOB,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
UPDATE_KEY_SUPERVISOR,
)
from .handler import get_supervisor_client
@dataclass(slots=True, frozen=True)
class JobSubscription:
"""Subscribe for updates on jobs which match filters.
UUID is preferred match but only available in cases of a background API that
returns the UUID before taking the action. Others are used to match jobs only
if UUID is omitted. Either name or UUID is required to be able to match.
event_callback must be safe annotated as a homeassistant.core.callback
and safe to call in the event loop.
"""
event_callback: Callable[[Job], Any]
uuid: str | None = None
name: str | None = None
reference: str | None | type[Any] = Any
def __post_init__(self) -> None:
"""Validate at least one filter option is present."""
if not self.name and not self.uuid:
raise ValueError("Either name or uuid must be provided!")
if not is_callback_check_partial(self.event_callback):
raise ValueError("event_callback must be a homeassistant.core.callback!")
def matches(self, job: Job) -> bool:
"""Return true if job matches subscription filters."""
if self.uuid:
return job.uuid == self.uuid
return job.name == self.name and self.reference in (Any, job.reference)
class SupervisorJobs:
"""Manage access to Supervisor jobs."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize object."""
self._hass = hass
self._supervisor_client = get_supervisor_client(hass)
self._jobs: dict[UUID, Job] = {}
self._subscriptions: set[JobSubscription] = set()
@property
def current_jobs(self) -> list[Job]:
"""Return current jobs."""
return list(self._jobs.values())
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
"""Subscribe to updates for job. Return callback is used to unsubscribe.
If any jobs match the subscription at the time this is called, creates
tasks to run their callback on it.
"""
self._subscriptions.add(subscription)
# As these are callbacks they are safe to run in the event loop
# We wrap these in an asyncio task so subscribing does not wait on the logic
if matches := [job for job in self._jobs.values() if subscription.matches(job)]:
async def event_callback_async(job: Job) -> Any:
return subscription.event_callback(job)
for match in matches:
self._hass.async_create_task(event_callback_async(match))
return partial(self._subscriptions.discard, subscription)
async def refresh_data(self, first_update: bool = False) -> None:
"""Refresh job data."""
job_data = await self._supervisor_client.jobs.info()
job_queue: list[Job] = job_data.jobs.copy()
new_jobs: dict[UUID, Job] = {}
changed_jobs: list[Job] = []
# Rebuild our job cache from new info and compare to find changes
while job_queue:
job = job_queue.pop(0)
job_queue.extend(job.child_jobs)
job = replace(job, child_jobs=[])
if job.uuid not in self._jobs or job != self._jobs[job.uuid]:
changed_jobs.append(job)
new_jobs[job.uuid] = replace(job, child_jobs=[])
# For any jobs that disappeared which weren't done, tell subscribers they
# changed to done. We don't know what else happened to them so leave the
# rest of their state as is rather then guessing
changed_jobs.extend(
[
replace(job, done=True)
for uuid, job in self._jobs.items()
if uuid not in new_jobs and job.done is False
]
)
# Replace our cache and inform subscribers of all changes
self._jobs = new_jobs
for job in changed_jobs:
self._process_job_change(job)
# If this is the first update register to receive Supervisor events
if first_update:
async_dispatcher_connect(
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
)
@callback
def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None:
"""Update job data cache from supervisor events."""
if ATTR_WS_EVENT not in event:
return
if (
event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
):
self._hass.async_create_task(self.refresh_data())
elif event[ATTR_WS_EVENT] == EVENT_JOB:
job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []})
self._jobs[job.uuid] = job
self._process_job_change(job)
def _process_job_change(self, job: Job) -> None:
"""Process a job change by triggering callbacks on subscribers."""
for sub in self._subscriptions:
if sub.matches(job):
sub.event_callback(job)

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.3.3"],
"requirements": ["aiohasupervisor==0.3.3b0"],
"single_config_entry": true
}

View File

@@ -6,6 +6,7 @@ import re
from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import Job
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.components.update import (
@@ -15,7 +16,7 @@ from homeassistant.components.update import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -35,6 +36,7 @@ from .entity import (
HassioOSEntity,
HassioSupervisorEntity,
)
from .jobs import JobSubscription
from .update_helper import update_addon, update_core, update_os
ENTITY_DESCRIPTION = UpdateEntityDescription(
@@ -89,6 +91,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.BACKUP
| UpdateEntityFeature.RELEASE_NOTES
| UpdateEntityFeature.PROGRESS
)
@property
@@ -154,6 +157,30 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
)
await self.coordinator.async_refresh()
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
if job.done is False:
self._attr_in_progress = True
self._attr_update_percentage = job.progress
else:
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.jobs.subscribe(
JobSubscription(
self._update_job_changed,
name="addon_manager_update",
reference=self._addon_slug,
)
)
)
class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
"""Update entity to handle updates for the Home Assistant Operating System."""
@@ -250,6 +277,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.SPECIFIC_VERSION
| UpdateEntityFeature.BACKUP
| UpdateEntityFeature.PROGRESS
)
_attr_title = "Home Assistant Core"
@@ -281,3 +309,25 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
) -> None:
"""Install an update."""
await update_core(self.hass, version, backup)
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
if job.done is False:
self._attr_in_progress = True
self._attr_update_percentage = job.progress
else:
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.jobs.subscribe(
JobSubscription(
self._update_job_changed, name="home_assistant_core_update"
)
)
)

View File

@@ -10,7 +10,6 @@ from homeassistant.components.homeassistant_hardware import firmware_config_flow
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.config_entries import (
ConfigEntry,
@@ -67,7 +66,6 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None

View File

@@ -16,7 +16,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
@@ -157,7 +156,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR]
bootloader_reset_type = None
def __init__(
self,

View File

@@ -39,7 +39,6 @@ from .util import (
FirmwareInfo,
OwningAddon,
OwningIntegration,
ResetTarget,
async_flash_silabs_firmware,
get_otbr_addon_manager,
guess_firmware_info,
@@ -80,8 +79,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
_picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -158,6 +155,34 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
async def _probe_firmware_info(
self,
probe_methods: tuple[ApplicationType, ...] = (
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
),
) -> bool:
"""Probe the firmware currently on the device."""
assert self._device is not None
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
probe_methods=probe_methods,
)
return (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type
in (
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
)
async def _install_firmware_step(
self,
fw_update_url: str,
@@ -211,6 +236,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
expected_installed_firmware_type: ApplicationType,
) -> None:
"""Install firmware."""
if not await self._probe_firmware_info():
raise AbortFlow(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
assert self._device is not None
# Keep track of the firmware we're working with, for error messages
@@ -219,8 +250,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
)
@@ -272,12 +301,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Otherwise, fail
raise AbortFlow(reason="firmware_download_failed") from err
self._probed_firmware_info = await async_flash_silabs_firmware(
await async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
bootloader_reset_type=None,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
@@ -285,6 +314,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
async def _configure_and_start_otbr_addon(self) -> None:
"""Configure and start the OTBR addon."""
# Before we start the addon, confirm that the correct firmware is running
# and populate `self._probed_firmware_info` with the correct information
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
raise AbortFlow(
"unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
@@ -406,12 +444,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
return await self.async_step_install_zigbee_firmware()
return await self.async_step_install_thread_firmware()
return await self.async_step_prepare_thread_installation()
async def async_step_finish_thread_installation(
async def async_step_prepare_thread_installation(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Finish Thread installation by starting the OTBR addon."""
"""Prepare for Thread installation by stopping the OTBR addon if needed."""
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
@@ -421,12 +459,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.RUNNING:
# Stop the addon before continuing to flash firmware
await otbr_manager.async_stop_addon()
return await self.async_step_install_thread_firmware()
async def async_step_finish_thread_installation(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Finish Thread installation by starting the OTBR addon."""
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.RUNNING:
await otbr_manager.async_stop_addon()
return await self.async_step_start_otbr_addon()
async def async_step_pick_firmware_zigbee(
@@ -463,6 +511,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._device is not None
assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if self._zigbee_integration == ZigbeeIntegration.OTHER:
return self._async_flow_finished()

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.0.35",
"universal-silabs-flasher==0.0.32",
"ha-silabs-firmware-client==0.2.0"
]
}

View File

@@ -22,12 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback
from .util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
async_flash_silabs_firmware,
)
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
_LOGGER = logging.getLogger(__name__)
@@ -86,7 +81,7 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = []
bootloader_reset_type: str | None = None
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -273,7 +268,7 @@ class BaseFirmwareUpdateEntity(
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods,
bootloader_reset_type=self.bootloader_reset_type,
progress_callback=self._update_progress,
)
finally:

View File

@@ -4,16 +4,13 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
import logging
from universal_silabs_flasher.const import (
ApplicationType as FlasherApplicationType,
ResetTarget as FlasherResetTarget,
)
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
@@ -45,9 +42,9 @@ class ApplicationType(StrEnum):
"""Application type running on a device."""
GECKO_BOOTLOADER = "bootloader"
CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
CPC = "cpc"
ROUTER = "router"
@classmethod
@@ -62,18 +59,6 @@ class ApplicationType(StrEnum):
return FlasherApplicationType(self.value)
class ResetTarget(StrEnum):
"""Methods to reset a device into bootloader mode."""
RTS_DTR = "rts_dtr"
BAUDRATE = "baudrate"
YELLOW = "yellow"
def as_flasher_reset_target(self) -> FlasherResetTarget:
"""Convert the reset target enum into one compatible with USF."""
return FlasherResetTarget(self.value)
@singleton(OTBR_ADDON_MANAGER_DATA)
@callback
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
@@ -357,7 +342,7 @@ async def async_flash_silabs_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
bootloader_reset_type: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
@@ -374,9 +359,7 @@ async def async_flash_silabs_firmware(
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
bootloader_reset=bootloader_reset_type,
)
async with AsyncExitStack() as stack:

View File

@@ -168,8 +168,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
# The ZBT-1 does not have a hardware bootloader trigger
bootloader_reset_methods = []
bootloader_reset_type = None
def __init__(
self,

View File

@@ -27,8 +27,6 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
probe_silabs_firmware_info,
)
from homeassistant.config_entries import (
SOURCE_HARDWARE,
@@ -84,8 +82,6 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -145,10 +141,8 @@ class HomeAssistantYellowConfigFlow(
self, data: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
await self._probe_firmware_info()
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (

View File

@@ -16,7 +16,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
@@ -174,7 +173,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
bootloader_reset_type = "yellow" # Triggers a GPIO reset
def __init__(
self,

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.19"],
"requirements": ["aiohomekit==3.2.18"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}

View File

@@ -8,16 +8,13 @@ from idasen_ha import Desk
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]
UPDATE_DEBOUNCE_TIME = 0.2
class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Class to manage updates for the Idasen Desk."""
@@ -36,22 +33,9 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
hass, _LOGGER, config_entry=config_entry, name=config_entry.title
)
self.address = address
self.desk = Desk(self._async_handle_update)
self._expected_connected = False
self._height: int | None = None
@callback
def async_update_data() -> None:
self.async_set_updated_data(self._height)
self._debouncer = Debouncer(
hass=self.hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=async_update_data,
)
self.desk = Desk(self.async_set_updated_data)
async def async_connect(self) -> bool:
"""Connect to desk."""
@@ -76,9 +60,3 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Ensure that the desk is connected if that is the expected state."""
if self._expected_connected:
await self.async_connect()
@callback
def _async_handle_update(self, height: int | None) -> None:
"""Handle an update from the desk."""
self._height = height
self._debouncer.async_schedule_call()

View File

@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
type INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR]

View File

@@ -1 +0,0 @@
"""Virtual integration: Konnected ESPHome."""

View File

@@ -1,6 +0,0 @@
{
"domain": "konnected_esphome",
"name": "Konnected",
"integration_type": "virtual",
"supported_by": "esphome"
}

View File

@@ -13,16 +13,28 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
from homeassistant.const import ( # noqa: F401
_DEPRECATED_STATE_JAMMED,
_DEPRECATED_STATE_LOCKED,
_DEPRECATED_STATE_LOCKING,
_DEPRECATED_STATE_UNLOCKED,
_DEPRECATED_STATE_UNLOCKING,
ATTR_CODE,
ATTR_CODE_FORMAT,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
STATE_OPEN,
STATE_OPENING,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import (
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType
@@ -305,3 +317,11 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return
self._lock_option_default_code = ""
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -1,64 +0,0 @@
"""The Lunatone integration."""
from typing import Final
from lunatone_rest_api_client import Auth, Devices, Info
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import (
LunatoneConfigEntry,
LunatoneData,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
devices_api = Devices(auth_api)
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()
if info_api.serial_number is None:
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(info_api.serial_number))},
name=info_api.name,
manufacturer="Lunatone",
sw_version=info_api.version,
hw_version=info_api.data.device.pcb,
configuration_url=entry.data[CONF_URL],
serial_number=str(info_api.serial_number),
model_id=(
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
),
)
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
await coordinator_devices.async_config_entry_first_refresh()
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,83 +0,0 @@
"""Config flow for Lunatone."""
from typing import Any, Final
import aiohttp
from lunatone_rest_api_client import Auth, Info
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA: Final[vol.Schema] = vol.Schema(
{vol.Required(CONF_URL, default="http://"): cv.string},
)
def compose_title(name: str | None, serial_number: int) -> str:
"""Compose a title string from a given name and serial number."""
return f"{name or 'DALI Gateway'} {serial_number}"
class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
"""Lunatone config flow."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_URL]
data = {CONF_URL: url}
self._async_abort_entries_match(data)
auth_api = Auth(
session=async_get_clientsession(self.hass),
base_url=url,
)
info_api = Info(auth_api)
try:
await info_api.async_update()
except aiohttp.InvalidUrlClientError:
errors["base"] = "invalid_url"
except aiohttp.ClientConnectionError:
errors["base"] = "cannot_connect"
else:
if info_api.data is None or info_api.serial_number is None:
errors["base"] = "missing_device_info"
else:
await self.async_set_unique_id(str(info_api.serial_number))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=data,
title=compose_title(info_api.name, info_api.serial_number),
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=compose_title(info_api.name, info_api.serial_number),
data={CONF_URL: url},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
return await self.async_step_user(user_input)

View File

@@ -1,5 +0,0 @@
"""Constants for the Lunatone integration."""
from typing import Final
DOMAIN: Final = "lunatone"

View File

@@ -1,101 +0,0 @@
"""Coordinator for handling data fetching and updates."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
import aiohttp
from lunatone_rest_api_client import Device, Devices, Info
from lunatone_rest_api_client.models import InfoData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
@dataclass
class LunatoneData:
"""Data for Lunatone integration."""
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
"""Data update coordinator for Lunatone info."""
config_entry: LunatoneConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-info",
always_update=False,
)
self.info_api = info_api
async def _async_update_data(self) -> InfoData:
"""Update info data."""
try:
await self.info_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve info data from Lunatone REST API"
) from ex
if self.info_api.data is None:
raise UpdateFailed("Did not receive info data from Lunatone REST API")
return self.info_api.data
class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]):
"""Data update coordinator for Lunatone devices."""
config_entry: LunatoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
devices_api: Devices,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-devices",
always_update=False,
update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
)
self.devices_api = devices_api
async def _async_update_data(self) -> dict[int, Device]:
"""Update devices data."""
try:
await self.devices_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve devices data from Lunatone REST API"
) from ex
if self.devices_api.data is None:
raise UpdateFailed("Did not receive devices data from Lunatone REST API")
return {device.id: device for device in self.devices_api.devices}

View File

@@ -1,103 +0,0 @@
"""Platform for Lunatone light integration."""
from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
PARALLEL_UPDATES = 0
STATUS_UPDATE_DELAY = 0.04
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lunatone Light platform."""
coordinator_info = config_entry.runtime_data.coordinator_info
coordinator_devices = config_entry.runtime_data.coordinator_devices
async_add_entities(
[
LunatoneLight(
coordinator_devices, device_id, coordinator_info.data.device.serial
)
for device_id in coordinator_devices.data
]
)
class LunatoneLight(
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
):
"""Representation of a Lunatone light."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
coordinator: LunatoneDevicesDataUpdateCoordinator,
device_id: int,
interface_serial_number: int,
) -> None:
"""Initialize a LunatoneLight."""
super().__init__(coordinator=coordinator)
self._device_id = device_id
self._interface_serial_number = interface_serial_number
self._device = self.coordinator.data.get(self._device_id)
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
assert self.unique_id
name = self._device.name if self._device is not None else None
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=name,
via_device=(DOMAIN, str(self._interface_serial_number)),
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._device is not None
@property
def is_on(self) -> bool:
"""Return True if light is on."""
return self._device is not None and self._device.is_on
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data.get(self._device_id)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()

View File

@@ -1,11 +0,0 @@
{
"domain": "lunatone",
"name": "Lunatone",
"codeowners": ["@MoonDevLT"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lunatone",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.4.8"]
}

View File

@@ -1,82 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules:
status: exempt
comment: |
This integration has only one platform which uses a coordinator.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: no actions
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: Discovery not yet supported
discovery:
status: todo
comment: Discovery not yet supported
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,36 +0,0 @@
{
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"user": {
"description": "Connect to the API of your Lunatone DALI IoT Gateway.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Lunatone gateway device."
}
},
"reconfigure": {
"description": "Update the URL.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
"missing_device_info": "Failed to read device information. Check the network connection of the device"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
}

View File

@@ -30,7 +30,6 @@ from .entity import MatterEntity
from .helpers import get_matter
from .models import MatterDiscoverySchema
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
HVAC_SYSTEM_MODE_MAP = {
HVACMode.OFF: 0,
@@ -262,18 +261,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
self._attr_current_humidity = (
int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR
if (
raw_measured_humidity := self.get_matter_attribute_value(
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue
)
)
is not None
else None
)
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
# if the mains power is off - treat it as if the HVAC mode is off
@@ -441,7 +428,6 @@ DISCOVERY_SCHEMAS = [
clusters.Thermostat.Attributes.TemperatureSetpointHold,
clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
clusters.OnOff.Attributes.OnOff,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),

View File

@@ -351,7 +351,6 @@ DISCOVERY_SCHEMAS = [
required_attributes=(
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
),
allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,

View File

@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
TIMEOUT = 10
type TokenManager = Callable[[], Awaitable[str]]
TokenManager = Callable[[], Awaitable[str]]
@asynccontextmanager

View File

@@ -55,6 +55,12 @@ from homeassistant.const import ( # noqa: F401
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.network import get_url
@@ -69,6 +75,26 @@ from .browse_media import ( # noqa: F401
async_process_play_media_url,
)
from .const import ( # noqa: F401
_DEPRECATED_MEDIA_CLASS_DIRECTORY,
_DEPRECATED_SUPPORT_BROWSE_MEDIA,
_DEPRECATED_SUPPORT_CLEAR_PLAYLIST,
_DEPRECATED_SUPPORT_GROUPING,
_DEPRECATED_SUPPORT_NEXT_TRACK,
_DEPRECATED_SUPPORT_PAUSE,
_DEPRECATED_SUPPORT_PLAY,
_DEPRECATED_SUPPORT_PLAY_MEDIA,
_DEPRECATED_SUPPORT_PREVIOUS_TRACK,
_DEPRECATED_SUPPORT_REPEAT_SET,
_DEPRECATED_SUPPORT_SEEK,
_DEPRECATED_SUPPORT_SELECT_SOUND_MODE,
_DEPRECATED_SUPPORT_SELECT_SOURCE,
_DEPRECATED_SUPPORT_SHUFFLE_SET,
_DEPRECATED_SUPPORT_STOP,
_DEPRECATED_SUPPORT_TURN_OFF,
_DEPRECATED_SUPPORT_TURN_ON,
_DEPRECATED_SUPPORT_VOLUME_MUTE,
_DEPRECATED_SUPPORT_VOLUME_SET,
_DEPRECATED_SUPPORT_VOLUME_STEP,
ATTR_APP_ID,
ATTR_APP_NAME,
ATTR_ENTITY_PICTURE_LOCAL,
@@ -162,6 +188,17 @@ class MediaPlayerDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
# DEVICE_CLASS* below are deprecated as of 2021.12
# use the MediaPlayerDeviceClass enum instead.
_DEPRECATED_DEVICE_CLASS_TV = DeprecatedConstantEnum(
MediaPlayerDeviceClass.TV, "2025.10"
)
_DEPRECATED_DEVICE_CLASS_SPEAKER = DeprecatedConstantEnum(
MediaPlayerDeviceClass.SPEAKER, "2025.10"
)
_DEPRECATED_DEVICE_CLASS_RECEIVER = DeprecatedConstantEnum(
MediaPlayerDeviceClass.RECEIVER, "2025.10"
)
DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass]
@@ -1159,7 +1196,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
media_content_id: str | None = None,
media_filter_classes: list[MediaClass] | None = None,
) -> SearchMedia:
"""Search for media."""
return await self.async_search_media(
query=SearchMediaQuery(
search_query=search_query,
@@ -1474,3 +1510,13 @@ async def async_fetch_image(
logger.warning("Error retrieving proxied image from %s", url)
return content, content_type
# As we import deprecated constants from the const module, we need to add these two functions
# otherwise this module will be logged for using deprecated constants and not the custom component
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -1,8 +1,15 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
from functools import partial
from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
EnumWithDeprecatedMembers,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
# How long our auth signature on the content should be valid for
CONTENT_AUTH_EXPIRY_TIME = 3600 * 24
@@ -87,6 +94,38 @@ class MediaClass(StrEnum):
VIDEO = "video"
# These MEDIA_CLASS_* constants are deprecated as of Home Assistant 2022.10.
# Please use the MediaClass enum instead.
_DEPRECATED_MEDIA_CLASS_ALBUM = DeprecatedConstantEnum(MediaClass.ALBUM, "2025.10")
_DEPRECATED_MEDIA_CLASS_APP = DeprecatedConstantEnum(MediaClass.APP, "2025.10")
_DEPRECATED_MEDIA_CLASS_ARTIST = DeprecatedConstantEnum(MediaClass.ARTIST, "2025.10")
_DEPRECATED_MEDIA_CLASS_CHANNEL = DeprecatedConstantEnum(MediaClass.CHANNEL, "2025.10")
_DEPRECATED_MEDIA_CLASS_COMPOSER = DeprecatedConstantEnum(
MediaClass.COMPOSER, "2025.10"
)
_DEPRECATED_MEDIA_CLASS_CONTRIBUTING_ARTIST = DeprecatedConstantEnum(
MediaClass.CONTRIBUTING_ARTIST, "2025.10"
)
_DEPRECATED_MEDIA_CLASS_DIRECTORY = DeprecatedConstantEnum(
MediaClass.DIRECTORY, "2025.10"
)
_DEPRECATED_MEDIA_CLASS_EPISODE = DeprecatedConstantEnum(MediaClass.EPISODE, "2025.10")
_DEPRECATED_MEDIA_CLASS_GAME = DeprecatedConstantEnum(MediaClass.GAME, "2025.10")
_DEPRECATED_MEDIA_CLASS_GENRE = DeprecatedConstantEnum(MediaClass.GENRE, "2025.10")
_DEPRECATED_MEDIA_CLASS_IMAGE = DeprecatedConstantEnum(MediaClass.IMAGE, "2025.10")
_DEPRECATED_MEDIA_CLASS_MOVIE = DeprecatedConstantEnum(MediaClass.MOVIE, "2025.10")
_DEPRECATED_MEDIA_CLASS_MUSIC = DeprecatedConstantEnum(MediaClass.MUSIC, "2025.10")
_DEPRECATED_MEDIA_CLASS_PLAYLIST = DeprecatedConstantEnum(
MediaClass.PLAYLIST, "2025.10"
)
_DEPRECATED_MEDIA_CLASS_PODCAST = DeprecatedConstantEnum(MediaClass.PODCAST, "2025.10")
_DEPRECATED_MEDIA_CLASS_SEASON = DeprecatedConstantEnum(MediaClass.SEASON, "2025.10")
_DEPRECATED_MEDIA_CLASS_TRACK = DeprecatedConstantEnum(MediaClass.TRACK, "2025.10")
_DEPRECATED_MEDIA_CLASS_TV_SHOW = DeprecatedConstantEnum(MediaClass.TV_SHOW, "2025.10")
_DEPRECATED_MEDIA_CLASS_URL = DeprecatedConstantEnum(MediaClass.URL, "2025.10")
_DEPRECATED_MEDIA_CLASS_VIDEO = DeprecatedConstantEnum(MediaClass.VIDEO, "2025.10")
class MediaType(StrEnum):
"""Media type for media player entities."""
@@ -113,6 +152,33 @@ class MediaType(StrEnum):
VIDEO = "video"
# These MEDIA_TYPE_* constants are deprecated as of Home Assistant 2022.10.
# Please use the MediaType enum instead.
_DEPRECATED_MEDIA_TYPE_ALBUM = DeprecatedConstantEnum(MediaType.ALBUM, "2025.10")
_DEPRECATED_MEDIA_TYPE_APP = DeprecatedConstantEnum(MediaType.APP, "2025.10")
_DEPRECATED_MEDIA_TYPE_APPS = DeprecatedConstantEnum(MediaType.APPS, "2025.10")
_DEPRECATED_MEDIA_TYPE_ARTIST = DeprecatedConstantEnum(MediaType.ARTIST, "2025.10")
_DEPRECATED_MEDIA_TYPE_CHANNEL = DeprecatedConstantEnum(MediaType.CHANNEL, "2025.10")
_DEPRECATED_MEDIA_TYPE_CHANNELS = DeprecatedConstantEnum(MediaType.CHANNELS, "2025.10")
_DEPRECATED_MEDIA_TYPE_COMPOSER = DeprecatedConstantEnum(MediaType.COMPOSER, "2025.10")
_DEPRECATED_MEDIA_TYPE_CONTRIBUTING_ARTIST = DeprecatedConstantEnum(
MediaType.CONTRIBUTING_ARTIST, "2025.10"
)
_DEPRECATED_MEDIA_TYPE_EPISODE = DeprecatedConstantEnum(MediaType.EPISODE, "2025.10")
_DEPRECATED_MEDIA_TYPE_GAME = DeprecatedConstantEnum(MediaType.GAME, "2025.10")
_DEPRECATED_MEDIA_TYPE_GENRE = DeprecatedConstantEnum(MediaType.GENRE, "2025.10")
_DEPRECATED_MEDIA_TYPE_IMAGE = DeprecatedConstantEnum(MediaType.IMAGE, "2025.10")
_DEPRECATED_MEDIA_TYPE_MOVIE = DeprecatedConstantEnum(MediaType.MOVIE, "2025.10")
_DEPRECATED_MEDIA_TYPE_MUSIC = DeprecatedConstantEnum(MediaType.MUSIC, "2025.10")
_DEPRECATED_MEDIA_TYPE_PLAYLIST = DeprecatedConstantEnum(MediaType.PLAYLIST, "2025.10")
_DEPRECATED_MEDIA_TYPE_PODCAST = DeprecatedConstantEnum(MediaType.PODCAST, "2025.10")
_DEPRECATED_MEDIA_TYPE_SEASON = DeprecatedConstantEnum(MediaType.SEASON, "2025.10")
_DEPRECATED_MEDIA_TYPE_TRACK = DeprecatedConstantEnum(MediaType.TRACK, "2025.10")
_DEPRECATED_MEDIA_TYPE_TVSHOW = DeprecatedConstantEnum(MediaType.TVSHOW, "2025.10")
_DEPRECATED_MEDIA_TYPE_URL = DeprecatedConstantEnum(MediaType.URL, "2025.10")
_DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10")
SERVICE_CLEAR_PLAYLIST = "clear_playlist"
SERVICE_JOIN = "join"
SERVICE_PLAY_MEDIA = "play_media"
@@ -131,6 +197,11 @@ class RepeatMode(StrEnum):
ONE = "one"
# These REPEAT_MODE_* constants are deprecated as of Home Assistant 2022.10.
# Please use the RepeatMode enum instead.
_DEPRECATED_REPEAT_MODE_ALL = DeprecatedConstantEnum(RepeatMode.ALL, "2025.10")
_DEPRECATED_REPEAT_MODE_OFF = DeprecatedConstantEnum(RepeatMode.OFF, "2025.10")
_DEPRECATED_REPEAT_MODE_ONE = DeprecatedConstantEnum(RepeatMode.ONE, "2025.10")
REPEAT_MODES = [cls.value for cls in RepeatMode]
@@ -160,3 +231,71 @@ class MediaPlayerEntityFeature(IntFlag):
MEDIA_ANNOUNCE = 1048576
MEDIA_ENQUEUE = 2097152
SEARCH_MEDIA = 4194304
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
# Please use the MediaPlayerEntityFeature enum instead.
_DEPRECATED_SUPPORT_PAUSE = DeprecatedConstantEnum(
MediaPlayerEntityFeature.PAUSE, "2025.10"
)
_DEPRECATED_SUPPORT_SEEK = DeprecatedConstantEnum(
MediaPlayerEntityFeature.SEEK, "2025.10"
)
_DEPRECATED_SUPPORT_VOLUME_SET = DeprecatedConstantEnum(
MediaPlayerEntityFeature.VOLUME_SET, "2025.10"
)
_DEPRECATED_SUPPORT_VOLUME_MUTE = DeprecatedConstantEnum(
MediaPlayerEntityFeature.VOLUME_MUTE, "2025.10"
)
_DEPRECATED_SUPPORT_PREVIOUS_TRACK = DeprecatedConstantEnum(
MediaPlayerEntityFeature.PREVIOUS_TRACK, "2025.10"
)
_DEPRECATED_SUPPORT_NEXT_TRACK = DeprecatedConstantEnum(
MediaPlayerEntityFeature.NEXT_TRACK, "2025.10"
)
_DEPRECATED_SUPPORT_TURN_ON = DeprecatedConstantEnum(
MediaPlayerEntityFeature.TURN_ON, "2025.10"
)
_DEPRECATED_SUPPORT_TURN_OFF = DeprecatedConstantEnum(
MediaPlayerEntityFeature.TURN_OFF, "2025.10"
)
_DEPRECATED_SUPPORT_PLAY_MEDIA = DeprecatedConstantEnum(
MediaPlayerEntityFeature.PLAY_MEDIA, "2025.10"
)
_DEPRECATED_SUPPORT_VOLUME_STEP = DeprecatedConstantEnum(
MediaPlayerEntityFeature.VOLUME_STEP, "2025.10"
)
_DEPRECATED_SUPPORT_SELECT_SOURCE = DeprecatedConstantEnum(
MediaPlayerEntityFeature.SELECT_SOURCE, "2025.10"
)
_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(
MediaPlayerEntityFeature.STOP, "2025.10"
)
_DEPRECATED_SUPPORT_CLEAR_PLAYLIST = DeprecatedConstantEnum(
MediaPlayerEntityFeature.CLEAR_PLAYLIST, "2025.10"
)
_DEPRECATED_SUPPORT_PLAY = DeprecatedConstantEnum(
MediaPlayerEntityFeature.PLAY, "2025.10"
)
_DEPRECATED_SUPPORT_SHUFFLE_SET = DeprecatedConstantEnum(
MediaPlayerEntityFeature.SHUFFLE_SET, "2025.10"
)
_DEPRECATED_SUPPORT_SELECT_SOUND_MODE = DeprecatedConstantEnum(
MediaPlayerEntityFeature.SELECT_SOUND_MODE, "2025.10"
)
_DEPRECATED_SUPPORT_BROWSE_MEDIA = DeprecatedConstantEnum(
MediaPlayerEntityFeature.BROWSE_MEDIA, "2025.10"
)
_DEPRECATED_SUPPORT_REPEAT_SET = DeprecatedConstantEnum(
MediaPlayerEntityFeature.REPEAT_SET, "2025.10"
)
_DEPRECATED_SUPPORT_GROUPING = DeprecatedConstantEnum(
MediaPlayerEntityFeature.GROUPING, "2025.10"
)
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -14,7 +14,6 @@ from homeassistant.const import (
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
STATE_PLAYING,
)
@@ -28,7 +27,6 @@ from .browse_media import SearchMedia
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN,
SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
@@ -41,8 +39,6 @@ INTENT_MEDIA_PAUSE = "HassMediaPause"
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
INTENT_MEDIA_NEXT = "HassMediaNext"
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
INTENT_PLAYER_MUTE = "HassMediaPlayerMute"
INTENT_PLAYER_UNMUTE = "HassMediaPlayerUnmute"
INTENT_SET_VOLUME = "HassSetVolume"
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
@@ -134,8 +130,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
),
)
intent.async_register(hass, MediaSetVolumeRelativeHandler())
intent.async_register(hass, MediaPlayerMuteUnmuteHandler(True))
intent.async_register(hass, MediaPlayerMuteUnmuteHandler(False))
intent.async_register(hass, MediaSearchAndPlayHandler())
@@ -237,42 +231,6 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler):
)
class MediaPlayerMuteUnmuteHandler(intent.ServiceIntentHandler):
"""Handle Mute/Unmute intents."""
def __init__(self, is_volume_muted: bool) -> None:
"""Initialize the mute/unmute handler objects."""
super().__init__(
(INTENT_PLAYER_MUTE if is_volume_muted else INTENT_PLAYER_UNMUTE),
DOMAIN,
SERVICE_VOLUME_MUTE,
required_domains={DOMAIN},
required_features=MediaPlayerEntityFeature.VOLUME_MUTE,
optional_slots={
ATTR_MEDIA_VOLUME_MUTED: intent.IntentSlotInfo(
description="Whether the media player should be muted or unmuted",
value_schema=vol.Boolean(),
),
},
description=(
"Mutes a media player" if is_volume_muted else "Unmutes a media player"
),
platforms={DOMAIN},
device_classes={MediaPlayerDeviceClass},
)
self.is_volume_muted = is_volume_muted
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
intent_obj.slots["is_volume_muted"] = {
"value": self.is_volume_muted,
"text": str(self.is_volume_muted),
}
return await super().async_handle(intent_obj)
class MediaSearchAndPlayHandler(intent.IntentHandler):
"""Handle HassMediaSearchAndPlay intents."""

View File

@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.translation import async_get_cached_translations
from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
@@ -63,15 +62,12 @@ class MediaSourceItem:
async def async_browse(self) -> BrowseMediaSource:
"""Browse this item."""
if self.domain is None:
title = async_get_cached_translations(
self.hass, self.hass.config.language, "common", "media_source"
).get("component.media_source.common.sources_default", "Media Sources")
base = BrowseMediaSource(
domain=None,
identifier=None,
media_class=MediaClass.APP,
media_content_type=MediaType.APPS,
title=title,
title="Media Sources",
can_play=False,
can_expand=True,
children_media_class=MediaClass.APP,

View File

@@ -9,8 +9,5 @@
"unknown_media_source": {
"message": "Unknown media source: {domain}"
}
},
"common": {
"sources_default": "Media sources"
}
}

View File

@@ -1,27 +0,0 @@
"""The Meteo.lt integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import CONF_PLACE_CODE, PLATFORMS
from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
"""Set up Meteo.lt from a config entry."""
coordinator = MeteoLtUpdateCoordinator(hass, entry.data[CONF_PLACE_CODE], entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,78 +0,0 @@
"""Config flow for Meteo.lt integration."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from meteo_lt import MeteoLtAPI, Place
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import CONF_PLACE_CODE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class MeteoLtConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Meteo.lt."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._api = MeteoLtAPI()
self._places: list[Place] = []
self._selected_place: Place | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
place_code = user_input[CONF_PLACE_CODE]
self._selected_place = next(
(place for place in self._places if place.code == place_code),
None,
)
if self._selected_place:
await self.async_set_unique_id(self._selected_place.code)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self._selected_place.name,
data={
CONF_PLACE_CODE: self._selected_place.code,
},
)
errors["base"] = "invalid_location"
if not self._places:
try:
await self._api.fetch_places()
self._places = self._api.places
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.error("Error fetching places: %s", err)
return self.async_abort(reason="cannot_connect")
if not self._places:
return self.async_abort(reason="no_places_found")
places_options = {
place.code: f"{place.name} ({place.administrative_division})"
for place in self._places
}
data_schema = vol.Schema(
{
vol.Required(CONF_PLACE_CODE): vol.In(places_options),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

View File

@@ -1,17 +0,0 @@
"""Constants for the Meteo.lt integration."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "meteo_lt"
PLATFORMS = [Platform.WEATHER]
MANUFACTURER = "Lithuanian Hydrometeorological Service"
MODEL = "Weather Station"
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30)
CONF_PLACE_CODE = "place_code"
ATTRIBUTION = "Data provided by Lithuanian Hydrometeorological Service (LHMT)"

View File

@@ -1,61 +0,0 @@
"""DataUpdateCoordinator for Meteo.lt integration."""
from __future__ import annotations
import logging
import aiohttp
from meteo_lt import Forecast as MeteoLtForecast, MeteoLtAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MeteoLtConfigEntry = ConfigEntry[MeteoLtUpdateCoordinator]
class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]):
"""Class to manage fetching Meteo.lt data."""
def __init__(
self,
hass: HomeAssistant,
place_code: str,
config_entry: MeteoLtConfigEntry,
) -> None:
"""Initialize the coordinator."""
self.client = MeteoLtAPI()
self.place_code = place_code
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_UPDATE_INTERVAL,
config_entry=config_entry,
)
async def _async_update_data(self) -> MeteoLtForecast:
"""Fetch data from Meteo.lt API."""
try:
forecast = await self.client.get_forecast(self.place_code)
except aiohttp.ClientResponseError as err:
raise UpdateFailed(
f"API returned error status {err.status}: {err.message}"
) from err
except aiohttp.ClientConnectionError as err:
raise UpdateFailed(f"Cannot connect to API: {err}") from err
except (aiohttp.ClientError, TimeoutError) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
# Check if forecast data is available
if not forecast.forecast_timestamps:
raise UpdateFailed(
f"No forecast data available for {self.place_code} - API returned empty timestamps"
)
return forecast

View File

@@ -1,11 +0,0 @@
{
"domain": "meteo_lt",
"name": "Meteo.lt",
"codeowners": ["@xE1H"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteo_lt",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["meteo-lt-pkg==0.2.4"]
}

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