mirror of
https://github.com/home-assistant/core.git
synced 2025-10-03 16:59:30 +00:00
Compare commits
3 Commits
dev
...
progress-a
Author | SHA1 | Date | |
---|---|---|---|
![]() |
04860f666f | ||
![]() |
8d009184a1 | ||
![]() |
7cf29ef136 |
@@ -203,7 +203,6 @@ homeassistant.components.feedreader.*
|
|||||||
homeassistant.components.file_upload.*
|
homeassistant.components.file_upload.*
|
||||||
homeassistant.components.filesize.*
|
homeassistant.components.filesize.*
|
||||||
homeassistant.components.filter.*
|
homeassistant.components.filter.*
|
||||||
homeassistant.components.firefly_iii.*
|
|
||||||
homeassistant.components.fitbit.*
|
homeassistant.components.fitbit.*
|
||||||
homeassistant.components.flexit_bacnet.*
|
homeassistant.components.flexit_bacnet.*
|
||||||
homeassistant.components.flux_led.*
|
homeassistant.components.flux_led.*
|
||||||
@@ -326,7 +325,6 @@ homeassistant.components.london_underground.*
|
|||||||
homeassistant.components.lookin.*
|
homeassistant.components.lookin.*
|
||||||
homeassistant.components.lovelace.*
|
homeassistant.components.lovelace.*
|
||||||
homeassistant.components.luftdaten.*
|
homeassistant.components.luftdaten.*
|
||||||
homeassistant.components.lunatone.*
|
|
||||||
homeassistant.components.madvr.*
|
homeassistant.components.madvr.*
|
||||||
homeassistant.components.manual.*
|
homeassistant.components.manual.*
|
||||||
homeassistant.components.mastodon.*
|
homeassistant.components.mastodon.*
|
||||||
|
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -492,8 +492,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/filesize/ @gjohansson-ST
|
/tests/components/filesize/ @gjohansson-ST
|
||||||
/homeassistant/components/filter/ @dgomes
|
/homeassistant/components/filter/ @dgomes
|
||||||
/tests/components/filter/ @dgomes
|
/tests/components/filter/ @dgomes
|
||||||
/homeassistant/components/firefly_iii/ @erwindouna
|
|
||||||
/tests/components/firefly_iii/ @erwindouna
|
|
||||||
/homeassistant/components/fireservicerota/ @cyberjunky
|
/homeassistant/components/fireservicerota/ @cyberjunky
|
||||||
/tests/components/fireservicerota/ @cyberjunky
|
/tests/components/fireservicerota/ @cyberjunky
|
||||||
/homeassistant/components/firmata/ @DaAwesomeP
|
/homeassistant/components/firmata/ @DaAwesomeP
|
||||||
@@ -910,8 +908,6 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/luci/ @mzdrale
|
/homeassistant/components/luci/ @mzdrale
|
||||||
/homeassistant/components/luftdaten/ @fabaff @frenck
|
/homeassistant/components/luftdaten/ @fabaff @frenck
|
||||||
/tests/components/luftdaten/ @fabaff @frenck
|
/tests/components/luftdaten/ @fabaff @frenck
|
||||||
/homeassistant/components/lunatone/ @MoonDevLT
|
|
||||||
/tests/components/lunatone/ @MoonDevLT
|
|
||||||
/homeassistant/components/lupusec/ @majuss @suaveolent
|
/homeassistant/components/lupusec/ @majuss @suaveolent
|
||||||
/tests/components/lupusec/ @majuss @suaveolent
|
/tests/components/lupusec/ @majuss @suaveolent
|
||||||
/homeassistant/components/lutron/ @cdheiser @wilburCForce
|
/homeassistant/components/lutron/ @cdheiser @wilburCForce
|
||||||
@@ -957,8 +953,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/met_eireann/ @DylanGore
|
/tests/components/met_eireann/ @DylanGore
|
||||||
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||||
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||||
/homeassistant/components/meteo_lt/ @xE1H
|
|
||||||
/tests/components/meteo_lt/ @xE1H
|
|
||||||
/homeassistant/components/meteoalarm/ @rolfberkenbosch
|
/homeassistant/components/meteoalarm/ @rolfberkenbosch
|
||||||
/homeassistant/components/meteoclimatic/ @adrianmo
|
/homeassistant/components/meteoclimatic/ @adrianmo
|
||||||
/tests/components/meteoclimatic/ @adrianmo
|
/tests/components/meteoclimatic/ @adrianmo
|
||||||
|
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
|||||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||||
build_from:
|
build_from:
|
||||||
aarch64: ghcr.io/home-assistant/aarch64-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.10.0
|
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3
|
||||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
|
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3
|
||||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
|
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3
|
||||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
|
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3
|
||||||
codenotary:
|
codenotary:
|
||||||
signer: notary@home-assistant.io
|
signer: notary@home-assistant.io
|
||||||
base_image: notary@home-assistant.io
|
base_image: notary@home-assistant.io
|
||||||
|
@@ -616,44 +616,34 @@ async def async_enable_logging(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
# Log errors to a file if we have write access to file or config dir
|
||||||
logger.setLevel(logging.INFO if verbose else logging.WARNING)
|
|
||||||
|
|
||||||
if log_file is None:
|
if log_file is None:
|
||||||
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
err_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
|
|
||||||
else:
|
else:
|
||||||
err_log_path = os.path.abspath(log_file)
|
err_log_path = os.path.abspath(log_file)
|
||||||
|
|
||||||
if err_log_path:
|
err_path_exists = os.path.isfile(err_log_path)
|
||||||
err_path_exists = os.path.isfile(err_log_path)
|
err_dir = os.path.dirname(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
|
# Check if we can write to the error log if it exists or that
|
||||||
# we can create files in the containing directory if not.
|
# we can create files in the containing directory if not.
|
||||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
|
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)
|
not err_path_exists and os.access(err_dir, os.W_OK)
|
||||||
):
|
):
|
||||||
err_handler = await hass.async_add_executor_job(
|
err_handler = await hass.async_add_executor_job(
|
||||||
_create_log_file, err_log_path, log_rotate_days
|
_create_log_file, err_log_path, log_rotate_days
|
||||||
)
|
)
|
||||||
|
|
||||||
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
|
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
|
||||||
logger.addHandler(err_handler)
|
|
||||||
|
|
||||||
# Save the log file location for access by other components.
|
logger = logging.getLogger()
|
||||||
hass.data[DATA_LOGGING] = err_log_path
|
logger.addHandler(err_handler)
|
||||||
else:
|
logger.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||||
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
|
|
||||||
|
# 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)
|
async_activate_log_queue_handler(hass)
|
||||||
|
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "eltako",
|
|
||||||
"name": "Eltako",
|
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "konnected",
|
|
||||||
"name": "Konnected",
|
|
||||||
"integrations": ["konnected", "konnected_esphome"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "level",
|
|
||||||
"name": "Level",
|
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "bronze",
|
"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):
|
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Airthings."""
|
"""Handle a config flow for Airthings."""
|
||||||
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=STEP_USER_DATA_SCHEMA,
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
description_placeholders=URL_API_INTEGRATION,
|
description_placeholders={
|
||||||
|
"url": (
|
||||||
|
"https://dashboard.airthings.com/integrations/api-integration"
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_create_entry(title="Airthings", data=user_input)
|
return self.async_create_entry(title="Airthings", data=user_input)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
data_schema=STEP_USER_DATA_SCHEMA,
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders=URL_API_INTEGRATION,
|
|
||||||
)
|
)
|
||||||
|
@@ -4,9 +4,9 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"secret": "Secret"
|
"secret": "Secret",
|
||||||
},
|
"description": "Login at {url} to find your credentials"
|
||||||
"description": "Log in at {url} to find your credentials"
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
@@ -171,7 +171,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_abort(reason="no_devices_found")
|
return self.async_abort(reason="no_devices_found")
|
||||||
|
|
||||||
titles = {
|
titles = {
|
||||||
address: get_name(discovery.device)
|
address: discovery.device.name
|
||||||
for (address, discovery) in self._discovered_devices.items()
|
for (address, discovery) in self._discovered_devices.items()
|
||||||
}
|
}
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
@@ -24,5 +24,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||||
"iot_class": "local_polling",
|
"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
|
@callback
|
||||||
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
|
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
|
||||||
|
@@ -6,9 +6,6 @@
|
|||||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||||
"data": {
|
"data": {
|
||||||
"address": "[%key:common::config_flow::data::device%]"
|
"address": "[%key:common::config_flow::data::device%]"
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"address": "The Airthings devices discovered via Bluetooth."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bluetooth_confirm": {
|
"bluetooth_confirm": {
|
||||||
|
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
from airtouch4pyapi import AirTouch
|
from airtouch4pyapi import AirTouch
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, Platform
|
from homeassistant.const import CONF_HOST, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator
|
from .coordinator import AirtouchDataUpdateCoordinator
|
||||||
|
|
||||||
PLATFORMS = [Platform.CLIMATE]
|
PLATFORMS = [Platform.CLIMATE]
|
||||||
|
|
||||||
|
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
|
||||||
"""Set up AirTouch4 from a config entry."""
|
"""Set up AirTouch4 from a config entry."""
|
||||||
@@ -19,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
|
|||||||
info = airtouch.GetAcs()
|
info = airtouch.GetAcs()
|
||||||
if not info:
|
if not info:
|
||||||
raise ConfigEntryNotReady
|
raise ConfigEntryNotReady
|
||||||
coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch)
|
coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
@@ -2,34 +2,26 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from airtouch4pyapi import AirTouch
|
|
||||||
from airtouch4pyapi.airtouch import AirTouchStatus
|
from airtouch4pyapi.airtouch import AirTouchStatus
|
||||||
|
|
||||||
from homeassistant.components.climate import SCAN_INTERVAL
|
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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
|
|
||||||
|
|
||||||
|
|
||||||
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
|
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
"""Class to manage fetching Airtouch data."""
|
"""Class to manage fetching Airtouch data."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass, airtouch):
|
||||||
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
|
|
||||||
) -> None:
|
|
||||||
"""Initialize global Airtouch data updater."""
|
"""Initialize global Airtouch data updater."""
|
||||||
self.airtouch = airtouch
|
self.airtouch = airtouch
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
config_entry=entry,
|
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=SCAN_INTERVAL,
|
update_interval=SCAN_INTERVAL,
|
||||||
)
|
)
|
||||||
|
@@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
|||||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
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."""
|
"""Return detailed information about entities and devices."""
|
||||||
dev_reg = dr.async_get(hass)
|
dev_reg = dr.async_get(hass)
|
||||||
ent_reg = er.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_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||||
|
|
||||||
removed_devices: set[str] = set()
|
|
||||||
|
|
||||||
# Get device list
|
# Get device list
|
||||||
for device_entry in dev_reg.devices.values():
|
for device_entry in dev_reg.devices.values():
|
||||||
if not device_entry.primary_config_entry:
|
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:
|
if config_entry is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
|
|
||||||
removed_devices.add(device_entry.id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
integration_domain = config_entry.domain
|
integration_domain = config_entry.domain
|
||||||
|
|
||||||
integration_input = integration_inputs.setdefault(integration_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)
|
device_config = integration_config.devices.get(device_id, device_config)
|
||||||
|
|
||||||
if device_config.remove:
|
if device_config.remove:
|
||||||
removed_devices.add(device_id)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
device_entry = dev_reg.devices[device_id]
|
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(
|
devices_info.append(
|
||||||
{
|
{
|
||||||
|
"entities": [],
|
||||||
"entry_type": device_entry.entry_type,
|
"entry_type": device_entry.entry_type,
|
||||||
"has_configuration_url": device_entry.configuration_url is not None,
|
"has_configuration_url": device_entry.configuration_url is not None,
|
||||||
"hw_version": device_entry.hw_version,
|
"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,
|
"model_id": device_entry.model_id,
|
||||||
"sw_version": device_entry.sw_version,
|
"sw_version": device_entry.sw_version,
|
||||||
"via_device": device_entry.via_device_id,
|
"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_entry = ent_reg.entities[entity_id]
|
||||||
|
|
||||||
entity_state = hass.states.get(entity_id)
|
entity_state = hass.states.get(entity_entry.entity_id)
|
||||||
|
|
||||||
entity_info = {
|
entity_info = {
|
||||||
# LIMITATION: `assumed_state` can be overridden by users;
|
# 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,
|
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device_id_ := entity_entry.device_id) is not None:
|
if (
|
||||||
if device_id_ in removed_devices:
|
((device_id_ := entity_entry.device_id) is not None)
|
||||||
# The device was removed, so we remove the entity too
|
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||||
continue
|
and (new_device_id[0] == integration_domain)
|
||||||
|
):
|
||||||
if (
|
device_info = devices_info[new_device_id[1]]
|
||||||
new_device_id := device_id_mapping.get(device_id_)
|
device_info["entities"].append(entity_info)
|
||||||
) is not None and (new_device_id[0] == integration_domain):
|
else:
|
||||||
device_info = devices_info[new_device_id[1]]
|
entities_info.append(entity_info)
|
||||||
device_info["entities"].append(entity_info)
|
|
||||||
continue
|
|
||||||
|
|
||||||
entities_info.append(entity_info)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": "home-assistant:1",
|
"version": "home-assistant:1",
|
||||||
|
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T", dict[str, Any], list[Any], None)
|
||||||
|
|
||||||
TRANSLATION_MAP = {
|
TRANSLATION_MAP = {
|
||||||
"wan_rx": "sensor_rx_bytes",
|
"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")}
|
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."""
|
"""Translate raw data to legacy format for dicts and lists."""
|
||||||
|
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
@@ -26,6 +26,9 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
if CONF_HOST in config_entry.data:
|
if CONF_HOST in config_entry.data:
|
||||||
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
|
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
|
||||||
|
config_entry.async_on_unload(
|
||||||
|
config_entry.add_update_listener(_async_update_listener)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
|
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
|
||||||
|
|
||||||
@@ -33,11 +36,6 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
config_entry.runtime_data = coordinator
|
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)
|
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@@ -272,13 +272,6 @@ async def async_setup_entry(
|
|||||||
observations: list[ConfigType] = [
|
observations: list[ConfigType] = [
|
||||||
dict(subentry.data) for subentry in config_entry.subentries.values()
|
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]
|
prior: float = config[CONF_PRIOR]
|
||||||
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
|
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
|
||||||
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
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.core import Event, HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
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 import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
@@ -112,6 +118,12 @@ ATTR_FILENAME: Final = "filename"
|
|||||||
ATTR_MEDIA_PLAYER: Final = "media_player"
|
ATTR_MEDIA_PLAYER: Final = "media_player"
|
||||||
ATTR_FORMAT: Final = "format"
|
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):
|
class CameraEntityFeature(IntFlag):
|
||||||
"""Supported features of the camera entity."""
|
"""Supported features of the camera entity."""
|
||||||
@@ -1105,3 +1117,11 @@ async def async_handle_record_service(
|
|||||||
duration=service_call.data[CONF_DURATION],
|
duration=service_call.data[CONF_DURATION],
|
||||||
lookback=service_call.data[CONF_LOOKBACK],
|
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",
|
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||||
"integration_type": "entity",
|
"integration_type": "entity",
|
||||||
"quality_scale": "internal",
|
"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",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"quality_scale": "bronze",
|
"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_key = "button"
|
||||||
self._attr_translation_placeholders = {"key": str(key)}
|
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."""
|
"""Update the binary sensor state."""
|
||||||
if (
|
if (
|
||||||
message[0] == self._remote_control_property.element_uid
|
message[0] == self._remote_control_property.element_uid
|
||||||
|
@@ -48,6 +48,7 @@ class DevoloDeviceEntity(Entity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.subscriber: Subscriber | None = None
|
self.subscriber: Subscriber | None = None
|
||||||
|
self.sync_callback = self._sync
|
||||||
|
|
||||||
self._value: float
|
self._value: float
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ class DevoloDeviceEntity(Entity):
|
|||||||
self._device_instance.uid, self.subscriber
|
self._device_instance.uid, self.subscriber
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_callback(self, message: tuple) -> None:
|
def _sync(self, message: tuple) -> None:
|
||||||
"""Update the state."""
|
"""Update the state."""
|
||||||
if message[0] == self._attr_unique_id:
|
if message[0] == self._attr_unique_id:
|
||||||
self._value = message[1]
|
self._value = message[1]
|
||||||
|
@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
|
|||||||
"""
|
"""
|
||||||
return f"{self._attr_unique_id}_{self._sensor_type}"
|
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."""
|
"""Update the consumption sensor state."""
|
||||||
if message[0] == self._attr_unique_id:
|
if message[0] == self._attr_unique_id:
|
||||||
self._value = getattr(
|
self._value = getattr(
|
||||||
|
@@ -13,3 +13,8 @@ class Subscriber:
|
|||||||
"""Initiate the subscriber."""
|
"""Initiate the subscriber."""
|
||||||
self.name = name
|
self.name = name
|
||||||
self.callback = callback
|
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."""
|
"""Switch off the device."""
|
||||||
self._binary_switch_property.set(state=False)
|
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."""
|
"""Update the binary switch state and consumption."""
|
||||||
if message[0].startswith("devolo.BinarySwitch"):
|
if message[0].startswith("devolo.BinarySwitch"):
|
||||||
self._attr_is_on = self._device_instance.binary_switch_property[
|
self._attr_is_on = self._device_instance.binary_switch_property[
|
||||||
|
@@ -176,7 +176,7 @@
|
|||||||
"description": "Sets the participating sensors for a climate program.",
|
"description": "Sets the participating sensors for a climate program.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"preset_mode": {
|
"preset_mode": {
|
||||||
"name": "Climate program",
|
"name": "Climate Name",
|
||||||
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
|
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
|
||||||
},
|
},
|
||||||
"device_ids": {
|
"device_ids": {
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"invalid_preset": {
|
"invalid_preset": {
|
||||||
"message": "Invalid climate program, available options are: {options}"
|
"message": "Invalid climate name, available options are: {options}"
|
||||||
},
|
},
|
||||||
"invalid_sensor": {
|
"invalid_sensor": {
|
||||||
"message": "Invalid sensor for thermostat, available options are: {options}"
|
"message": "Invalid sensor for thermostat, available options are: {options}"
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"dependencies": ["webhook"],
|
"dependencies": ["webhook"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["aioecowitt==2025.9.2"]
|
"requirements": ["aioecowitt==2025.9.1"]
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
|
@@ -1,27 +0,0 @@
|
|||||||
"""The Firefly III integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
|
||||||
|
|
||||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
|
|
||||||
"""Set up Firefly III from a config entry."""
|
|
||||||
|
|
||||||
coordinator = FireflyDataUpdateCoordinator(hass, entry)
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
|
@@ -1,140 +0,0 @@
|
|||||||
"""Config flow for the Firefly III integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pyfirefly import (
|
|
||||||
Firefly,
|
|
||||||
FireflyAuthenticationError,
|
|
||||||
FireflyConnectionError,
|
|
||||||
FireflyTimeoutError,
|
|
||||||
)
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_URL): str,
|
|
||||||
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
|
|
||||||
vol.Required(CONF_API_KEY): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
|
||||||
"""Validate the user input allows us to connect."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = Firefly(
|
|
||||||
api_url=data[CONF_URL],
|
|
||||||
api_key=data[CONF_API_KEY],
|
|
||||||
session=async_get_clientsession(hass),
|
|
||||||
)
|
|
||||||
await client.get_about()
|
|
||||||
except FireflyAuthenticationError:
|
|
||||||
raise InvalidAuth from None
|
|
||||||
except FireflyConnectionError as err:
|
|
||||||
raise CannotConnect from err
|
|
||||||
except FireflyTimeoutError as err:
|
|
||||||
raise FireflyClientTimeout from err
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Firefly III."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle the initial step."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
if user_input is not None:
|
|
||||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
|
||||||
try:
|
|
||||||
await _validate_input(self.hass, user_input)
|
|
||||||
except CannotConnect:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
except InvalidAuth:
|
|
||||||
errors["base"] = "invalid_auth"
|
|
||||||
except FireflyClientTimeout:
|
|
||||||
errors["base"] = "timeout_connect"
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Unexpected exception")
|
|
||||||
errors["base"] = "unknown"
|
|
||||||
else:
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=user_input[CONF_URL], data=user_input
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_reauth(
|
|
||||||
self, entry_data: Mapping[str, Any]
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Perform reauth when Firefly III API authentication fails."""
|
|
||||||
return await self.async_step_reauth_confirm()
|
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle reauth: ask for a new API key and validate."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
reauth_entry = self._get_reauth_entry()
|
|
||||||
if user_input is not None:
|
|
||||||
try:
|
|
||||||
await _validate_input(
|
|
||||||
self.hass,
|
|
||||||
data={
|
|
||||||
**reauth_entry.data,
|
|
||||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except CannotConnect:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
except InvalidAuth:
|
|
||||||
errors["base"] = "invalid_auth"
|
|
||||||
except FireflyClientTimeout:
|
|
||||||
errors["base"] = "timeout_connect"
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Unexpected exception")
|
|
||||||
errors["base"] = "unknown"
|
|
||||||
else:
|
|
||||||
return self.async_update_reload_and_abort(
|
|
||||||
reauth_entry,
|
|
||||||
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="reauth_confirm",
|
|
||||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(HomeAssistantError):
|
|
||||||
"""Error to indicate we cannot connect."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidAuth(HomeAssistantError):
|
|
||||||
"""Error to indicate there is invalid auth."""
|
|
||||||
|
|
||||||
|
|
||||||
class FireflyClientTimeout(HomeAssistantError):
|
|
||||||
"""Error to indicate a timeout occurred."""
|
|
@@ -1,6 +0,0 @@
|
|||||||
"""Constants for the Firefly III integration."""
|
|
||||||
|
|
||||||
DOMAIN = "firefly_iii"
|
|
||||||
|
|
||||||
MANUFACTURER = "Firefly III"
|
|
||||||
NAME = "Firefly III"
|
|
@@ -1,137 +0,0 @@
|
|||||||
"""Data Update Coordinator for Firefly III integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from aiohttp import CookieJar
|
|
||||||
from pyfirefly import (
|
|
||||||
Firefly,
|
|
||||||
FireflyAuthenticationError,
|
|
||||||
FireflyConnectionError,
|
|
||||||
FireflyTimeoutError,
|
|
||||||
)
|
|
||||||
from pyfirefly.models import Account, Bill, Budget, Category, Currency
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
|
|
||||||
|
|
||||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FireflyCoordinatorData:
|
|
||||||
"""Data structure for Firefly III coordinator data."""
|
|
||||||
|
|
||||||
accounts: list[Account]
|
|
||||||
categories: list[Category]
|
|
||||||
category_details: list[Category]
|
|
||||||
budgets: list[Budget]
|
|
||||||
bills: list[Bill]
|
|
||||||
primary_currency: Currency
|
|
||||||
|
|
||||||
|
|
||||||
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
|
|
||||||
"""Coordinator to manage data updates for Firefly III integration."""
|
|
||||||
|
|
||||||
config_entry: FireflyConfigEntry
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
|
|
||||||
"""Initialize the coordinator."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
config_entry=config_entry,
|
|
||||||
name=DOMAIN,
|
|
||||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
|
||||||
)
|
|
||||||
self.firefly = Firefly(
|
|
||||||
api_url=self.config_entry.data[CONF_URL],
|
|
||||||
api_key=self.config_entry.data[CONF_API_KEY],
|
|
||||||
session=async_create_clientsession(
|
|
||||||
self.hass,
|
|
||||||
self.config_entry.data[CONF_VERIFY_SSL],
|
|
||||||
cookie_jar=CookieJar(unsafe=True),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
|
||||||
"""Set up the coordinator."""
|
|
||||||
try:
|
|
||||||
await self.firefly.get_about()
|
|
||||||
except FireflyAuthenticationError as err:
|
|
||||||
raise ConfigEntryAuthFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_auth",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except FireflyConnectionError as err:
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_connect",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except FireflyTimeoutError as err:
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="timeout_connect",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> FireflyCoordinatorData:
|
|
||||||
"""Fetch data from Firefly III API."""
|
|
||||||
now = datetime.now()
|
|
||||||
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
end_date = now
|
|
||||||
|
|
||||||
try:
|
|
||||||
accounts = await self.firefly.get_accounts()
|
|
||||||
categories = await self.firefly.get_categories()
|
|
||||||
category_details = [
|
|
||||||
await self.firefly.get_category(
|
|
||||||
category_id=int(category.id), start=start_date, end=end_date
|
|
||||||
)
|
|
||||||
for category in categories
|
|
||||||
]
|
|
||||||
primary_currency = await self.firefly.get_currency_primary()
|
|
||||||
budgets = await self.firefly.get_budgets()
|
|
||||||
bills = await self.firefly.get_bills()
|
|
||||||
except FireflyAuthenticationError as err:
|
|
||||||
raise ConfigEntryAuthFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_auth",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except FireflyConnectionError as err:
|
|
||||||
raise UpdateFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_connect",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except FireflyTimeoutError as err:
|
|
||||||
raise UpdateFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="timeout_connect",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return FireflyCoordinatorData(
|
|
||||||
accounts=accounts,
|
|
||||||
categories=categories,
|
|
||||||
category_details=category_details,
|
|
||||||
budgets=budgets,
|
|
||||||
bills=bills,
|
|
||||||
primary_currency=primary_currency,
|
|
||||||
)
|
|
@@ -1,40 +0,0 @@
|
|||||||
"""Base entity for Firefly III integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from homeassistant.const import CONF_URL
|
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity import EntityDescription
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from .const import DOMAIN, MANUFACTURER
|
|
||||||
from .coordinator import FireflyDataUpdateCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
|
|
||||||
"""Base class for Firefly III entity."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: FireflyDataUpdateCoordinator,
|
|
||||||
entity_description: EntityDescription,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a Firefly entity."""
|
|
||||||
super().__init__(coordinator)
|
|
||||||
|
|
||||||
self.entity_description = entity_description
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
|
||||||
manufacturer=MANUFACTURER,
|
|
||||||
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
|
|
||||||
identifiers={
|
|
||||||
(
|
|
||||||
DOMAIN,
|
|
||||||
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"entity": {
|
|
||||||
"sensor": {
|
|
||||||
"account_type": {
|
|
||||||
"default": "mdi:bank",
|
|
||||||
"state": {
|
|
||||||
"expense": "mdi:cash-minus",
|
|
||||||
"revenue": "mdi:cash-plus",
|
|
||||||
"asset": "mdi:account-cash",
|
|
||||||
"liability": "mdi:hand-coin"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"default": "mdi:label"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "firefly_iii",
|
|
||||||
"name": "Firefly III",
|
|
||||||
"codeowners": ["@erwindouna"],
|
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
|
|
||||||
"iot_class": "local_polling",
|
|
||||||
"quality_scale": "bronze",
|
|
||||||
"requirements": ["pyfirefly==0.1.6"]
|
|
||||||
}
|
|
@@ -1,68 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup: done
|
|
||||||
appropriate-polling: done
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage: done
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions: done
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup: done
|
|
||||||
entity-unique-id: done
|
|
||||||
has-entity-name: done
|
|
||||||
runtime-data: done
|
|
||||||
test-before-configure: done
|
|
||||||
test-before-setup: done
|
|
||||||
unique-config-entry: done
|
|
||||||
|
|
||||||
# Silver
|
|
||||||
action-exceptions:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
No custom actions are defined.
|
|
||||||
config-entry-unloading: done
|
|
||||||
docs-configuration-parameters: done
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
No explicit parallel updates are defined.
|
|
||||||
reauthentication-flow:
|
|
||||||
status: todo
|
|
||||||
comment: |
|
|
||||||
No reauthentication flow is defined. It will be done in a next iteration.
|
|
||||||
test-coverage: done
|
|
||||||
# Gold
|
|
||||||
devices: done
|
|
||||||
diagnostics: todo
|
|
||||||
discovery-update-info: todo
|
|
||||||
discovery: todo
|
|
||||||
docs-data-update: todo
|
|
||||||
docs-examples: todo
|
|
||||||
docs-known-limitations: todo
|
|
||||||
docs-supported-devices: todo
|
|
||||||
docs-supported-functions: todo
|
|
||||||
docs-troubleshooting: todo
|
|
||||||
docs-use-cases: todo
|
|
||||||
dynamic-devices: todo
|
|
||||||
entity-category: todo
|
|
||||||
entity-device-class: todo
|
|
||||||
entity-disabled-by-default: todo
|
|
||||||
entity-translations: todo
|
|
||||||
exception-translations: todo
|
|
||||||
icon-translations: todo
|
|
||||||
reconfiguration-flow: todo
|
|
||||||
repair-issues: todo
|
|
||||||
stale-devices: todo
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: done
|
|
||||||
inject-websession: done
|
|
||||||
strict-typing: done
|
|
@@ -1,133 +0,0 @@
|
|||||||
"""Sensor platform for Firefly III integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pyfirefly.models import Account, Category
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
|
||||||
SensorStateClass,
|
|
||||||
)
|
|
||||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
|
||||||
from .entity import FireflyBaseEntity
|
|
||||||
|
|
||||||
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="account_type",
|
|
||||||
translation_key="account",
|
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
|
|
||||||
SensorEntityDescription(
|
|
||||||
key="category",
|
|
||||||
translation_key="category",
|
|
||||||
device_class=SensorDeviceClass.MONETARY,
|
|
||||||
state_class=SensorStateClass.TOTAL,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: FireflyConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the Firefly III sensor platform."""
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
entities: list[SensorEntity] = [
|
|
||||||
FireflyAccountEntity(
|
|
||||||
coordinator=coordinator,
|
|
||||||
entity_description=description,
|
|
||||||
account=account,
|
|
||||||
)
|
|
||||||
for account in coordinator.data.accounts
|
|
||||||
for description in ACCOUNT_SENSORS
|
|
||||||
]
|
|
||||||
|
|
||||||
entities.extend(
|
|
||||||
FireflyCategoryEntity(
|
|
||||||
coordinator=coordinator,
|
|
||||||
entity_description=description,
|
|
||||||
category=category,
|
|
||||||
)
|
|
||||||
for category in coordinator.data.category_details
|
|
||||||
for description in CATEGORY_SENSORS
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
|
|
||||||
"""Entity for Firefly III account."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: FireflyDataUpdateCoordinator,
|
|
||||||
entity_description: SensorEntityDescription,
|
|
||||||
account: Account,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize Firefly account entity."""
|
|
||||||
super().__init__(coordinator, entity_description)
|
|
||||||
self._account = account
|
|
||||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
|
|
||||||
self._attr_name = account.attributes.name
|
|
||||||
self._attr_native_unit_of_measurement = (
|
|
||||||
coordinator.data.primary_currency.attributes.code
|
|
||||||
)
|
|
||||||
|
|
||||||
# Account type state doesn't go well with the icons.json. Need to fix it.
|
|
||||||
if account.attributes.type == "expense":
|
|
||||||
self._attr_icon = "mdi:cash-minus"
|
|
||||||
elif account.attributes.type == "asset":
|
|
||||||
self._attr_icon = "mdi:account-cash"
|
|
||||||
elif account.attributes.type == "revenue":
|
|
||||||
self._attr_icon = "mdi:cash-plus"
|
|
||||||
elif account.attributes.type == "liability":
|
|
||||||
self._attr_icon = "mdi:hand-coin"
|
|
||||||
else:
|
|
||||||
self._attr_icon = "mdi:bank"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> str | None:
|
|
||||||
"""Return the state of the sensor."""
|
|
||||||
return self._account.attributes.current_balance
|
|
||||||
|
|
||||||
|
|
||||||
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
|
|
||||||
"""Entity for Firefly III category."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: FireflyDataUpdateCoordinator,
|
|
||||||
entity_description: SensorEntityDescription,
|
|
||||||
category: Category,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize Firefly category entity."""
|
|
||||||
super().__init__(coordinator, entity_description)
|
|
||||||
self._category = category
|
|
||||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
|
|
||||||
self._attr_name = category.attributes.name
|
|
||||||
self._attr_native_unit_of_measurement = (
|
|
||||||
coordinator.data.primary_currency.attributes.code
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> float | None:
|
|
||||||
"""Return the state of the sensor."""
|
|
||||||
spent_items = self._category.attributes.spent or []
|
|
||||||
earned_items = self._category.attributes.earned or []
|
|
||||||
|
|
||||||
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
|
|
||||||
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
|
|
||||||
|
|
||||||
if spent == 0 and earned == 0:
|
|
||||||
return None
|
|
||||||
return spent + earned
|
|
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"data": {
|
|
||||||
"url": "[%key:common::config_flow::data::url%]",
|
|
||||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
|
||||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"url": "[%key:common::config_flow::data::url%]",
|
|
||||||
"api_key": "The API key for authenticating with Firefly",
|
|
||||||
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
|
|
||||||
},
|
|
||||||
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
|
|
||||||
},
|
|
||||||
"reauth_confirm": {
|
|
||||||
"data": {
|
|
||||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"api_key": "The new API access token for authenticating with Firefly III"
|
|
||||||
},
|
|
||||||
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
|
||||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exceptions": {
|
|
||||||
"cannot_connect": {
|
|
||||||
"message": "An error occurred while trying to connect to the Firefly instance: {error}"
|
|
||||||
},
|
|
||||||
"invalid_auth": {
|
|
||||||
"message": "An error occurred while trying to authenticate: {error}"
|
|
||||||
},
|
|
||||||
"timeout_connect": {
|
|
||||||
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -452,10 +452,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
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(hass, "profile")
|
||||||
|
|
||||||
async_register_built_in_panel(
|
async_register_built_in_panel(
|
||||||
|
@@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20251001.0"]
|
"requirements": ["home-assistant-frontend==20250926.0"]
|
||||||
}
|
}
|
||||||
|
@@ -620,13 +620,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
|||||||
def create_generate_content_config(self) -> GenerateContentConfig:
|
def create_generate_content_config(self) -> GenerateContentConfig:
|
||||||
"""Create the GenerateContentConfig for the LLM."""
|
"""Create the GenerateContentConfig for the LLM."""
|
||||||
options = self.subentry.data
|
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(
|
return GenerateContentConfig(
|
||||||
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||||
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,18 +1,14 @@
|
|||||||
"""The Growatt server PV inverter sensor integration."""
|
"""The Growatt server PV inverter sensor integration."""
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import logging
|
|
||||||
|
|
||||||
import growattServer
|
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.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
AUTH_API_TOKEN,
|
|
||||||
AUTH_PASSWORD,
|
|
||||||
CONF_AUTH_TYPE,
|
|
||||||
CONF_PLANT_ID,
|
CONF_PLANT_ID,
|
||||||
DEFAULT_PLANT_ID,
|
DEFAULT_PLANT_ID,
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
@@ -23,110 +19,36 @@ from .const import (
|
|||||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||||
from .models import GrowattRuntimeData
|
from .models import GrowattRuntimeData
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
def get_device_list(
|
||||||
def get_device_list_classic(
|
|
||||||
api: growattServer.GrowattApi, config: Mapping[str, str]
|
api: growattServer.GrowattApi, config: Mapping[str, str]
|
||||||
) -> tuple[list[dict[str, str]], str]:
|
) -> tuple[list[dict[str, str]], str]:
|
||||||
"""Retrieve the device list for the selected plant."""
|
"""Retrieve the device list for the selected plant."""
|
||||||
plant_id = config[CONF_PLANT_ID]
|
plant_id = config[CONF_PLANT_ID]
|
||||||
|
|
||||||
# Log in to api and fetch first plant if no plant id is defined.
|
# 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])
|
||||||
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
|
if (
|
||||||
# DEBUG: Log the actual response structure
|
not login_response["success"]
|
||||||
except Exception as ex:
|
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
|
||||||
_LOGGER.error("DEBUG - Login response: %s", login_response)
|
):
|
||||||
raise ConfigEntryError(
|
raise ConfigEntryError("Username, Password or URL may be incorrect!")
|
||||||
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}")
|
|
||||||
|
|
||||||
user_id = login_response["user"]["id"]
|
user_id = login_response["user"]["id"]
|
||||||
|
|
||||||
if plant_id == DEFAULT_PLANT_ID:
|
if plant_id == DEFAULT_PLANT_ID:
|
||||||
try:
|
plant_info = api.plant_list(user_id)
|
||||||
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_id = plant_info["data"][0]["plantId"]
|
plant_id = plant_info["data"][0]["plantId"]
|
||||||
|
|
||||||
# Get a list of devices for specified plant to add sensors for.
|
# Get a list of devices for specified plant to add sensors for.
|
||||||
try:
|
devices = api.device_list(plant_id)
|
||||||
devices = api.device_list(plant_id)
|
|
||||||
except Exception as ex:
|
|
||||||
raise ConfigEntryError(
|
|
||||||
f"Error communicating with Growatt API during device list: {ex}"
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
return devices, 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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, config_entry: GrowattConfigEntry
|
hass: HomeAssistant, config_entry: GrowattConfigEntry
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Set up Growatt from a config entry."""
|
"""Set up Growatt from a config entry."""
|
||||||
|
|
||||||
config = config_entry.data
|
config = config_entry.data
|
||||||
|
username = config[CONF_USERNAME]
|
||||||
url = config.get(CONF_URL, DEFAULT_URL)
|
url = config.get(CONF_URL, DEFAULT_URL)
|
||||||
|
|
||||||
# If the URL has been deprecated then change to the default instead
|
# 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
|
new_data[CONF_URL] = url
|
||||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||||
|
|
||||||
# Determine API version
|
# Initialise the library with the username & a random id each time it is started
|
||||||
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
|
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
|
||||||
api_version = "v1"
|
api.server_url = url
|
||||||
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.")
|
|
||||||
|
|
||||||
devices, plant_id = await hass.async_add_executor_job(
|
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
|
||||||
get_device_list, api, config, api_version
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a coordinator for the total sensors
|
# Create a coordinator for the total sensors
|
||||||
total_coordinator = GrowattCoordinator(
|
total_coordinator = GrowattCoordinator(
|
||||||
@@ -166,7 +75,7 @@ async def async_setup_entry(
|
|||||||
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
||||||
)
|
)
|
||||||
for device in devices
|
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
|
# Perform the first refresh for the total coordinator
|
||||||
|
@@ -1,38 +1,22 @@
|
|||||||
"""Config flow for growatt server integration."""
|
"""Config flow for growatt server integration."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import growattServer
|
import growattServer
|
||||||
import requests
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import (
|
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||||
CONF_NAME,
|
|
||||||
CONF_PASSWORD,
|
|
||||||
CONF_TOKEN,
|
|
||||||
CONF_URL,
|
|
||||||
CONF_USERNAME,
|
|
||||||
)
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
ABORT_NO_PLANTS,
|
|
||||||
AUTH_API_TOKEN,
|
|
||||||
AUTH_PASSWORD,
|
|
||||||
CONF_AUTH_TYPE,
|
|
||||||
CONF_PLANT_ID,
|
CONF_PLANT_ID,
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ERROR_CANNOT_CONNECT,
|
|
||||||
ERROR_INVALID_AUTH,
|
|
||||||
LOGIN_INVALID_AUTH_CODE,
|
LOGIN_INVALID_AUTH_CODE,
|
||||||
SERVER_URLS,
|
SERVER_URLS,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Config flow class."""
|
"""Config flow class."""
|
||||||
@@ -43,98 +27,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialise growatt server flow."""
|
"""Initialise growatt server flow."""
|
||||||
self.user_id: str | None = None
|
self.user_id = None
|
||||||
self.data: dict[str, Any] = {}
|
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
|
@callback
|
||||||
def _async_show_password_form(
|
def _async_show_user_form(self, errors=None):
|
||||||
self, errors: dict[str, Any] | None = None
|
"""Show the form to the user."""
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Show the username/password form to the user."""
|
|
||||||
data_schema = vol.Schema(
|
data_schema = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_USERNAME): str,
|
vol.Required(CONF_USERNAME): str,
|
||||||
@@ -144,87 +42,58 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
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
|
async def async_step_user(
|
||||||
def _async_show_token_form(
|
self, user_input: dict[str, Any] | None = None
|
||||||
self, errors: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Show the API token form to the user."""
|
"""Handle the start of the config flow."""
|
||||||
data_schema = vol.Schema(
|
if not user_input:
|
||||||
{
|
return self._async_show_user_form()
|
||||||
vol.Required(CONF_TOKEN): str,
|
|
||||||
}
|
# 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(
|
if (
|
||||||
step_id="token_auth",
|
not login_response["success"]
|
||||||
data_schema=data_schema,
|
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
|
||||||
errors=errors,
|
):
|
||||||
)
|
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(
|
async def async_step_plant(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle adding a "plant" to Home Assistant."""
|
"""Handle adding a "plant" to Home Assistant."""
|
||||||
if self.auth_type == AUTH_API_TOKEN:
|
plant_info = await self.hass.async_add_executor_job(
|
||||||
# Using V1 API with token
|
self.api.plant_list, self.user_id
|
||||||
if not self.plants:
|
)
|
||||||
return self.async_abort(reason=ABORT_NO_PLANTS)
|
|
||||||
|
|
||||||
# Create dictionary of plant_id -> name
|
if not plant_info["data"]:
|
||||||
plant_dict = {
|
return self.async_abort(reason="no_plants")
|
||||||
str(plant["plant_id"]): plant.get("name", "Unknown Plant")
|
|
||||||
for plant in self.plants
|
|
||||||
}
|
|
||||||
|
|
||||||
if user_input is None and len(plant_dict) > 1:
|
plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]}
|
||||||
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)
|
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is None and len(plant_info["data"]) > 1:
|
||||||
# Single plant => mark it as selected
|
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
|
||||||
user_input = {CONF_PLANT_ID: list(plant_dict.keys())[0]}
|
|
||||||
|
|
||||||
user_input[CONF_NAME] = plant_dict[user_input[CONF_PLANT_ID]]
|
return self.async_show_form(step_id="plant", data_schema=data_schema)
|
||||||
|
|
||||||
else:
|
if user_input is None:
|
||||||
# Traditional API
|
# single plant => mark it as selected
|
||||||
try:
|
user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]}
|
||||||
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]]
|
|
||||||
|
|
||||||
|
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
|
||||||
await self.async_set_unique_id(user_input[CONF_PLANT_ID])
|
await self.async_set_unique_id(user_input[CONF_PLANT_ID])
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
self.data.update(user_input)
|
self.data.update(user_input)
|
||||||
|
@@ -4,16 +4,6 @@ from homeassistant.const import Platform
|
|||||||
|
|
||||||
CONF_PLANT_ID = "plant_id"
|
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_PLANT_ID = "0"
|
||||||
|
|
||||||
DEFAULT_NAME = "Growatt"
|
DEFAULT_NAME = "Growatt"
|
||||||
@@ -39,10 +29,3 @@ DOMAIN = "growatt_server"
|
|||||||
PLATFORMS = [Platform.SENSOR]
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
LOGIN_INVALID_AUTH_CODE = "502"
|
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."""
|
"""Coordinator module for managing Growatt data fetching."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -40,30 +38,22 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
plant_id: str,
|
plant_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
self.api_version = (
|
self.username = config_entry.data[CONF_USERNAME]
|
||||||
"v1" if config_entry.data.get("auth_type") == "api_token" else "classic"
|
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_id = device_id
|
||||||
self.device_type = device_type
|
self.device_type = device_type
|
||||||
self.plant_id = plant_id
|
self.plant_id = plant_id
|
||||||
self.previous_values: dict[str, Any] = {}
|
|
||||||
|
|
||||||
if self.api_version == "v1":
|
# Initialize previous_values to store historical data
|
||||||
self.username = None
|
self.previous_values: dict[str, Any] = {}
|
||||||
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}")
|
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@@ -77,54 +67,21 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"""Update data via library synchronously."""
|
"""Update data via library synchronously."""
|
||||||
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
|
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
|
||||||
|
|
||||||
# login only required for classic API
|
# Login in to the Growatt server
|
||||||
if self.api_version == "classic":
|
self.api.login(self.username, self.password)
|
||||||
self.api.login(self.username, self.password)
|
|
||||||
|
|
||||||
if self.device_type == "total":
|
if self.device_type == "total":
|
||||||
if self.api_version == "v1":
|
total_info = self.api.plant_info(self.device_id)
|
||||||
# The V1 Plant APIs do not provide the same information as the classic plant_info() API
|
del total_info["deviceList"]
|
||||||
# More specifically:
|
plant_money_text, currency = total_info["plantMoneyText"].split("/")
|
||||||
# 1. There is no monetary information to be found, so today and lifetime money is not available
|
total_info["plantMoneyText"] = plant_money_text
|
||||||
# 2. There is no nominal power, this is provided by inverter min_energy()
|
total_info["currency"] = currency
|
||||||
# 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)
|
|
||||||
self.data = total_info
|
self.data = total_info
|
||||||
elif self.device_type == "inverter":
|
elif self.device_type == "inverter":
|
||||||
self.data = self.api.inverter_detail(self.device_id)
|
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":
|
elif self.device_type == "tlx":
|
||||||
tlx_info = self.api.tlx_detail(self.device_id)
|
tlx_info = self.api.tlx_detail(self.device_id)
|
||||||
self.data = tlx_info["data"]
|
self.data = tlx_info["data"]
|
||||||
_LOGGER.debug("tlx_info for device %s: %r", self.device_id, tlx_info)
|
|
||||||
elif self.device_type == "storage":
|
elif self.device_type == "storage":
|
||||||
storage_info_detail = self.api.storage_params(self.device_id)
|
storage_info_detail = self.api.storage_params(self.device_id)
|
||||||
storage_energy_overview = self.api.storage_energy_overview(
|
storage_energy_overview = self.api.storage_energy_overview(
|
||||||
@@ -188,7 +145,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return self.data.get("currency")
|
return self.data.get("currency")
|
||||||
|
|
||||||
def get_data(
|
def get_data(
|
||||||
self, entity_description: GrowattSensorEntityDescription
|
self, entity_description: "GrowattSensorEntityDescription"
|
||||||
) -> str | int | float | None:
|
) -> str | int | float | None:
|
||||||
"""Get the data."""
|
"""Get the data."""
|
||||||
variable = entity_description.api_key
|
variable = entity_description.api_key
|
||||||
|
@@ -51,7 +51,7 @@ async def async_setup_entry(
|
|||||||
sensor_descriptions: list = []
|
sensor_descriptions: list = []
|
||||||
if device_coordinator.device_type == "inverter":
|
if device_coordinator.device_type == "inverter":
|
||||||
sensor_descriptions = list(INVERTER_SENSOR_TYPES)
|
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)
|
sensor_descriptions = list(TLX_SENSOR_TYPES)
|
||||||
elif device_coordinator.device_type == "storage":
|
elif device_coordinator.device_type == "storage":
|
||||||
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
|
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
|
||||||
|
@@ -2,42 +2,26 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"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"
|
"no_plants": "No plants have been found on this account"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_auth": "Authentication failed. Please check your credentials and try again.",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||||
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again."
|
|
||||||
},
|
},
|
||||||
"step": {
|
"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": {
|
"plant": {
|
||||||
"data": {
|
"data": {
|
||||||
"plant_id": "Plant"
|
"plant_id": "Plant"
|
||||||
},
|
},
|
||||||
"title": "Select your 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -4,14 +4,9 @@ from uuid import UUID
|
|||||||
|
|
||||||
from habiticalib import Habitica
|
from habiticalib import Habitica
|
||||||
|
|
||||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||||
config_validation as cv,
|
|
||||||
device_registry as dr,
|
|
||||||
entity_registry as er,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
@@ -32,7 +27,6 @@ PLATFORMS = [
|
|||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
Platform.CALENDAR,
|
Platform.CALENDAR,
|
||||||
Platform.IMAGE,
|
Platform.IMAGE,
|
||||||
Platform.NOTIFY,
|
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
Platform.TODO,
|
Platform.TODO,
|
||||||
@@ -52,7 +46,6 @@ async def async_setup_entry(
|
|||||||
"""Set up habitica from a config entry."""
|
"""Set up habitica from a config entry."""
|
||||||
party_added_by_this_entry: UUID | None = None
|
party_added_by_this_entry: UUID | None = None
|
||||||
device_reg = dr.async_get(hass)
|
device_reg = dr.async_get(hass)
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
|
|
||||||
session = async_get_clientsession(
|
session = async_get_clientsession(
|
||||||
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
|
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
|
||||||
@@ -103,15 +96,6 @@ async def async_setup_entry(
|
|||||||
device.id, remove_config_entry_id=config_entry.entry_id
|
device.id, remove_config_entry_id=config_entry.entry_id
|
||||||
)
|
)
|
||||||
|
|
||||||
notify_entities = [
|
|
||||||
entry.entity_id
|
|
||||||
for entry in entity_registry.entities.values()
|
|
||||||
if entry.domain == NOTIFY_DOMAIN
|
|
||||||
and entry.config_entry_id == config_entry.entry_id
|
|
||||||
]
|
|
||||||
for entity_id in notify_entities:
|
|
||||||
entity_registry.async_remove(entity_id)
|
|
||||||
|
|
||||||
hass.config_entries.async_schedule_reload(config_entry.entry_id)
|
hass.config_entries.async_schedule_reload(config_entry.entry_id)
|
||||||
|
|
||||||
coordinator.async_add_listener(_party_update_listener)
|
coordinator.async_add_listener(_party_update_listener)
|
||||||
|
@@ -121,4 +121,4 @@ class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity):
|
|||||||
@property
|
@property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None:
|
||||||
"""If the binary sensor is on."""
|
"""If the binary sensor is on."""
|
||||||
return self.coordinator.data.party.quest.active
|
return self.coordinator.data.quest.active
|
||||||
|
@@ -9,7 +9,6 @@ from datetime import timedelta
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
from habiticalib import (
|
from habiticalib import (
|
||||||
@@ -49,14 +48,6 @@ class HabiticaData:
|
|||||||
tasks: list[TaskData]
|
tasks: list[TaskData]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HabiticaPartyData:
|
|
||||||
"""Habitica party data."""
|
|
||||||
|
|
||||||
party: GroupData
|
|
||||||
members: dict[UUID, UserData]
|
|
||||||
|
|
||||||
|
|
||||||
type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
|
type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
@@ -201,19 +192,11 @@ class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]):
|
|||||||
return png.getvalue()
|
return png.getvalue()
|
||||||
|
|
||||||
|
|
||||||
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]):
|
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]):
|
||||||
"""Habitica Party Coordinator."""
|
"""Habitica Party Coordinator."""
|
||||||
|
|
||||||
_update_interval = timedelta(minutes=15)
|
_update_interval = timedelta(minutes=15)
|
||||||
|
|
||||||
async def _update_data(self) -> HabiticaPartyData:
|
async def _update_data(self) -> GroupData:
|
||||||
"""Fetch the latest party data."""
|
"""Fetch the latest party data."""
|
||||||
|
return (await self.habitica.get_group()).data
|
||||||
return HabiticaPartyData(
|
|
||||||
party=(await self.habitica.get_group()).data,
|
|
||||||
members={
|
|
||||||
member.id: member
|
|
||||||
for member in (await self.habitica.get_group_members()).data
|
|
||||||
if member.id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
@@ -68,14 +68,14 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]):
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert config_entry.unique_id
|
assert config_entry.unique_id
|
||||||
unique_id = f"{config_entry.unique_id}_{coordinator.data.party.id!s}"
|
unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}"
|
||||||
self.entity_description = entity_description
|
self.entity_description = entity_description
|
||||||
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
|
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
manufacturer=MANUFACTURER,
|
manufacturer=MANUFACTURER,
|
||||||
model=NAME,
|
model=NAME,
|
||||||
name=coordinator.data.party.summary,
|
name=coordinator.data.summary,
|
||||||
identifiers={(DOMAIN, unique_id)},
|
identifiers={(DOMAIN, unique_id)},
|
||||||
via_device=(DOMAIN, config_entry.unique_id),
|
via_device=(DOMAIN, config_entry.unique_id),
|
||||||
)
|
)
|
||||||
|
@@ -174,9 +174,6 @@
|
|||||||
},
|
},
|
||||||
"collected_items": {
|
"collected_items": {
|
||||||
"default": "mdi:sack"
|
"default": "mdi:sack"
|
||||||
},
|
|
||||||
"last_checkin": {
|
|
||||||
"default": "mdi:login-variant"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
@@ -197,11 +194,6 @@
|
|||||||
"quest_running": {
|
"quest_running": {
|
||||||
"default": "mdi:script-text-play"
|
"default": "mdi:script-text-play"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"notify": {
|
|
||||||
"party_chat": {
|
|
||||||
"default": "mdi:forum"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
|
@@ -128,7 +128,7 @@ class HabiticaPartyImage(HabiticaPartyBase, ImageEntity):
|
|||||||
"""Return URL of image."""
|
"""Return URL of image."""
|
||||||
return (
|
return (
|
||||||
f"{ASSETS_URL}quest_{key}.png"
|
f"{ASSETS_URL}quest_{key}.png"
|
||||||
if (key := self.coordinator.data.party.quest.key)
|
if (key := self.coordinator.data.quest.key)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -1,202 +0,0 @@
|
|||||||
"""Notify platform for the Habitica integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import abstractmethod
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from aiohttp import ClientError
|
|
||||||
from habiticalib import (
|
|
||||||
GroupData,
|
|
||||||
HabiticaException,
|
|
||||||
NotAuthorizedError,
|
|
||||||
NotFoundError,
|
|
||||||
TooManyRequestsError,
|
|
||||||
UserData,
|
|
||||||
)
|
|
||||||
|
|
||||||
from homeassistant.components.notify import (
|
|
||||||
DOMAIN as NOTIFY_DOMAIN,
|
|
||||||
NotifyEntity,
|
|
||||||
NotifyEntityDescription,
|
|
||||||
)
|
|
||||||
from homeassistant.const import CONF_NAME
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from . import HABITICA_KEY
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
|
|
||||||
from .entity import HabiticaBase
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 10
|
|
||||||
|
|
||||||
|
|
||||||
class HabiticaNotify(StrEnum):
|
|
||||||
"""Habitica Notifier."""
|
|
||||||
|
|
||||||
PARTY_CHAT = "party_chat"
|
|
||||||
PRIVATE_MESSAGE = "private_message"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: HabiticaConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the notify entity platform."""
|
|
||||||
members_added: set[UUID] = set()
|
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
|
|
||||||
coordinator = config_entry.runtime_data
|
|
||||||
|
|
||||||
if party := coordinator.data.user.party.id:
|
|
||||||
party_coordinator = hass.data[HABITICA_KEY][party]
|
|
||||||
async_add_entities(
|
|
||||||
[HabiticaPartyChatNotifyEntity(coordinator, party_coordinator.data.party)]
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def add_entities() -> None:
|
|
||||||
nonlocal members_added
|
|
||||||
|
|
||||||
new_members = set(party_coordinator.data.members.keys()) - members_added
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert coordinator.data.user.id
|
|
||||||
new_members.discard(coordinator.data.user.id)
|
|
||||||
if new_members:
|
|
||||||
async_add_entities(
|
|
||||||
HabiticaPrivateMessageNotifyEntity(
|
|
||||||
coordinator, party_coordinator.data.members[member]
|
|
||||||
)
|
|
||||||
for member in new_members
|
|
||||||
)
|
|
||||||
members_added |= new_members
|
|
||||||
|
|
||||||
delete_members = members_added - set(party_coordinator.data.members.keys())
|
|
||||||
for member in delete_members:
|
|
||||||
if entity_id := entity_registry.async_get_entity_id(
|
|
||||||
NOTIFY_DOMAIN,
|
|
||||||
DOMAIN,
|
|
||||||
f"{coordinator.config_entry.unique_id}_{member!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
|
|
||||||
):
|
|
||||||
entity_registry.async_remove(entity_id)
|
|
||||||
|
|
||||||
members_added.discard(member)
|
|
||||||
|
|
||||||
party_coordinator.async_add_listener(add_entities)
|
|
||||||
add_entities()
|
|
||||||
|
|
||||||
|
|
||||||
class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
|
|
||||||
"""Habitica base notify entity."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: HabiticaDataUpdateCoordinator,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a Habitica entity."""
|
|
||||||
super().__init__(coordinator, self.entity_description)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def _send_message(self, message: str) -> None:
|
|
||||||
"""Send a Habitica message."""
|
|
||||||
|
|
||||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
|
||||||
"""Send a message."""
|
|
||||||
try:
|
|
||||||
await self._send_message(message)
|
|
||||||
except NotAuthorizedError as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="send_message_forbidden",
|
|
||||||
translation_placeholders={
|
|
||||||
**self.translation_placeholders,
|
|
||||||
"reason": e.error.message,
|
|
||||||
},
|
|
||||||
) from e
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="send_message_not_found",
|
|
||||||
translation_placeholders={
|
|
||||||
**self.translation_placeholders,
|
|
||||||
"reason": e.error.message,
|
|
||||||
},
|
|
||||||
) from e
|
|
||||||
except TooManyRequestsError as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="setup_rate_limit_exception",
|
|
||||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
|
||||||
) from e
|
|
||||||
except HabiticaException as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="service_call_exception",
|
|
||||||
translation_placeholders={"reason": e.error.message},
|
|
||||||
) from e
|
|
||||||
except ClientError as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="service_call_exception",
|
|
||||||
translation_placeholders={"reason": str(e)},
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
class HabiticaPartyChatNotifyEntity(HabiticaBaseNotifyEntity):
|
|
||||||
"""Representation of a Habitica party chat notify entity."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: HabiticaDataUpdateCoordinator,
|
|
||||||
party: GroupData,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a Habitica entity."""
|
|
||||||
self._attr_translation_placeholders = {CONF_NAME: party.name}
|
|
||||||
|
|
||||||
self.entity_description = NotifyEntityDescription(
|
|
||||||
key=HabiticaNotify.PARTY_CHAT,
|
|
||||||
translation_key=HabiticaNotify.PARTY_CHAT,
|
|
||||||
)
|
|
||||||
self.party = party
|
|
||||||
super().__init__(coordinator)
|
|
||||||
|
|
||||||
async def _send_message(self, message: str) -> None:
|
|
||||||
"""Send a Habitica party chat message."""
|
|
||||||
|
|
||||||
await self.coordinator.habitica.send_group_message(
|
|
||||||
message=message,
|
|
||||||
group_id=self.party.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HabiticaPrivateMessageNotifyEntity(HabiticaBaseNotifyEntity):
|
|
||||||
"""Representation of a Habitica private message notify entity."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: HabiticaDataUpdateCoordinator,
|
|
||||||
member: UserData,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a Habitica entity."""
|
|
||||||
self._attr_translation_placeholders = {CONF_NAME: member.profile.name or ""}
|
|
||||||
self.entity_description = NotifyEntityDescription(
|
|
||||||
key=f"{member.id!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
|
|
||||||
translation_key=HabiticaNotify.PRIVATE_MESSAGE,
|
|
||||||
)
|
|
||||||
self.member = member
|
|
||||||
super().__init__(coordinator)
|
|
||||||
|
|
||||||
async def _send_message(self, message: str) -> None:
|
|
||||||
"""Send a Habitica private message."""
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert self.member.id
|
|
||||||
await self.coordinator.habitica.send_private_message(
|
|
||||||
message=message,
|
|
||||||
to_user_id=self.member.id,
|
|
||||||
)
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -54,7 +53,7 @@ PARALLEL_UPDATES = 1
|
|||||||
class HabiticaSensorEntityDescription(SensorEntityDescription):
|
class HabiticaSensorEntityDescription(SensorEntityDescription):
|
||||||
"""Habitica Sensor Description."""
|
"""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 = (
|
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
@@ -115,7 +114,6 @@ class HabiticaSensorEntity(StrEnum):
|
|||||||
COLLECTED_ITEMS = "collected_items"
|
COLLECTED_ITEMS = "collected_items"
|
||||||
BOSS_RAGE = "boss_rage"
|
BOSS_RAGE = "boss_rage"
|
||||||
BOSS_RAGE_LIMIT = "boss_rage_limit"
|
BOSS_RAGE_LIMIT = "boss_rage_limit"
|
||||||
LAST_CHECKIN = "last_checkin"
|
|
||||||
|
|
||||||
|
|
||||||
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||||
@@ -286,16 +284,6 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
|||||||
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
|
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
|
||||||
value_fn=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
|
entity_description: HabiticaSensorEntityDescription
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType | datetime:
|
def native_value(self) -> StateType:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
|
|
||||||
return self.entity_description.value_fn(
|
return self.entity_description.value_fn(
|
||||||
@@ -454,12 +442,10 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
|||||||
entity_description: HabiticaPartySensorEntityDescription
|
entity_description: HabiticaPartySensorEntityDescription
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType | datetime:
|
def native_value(self) -> StateType:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
|
|
||||||
return self.entity_description.value_fn(
|
return self.entity_description.value_fn(self.coordinator.data, self.content)
|
||||||
self.coordinator.data.party, self.content
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entity_picture(self) -> str | None:
|
def entity_picture(self) -> str | None:
|
||||||
@@ -467,9 +453,7 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
|||||||
pic = self.entity_description.entity_picture
|
pic = self.entity_description.entity_picture
|
||||||
|
|
||||||
entity_picture = (
|
entity_picture = (
|
||||||
pic
|
pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data)
|
||||||
if isinstance(pic, str) or pic is None
|
|
||||||
else pic(self.coordinator.data.party)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -484,5 +468,5 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
|||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
"""Return entity specific state attributes."""
|
"""Return entity specific state attributes."""
|
||||||
if func := self.entity_description.attributes_fn:
|
if func := self.entity_description.attributes_fn:
|
||||||
return func(self.coordinator.data.party, self.content)
|
return func(self.coordinator.data, self.content)
|
||||||
return None
|
return None
|
||||||
|
@@ -264,14 +264,6 @@
|
|||||||
"name": "[%key:component::habitica::common::quest_name%]"
|
"name": "[%key:component::habitica::common::quest_name%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notify": {
|
|
||||||
"party_chat": {
|
|
||||||
"name": "Party chat"
|
|
||||||
},
|
|
||||||
"private_message": {
|
|
||||||
"name": "Private message: {name}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"display_name": {
|
"display_name": {
|
||||||
"name": "Display name",
|
"name": "Display name",
|
||||||
@@ -290,9 +282,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"last_checkin": {
|
|
||||||
"name": "Last check-in"
|
|
||||||
},
|
|
||||||
"health": {
|
"health": {
|
||||||
"name": "Health",
|
"name": "Health",
|
||||||
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
|
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
|
||||||
@@ -583,12 +572,6 @@
|
|||||||
},
|
},
|
||||||
"frequency_not_monthly": {
|
"frequency_not_monthly": {
|
||||||
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
|
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
|
||||||
},
|
|
||||||
"send_message_forbidden": {
|
|
||||||
"message": "You are not allowed to send messages to {name}. ({reason})"
|
|
||||||
},
|
|
||||||
"send_message_not_found": {
|
|
||||||
"message": "Unable to send message, {name} not found. ({reason})"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
|
@@ -68,6 +68,7 @@ EVENT_HEALTH_CHANGED = "health_changed"
|
|||||||
EVENT_SUPPORTED_CHANGED = "supported_changed"
|
EVENT_SUPPORTED_CHANGED = "supported_changed"
|
||||||
EVENT_ISSUE_CHANGED = "issue_changed"
|
EVENT_ISSUE_CHANGED = "issue_changed"
|
||||||
EVENT_ISSUE_REMOVED = "issue_removed"
|
EVENT_ISSUE_REMOVED = "issue_removed"
|
||||||
|
EVENT_JOB = "job"
|
||||||
|
|
||||||
UPDATE_KEY_SUPERVISOR = "supervisor"
|
UPDATE_KEY_SUPERVISOR = "supervisor"
|
||||||
|
|
||||||
|
@@ -56,6 +56,7 @@ from .const import (
|
|||||||
SupervisorEntityModel,
|
SupervisorEntityModel,
|
||||||
)
|
)
|
||||||
from .handler import HassioAPIError, get_supervisor_client
|
from .handler import HassioAPIError, get_supervisor_client
|
||||||
|
from .jobs import SupervisorJobs
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .issues import SupervisorIssues
|
from .issues import SupervisorIssues
|
||||||
@@ -311,6 +312,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
lambda: defaultdict(set)
|
lambda: defaultdict(set)
|
||||||
)
|
)
|
||||||
self.supervisor_client = get_supervisor_client(hass)
|
self.supervisor_client = get_supervisor_client(hass)
|
||||||
|
self.jobs = SupervisorJobs(hass)
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Update data via library."""
|
"""Update data via library."""
|
||||||
@@ -485,6 +487,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Refresh jobs data
|
||||||
|
await self.jobs.refresh_data(first_update)
|
||||||
|
|
||||||
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
|
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
|
||||||
"""Update single addon stats."""
|
"""Update single addon stats."""
|
||||||
try:
|
try:
|
||||||
|
157
homeassistant/components/hassio/jobs.py
Normal file
157
homeassistant/components/hassio/jobs.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Track Supervisor job data and allow subscription to updates."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from aiohasupervisor.models import Job
|
||||||
|
|
||||||
|
from homeassistant.core import (
|
||||||
|
CALLBACK_TYPE,
|
||||||
|
HomeAssistant,
|
||||||
|
callback,
|
||||||
|
is_callback_check_partial,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_DATA,
|
||||||
|
ATTR_UPDATE_KEY,
|
||||||
|
ATTR_WS_EVENT,
|
||||||
|
EVENT_JOB,
|
||||||
|
EVENT_SUPERVISOR_EVENT,
|
||||||
|
EVENT_SUPERVISOR_UPDATE,
|
||||||
|
UPDATE_KEY_SUPERVISOR,
|
||||||
|
)
|
||||||
|
from .handler import get_supervisor_client
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class JobSubscription:
|
||||||
|
"""Subscribe for updates on jobs which match filters.
|
||||||
|
|
||||||
|
UUID is preferred match but only available in cases of a background API that
|
||||||
|
returns the UUID before taking the action. Others are used to match jobs only
|
||||||
|
if UUID is omitted. Either name or UUID is required to be able to match.
|
||||||
|
|
||||||
|
event_callback must be safe annotated as a homeassistant.core.callback
|
||||||
|
and safe to call in the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event_callback: Callable[[Job], Any]
|
||||||
|
uuid: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
reference: str | None | type[Any] = Any
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate at least one filter option is present."""
|
||||||
|
if not self.name and not self.uuid:
|
||||||
|
raise ValueError("Either name or uuid must be provided!")
|
||||||
|
if not is_callback_check_partial(self.event_callback):
|
||||||
|
raise ValueError("event_callback must be a homeassistant.core.callback!")
|
||||||
|
|
||||||
|
def matches(self, job: Job) -> bool:
|
||||||
|
"""Return true if job matches subscription filters."""
|
||||||
|
if self.uuid:
|
||||||
|
return job.uuid == self.uuid
|
||||||
|
return job.name == self.name and self.reference in (Any, job.reference)
|
||||||
|
|
||||||
|
|
||||||
|
class SupervisorJobs:
|
||||||
|
"""Manage access to Supervisor jobs."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize object."""
|
||||||
|
self._hass = hass
|
||||||
|
self._supervisor_client = get_supervisor_client(hass)
|
||||||
|
self._jobs: dict[UUID, Job] = {}
|
||||||
|
self._subscriptions: set[JobSubscription] = set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_jobs(self) -> list[Job]:
|
||||||
|
"""Return current jobs."""
|
||||||
|
return list(self._jobs.values())
|
||||||
|
|
||||||
|
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
|
||||||
|
"""Subscribe to updates for job. Return callback is used to unsubscribe.
|
||||||
|
|
||||||
|
If any jobs match the subscription at the time this is called, creates
|
||||||
|
tasks to run their callback on it.
|
||||||
|
"""
|
||||||
|
self._subscriptions.add(subscription)
|
||||||
|
|
||||||
|
# As these are callbacks they are safe to run in the event loop
|
||||||
|
# We wrap these in an asyncio task so subscribing does not wait on the logic
|
||||||
|
if matches := [job for job in self._jobs.values() if subscription.matches(job)]:
|
||||||
|
|
||||||
|
async def event_callback_async(job: Job) -> Any:
|
||||||
|
return subscription.event_callback(job)
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
self._hass.async_create_task(event_callback_async(match))
|
||||||
|
|
||||||
|
return partial(self._subscriptions.discard, subscription)
|
||||||
|
|
||||||
|
async def refresh_data(self, first_update: bool = False) -> None:
|
||||||
|
"""Refresh job data."""
|
||||||
|
job_data = await self._supervisor_client.jobs.info()
|
||||||
|
job_queue: list[Job] = job_data.jobs.copy()
|
||||||
|
new_jobs: dict[UUID, Job] = {}
|
||||||
|
changed_jobs: list[Job] = []
|
||||||
|
|
||||||
|
# Rebuild our job cache from new info and compare to find changes
|
||||||
|
while job_queue:
|
||||||
|
job = job_queue.pop(0)
|
||||||
|
job_queue.extend(job.child_jobs)
|
||||||
|
job = replace(job, child_jobs=[])
|
||||||
|
|
||||||
|
if job.uuid not in self._jobs or job != self._jobs[job.uuid]:
|
||||||
|
changed_jobs.append(job)
|
||||||
|
new_jobs[job.uuid] = replace(job, child_jobs=[])
|
||||||
|
|
||||||
|
# For any jobs that disappeared which weren't done, tell subscribers they
|
||||||
|
# changed to done. We don't know what else happened to them so leave the
|
||||||
|
# rest of their state as is rather then guessing
|
||||||
|
changed_jobs.extend(
|
||||||
|
[
|
||||||
|
replace(job, done=True)
|
||||||
|
for uuid, job in self._jobs.items()
|
||||||
|
if uuid not in new_jobs and job.done is False
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace our cache and inform subscribers of all changes
|
||||||
|
self._jobs = new_jobs
|
||||||
|
for job in changed_jobs:
|
||||||
|
self._process_job_change(job)
|
||||||
|
|
||||||
|
# If this is the first update register to receive Supervisor events
|
||||||
|
if first_update:
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None:
|
||||||
|
"""Update job data cache from supervisor events."""
|
||||||
|
if ATTR_WS_EVENT not in event:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
|
||||||
|
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
|
||||||
|
):
|
||||||
|
self._hass.async_create_task(self.refresh_data())
|
||||||
|
|
||||||
|
elif event[ATTR_WS_EVENT] == EVENT_JOB:
|
||||||
|
job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []})
|
||||||
|
self._jobs[job.uuid] = job
|
||||||
|
self._process_job_change(job)
|
||||||
|
|
||||||
|
def _process_job_change(self, job: Job) -> None:
|
||||||
|
"""Process a job change by triggering callbacks on subscribers."""
|
||||||
|
for sub in self._subscriptions:
|
||||||
|
if sub.matches(job):
|
||||||
|
sub.event_callback(job)
|
@@ -6,6 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["aiohasupervisor==0.3.3"],
|
"requirements": ["aiohasupervisor==0.3.3b0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohasupervisor import SupervisorError
|
from aiohasupervisor import SupervisorError
|
||||||
|
from aiohasupervisor.models import Job
|
||||||
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
|
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
|
||||||
|
|
||||||
from homeassistant.components.update import (
|
from homeassistant.components.update import (
|
||||||
@@ -15,7 +16,7 @@ from homeassistant.components.update import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_ICON, ATTR_NAME
|
from homeassistant.const import ATTR_ICON, ATTR_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ from .entity import (
|
|||||||
HassioOSEntity,
|
HassioOSEntity,
|
||||||
HassioSupervisorEntity,
|
HassioSupervisorEntity,
|
||||||
)
|
)
|
||||||
|
from .jobs import JobSubscription
|
||||||
from .update_helper import update_addon, update_core, update_os
|
from .update_helper import update_addon, update_core, update_os
|
||||||
|
|
||||||
ENTITY_DESCRIPTION = UpdateEntityDescription(
|
ENTITY_DESCRIPTION = UpdateEntityDescription(
|
||||||
@@ -89,6 +91,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
|||||||
UpdateEntityFeature.INSTALL
|
UpdateEntityFeature.INSTALL
|
||||||
| UpdateEntityFeature.BACKUP
|
| UpdateEntityFeature.BACKUP
|
||||||
| UpdateEntityFeature.RELEASE_NOTES
|
| UpdateEntityFeature.RELEASE_NOTES
|
||||||
|
| UpdateEntityFeature.PROGRESS
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -154,6 +157,30 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
|||||||
)
|
)
|
||||||
await self.coordinator.async_refresh()
|
await self.coordinator.async_refresh()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _update_job_changed(self, job: Job) -> None:
|
||||||
|
"""Process update for this entity's update job."""
|
||||||
|
if job.done is False:
|
||||||
|
self._attr_in_progress = True
|
||||||
|
self._attr_update_percentage = job.progress
|
||||||
|
else:
|
||||||
|
self._attr_in_progress = False
|
||||||
|
self._attr_update_percentage = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Subscribe to progress updates."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.jobs.subscribe(
|
||||||
|
JobSubscription(
|
||||||
|
self._update_job_changed,
|
||||||
|
name="addon_manager_update",
|
||||||
|
reference=self._addon_slug,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
|
class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
|
||||||
"""Update entity to handle updates for the Home Assistant Operating System."""
|
"""Update entity to handle updates for the Home Assistant Operating System."""
|
||||||
@@ -250,6 +277,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
|||||||
UpdateEntityFeature.INSTALL
|
UpdateEntityFeature.INSTALL
|
||||||
| UpdateEntityFeature.SPECIFIC_VERSION
|
| UpdateEntityFeature.SPECIFIC_VERSION
|
||||||
| UpdateEntityFeature.BACKUP
|
| UpdateEntityFeature.BACKUP
|
||||||
|
| UpdateEntityFeature.PROGRESS
|
||||||
)
|
)
|
||||||
_attr_title = "Home Assistant Core"
|
_attr_title = "Home Assistant Core"
|
||||||
|
|
||||||
@@ -281,3 +309,25 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Install an update."""
|
"""Install an update."""
|
||||||
await update_core(self.hass, version, backup)
|
await update_core(self.hass, version, backup)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _update_job_changed(self, job: Job) -> None:
|
||||||
|
"""Process update for this entity's update job."""
|
||||||
|
if job.done is False:
|
||||||
|
self._attr_in_progress = True
|
||||||
|
self._attr_update_percentage = job.progress
|
||||||
|
else:
|
||||||
|
self._attr_in_progress = False
|
||||||
|
self._attr_update_percentage = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Subscribe to progress updates."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.jobs.subscribe(
|
||||||
|
JobSubscription(
|
||||||
|
self._update_job_changed, name="home_assistant_core_update"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@@ -10,7 +10,6 @@ from homeassistant.components.homeassistant_hardware import firmware_config_flow
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
@@ -67,7 +66,6 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
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 (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -157,7 +156,7 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Connect ZBT-2 firmware update entity."""
|
"""Connect ZBT-2 firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.RTS_DTR]
|
bootloader_reset_type = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@@ -39,7 +39,6 @@ from .util import (
|
|||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
OwningAddon,
|
OwningAddon,
|
||||||
OwningIntegration,
|
OwningIntegration,
|
||||||
ResetTarget,
|
|
||||||
async_flash_silabs_firmware,
|
async_flash_silabs_firmware,
|
||||||
get_otbr_addon_manager,
|
get_otbr_addon_manager,
|
||||||
guess_firmware_info,
|
guess_firmware_info,
|
||||||
@@ -80,8 +79,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
"""Base flow to install firmware."""
|
"""Base flow to install firmware."""
|
||||||
|
|
||||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
|
||||||
|
|
||||||
_picked_firmware_type: PickedFirmwareType
|
_picked_firmware_type: PickedFirmwareType
|
||||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||||
|
|
||||||
@@ -158,6 +155,34 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
description_placeholders=self._get_translation_placeholders(),
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _probe_firmware_info(
|
||||||
|
self,
|
||||||
|
probe_methods: tuple[ApplicationType, ...] = (
|
||||||
|
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
|
||||||
|
ApplicationType.GECKO_BOOTLOADER,
|
||||||
|
ApplicationType.EZSP,
|
||||||
|
ApplicationType.SPINEL,
|
||||||
|
ApplicationType.CPC,
|
||||||
|
),
|
||||||
|
) -> bool:
|
||||||
|
"""Probe the firmware currently on the device."""
|
||||||
|
assert self._device is not None
|
||||||
|
|
||||||
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
probe_methods=probe_methods,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._probed_firmware_info is not None
|
||||||
|
and self._probed_firmware_info.firmware_type
|
||||||
|
in (
|
||||||
|
ApplicationType.EZSP,
|
||||||
|
ApplicationType.SPINEL,
|
||||||
|
ApplicationType.CPC,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _install_firmware_step(
|
async def _install_firmware_step(
|
||||||
self,
|
self,
|
||||||
fw_update_url: str,
|
fw_update_url: str,
|
||||||
@@ -211,6 +236,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
expected_installed_firmware_type: ApplicationType,
|
expected_installed_firmware_type: ApplicationType,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Install firmware."""
|
"""Install firmware."""
|
||||||
|
if not await self._probe_firmware_info():
|
||||||
|
raise AbortFlow(
|
||||||
|
reason="unsupported_firmware",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
)
|
||||||
|
|
||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
|
|
||||||
# Keep track of the firmware we're working with, for error messages
|
# Keep track of the firmware we're working with, for error messages
|
||||||
@@ -219,8 +250,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
# Installing new firmware is only truly required if the wrong type is
|
# Installing new firmware is only truly required if the wrong type is
|
||||||
# installed: upgrading to the latest release of the current firmware type
|
# installed: upgrading to the latest release of the current firmware type
|
||||||
# isn't strictly necessary for functionality.
|
# isn't strictly necessary for functionality.
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
|
||||||
|
|
||||||
firmware_install_required = self._probed_firmware_info is None or (
|
firmware_install_required = self._probed_firmware_info is None or (
|
||||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||||
)
|
)
|
||||||
@@ -272,12 +301,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
# Otherwise, fail
|
# Otherwise, fail
|
||||||
raise AbortFlow(reason="firmware_download_failed") from err
|
raise AbortFlow(reason="firmware_download_failed") from err
|
||||||
|
|
||||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
await async_flash_silabs_firmware(
|
||||||
hass=self.hass,
|
hass=self.hass,
|
||||||
device=self._device,
|
device=self._device,
|
||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
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(
|
progress_callback=lambda offset, total: self.async_update_progress(
|
||||||
offset / total
|
offset / total
|
||||||
),
|
),
|
||||||
@@ -285,6 +314,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
|
|
||||||
async def _configure_and_start_otbr_addon(self) -> None:
|
async def _configure_and_start_otbr_addon(self) -> None:
|
||||||
"""Configure and start the OTBR addon."""
|
"""Configure and start the OTBR addon."""
|
||||||
|
|
||||||
|
# Before we start the addon, confirm that the correct firmware is running
|
||||||
|
# and populate `self._probed_firmware_info` with the correct information
|
||||||
|
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
|
||||||
|
raise AbortFlow(
|
||||||
|
"unsupported_firmware",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
)
|
||||||
|
|
||||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||||
|
|
||||||
@@ -406,12 +444,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||||
return await self.async_step_install_zigbee_firmware()
|
return await self.async_step_install_zigbee_firmware()
|
||||||
|
|
||||||
return await self.async_step_install_thread_firmware()
|
return await self.async_step_prepare_thread_installation()
|
||||||
|
|
||||||
async def async_step_finish_thread_installation(
|
async def async_step_prepare_thread_installation(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Finish Thread installation by starting the OTBR addon."""
|
"""Prepare for Thread installation by stopping the OTBR addon if needed."""
|
||||||
if not is_hassio(self.hass):
|
if not is_hassio(self.hass):
|
||||||
return self.async_abort(
|
return self.async_abort(
|
||||||
reason="not_hassio_thread",
|
reason="not_hassio_thread",
|
||||||
@@ -421,12 +459,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||||
|
|
||||||
|
if addon_info.state == AddonState.RUNNING:
|
||||||
|
# Stop the addon before continuing to flash firmware
|
||||||
|
await otbr_manager.async_stop_addon()
|
||||||
|
|
||||||
|
return await self.async_step_install_thread_firmware()
|
||||||
|
|
||||||
|
async def async_step_finish_thread_installation(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Finish Thread installation by starting the OTBR addon."""
|
||||||
|
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||||
|
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||||
|
|
||||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||||
return await self.async_step_install_otbr_addon()
|
return await self.async_step_install_otbr_addon()
|
||||||
|
|
||||||
if addon_info.state == AddonState.RUNNING:
|
|
||||||
await otbr_manager.async_stop_addon()
|
|
||||||
|
|
||||||
return await self.async_step_start_otbr_addon()
|
return await self.async_step_start_otbr_addon()
|
||||||
|
|
||||||
async def async_step_pick_firmware_zigbee(
|
async def async_step_pick_firmware_zigbee(
|
||||||
@@ -463,6 +511,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
assert self._hardware_name is not None
|
assert self._hardware_name is not None
|
||||||
|
|
||||||
|
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||||
|
return self.async_abort(
|
||||||
|
reason="unsupported_firmware",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
)
|
||||||
|
|
||||||
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
||||||
return self._async_flow_finished()
|
return self._async_flow_finished()
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"universal-silabs-flasher==0.0.35",
|
"universal-silabs-flasher==0.0.32",
|
||||||
"ha-silabs-firmware-client==0.2.0"
|
"ha-silabs-firmware-client==0.2.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@@ -22,12 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|||||||
|
|
||||||
from .coordinator import FirmwareUpdateCoordinator
|
from .coordinator import FirmwareUpdateCoordinator
|
||||||
from .helpers import async_register_firmware_info_callback
|
from .helpers import async_register_firmware_info_callback
|
||||||
from .util import (
|
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
|
||||||
ApplicationType,
|
|
||||||
FirmwareInfo,
|
|
||||||
ResetTarget,
|
|
||||||
async_flash_silabs_firmware,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -86,7 +81,7 @@ class BaseFirmwareUpdateEntity(
|
|||||||
|
|
||||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||||
entity_description: FirmwareUpdateEntityDescription
|
entity_description: FirmwareUpdateEntityDescription
|
||||||
bootloader_reset_methods: list[ResetTarget] = []
|
bootloader_reset_type: str | None = None
|
||||||
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||||
@@ -273,7 +268,7 @@ class BaseFirmwareUpdateEntity(
|
|||||||
device=self._current_device,
|
device=self._current_device,
|
||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
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,
|
progress_callback=self._update_progress,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
@@ -4,16 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
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 contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from universal_silabs_flasher.const import (
|
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
|
||||||
ApplicationType as FlasherApplicationType,
|
|
||||||
ResetTarget as FlasherResetTarget,
|
|
||||||
)
|
|
||||||
from universal_silabs_flasher.firmware import parse_firmware_image
|
from universal_silabs_flasher.firmware import parse_firmware_image
|
||||||
from universal_silabs_flasher.flasher import Flasher
|
from universal_silabs_flasher.flasher import Flasher
|
||||||
|
|
||||||
@@ -45,9 +42,9 @@ class ApplicationType(StrEnum):
|
|||||||
"""Application type running on a device."""
|
"""Application type running on a device."""
|
||||||
|
|
||||||
GECKO_BOOTLOADER = "bootloader"
|
GECKO_BOOTLOADER = "bootloader"
|
||||||
|
CPC = "cpc"
|
||||||
EZSP = "ezsp"
|
EZSP = "ezsp"
|
||||||
SPINEL = "spinel"
|
SPINEL = "spinel"
|
||||||
CPC = "cpc"
|
|
||||||
ROUTER = "router"
|
ROUTER = "router"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -62,18 +59,6 @@ class ApplicationType(StrEnum):
|
|||||||
return FlasherApplicationType(self.value)
|
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)
|
@singleton(OTBR_ADDON_MANAGER_DATA)
|
||||||
@callback
|
@callback
|
||||||
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
||||||
@@ -357,7 +342,7 @@ async def async_flash_silabs_firmware(
|
|||||||
device: str,
|
device: str,
|
||||||
fw_data: bytes,
|
fw_data: bytes,
|
||||||
expected_installed_firmware_type: ApplicationType,
|
expected_installed_firmware_type: ApplicationType,
|
||||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
bootloader_reset_type: str | None = None,
|
||||||
progress_callback: Callable[[int, int], None] | None = None,
|
progress_callback: Callable[[int, int], None] | None = None,
|
||||||
) -> FirmwareInfo:
|
) -> FirmwareInfo:
|
||||||
"""Flash firmware to the SiLabs device."""
|
"""Flash firmware to the SiLabs device."""
|
||||||
@@ -374,9 +359,7 @@ async def async_flash_silabs_firmware(
|
|||||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||||
ApplicationType.CPC.as_flasher_application_type(),
|
ApplicationType.CPC.as_flasher_application_type(),
|
||||||
),
|
),
|
||||||
bootloader_reset=tuple(
|
bootloader_reset=bootloader_reset_type,
|
||||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
|
@@ -168,8 +168,7 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""SkyConnect firmware update entity."""
|
"""SkyConnect firmware update entity."""
|
||||||
|
|
||||||
# The ZBT-1 does not have a hardware bootloader trigger
|
bootloader_reset_type = None
|
||||||
bootloader_reset_methods = []
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@@ -27,8 +27,6 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
probe_silabs_firmware_info,
|
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
SOURCE_HARDWARE,
|
SOURCE_HARDWARE,
|
||||||
@@ -84,8 +82,6 @@ else:
|
|||||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||||
|
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -145,10 +141,8 @@ class HomeAssistantYellowConfigFlow(
|
|||||||
self, data: dict[str, Any] | None = None
|
self, data: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
assert self._device is not None
|
|
||||||
|
|
||||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
await self._probe_firmware_info()
|
||||||
|
|
||||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||||
if (
|
if (
|
||||||
|
@@ -16,7 +16,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -174,7 +173,7 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Yellow firmware update entity."""
|
"""Yellow firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
bootloader_reset_type = "yellow" # Triggers a GPIO reset
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@@ -14,6 +14,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aiohomekit", "commentjson"],
|
"loggers": ["aiohomekit", "commentjson"],
|
||||||
"requirements": ["aiohomekit==3.2.19"],
|
"requirements": ["aiohomekit==3.2.18"],
|
||||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||||
}
|
}
|
||||||
|
@@ -8,16 +8,13 @@ from idasen_ha import Desk
|
|||||||
|
|
||||||
from homeassistant.components import bluetooth
|
from homeassistant.components import bluetooth
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.debounce import Debouncer
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]
|
type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]
|
||||||
|
|
||||||
UPDATE_DEBOUNCE_TIME = 0.2
|
|
||||||
|
|
||||||
|
|
||||||
class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
|
class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
|
||||||
"""Class to manage updates for the Idasen Desk."""
|
"""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
|
hass, _LOGGER, config_entry=config_entry, name=config_entry.title
|
||||||
)
|
)
|
||||||
self.address = address
|
self.address = address
|
||||||
self.desk = Desk(self._async_handle_update)
|
|
||||||
|
|
||||||
self._expected_connected = False
|
self._expected_connected = False
|
||||||
self._height: int | None = None
|
|
||||||
|
|
||||||
@callback
|
self.desk = Desk(self.async_set_updated_data)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_connect(self) -> bool:
|
async def async_connect(self) -> bool:
|
||||||
"""Connect to desk."""
|
"""Connect to desk."""
|
||||||
@@ -76,9 +60,3 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
|
|||||||
"""Ensure that the desk is connected if that is the expected state."""
|
"""Ensure that the desk is connected if that is the expected state."""
|
||||||
if self._expected_connected:
|
if self._expected_connected:
|
||||||
await self.async_connect()
|
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 .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
|
||||||
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
|
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
|
||||||
|
|
||||||
type INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
|
INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
@@ -1 +0,0 @@
|
|||||||
"""Virtual integration: Konnected ESPHome."""
|
|
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "konnected_esphome",
|
|
||||||
"name": "Konnected",
|
|
||||||
"integration_type": "virtual",
|
|
||||||
"supported_by": "esphome"
|
|
||||||
}
|
|
@@ -13,16 +13,28 @@ from propcache.api import cached_property
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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,
|
||||||
ATTR_CODE_FORMAT,
|
ATTR_CODE_FORMAT,
|
||||||
SERVICE_LOCK,
|
SERVICE_LOCK,
|
||||||
SERVICE_OPEN,
|
SERVICE_OPEN,
|
||||||
SERVICE_UNLOCK,
|
SERVICE_UNLOCK,
|
||||||
|
STATE_OPEN,
|
||||||
|
STATE_OPENING,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import config_validation as cv
|
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 import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType, StateType
|
from homeassistant.helpers.typing import ConfigType, StateType
|
||||||
@@ -305,3 +317,11 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._lock_option_default_code = ""
|
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%]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -30,7 +30,6 @@ from .entity import MatterEntity
|
|||||||
from .helpers import get_matter
|
from .helpers import get_matter
|
||||||
from .models import MatterDiscoverySchema
|
from .models import MatterDiscoverySchema
|
||||||
|
|
||||||
HUMIDITY_SCALING_FACTOR = 100
|
|
||||||
TEMPERATURE_SCALING_FACTOR = 100
|
TEMPERATURE_SCALING_FACTOR = 100
|
||||||
HVAC_SYSTEM_MODE_MAP = {
|
HVAC_SYSTEM_MODE_MAP = {
|
||||||
HVACMode.OFF: 0,
|
HVACMode.OFF: 0,
|
||||||
@@ -262,18 +261,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
|
|||||||
self._attr_current_temperature = self._get_temperature_in_degrees(
|
self._attr_current_temperature = self._get_temperature_in_degrees(
|
||||||
clusters.Thermostat.Attributes.LocalTemperature
|
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:
|
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
|
# 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
|
# 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.TemperatureSetpointHold,
|
||||||
clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
|
clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
|
||||||
clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
|
clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
|
||||||
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
|
||||||
clusters.OnOff.Attributes.OnOff,
|
clusters.OnOff.Attributes.OnOff,
|
||||||
),
|
),
|
||||||
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
|
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
|
||||||
|
@@ -351,7 +351,6 @@ DISCOVERY_SCHEMAS = [
|
|||||||
required_attributes=(
|
required_attributes=(
|
||||||
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
||||||
),
|
),
|
||||||
allow_multi=True, # also used for climate entity
|
|
||||||
),
|
),
|
||||||
MatterDiscoverySchema(
|
MatterDiscoverySchema(
|
||||||
platform=Platform.SENSOR,
|
platform=Platform.SENSOR,
|
||||||
|
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
|
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
|
||||||
TIMEOUT = 10
|
TIMEOUT = 10
|
||||||
|
|
||||||
type TokenManager = Callable[[], Awaitable[str]]
|
TokenManager = Callable[[], Awaitable[str]]
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
@@ -55,6 +55,12 @@ from homeassistant.const import ( # noqa: F401
|
|||||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
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 import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.network import get_url
|
from homeassistant.helpers.network import get_url
|
||||||
@@ -69,6 +75,26 @@ from .browse_media import ( # noqa: F401
|
|||||||
async_process_play_media_url,
|
async_process_play_media_url,
|
||||||
)
|
)
|
||||||
from .const import ( # noqa: F401
|
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_ID,
|
||||||
ATTR_APP_NAME,
|
ATTR_APP_NAME,
|
||||||
ATTR_ENTITY_PICTURE_LOCAL,
|
ATTR_ENTITY_PICTURE_LOCAL,
|
||||||
@@ -162,6 +188,17 @@ class MediaPlayerDeviceClass(StrEnum):
|
|||||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
|
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]
|
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_content_id: str | None = None,
|
||||||
media_filter_classes: list[MediaClass] | None = None,
|
media_filter_classes: list[MediaClass] | None = None,
|
||||||
) -> SearchMedia:
|
) -> SearchMedia:
|
||||||
"""Search for media."""
|
|
||||||
return await self.async_search_media(
|
return await self.async_search_media(
|
||||||
query=SearchMediaQuery(
|
query=SearchMediaQuery(
|
||||||
search_query=search_query,
|
search_query=search_query,
|
||||||
@@ -1474,3 +1510,13 @@ async def async_fetch_image(
|
|||||||
logger.warning("Error retrieving proxied image from %s", url)
|
logger.warning("Error retrieving proxied image from %s", url)
|
||||||
|
|
||||||
return content, content_type
|
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."""
|
"""Provides the constants needed for component."""
|
||||||
|
|
||||||
from enum import IntFlag, StrEnum
|
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
|
# How long our auth signature on the content should be valid for
|
||||||
CONTENT_AUTH_EXPIRY_TIME = 3600 * 24
|
CONTENT_AUTH_EXPIRY_TIME = 3600 * 24
|
||||||
@@ -87,6 +94,38 @@ class MediaClass(StrEnum):
|
|||||||
VIDEO = "video"
|
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):
|
class MediaType(StrEnum):
|
||||||
"""Media type for media player entities."""
|
"""Media type for media player entities."""
|
||||||
|
|
||||||
@@ -113,6 +152,33 @@ class MediaType(StrEnum):
|
|||||||
VIDEO = "video"
|
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_CLEAR_PLAYLIST = "clear_playlist"
|
||||||
SERVICE_JOIN = "join"
|
SERVICE_JOIN = "join"
|
||||||
SERVICE_PLAY_MEDIA = "play_media"
|
SERVICE_PLAY_MEDIA = "play_media"
|
||||||
@@ -131,6 +197,11 @@ class RepeatMode(StrEnum):
|
|||||||
ONE = "one"
|
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]
|
REPEAT_MODES = [cls.value for cls in RepeatMode]
|
||||||
|
|
||||||
|
|
||||||
@@ -160,3 +231,71 @@ class MediaPlayerEntityFeature(IntFlag):
|
|||||||
MEDIA_ANNOUNCE = 1048576
|
MEDIA_ANNOUNCE = 1048576
|
||||||
MEDIA_ENQUEUE = 2097152
|
MEDIA_ENQUEUE = 2097152
|
||||||
SEARCH_MEDIA = 4194304
|
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())
|
||||||
|
@@ -14,7 +14,6 @@ from homeassistant.const import (
|
|||||||
SERVICE_MEDIA_PAUSE,
|
SERVICE_MEDIA_PAUSE,
|
||||||
SERVICE_MEDIA_PLAY,
|
SERVICE_MEDIA_PLAY,
|
||||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||||
SERVICE_VOLUME_MUTE,
|
|
||||||
SERVICE_VOLUME_SET,
|
SERVICE_VOLUME_SET,
|
||||||
STATE_PLAYING,
|
STATE_PLAYING,
|
||||||
)
|
)
|
||||||
@@ -28,7 +27,6 @@ from .browse_media import SearchMedia
|
|||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MEDIA_FILTER_CLASSES,
|
ATTR_MEDIA_FILTER_CLASSES,
|
||||||
ATTR_MEDIA_VOLUME_LEVEL,
|
ATTR_MEDIA_VOLUME_LEVEL,
|
||||||
ATTR_MEDIA_VOLUME_MUTED,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
SERVICE_SEARCH_MEDIA,
|
SERVICE_SEARCH_MEDIA,
|
||||||
@@ -41,8 +39,6 @@ INTENT_MEDIA_PAUSE = "HassMediaPause"
|
|||||||
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
|
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
|
||||||
INTENT_MEDIA_NEXT = "HassMediaNext"
|
INTENT_MEDIA_NEXT = "HassMediaNext"
|
||||||
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
|
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
|
||||||
INTENT_PLAYER_MUTE = "HassMediaPlayerMute"
|
|
||||||
INTENT_PLAYER_UNMUTE = "HassMediaPlayerUnmute"
|
|
||||||
INTENT_SET_VOLUME = "HassSetVolume"
|
INTENT_SET_VOLUME = "HassSetVolume"
|
||||||
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
|
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
|
||||||
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
|
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
|
||||||
@@ -134,8 +130,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
intent.async_register(hass, MediaSetVolumeRelativeHandler())
|
intent.async_register(hass, MediaSetVolumeRelativeHandler())
|
||||||
intent.async_register(hass, MediaPlayerMuteUnmuteHandler(True))
|
|
||||||
intent.async_register(hass, MediaPlayerMuteUnmuteHandler(False))
|
|
||||||
intent.async_register(hass, MediaSearchAndPlayHandler())
|
intent.async_register(hass, MediaSearchAndPlayHandler())
|
||||||
|
|
||||||
|
|
||||||
@@ -237,42 +231,6 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerMuteUnmuteHandler(intent.ServiceIntentHandler):
|
|
||||||
"""Handle Mute/Unmute intents."""
|
|
||||||
|
|
||||||
def __init__(self, is_volume_muted: bool) -> None:
|
|
||||||
"""Initialize the mute/unmute handler objects."""
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
(INTENT_PLAYER_MUTE if is_volume_muted else INTENT_PLAYER_UNMUTE),
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_VOLUME_MUTE,
|
|
||||||
required_domains={DOMAIN},
|
|
||||||
required_features=MediaPlayerEntityFeature.VOLUME_MUTE,
|
|
||||||
optional_slots={
|
|
||||||
ATTR_MEDIA_VOLUME_MUTED: intent.IntentSlotInfo(
|
|
||||||
description="Whether the media player should be muted or unmuted",
|
|
||||||
value_schema=vol.Boolean(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
description=(
|
|
||||||
"Mutes a media player" if is_volume_muted else "Unmutes a media player"
|
|
||||||
),
|
|
||||||
platforms={DOMAIN},
|
|
||||||
device_classes={MediaPlayerDeviceClass},
|
|
||||||
)
|
|
||||||
self.is_volume_muted = is_volume_muted
|
|
||||||
|
|
||||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
|
||||||
"""Handle the intent."""
|
|
||||||
|
|
||||||
intent_obj.slots["is_volume_muted"] = {
|
|
||||||
"value": self.is_volume_muted,
|
|
||||||
"text": str(self.is_volume_muted),
|
|
||||||
}
|
|
||||||
return await super().async_handle(intent_obj)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaSearchAndPlayHandler(intent.IntentHandler):
|
class MediaSearchAndPlayHandler(intent.IntentHandler):
|
||||||
"""Handle HassMediaSearchAndPlay intents."""
|
"""Handle HassMediaSearchAndPlay intents."""
|
||||||
|
|
||||||
|
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
|
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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
|
from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
|
||||||
|
|
||||||
@@ -63,15 +62,12 @@ class MediaSourceItem:
|
|||||||
async def async_browse(self) -> BrowseMediaSource:
|
async def async_browse(self) -> BrowseMediaSource:
|
||||||
"""Browse this item."""
|
"""Browse this item."""
|
||||||
if self.domain is None:
|
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(
|
base = BrowseMediaSource(
|
||||||
domain=None,
|
domain=None,
|
||||||
identifier=None,
|
identifier=None,
|
||||||
media_class=MediaClass.APP,
|
media_class=MediaClass.APP,
|
||||||
media_content_type=MediaType.APPS,
|
media_content_type=MediaType.APPS,
|
||||||
title=title,
|
title="Media Sources",
|
||||||
can_play=False,
|
can_play=False,
|
||||||
can_expand=True,
|
can_expand=True,
|
||||||
children_media_class=MediaClass.APP,
|
children_media_class=MediaClass.APP,
|
||||||
|
@@ -9,8 +9,5 @@
|
|||||||
"unknown_media_source": {
|
"unknown_media_source": {
|
||||||
"message": "Unknown media source: {domain}"
|
"message": "Unknown media source: {domain}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"sources_default": "Media sources"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,27 +0,0 @@
|
|||||||
"""The Meteo.lt integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .const import CONF_PLACE_CODE, PLATFORMS
|
|
||||||
from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
|
|
||||||
"""Set up Meteo.lt from a config entry."""
|
|
||||||
|
|
||||||
coordinator = MeteoLtUpdateCoordinator(hass, entry.data[CONF_PLACE_CODE], entry)
|
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
@@ -1,78 +0,0 @@
|
|||||||
"""Config flow for Meteo.lt integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from meteo_lt import MeteoLtAPI, Place
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|
||||||
|
|
||||||
from .const import CONF_PLACE_CODE, DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MeteoLtConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Meteo.lt."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Initialize the config flow."""
|
|
||||||
self._api = MeteoLtAPI()
|
|
||||||
self._places: list[Place] = []
|
|
||||||
self._selected_place: Place | None = None
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle the initial step."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
place_code = user_input[CONF_PLACE_CODE]
|
|
||||||
self._selected_place = next(
|
|
||||||
(place for place in self._places if place.code == place_code),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if self._selected_place:
|
|
||||||
await self.async_set_unique_id(self._selected_place.code)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=self._selected_place.name,
|
|
||||||
data={
|
|
||||||
CONF_PLACE_CODE: self._selected_place.code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
errors["base"] = "invalid_location"
|
|
||||||
|
|
||||||
if not self._places:
|
|
||||||
try:
|
|
||||||
await self._api.fetch_places()
|
|
||||||
self._places = self._api.places
|
|
||||||
except (aiohttp.ClientError, TimeoutError) as err:
|
|
||||||
_LOGGER.error("Error fetching places: %s", err)
|
|
||||||
return self.async_abort(reason="cannot_connect")
|
|
||||||
|
|
||||||
if not self._places:
|
|
||||||
return self.async_abort(reason="no_places_found")
|
|
||||||
|
|
||||||
places_options = {
|
|
||||||
place.code: f"{place.name} ({place.administrative_division})"
|
|
||||||
for place in self._places
|
|
||||||
}
|
|
||||||
|
|
||||||
data_schema = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_PLACE_CODE): vol.In(places_options),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=data_schema,
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
@@ -1,17 +0,0 @@
|
|||||||
"""Constants for the Meteo.lt integration."""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
|
|
||||||
DOMAIN = "meteo_lt"
|
|
||||||
PLATFORMS = [Platform.WEATHER]
|
|
||||||
|
|
||||||
MANUFACTURER = "Lithuanian Hydrometeorological Service"
|
|
||||||
MODEL = "Weather Station"
|
|
||||||
|
|
||||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30)
|
|
||||||
|
|
||||||
CONF_PLACE_CODE = "place_code"
|
|
||||||
|
|
||||||
ATTRIBUTION = "Data provided by Lithuanian Hydrometeorological Service (LHMT)"
|
|
@@ -1,61 +0,0 @@
|
|||||||
"""DataUpdateCoordinator for Meteo.lt integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from meteo_lt import Forecast as MeteoLtForecast, MeteoLtAPI
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
type MeteoLtConfigEntry = ConfigEntry[MeteoLtUpdateCoordinator]
|
|
||||||
|
|
||||||
|
|
||||||
class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]):
|
|
||||||
"""Class to manage fetching Meteo.lt data."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
place_code: str,
|
|
||||||
config_entry: MeteoLtConfigEntry,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the coordinator."""
|
|
||||||
self.client = MeteoLtAPI()
|
|
||||||
self.place_code = place_code
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name=DOMAIN,
|
|
||||||
update_interval=DEFAULT_UPDATE_INTERVAL,
|
|
||||||
config_entry=config_entry,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> MeteoLtForecast:
|
|
||||||
"""Fetch data from Meteo.lt API."""
|
|
||||||
try:
|
|
||||||
forecast = await self.client.get_forecast(self.place_code)
|
|
||||||
except aiohttp.ClientResponseError as err:
|
|
||||||
raise UpdateFailed(
|
|
||||||
f"API returned error status {err.status}: {err.message}"
|
|
||||||
) from err
|
|
||||||
except aiohttp.ClientConnectionError as err:
|
|
||||||
raise UpdateFailed(f"Cannot connect to API: {err}") from err
|
|
||||||
except (aiohttp.ClientError, TimeoutError) as err:
|
|
||||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
|
||||||
|
|
||||||
# Check if forecast data is available
|
|
||||||
if not forecast.forecast_timestamps:
|
|
||||||
raise UpdateFailed(
|
|
||||||
f"No forecast data available for {self.place_code} - API returned empty timestamps"
|
|
||||||
)
|
|
||||||
|
|
||||||
return forecast
|
|
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "meteo_lt",
|
|
||||||
"name": "Meteo.lt",
|
|
||||||
"codeowners": ["@xE1H"],
|
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/meteo_lt",
|
|
||||||
"integration_type": "service",
|
|
||||||
"iot_class": "cloud_polling",
|
|
||||||
"quality_scale": "bronze",
|
|
||||||
"requirements": ["meteo-lt-pkg==0.2.4"]
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user