mirror of
https://github.com/home-assistant/core.git
synced 2025-10-04 09:19:28 +00:00
Compare commits
3 Commits
dev
...
improve-zh
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c1b799856a | ||
![]() |
2f4e3b98f3 | ||
![]() |
f2c354eb3d |
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
@@ -326,7 +326,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.*
|
||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -910,8 +910,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
|
||||
|
10
build.yaml
10
build.yaml
@@ -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
|
||||
|
@@ -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)
|
||||
|
||||
|
5
homeassistant/brands/ibm.json
Normal file
5
homeassistant/brands/ibm.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "ibm",
|
||||
"name": "IBM",
|
||||
"integrations": ["watson_iot", "watson_tts"]
|
||||
}
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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": {
|
||||
|
@@ -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(
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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:
|
||||
|
@@ -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": {
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.2.8"]
|
||||
"requirements": ["aioamazondevices==6.2.7"]
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.1"]
|
||||
"requirements": ["pycync==0.4.0"]
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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]
|
||||
|
@@ -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(
|
||||
|
@@ -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)
|
||||
|
@@ -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[
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.0"],
|
||||
"requirements": ["pyenphase==2.3.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
@@ -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)
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -85,48 +84,6 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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."""
|
||||
|
@@ -18,7 +18,7 @@ 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.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -72,7 +72,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
|
||||
try:
|
||||
await self.firefly.get_about()
|
||||
except FireflyAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
@@ -109,7 +109,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
|
||||
budgets = await self.firefly.get_budgets()
|
||||
bills = await self.firefly.get_bills()
|
||||
except FireflyAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.6"]
|
||||
"requirements": ["pyfirefly==0.1.5"]
|
||||
}
|
||||
|
@@ -100,6 +100,15 @@ class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Return the state of the sensor."""
|
||||
return self._account.attributes.current_balance
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
"""Return extra state attributes for the account entity."""
|
||||
return {
|
||||
"account_role": self._account.attributes.account_role or "",
|
||||
"account_type": self._account.attributes.type or "",
|
||||
"current_balance": str(self._account.attributes.current_balance or ""),
|
||||
}
|
||||
|
||||
|
||||
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III category."""
|
||||
|
@@ -13,15 +13,6 @@
|
||||
"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": {
|
||||
@@ -31,8 +22,7 @@
|
||||
"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%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@@ -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(
|
||||
|
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -22,7 +22,6 @@ from google.protobuf import timestamp_pb2
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -92,16 +91,6 @@ def convert_time(time_str: str) -> timestamp_pb2.Timestamp | None:
|
||||
return timestamp
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = [
|
||||
SensorEntityDescription(
|
||||
key="duration",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
@@ -116,20 +105,20 @@ async def async_setup_entry(
|
||||
client_options = ClientOptions(api_key=api_key)
|
||||
client = RoutesAsyncClient(client_options=client_options)
|
||||
|
||||
sensors = [
|
||||
GoogleTravelTimeSensor(
|
||||
config_entry, name, api_key, origin, destination, client, sensor_description
|
||||
)
|
||||
for sensor_description in SENSOR_DESCRIPTIONS
|
||||
]
|
||||
sensor = GoogleTravelTimeSensor(
|
||||
config_entry, name, api_key, origin, destination, client
|
||||
)
|
||||
|
||||
async_add_entities(sensors, False)
|
||||
async_add_entities([sensor], False)
|
||||
|
||||
|
||||
class GoogleTravelTimeSensor(SensorEntity):
|
||||
"""Representation of a Google travel time sensor."""
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
|
||||
_attr_device_class = SensorDeviceClass.DURATION
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -139,10 +128,8 @@ class GoogleTravelTimeSensor(SensorEntity):
|
||||
origin: str,
|
||||
destination: str,
|
||||
client: RoutesAsyncClient,
|
||||
sensor_description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_description = sensor_description
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -174,9 +174,6 @@
|
||||
},
|
||||
"collected_items": {
|
||||
"default": "mdi:sack"
|
||||
},
|
||||
"last_checkin": {
|
||||
"default": "mdi:login-variant"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -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,7 +442,7 @@ 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(
|
||||
|
@@ -290,9 +290,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"last_checkin": {
|
||||
"name": "Last check-in"
|
||||
},
|
||||
"health": {
|
||||
"name": "Health",
|
||||
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -73,6 +73,7 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
|
||||
try:
|
||||
await supervisor_client.addons.start_addon(self._addon_slug)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Failed to start addon %s: %s", self._addon_slug, err)
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
||||
@@ -277,7 +274,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
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
|
||||
),
|
||||
|
@@ -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"
|
||||
]
|
||||
}
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
@@ -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,
|
||||
|
@@ -27,7 +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 (
|
||||
@@ -84,8 +83,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:
|
||||
|
@@ -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,
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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]
|
||||
|
||||
|
@@ -196,11 +196,11 @@ class LocalTodoListEntity(TodoListEntity):
|
||||
item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)}
|
||||
if uid not in item_idx:
|
||||
raise HomeAssistantError(
|
||||
f"Item '{uid}' not found in todo list {self.entity_id}"
|
||||
"Item '{uid}' not found in todo list {self.entity_id}"
|
||||
)
|
||||
if previous_uid and previous_uid not in item_idx:
|
||||
raise HomeAssistantError(
|
||||
f"Item '{previous_uid}' not found in todo list {self.entity_id}"
|
||||
"Item '{previous_uid}' not found in todo list {self.entity_id}"
|
||||
)
|
||||
dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0
|
||||
src_idx = item_idx[uid]
|
||||
|
@@ -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())
|
||||
|
@@ -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)
|
@@ -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)
|
@@ -1,5 +0,0 @@
|
||||
"""Constants for the Lunatone integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "lunatone"
|
@@ -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}
|
@@ -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()
|
@@ -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"]
|
||||
}
|
@@ -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
|
@@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -88,17 +88,6 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="ThermostatOccupancySensor",
|
||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||
# The first bit = if occupied
|
||||
device_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.Thermostat.Attributes.Occupancy,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
|
@@ -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),
|
||||
|
@@ -351,7 +351,6 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(
|
||||
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
||||
),
|
||||
allow_multi=True, # also used for climate entity
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
|
@@ -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
|
||||
|
@@ -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())
|
||||
|
@@ -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())
|
||||
|
@@ -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,
|
||||
|
@@ -9,8 +9,5 @@
|
||||
"unknown_media_source": {
|
||||
"message": "Unknown media source: {domain}"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"sources_default": "Media sources"
|
||||
}
|
||||
}
|
||||
|
@@ -208,7 +208,7 @@ class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
|
||||
|
||||
def __process_raw_value(self, entry: float | str | bytes) -> str | None:
|
||||
"""Process value from sensor with NaN handling, scaling, offset, min/max etc."""
|
||||
if self._nan_value is not None and entry in (self._nan_value, -self._nan_value):
|
||||
if self._nan_value and entry in (self._nan_value, -self._nan_value):
|
||||
return None
|
||||
if isinstance(entry, bytes):
|
||||
return entry.decode()
|
||||
|
@@ -34,7 +34,6 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = NordPoolDataUpdateCoordinator(hass, config_entry)
|
||||
await coordinator.fetch_data(dt_util.utcnow(), True)
|
||||
await coordinator.update_listeners(dt_util.utcnow())
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
|
@@ -44,10 +44,9 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
|
||||
name=DOMAIN,
|
||||
)
|
||||
self.client = NordPoolClient(session=async_get_clientsession(hass))
|
||||
self.data_unsub: Callable[[], None] | None = None
|
||||
self.listener_unsub: Callable[[], None] | None = None
|
||||
self.unsub: Callable[[], None] | None = None
|
||||
|
||||
def get_next_data_interval(self, now: datetime) -> datetime:
|
||||
def get_next_interval(self, now: datetime) -> datetime:
|
||||
"""Compute next time an update should occur."""
|
||||
next_hour = dt_util.utcnow() + timedelta(hours=1)
|
||||
next_run = datetime(
|
||||
@@ -57,45 +56,23 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
|
||||
next_hour.hour,
|
||||
tzinfo=dt_util.UTC,
|
||||
)
|
||||
LOGGER.debug("Next data update at %s", next_run)
|
||||
return next_run
|
||||
|
||||
def get_next_15_interval(self, now: datetime) -> datetime:
|
||||
"""Compute next time we need to notify listeners."""
|
||||
next_run = dt_util.utcnow() + timedelta(minutes=15)
|
||||
next_minute = next_run.minute // 15 * 15
|
||||
next_run = next_run.replace(
|
||||
minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC
|
||||
)
|
||||
|
||||
LOGGER.debug("Next listener update at %s", next_run)
|
||||
LOGGER.debug("Next update at %s", next_run)
|
||||
return next_run
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Cancel any scheduled call, and ignore new runs."""
|
||||
await super().async_shutdown()
|
||||
if self.data_unsub:
|
||||
self.data_unsub()
|
||||
self.data_unsub = None
|
||||
if self.listener_unsub:
|
||||
self.listener_unsub()
|
||||
self.listener_unsub = None
|
||||
|
||||
async def update_listeners(self, now: datetime) -> None:
|
||||
"""Update entity listeners."""
|
||||
self.listener_unsub = async_track_point_in_utc_time(
|
||||
self.hass,
|
||||
self.update_listeners,
|
||||
self.get_next_15_interval(dt_util.utcnow()),
|
||||
)
|
||||
self.async_update_listeners()
|
||||
if self.unsub:
|
||||
self.unsub()
|
||||
self.unsub = None
|
||||
|
||||
async def fetch_data(self, now: datetime, initial: bool = False) -> None:
|
||||
"""Fetch data from Nord Pool."""
|
||||
self.data_unsub = async_track_point_in_utc_time(
|
||||
self.hass, self.fetch_data, self.get_next_data_interval(dt_util.utcnow())
|
||||
self.unsub = async_track_point_in_utc_time(
|
||||
self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow())
|
||||
)
|
||||
if self.config_entry.pref_disable_polling and not initial:
|
||||
self.async_update_listeners()
|
||||
return
|
||||
try:
|
||||
data = await self.handle_data(initial)
|
||||
|
@@ -8,6 +8,6 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynordpool"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pynordpool==0.3.1"],
|
||||
"requirements": ["pynordpool==0.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -307,7 +307,7 @@
|
||||
},
|
||||
"markdown": {
|
||||
"name": "Format as Markdown",
|
||||
"description": "Enable Markdown formatting for the message body. See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/."
|
||||
"description": "Enable Markdown formatting for the message body (Web app only). See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/."
|
||||
},
|
||||
"tags": {
|
||||
"name": "Tags/Emojis",
|
||||
|
@@ -35,7 +35,7 @@ from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPLOAD_CHUNK_SIZE = 32 * 320 * 1024 # 10.4MB
|
||||
UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
|
||||
TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
|
||||
METADATA_VERSION = 2
|
||||
CACHE_TTL = 300
|
||||
@@ -163,10 +163,7 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
)
|
||||
try:
|
||||
backup_file = await LargeFileUploadClient.upload(
|
||||
self._token_function,
|
||||
file,
|
||||
upload_chunk_size=UPLOAD_CHUNK_SIZE,
|
||||
session=async_get_clientsession(self._hass),
|
||||
self._token_function, file, session=async_get_clientsession(self._hass)
|
||||
)
|
||||
except HashMismatchError as err:
|
||||
raise BackupAgentError(
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ovoenergy"],
|
||||
"requirements": ["ovoenergy==3.0.2"]
|
||||
"requirements": ["ovoenergy==2.0.1"]
|
||||
}
|
||||
|
@@ -115,7 +115,9 @@ class PandoraMediaPlayer(MediaPlayerEntity):
|
||||
async def _start_pianobar(self) -> bool:
|
||||
pianobar = pexpect.spawn("pianobar", encoding="utf-8")
|
||||
pianobar.delaybeforesend = None
|
||||
pianobar.delayafterread = None
|
||||
# mypy thinks delayafterread must be a float but that is not what pexpect says
|
||||
# https://github.com/pexpect/pexpect/blob/4.9/pexpect/expect.py#L170
|
||||
pianobar.delayafterread = None # type: ignore[assignment]
|
||||
pianobar.delayafterclose = 0
|
||||
pianobar.delayafterterminate = 0
|
||||
_LOGGER.debug("Started pianobar subprocess")
|
||||
|
@@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .coordinator import PortainerCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH]
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
|
||||
|
||||
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
|
||||
|
||||
|
@@ -131,7 +131,15 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
# Container ID's are ephemeral, so use the container name for the unique ID
|
||||
# The first one, should always be unique, it's fine if users have aliases
|
||||
# According to Docker's API docs, the first name is unique
|
||||
device_identifier = (
|
||||
self._device_info.names[0].replace("/", " ").strip()
|
||||
if self._device_info.names
|
||||
else None
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
@@ -1,9 +1,7 @@
|
||||
"""Base class for Portainer entities."""
|
||||
|
||||
from pyportainer.models.docker import DockerContainer
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -33,9 +31,6 @@ class PortainerEndpointEntity(PortainerCoordinatorEntity):
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_{self.device_id}")
|
||||
},
|
||||
configuration_url=URL(
|
||||
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.device_id}/docker/dashboard"
|
||||
),
|
||||
manufacturer=DEFAULT_NAME,
|
||||
model="Endpoint",
|
||||
name=device_info.endpoint.name,
|
||||
@@ -57,25 +52,22 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
self.device_id = self._device_info.id
|
||||
self.endpoint_id = via_device.endpoint.id
|
||||
|
||||
# Container ID's are ephemeral, so use the container name for the unique ID
|
||||
# The first one, should always be unique, it's fine if users have aliases
|
||||
# According to Docker's API docs, the first name is unique
|
||||
assert self._device_info.names, "Container names list unexpectedly empty"
|
||||
self.device_name = self._device_info.names[0].replace("/", " ").strip()
|
||||
device_name = (
|
||||
self._device_info.names[0].replace("/", " ").strip()
|
||||
if self._device_info.names
|
||||
else None
|
||||
)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_name}")
|
||||
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}")
|
||||
},
|
||||
manufacturer=DEFAULT_NAME,
|
||||
configuration_url=URL(
|
||||
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/containers/{self.device_id}"
|
||||
),
|
||||
model="Container",
|
||||
name=self.device_name,
|
||||
name=device_name,
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}",
|
||||
),
|
||||
translation_key=None if self.device_name else "unknown_container",
|
||||
translation_key=None if device_name else "unknown_container",
|
||||
)
|
||||
|
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"container": {
|
||||
"default": "mdi:arrow-down-box",
|
||||
"state": {
|
||||
"on": "mdi:arrow-up-box"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/portainer",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.3"]
|
||||
"requirements": ["pyportainer==0.1.7"]
|
||||
}
|
||||
|
@@ -45,11 +45,6 @@
|
||||
"status": {
|
||||
"name": "Status"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"container": {
|
||||
"name": "Container"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@@ -1,141 +0,0 @@
|
||||
"""Switch platform for Portainer containers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyportainer import Portainer
|
||||
from pyportainer.exceptions import (
|
||||
PortainerAuthenticationError,
|
||||
PortainerConnectionError,
|
||||
PortainerTimeoutError,
|
||||
)
|
||||
from pyportainer.models.docker import DockerContainer
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PortainerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import PortainerCoordinator
|
||||
from .entity import PortainerContainerEntity, PortainerCoordinatorData
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Class to hold Portainer switch description."""
|
||||
|
||||
is_on_fn: Callable[[DockerContainer], bool | None]
|
||||
turn_on_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
|
||||
turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
async def perform_action(
|
||||
action: str, portainer: Portainer, endpoint_id: int, container_id: str
|
||||
) -> None:
|
||||
"""Stop a container."""
|
||||
try:
|
||||
if action == "start":
|
||||
await portainer.start_container(endpoint_id, container_id)
|
||||
elif action == "stop":
|
||||
await portainer.stop_container(endpoint_id, container_id)
|
||||
except PortainerAuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except PortainerConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except PortainerTimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
|
||||
SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
|
||||
PortainerSwitchEntityDescription(
|
||||
key="container",
|
||||
translation_key="container",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
is_on_fn=lambda data: data.state == "running",
|
||||
turn_on_fn=perform_action,
|
||||
turn_off_fn=perform_action,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PortainerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Portainer switch sensors."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
PortainerContainerSwitch(
|
||||
coordinator=coordinator,
|
||||
entity_description=entity_description,
|
||||
device_info=container,
|
||||
via_device=endpoint,
|
||||
)
|
||||
for endpoint in coordinator.data.values()
|
||||
for container in endpoint.containers.values()
|
||||
for entity_description in SWITCHES
|
||||
)
|
||||
|
||||
|
||||
class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
|
||||
"""Representation of a Portainer container switch."""
|
||||
|
||||
entity_description: PortainerSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerSwitchEntityDescription,
|
||||
device_info: DockerContainer,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer container switch."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the device."""
|
||||
return self.entity_description.is_on_fn(
|
||||
self.coordinator.data[self.endpoint_id].containers[self.device_id]
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Start (turn on) the container."""
|
||||
await self.entity_description.turn_on_fn(
|
||||
"start", self.coordinator.portainer, self.endpoint_id, self.device_id
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Stop (turn off) the container."""
|
||||
await self.entity_description.turn_off_fn(
|
||||
"stop", self.coordinator.portainer, self.endpoint_id, self.device_id
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
@@ -215,7 +215,6 @@ def create_coordinator_container_vm(
|
||||
return DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=None,
|
||||
name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
|
@@ -16,6 +16,7 @@ ATTR_HTML: Final = "html"
|
||||
ATTR_CALLBACK_URL: Final = "callback_url"
|
||||
ATTR_EXPIRE: Final = "expire"
|
||||
ATTR_TTL: Final = "ttl"
|
||||
ATTR_DATA: Final = "data"
|
||||
ATTR_TIMESTAMP: Final = "timestamp"
|
||||
|
||||
CONF_USER_KEY: Final = "user_key"
|
||||
|
@@ -67,7 +67,7 @@ class PushoverNotificationService(BaseNotificationService):
|
||||
|
||||
# Extract params from data dict
|
||||
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
|
||||
data = kwargs.get(ATTR_DATA) or {}
|
||||
data = kwargs.get(ATTR_DATA, {})
|
||||
url = data.get(ATTR_URL)
|
||||
url_title = data.get(ATTR_URL_TITLE)
|
||||
priority = data.get(ATTR_PRIORITY)
|
||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
from typing import cast
|
||||
from typing import Generic, TypeVar, cast
|
||||
|
||||
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
|
||||
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
|
||||
@@ -20,6 +20,8 @@ from .coordinator import QbusControllerCoordinator
|
||||
|
||||
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
|
||||
|
||||
StateT = TypeVar("StateT", bound=QbusMqttState)
|
||||
|
||||
|
||||
def create_new_entities(
|
||||
coordinator: QbusControllerCoordinator,
|
||||
@@ -76,7 +78,7 @@ def create_unique_id(serial_number: str, suffix: str) -> str:
|
||||
return f"ctd_{serial_number}_{suffix}"
|
||||
|
||||
|
||||
class QbusEntity[StateT: QbusMqttState](Entity, ABC):
|
||||
class QbusEntity(Entity, ABC, Generic[StateT]):
|
||||
"""Representation of a Qbus entity."""
|
||||
|
||||
_state_cls: type[StateT] = cast(type[StateT], QbusMqttState)
|
||||
|
@@ -53,6 +53,7 @@ KEEPALIVE_TIME = 30
|
||||
CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
|
||||
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
|
||||
STATES_META_SCHEMA_VERSION = 38
|
||||
LAST_REPORTED_SCHEMA_VERSION = 43
|
||||
CIRCULAR_MEAN_SCHEMA_VERSION = 49
|
||||
|
||||
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
||||
|
@@ -56,6 +56,7 @@ from .const import (
|
||||
DEFAULT_MAX_BIND_VARS,
|
||||
DOMAIN,
|
||||
KEEPALIVE_TIME,
|
||||
LAST_REPORTED_SCHEMA_VERSION,
|
||||
MARIADB_PYMYSQL_URL_PREFIX,
|
||||
MARIADB_URL_PREFIX,
|
||||
MAX_QUEUE_BACKLOG_MIN_VALUE,
|
||||
@@ -805,10 +806,6 @@ class Recorder(threading.Thread):
|
||||
|
||||
# Catch up with missed statistics
|
||||
self._schedule_compile_missing_statistics()
|
||||
|
||||
# Kick off live migrations
|
||||
migration.migrate_data_live(self, self.get_session, schema_status)
|
||||
|
||||
_LOGGER.debug("Recorder processing the queue")
|
||||
self._adjust_lru_size()
|
||||
self.hass.add_job(self._async_set_recorder_ready_migration_done)
|
||||
@@ -825,6 +822,8 @@ class Recorder(threading.Thread):
|
||||
# there are a lot of statistics graphs on the frontend.
|
||||
self.statistics_meta_manager.load(session)
|
||||
|
||||
migration.migrate_data_live(self, self.get_session, schema_status)
|
||||
|
||||
# We must only set the db ready after we have set the table managers
|
||||
# to active if there is no data to migrate.
|
||||
#
|
||||
@@ -1128,6 +1127,9 @@ class Recorder(threading.Thread):
|
||||
else:
|
||||
states_manager.add_pending(entity_id, dbstate)
|
||||
|
||||
if states_meta_manager.active:
|
||||
dbstate.entity_id = None
|
||||
|
||||
if entity_id is None or not (
|
||||
shared_attrs_bytes := state_attributes_manager.serialize_from_event(event)
|
||||
):
|
||||
@@ -1138,7 +1140,7 @@ class Recorder(threading.Thread):
|
||||
dbstate.states_meta_rel = pending_states_meta
|
||||
elif metadata_id := states_meta_manager.get(entity_id, session, True):
|
||||
dbstate.metadata_id = metadata_id
|
||||
elif entity_removed:
|
||||
elif states_meta_manager.active and entity_removed:
|
||||
# If the entity was removed, we don't need to add it to the
|
||||
# StatesMeta table or record it in the pending commit
|
||||
# if it does not have a metadata_id allocated to it as
|
||||
@@ -1225,7 +1227,7 @@ class Recorder(threading.Thread):
|
||||
if (
|
||||
pending_last_reported
|
||||
:= self.states_manager.get_pending_last_reported_timestamp()
|
||||
):
|
||||
) and self.schema_version >= LAST_REPORTED_SCHEMA_VERSION:
|
||||
with session.no_autoflush:
|
||||
session.execute(
|
||||
update(States),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user